803 lines
27 KiB
Python
803 lines
27 KiB
Python
|
|
"""McRogueFace Web Demo - A roguelike dungeon crawler showcasing engine features.
|
||
|
|
|
||
|
|
Features demonstrated:
|
||
|
|
- BSP dungeon generation with corridors
|
||
|
|
- Wang tile autotiling for pretty dungeons
|
||
|
|
- Entity system with player and enemies
|
||
|
|
- Field of view with fog of war
|
||
|
|
- Turn-based bump combat
|
||
|
|
- UI overlays (health bar, messages, depth counter)
|
||
|
|
- Animations and timers
|
||
|
|
"""
|
||
|
|
import mcrfpy
|
||
|
|
import random
|
||
|
|
|
||
|
|
# -- Assets ------------------------------------------------------------------
|
||
|
|
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
|
||
|
|
font = mcrfpy.Font("assets/JetbrainsMono.ttf")
|
||
|
|
|
||
|
|
# Try to load Wang tileset for pretty autotiling
|
||
|
|
try:
|
||
|
|
_tileset = mcrfpy.TileSetFile("assets/kenney_TD_MR_IP.tsx")
|
||
|
|
_wang_set = _tileset.wang_set("dungeon")
|
||
|
|
_Terrain = _wang_set.terrain_enum()
|
||
|
|
HAS_WANG = True
|
||
|
|
except Exception:
|
||
|
|
HAS_WANG = False
|
||
|
|
|
||
|
|
# -- Sprite indices from kenney_TD_MR_IP (12 cols x 55 rows, 16x16) ----------
|
||
|
|
# Rows are 0-indexed; sprite_index = row * 12 + col
|
||
|
|
FLOOR_TILE = 145 # open stone floor
|
||
|
|
WALL_TILE = 251 # solid wall
|
||
|
|
PLAYER_SPRITE = 84 # hero character
|
||
|
|
RAT_SPRITE = 123 # small rat enemy
|
||
|
|
CYCLOPS_SPRITE = 109 # big enemy
|
||
|
|
SKELETON_SPRITE = 110 # skeleton enemy
|
||
|
|
HEART_FULL = 210
|
||
|
|
HEART_HALF = 209
|
||
|
|
HEART_EMPTY = 208
|
||
|
|
POTION_SPRITE = 115 # red potion
|
||
|
|
STAIRS_SPRITE = 91 # stairs down
|
||
|
|
SKULL_SPRITE = 135 # death indicator
|
||
|
|
TREASURE_SPRITE = 127 # treasure chest
|
||
|
|
|
||
|
|
# -- Configuration ------------------------------------------------------------
|
||
|
|
MAP_W, MAP_H = 40, 30
|
||
|
|
ZOOM = 2.0
|
||
|
|
GRID_PX_W = 1024
|
||
|
|
GRID_PX_H = 700
|
||
|
|
FOV_RADIUS = 10
|
||
|
|
MAX_HP = 10
|
||
|
|
ENEMY_SIGHT = 8
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Wang tile autotiling
|
||
|
|
# =============================================================================
|
||
|
|
def paint_tiles(grid, w, h):
|
||
|
|
"""Apply Wang tile autotiling or fallback to simple tiles."""
|
||
|
|
if HAS_WANG:
|
||
|
|
dm = mcrfpy.DiscreteMap((w, h))
|
||
|
|
for y in range(h):
|
||
|
|
for x in range(w):
|
||
|
|
if grid.at((x, y)).walkable:
|
||
|
|
dm.set(x, y, int(_Terrain.GROUND))
|
||
|
|
else:
|
||
|
|
dm.set(x, y, int(_Terrain.WALL))
|
||
|
|
tiles = _wang_set.resolve(dm)
|
||
|
|
for y in range(h):
|
||
|
|
for x in range(w):
|
||
|
|
tid = tiles[y * w + x]
|
||
|
|
if tid >= 0:
|
||
|
|
grid.at((x, y)).tilesprite = tid
|
||
|
|
else:
|
||
|
|
grid.at((x, y)).tilesprite = (
|
||
|
|
FLOOR_TILE if grid.at((x, y)).walkable else WALL_TILE
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
for y in range(h):
|
||
|
|
for x in range(w):
|
||
|
|
grid.at((x, y)).tilesprite = (
|
||
|
|
FLOOR_TILE if grid.at((x, y)).walkable else WALL_TILE
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Dungeon generator (BSP)
|
||
|
|
# =============================================================================
|
||
|
|
class Dungeon:
|
||
|
|
def __init__(self, grid, w, h):
|
||
|
|
self.grid = grid
|
||
|
|
self.w = w
|
||
|
|
self.h = h
|
||
|
|
self.rooms = [] # list of (cx, cy) room centers
|
||
|
|
self.walkable = set() # walkable cell coords
|
||
|
|
|
||
|
|
def generate(self):
|
||
|
|
# Reset all cells to walls
|
||
|
|
for x in range(self.w):
|
||
|
|
for y in range(self.h):
|
||
|
|
self.grid.at((x, y)).walkable = False
|
||
|
|
self.grid.at((x, y)).transparent = False
|
||
|
|
|
||
|
|
# BSP split
|
||
|
|
bsp = mcrfpy.BSP(pos=(1, 1), size=(self.w - 2, self.h - 2))
|
||
|
|
bsp.split_recursive(depth=5, min_size=(4, 4), max_ratio=1.5)
|
||
|
|
leaves = list(bsp.leaves())
|
||
|
|
|
||
|
|
# Carve rooms (1-cell margin from leaf edges)
|
||
|
|
for leaf in leaves:
|
||
|
|
lx, ly = int(leaf.pos[0]), int(leaf.pos[1])
|
||
|
|
lw, lh = int(leaf.size[0]), int(leaf.size[1])
|
||
|
|
cx = lx + lw // 2
|
||
|
|
cy = ly + lh // 2
|
||
|
|
self.rooms.append((cx, cy))
|
||
|
|
for rx in range(lx + 1, lx + lw - 1):
|
||
|
|
for ry in range(ly + 1, ly + lh - 1):
|
||
|
|
if 0 <= rx < self.w and 0 <= ry < self.h:
|
||
|
|
self.grid.at((rx, ry)).walkable = True
|
||
|
|
self.grid.at((rx, ry)).transparent = True
|
||
|
|
self.walkable.add((rx, ry))
|
||
|
|
|
||
|
|
# Carve corridors using BSP adjacency
|
||
|
|
adj = bsp.adjacency
|
||
|
|
connected = set()
|
||
|
|
for i in range(len(adj)):
|
||
|
|
for j in adj[i]:
|
||
|
|
edge = (min(i, j), max(i, j))
|
||
|
|
if edge in connected:
|
||
|
|
continue
|
||
|
|
connected.add(edge)
|
||
|
|
self._dig_corridor(self.rooms[i], self.rooms[j])
|
||
|
|
|
||
|
|
# Apply tile graphics
|
||
|
|
paint_tiles(self.grid, self.w, self.h)
|
||
|
|
|
||
|
|
def _dig_corridor(self, start, end):
|
||
|
|
x1, x2 = min(start[0], end[0]), max(start[0], end[0])
|
||
|
|
y1, y2 = min(start[1], end[1]), max(start[1], end[1])
|
||
|
|
# L-shaped corridor
|
||
|
|
if random.random() < 0.5:
|
||
|
|
tx, ty = x1, y2
|
||
|
|
else:
|
||
|
|
tx, ty = x2, y1
|
||
|
|
for x in range(x1, x2 + 1):
|
||
|
|
if 0 <= x < self.w and 0 <= ty < self.h:
|
||
|
|
self.grid.at((x, ty)).walkable = True
|
||
|
|
self.grid.at((x, ty)).transparent = True
|
||
|
|
self.walkable.add((x, ty))
|
||
|
|
for y in range(y1, y2 + 1):
|
||
|
|
if 0 <= tx < self.w and 0 <= y < self.h:
|
||
|
|
self.grid.at((tx, y)).walkable = True
|
||
|
|
self.grid.at((tx, y)).transparent = True
|
||
|
|
self.walkable.add((tx, y))
|
||
|
|
|
||
|
|
def random_floor(self, exclude=None):
|
||
|
|
"""Find a random walkable cell not in the exclude set."""
|
||
|
|
exclude = exclude or set()
|
||
|
|
candidates = list(self.walkable - exclude)
|
||
|
|
if not candidates:
|
||
|
|
return None
|
||
|
|
return random.choice(candidates)
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Game state
|
||
|
|
# =============================================================================
|
||
|
|
class Game:
|
||
|
|
def __init__(self):
|
||
|
|
self.scene = mcrfpy.Scene("game")
|
||
|
|
self.ui = self.scene.children
|
||
|
|
self.depth = 1
|
||
|
|
self.player_hp = MAX_HP
|
||
|
|
self.player_max_hp = MAX_HP
|
||
|
|
self.player_atk = 2
|
||
|
|
self.player_def = 0
|
||
|
|
self.score = 0
|
||
|
|
self.game_over = False
|
||
|
|
self.enemies = [] # list of dicts: {entity, hp, atk, def, sprite, name}
|
||
|
|
self.items = [] # list of dicts: {entity, kind}
|
||
|
|
self.dungeon = None
|
||
|
|
self.fog_layer = None
|
||
|
|
self.grid = None
|
||
|
|
self.player = None
|
||
|
|
self.message_timer = None
|
||
|
|
self.occupied = set() # cells occupied by entities
|
||
|
|
|
||
|
|
self._build_ui()
|
||
|
|
self._new_level()
|
||
|
|
|
||
|
|
self.scene.on_key = self.on_key
|
||
|
|
self.scene.activate()
|
||
|
|
|
||
|
|
# -- UI -------------------------------------------------------------------
|
||
|
|
def _build_ui(self):
|
||
|
|
# Main grid
|
||
|
|
self.grid = mcrfpy.Grid(
|
||
|
|
grid_size=(MAP_W, MAP_H),
|
||
|
|
texture=texture,
|
||
|
|
pos=(0, 0),
|
||
|
|
size=(GRID_PX_W, GRID_PX_H),
|
||
|
|
)
|
||
|
|
self.grid.zoom = ZOOM
|
||
|
|
self.grid.center = (MAP_W / 2.0 * 16, MAP_H / 2.0 * 16)
|
||
|
|
self.ui.append(self.grid)
|
||
|
|
|
||
|
|
# HUD bar at bottom
|
||
|
|
self.hud = mcrfpy.Frame(
|
||
|
|
pos=(0, GRID_PX_H), size=(1024, 68),
|
||
|
|
fill_color=(20, 16, 28, 240)
|
||
|
|
)
|
||
|
|
self.ui.append(self.hud)
|
||
|
|
|
||
|
|
# Health display
|
||
|
|
self.health_label = mcrfpy.Caption(
|
||
|
|
text="HP: 10/10", pos=(12, 6), font=font,
|
||
|
|
fill_color=(220, 50, 50)
|
||
|
|
)
|
||
|
|
self.health_label.font_size = 20
|
||
|
|
self.health_label.outline = 2
|
||
|
|
self.health_label.outline_color = (0, 0, 0)
|
||
|
|
self.hud.children.append(self.health_label)
|
||
|
|
|
||
|
|
# Heart sprites
|
||
|
|
self.hearts = []
|
||
|
|
for i in range(5):
|
||
|
|
h = mcrfpy.Sprite(
|
||
|
|
x=12 + i * 36, y=32,
|
||
|
|
texture=texture, sprite_index=HEART_FULL, scale=2.0
|
||
|
|
)
|
||
|
|
self.hearts.append(h)
|
||
|
|
self.hud.children.append(h)
|
||
|
|
|
||
|
|
# Depth label
|
||
|
|
self.depth_label = mcrfpy.Caption(
|
||
|
|
text="Depth: 1", pos=(220, 6), font=font,
|
||
|
|
fill_color=(180, 180, 220)
|
||
|
|
)
|
||
|
|
self.depth_label.font_size = 20
|
||
|
|
self.depth_label.outline = 2
|
||
|
|
self.depth_label.outline_color = (0, 0, 0)
|
||
|
|
self.hud.children.append(self.depth_label)
|
||
|
|
|
||
|
|
# Score label
|
||
|
|
self.score_label = mcrfpy.Caption(
|
||
|
|
text="Score: 0", pos=(220, 32), font=font,
|
||
|
|
fill_color=(220, 200, 80)
|
||
|
|
)
|
||
|
|
self.score_label.font_size = 18
|
||
|
|
self.score_label.outline = 2
|
||
|
|
self.score_label.outline_color = (0, 0, 0)
|
||
|
|
self.hud.children.append(self.score_label)
|
||
|
|
|
||
|
|
# Message area
|
||
|
|
self.msg_label = mcrfpy.Caption(
|
||
|
|
text="Arrow keys to move. Bump enemies to attack.",
|
||
|
|
pos=(450, 6), font=font,
|
||
|
|
fill_color=(160, 200, 160)
|
||
|
|
)
|
||
|
|
self.msg_label.font_size = 16
|
||
|
|
self.msg_label.outline = 1
|
||
|
|
self.msg_label.outline_color = (0, 0, 0)
|
||
|
|
self.hud.children.append(self.msg_label)
|
||
|
|
|
||
|
|
self.msg_label2 = mcrfpy.Caption(
|
||
|
|
text="Find the stairs to descend deeper!",
|
||
|
|
pos=(450, 30), font=font,
|
||
|
|
fill_color=(140, 160, 180)
|
||
|
|
)
|
||
|
|
self.msg_label2.font_size = 14
|
||
|
|
self.msg_label2.outline = 1
|
||
|
|
self.msg_label2.outline_color = (0, 0, 0)
|
||
|
|
self.hud.children.append(self.msg_label2)
|
||
|
|
|
||
|
|
# -- Level generation -----------------------------------------------------
|
||
|
|
def _new_level(self):
|
||
|
|
# Clear old entities
|
||
|
|
while len(self.grid.entities) > 0:
|
||
|
|
self.grid.entities.pop(0)
|
||
|
|
self.enemies.clear()
|
||
|
|
self.items.clear()
|
||
|
|
self.occupied.clear()
|
||
|
|
|
||
|
|
# Generate dungeon
|
||
|
|
self.dungeon = Dungeon(self.grid, MAP_W, MAP_H)
|
||
|
|
self.dungeon.generate()
|
||
|
|
|
||
|
|
# Place player in first room
|
||
|
|
px, py = self.dungeon.rooms[0]
|
||
|
|
if self.player is None:
|
||
|
|
self.player = mcrfpy.Entity(
|
||
|
|
grid_pos=(px, py), texture=texture,
|
||
|
|
sprite_index=PLAYER_SPRITE
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
self.player.grid_pos = (px, py)
|
||
|
|
self.grid.entities.append(self.player)
|
||
|
|
self.occupied.add((px, py))
|
||
|
|
|
||
|
|
# Place stairs in last room
|
||
|
|
sx, sy = self.dungeon.rooms[-1]
|
||
|
|
stairs = mcrfpy.Entity(
|
||
|
|
grid_pos=(sx, sy), texture=texture,
|
||
|
|
sprite_index=STAIRS_SPRITE
|
||
|
|
)
|
||
|
|
self.grid.entities.append(stairs)
|
||
|
|
self.stairs_pos = (sx, sy)
|
||
|
|
|
||
|
|
# Place enemies (more enemies on deeper levels)
|
||
|
|
num_enemies = min(3 + self.depth * 2, 15)
|
||
|
|
enemy_types = self._enemy_table()
|
||
|
|
for _ in range(num_enemies):
|
||
|
|
pos = self.dungeon.random_floor(exclude=self.occupied)
|
||
|
|
if pos is None:
|
||
|
|
break
|
||
|
|
etype = random.choice(enemy_types)
|
||
|
|
e = mcrfpy.Entity(
|
||
|
|
grid_pos=pos, texture=texture,
|
||
|
|
sprite_index=etype["sprite"]
|
||
|
|
)
|
||
|
|
self.grid.entities.append(e)
|
||
|
|
self.enemies.append({
|
||
|
|
"entity": e,
|
||
|
|
"hp": etype["hp"],
|
||
|
|
"max_hp": etype["hp"],
|
||
|
|
"atk": etype["atk"],
|
||
|
|
"def": etype["def"],
|
||
|
|
"name": etype["name"],
|
||
|
|
"sprite": etype["sprite"],
|
||
|
|
})
|
||
|
|
self.occupied.add(pos)
|
||
|
|
|
||
|
|
# Place health potions
|
||
|
|
num_potions = random.randint(1, 3)
|
||
|
|
for _ in range(num_potions):
|
||
|
|
pos = self.dungeon.random_floor(exclude=self.occupied)
|
||
|
|
if pos is None:
|
||
|
|
break
|
||
|
|
item = mcrfpy.Entity(
|
||
|
|
grid_pos=pos, texture=texture,
|
||
|
|
sprite_index=POTION_SPRITE
|
||
|
|
)
|
||
|
|
self.grid.entities.append(item)
|
||
|
|
self.items.append({"entity": item, "kind": "potion", "pos": pos})
|
||
|
|
self.occupied.add(pos)
|
||
|
|
|
||
|
|
# Place treasure
|
||
|
|
num_treasure = random.randint(1, 2 + self.depth)
|
||
|
|
for _ in range(num_treasure):
|
||
|
|
pos = self.dungeon.random_floor(exclude=self.occupied)
|
||
|
|
if pos is None:
|
||
|
|
break
|
||
|
|
item = mcrfpy.Entity(
|
||
|
|
grid_pos=pos, texture=texture,
|
||
|
|
sprite_index=TREASURE_SPRITE
|
||
|
|
)
|
||
|
|
self.grid.entities.append(item)
|
||
|
|
self.items.append({"entity": item, "kind": "treasure", "pos": pos})
|
||
|
|
self.occupied.add(pos)
|
||
|
|
|
||
|
|
# Set up fog of war
|
||
|
|
self.fog_layer = mcrfpy.ColorLayer(name="fog", z_index=10)
|
||
|
|
self.grid.add_layer(self.fog_layer)
|
||
|
|
self.fog_layer.fill(mcrfpy.Color(0, 0, 0, 255))
|
||
|
|
self.discovered = set()
|
||
|
|
|
||
|
|
# Center camera on player
|
||
|
|
self._center_camera()
|
||
|
|
self._update_fov()
|
||
|
|
self._update_hud()
|
||
|
|
|
||
|
|
# Depth label
|
||
|
|
self.depth_label.text = f"Depth: {self.depth}"
|
||
|
|
|
||
|
|
def _enemy_table(self):
|
||
|
|
"""Return available enemy types scaled by depth."""
|
||
|
|
table = [
|
||
|
|
{"name": "Rat", "sprite": RAT_SPRITE,
|
||
|
|
"hp": 2 + self.depth // 3, "atk": 1, "def": 0},
|
||
|
|
]
|
||
|
|
if self.depth >= 2:
|
||
|
|
table.append(
|
||
|
|
{"name": "Skeleton", "sprite": SKELETON_SPRITE,
|
||
|
|
"hp": 3 + self.depth // 2, "atk": 2, "def": 1}
|
||
|
|
)
|
||
|
|
if self.depth >= 4:
|
||
|
|
table.append(
|
||
|
|
{"name": "Cyclops", "sprite": CYCLOPS_SPRITE,
|
||
|
|
"hp": 6 + self.depth, "atk": 3, "def": 2}
|
||
|
|
)
|
||
|
|
return table
|
||
|
|
|
||
|
|
# -- Camera ---------------------------------------------------------------
|
||
|
|
def _center_camera(self):
|
||
|
|
px = int(self.player.grid_pos.x)
|
||
|
|
py = int(self.player.grid_pos.y)
|
||
|
|
self.grid.center = (px * 16 + 8, py * 16 + 8)
|
||
|
|
|
||
|
|
# -- FOV ------------------------------------------------------------------
|
||
|
|
def _update_fov(self):
|
||
|
|
if self.fog_layer is None:
|
||
|
|
return
|
||
|
|
px = int(self.player.grid_pos.x)
|
||
|
|
py = int(self.player.grid_pos.y)
|
||
|
|
self.grid.compute_fov((px, py), radius=FOV_RADIUS)
|
||
|
|
|
||
|
|
for x in range(MAP_W):
|
||
|
|
for y in range(MAP_H):
|
||
|
|
if self.grid.is_in_fov((x, y)):
|
||
|
|
self.discovered.add((x, y))
|
||
|
|
self.fog_layer.set((x, y), mcrfpy.Color(0, 0, 0, 0))
|
||
|
|
elif (x, y) in self.discovered:
|
||
|
|
self.fog_layer.set((x, y), mcrfpy.Color(0, 0, 0, 140))
|
||
|
|
# else: stays at 255 alpha (fully hidden)
|
||
|
|
|
||
|
|
# -- HUD ------------------------------------------------------------------
|
||
|
|
def _update_hud(self):
|
||
|
|
self.health_label.text = f"HP: {self.player_hp}/{self.player_max_hp}"
|
||
|
|
self.score_label.text = f"Score: {self.score}"
|
||
|
|
# Update heart sprites
|
||
|
|
for i, h in enumerate(self.hearts):
|
||
|
|
full = self.player_hp - i * 2
|
||
|
|
cap = self.player_max_hp - i * 2
|
||
|
|
if cap < 1:
|
||
|
|
h.sprite_index = 659 # invisible/blank
|
||
|
|
elif full >= 2:
|
||
|
|
h.sprite_index = HEART_FULL
|
||
|
|
elif full == 1:
|
||
|
|
h.sprite_index = HEART_HALF
|
||
|
|
else:
|
||
|
|
h.sprite_index = HEART_EMPTY
|
||
|
|
|
||
|
|
def _show_message(self, line1, line2=""):
|
||
|
|
self.msg_label.text = line1
|
||
|
|
self.msg_label2.text = line2
|
||
|
|
|
||
|
|
# -- Combat ---------------------------------------------------------------
|
||
|
|
def _attack_enemy(self, enemy_data):
|
||
|
|
dmg = max(1, self.player_atk - enemy_data["def"])
|
||
|
|
enemy_data["hp"] -= dmg
|
||
|
|
name = enemy_data["name"]
|
||
|
|
if enemy_data["hp"] <= 0:
|
||
|
|
self._show_message(
|
||
|
|
f"You slay the {name}! (+{10 * self.depth} pts)",
|
||
|
|
f"Hit for {dmg} damage - lethal!"
|
||
|
|
)
|
||
|
|
self.score += 10 * self.depth
|
||
|
|
# Remove from grid
|
||
|
|
for i in range(len(self.grid.entities)):
|
||
|
|
if self.grid.entities[i] is enemy_data["entity"]:
|
||
|
|
self.grid.entities.pop(i)
|
||
|
|
break
|
||
|
|
ex = int(enemy_data["entity"].grid_pos.x)
|
||
|
|
ey = int(enemy_data["entity"].grid_pos.y)
|
||
|
|
self.occupied.discard((ex, ey))
|
||
|
|
self.enemies.remove(enemy_data)
|
||
|
|
else:
|
||
|
|
self._show_message(
|
||
|
|
f"You hit the {name} for {dmg}!",
|
||
|
|
f"{name} HP: {enemy_data['hp']}/{enemy_data['max_hp']}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _enemy_attacks_player(self, enemy_data):
|
||
|
|
dmg = max(1, enemy_data["atk"] - self.player_def)
|
||
|
|
self.player_hp -= dmg
|
||
|
|
name = enemy_data["name"]
|
||
|
|
if self.player_hp <= 0:
|
||
|
|
self.player_hp = 0
|
||
|
|
self._update_hud()
|
||
|
|
self._game_over()
|
||
|
|
return
|
||
|
|
self._show_message(
|
||
|
|
f"The {name} hits you for {dmg}!",
|
||
|
|
f"HP: {self.player_hp}/{self.player_max_hp}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _game_over(self):
|
||
|
|
self.game_over = True
|
||
|
|
# Darken screen
|
||
|
|
overlay = mcrfpy.Frame(
|
||
|
|
pos=(0, 0), size=(1024, 768),
|
||
|
|
fill_color=(0, 0, 0, 180)
|
||
|
|
)
|
||
|
|
self.ui.append(overlay)
|
||
|
|
|
||
|
|
title = mcrfpy.Caption(
|
||
|
|
text="YOU DIED", pos=(340, 250), font=font,
|
||
|
|
fill_color=(200, 30, 30)
|
||
|
|
)
|
||
|
|
title.font_size = 60
|
||
|
|
title.outline = 4
|
||
|
|
title.outline_color = (0, 0, 0)
|
||
|
|
overlay.children.append(title)
|
||
|
|
|
||
|
|
info = mcrfpy.Caption(
|
||
|
|
text=f"Reached depth {self.depth} with {self.score} points",
|
||
|
|
pos=(280, 340), font=font,
|
||
|
|
fill_color=(200, 200, 200)
|
||
|
|
)
|
||
|
|
info.font_size = 22
|
||
|
|
info.outline = 2
|
||
|
|
info.outline_color = (0, 0, 0)
|
||
|
|
overlay.children.append(info)
|
||
|
|
|
||
|
|
restart = mcrfpy.Caption(
|
||
|
|
text="Press R to restart",
|
||
|
|
pos=(370, 400), font=font,
|
||
|
|
fill_color=(160, 200, 160)
|
||
|
|
)
|
||
|
|
restart.font_size = 20
|
||
|
|
restart.outline = 2
|
||
|
|
restart.outline_color = (0, 0, 0)
|
||
|
|
overlay.children.append(restart)
|
||
|
|
self.game_over_overlay = overlay
|
||
|
|
|
||
|
|
def _restart(self):
|
||
|
|
self.game_over = False
|
||
|
|
self.player_hp = MAX_HP
|
||
|
|
self.player_max_hp = MAX_HP
|
||
|
|
self.player_atk = 2
|
||
|
|
self.player_def = 0
|
||
|
|
self.depth = 1
|
||
|
|
self.score = 0
|
||
|
|
self.player = None
|
||
|
|
# Remove overlay
|
||
|
|
if hasattr(self, 'game_over_overlay'):
|
||
|
|
# Rebuild UI from scratch
|
||
|
|
while len(self.ui) > 0:
|
||
|
|
self.ui.pop(0)
|
||
|
|
self._build_ui()
|
||
|
|
self._new_level()
|
||
|
|
self.scene.on_key = self.on_key
|
||
|
|
|
||
|
|
# -- Items ----------------------------------------------------------------
|
||
|
|
def _check_items(self, px, py):
|
||
|
|
for item in self.items[:]:
|
||
|
|
ix = int(item["entity"].grid_pos.x)
|
||
|
|
iy = int(item["entity"].grid_pos.y)
|
||
|
|
if ix == px and iy == py:
|
||
|
|
if item["kind"] == "potion":
|
||
|
|
heal = random.randint(2, 4)
|
||
|
|
self.player_hp = min(self.player_max_hp, self.player_hp + heal)
|
||
|
|
self._show_message(
|
||
|
|
f"Healed {heal} HP!",
|
||
|
|
f"HP: {self.player_hp}/{self.player_max_hp}"
|
||
|
|
)
|
||
|
|
elif item["kind"] == "treasure":
|
||
|
|
pts = random.randint(5, 15) * self.depth
|
||
|
|
self.score += pts
|
||
|
|
self._show_message(
|
||
|
|
f"Found treasure! (+{pts} pts)",
|
||
|
|
f"Total score: {self.score}"
|
||
|
|
)
|
||
|
|
# Remove item entity
|
||
|
|
for i in range(len(self.grid.entities)):
|
||
|
|
if self.grid.entities[i] is item["entity"]:
|
||
|
|
self.grid.entities.pop(i)
|
||
|
|
break
|
||
|
|
self.occupied.discard((ix, iy))
|
||
|
|
self.items.remove(item)
|
||
|
|
|
||
|
|
# -- Enemy AI (simple: move toward player if visible) ---------------------
|
||
|
|
def _enemy_turn(self):
|
||
|
|
px = int(self.player.grid_pos.x)
|
||
|
|
py = int(self.player.grid_pos.y)
|
||
|
|
|
||
|
|
for edata in self.enemies[:]:
|
||
|
|
ex = int(edata["entity"].grid_pos.x)
|
||
|
|
ey = int(edata["entity"].grid_pos.y)
|
||
|
|
|
||
|
|
# Only act if in FOV (player can see them)
|
||
|
|
if not self.grid.is_in_fov((ex, ey)):
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Manhattan distance
|
||
|
|
dist = abs(ex - px) + abs(ey - py)
|
||
|
|
if dist > ENEMY_SIGHT:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Adjacent? Attack!
|
||
|
|
if dist == 1:
|
||
|
|
self._enemy_attacks_player(edata)
|
||
|
|
if self.game_over:
|
||
|
|
return
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Move toward player (simple greedy)
|
||
|
|
dx = 0
|
||
|
|
dy = 0
|
||
|
|
if abs(ex - px) > abs(ey - py):
|
||
|
|
dx = 1 if px > ex else -1
|
||
|
|
else:
|
||
|
|
dy = 1 if py > ey else -1
|
||
|
|
|
||
|
|
nx, ny = ex + dx, ey + dy
|
||
|
|
|
||
|
|
# Check bounds and walkability
|
||
|
|
if (0 <= nx < MAP_W and 0 <= ny < MAP_H
|
||
|
|
and self.grid.at((nx, ny)).walkable
|
||
|
|
and (nx, ny) not in self.occupied):
|
||
|
|
self.occupied.discard((ex, ey))
|
||
|
|
edata["entity"].grid_pos = (nx, ny)
|
||
|
|
self.occupied.add((nx, ny))
|
||
|
|
|
||
|
|
# -- Input ----------------------------------------------------------------
|
||
|
|
def on_key(self, key, state):
|
||
|
|
if state != mcrfpy.InputState.PRESSED:
|
||
|
|
return
|
||
|
|
|
||
|
|
if self.game_over:
|
||
|
|
if key == mcrfpy.Key.R:
|
||
|
|
self._restart()
|
||
|
|
return
|
||
|
|
|
||
|
|
dx, dy = 0, 0
|
||
|
|
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
|
||
|
|
dy = -1
|
||
|
|
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
|
||
|
|
dy = 1
|
||
|
|
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
|
||
|
|
dx = -1
|
||
|
|
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
|
||
|
|
dx = 1
|
||
|
|
elif key == mcrfpy.Key.PERIOD:
|
||
|
|
# Wait a turn
|
||
|
|
self._enemy_turn()
|
||
|
|
self._update_fov()
|
||
|
|
self._update_hud()
|
||
|
|
return
|
||
|
|
else:
|
||
|
|
return
|
||
|
|
|
||
|
|
if dx == 0 and dy == 0:
|
||
|
|
return
|
||
|
|
|
||
|
|
px = int(self.player.grid_pos.x)
|
||
|
|
py = int(self.player.grid_pos.y)
|
||
|
|
nx, ny = px + dx, py + dy
|
||
|
|
|
||
|
|
# Bounds check
|
||
|
|
if nx < 0 or nx >= MAP_W or ny < 0 or ny >= MAP_H:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check for enemy at target
|
||
|
|
for edata in self.enemies:
|
||
|
|
ex = int(edata["entity"].grid_pos.x)
|
||
|
|
ey = int(edata["entity"].grid_pos.y)
|
||
|
|
if ex == nx and ey == ny:
|
||
|
|
self._attack_enemy(edata)
|
||
|
|
self._enemy_turn()
|
||
|
|
self._update_fov()
|
||
|
|
self._center_camera()
|
||
|
|
self._update_hud()
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check walkability
|
||
|
|
if not self.grid.at((nx, ny)).walkable:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Move player
|
||
|
|
self.occupied.discard((px, py))
|
||
|
|
self.player.grid_pos = (nx, ny)
|
||
|
|
self.occupied.add((nx, ny))
|
||
|
|
|
||
|
|
# Check stairs
|
||
|
|
if (nx, ny) == self.stairs_pos:
|
||
|
|
self.depth += 1
|
||
|
|
self._show_message(
|
||
|
|
f"Descending to depth {self.depth}...",
|
||
|
|
"The dungeon grows more dangerous."
|
||
|
|
)
|
||
|
|
self._new_level()
|
||
|
|
self._enemy_turn()
|
||
|
|
self._update_fov()
|
||
|
|
self._center_camera()
|
||
|
|
self._update_hud()
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check items
|
||
|
|
self._check_items(nx, ny)
|
||
|
|
|
||
|
|
# Enemy turn
|
||
|
|
self._enemy_turn()
|
||
|
|
if not self.game_over:
|
||
|
|
self._update_fov()
|
||
|
|
self._center_camera()
|
||
|
|
self._update_hud()
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Title screen
|
||
|
|
# =============================================================================
|
||
|
|
class TitleScreen:
|
||
|
|
def __init__(self):
|
||
|
|
self.scene = mcrfpy.Scene("title")
|
||
|
|
ui = self.scene.children
|
||
|
|
|
||
|
|
# Dark background
|
||
|
|
bg = mcrfpy.Frame(
|
||
|
|
pos=(0, 0), size=(1024, 768),
|
||
|
|
fill_color=(12, 10, 20)
|
||
|
|
)
|
||
|
|
ui.append(bg)
|
||
|
|
|
||
|
|
# Title
|
||
|
|
title = mcrfpy.Caption(
|
||
|
|
text="McRogueFace", pos=(240, 140), font=font,
|
||
|
|
fill_color=(220, 60, 60)
|
||
|
|
)
|
||
|
|
title.font_size = 72
|
||
|
|
title.outline = 4
|
||
|
|
title.outline_color = (0, 0, 0)
|
||
|
|
bg.children.append(title)
|
||
|
|
|
||
|
|
# Subtitle
|
||
|
|
sub = mcrfpy.Caption(
|
||
|
|
text="A Python-Powered Roguelike Engine",
|
||
|
|
pos=(270, 240), font=font,
|
||
|
|
fill_color=(160, 160, 200)
|
||
|
|
)
|
||
|
|
sub.font_size = 22
|
||
|
|
sub.outline = 2
|
||
|
|
sub.outline_color = (0, 0, 0)
|
||
|
|
bg.children.append(sub)
|
||
|
|
|
||
|
|
# Features list
|
||
|
|
features = [
|
||
|
|
"BSP dungeon generation",
|
||
|
|
"Wang tile autotiling",
|
||
|
|
"Field of view & fog of war",
|
||
|
|
"Turn-based combat",
|
||
|
|
"Entity system with Python scripting",
|
||
|
|
]
|
||
|
|
for i, feat in enumerate(features):
|
||
|
|
dot = mcrfpy.Caption(
|
||
|
|
text=f" {feat}",
|
||
|
|
pos=(320, 320 + i * 32), font=font,
|
||
|
|
fill_color=(140, 180, 140)
|
||
|
|
)
|
||
|
|
dot.font_size = 16
|
||
|
|
dot.outline = 1
|
||
|
|
dot.outline_color = (0, 0, 0)
|
||
|
|
bg.children.append(dot)
|
||
|
|
|
||
|
|
# Start prompt
|
||
|
|
start = mcrfpy.Caption(
|
||
|
|
text="Press ENTER or SPACE to begin",
|
||
|
|
pos=(310, 540), font=font,
|
||
|
|
fill_color=(200, 200, 100)
|
||
|
|
)
|
||
|
|
start.font_size = 20
|
||
|
|
start.outline = 2
|
||
|
|
start.outline_color = (0, 0, 0)
|
||
|
|
bg.children.append(start)
|
||
|
|
|
||
|
|
# Animate the start prompt
|
||
|
|
self._blink_visible = True
|
||
|
|
self._start_caption = start
|
||
|
|
|
||
|
|
# Controls hint
|
||
|
|
controls = mcrfpy.Caption(
|
||
|
|
text="Controls: Arrow keys / WASD to move, . to wait, R to restart",
|
||
|
|
pos=(200, 600), font=font,
|
||
|
|
fill_color=(100, 100, 130)
|
||
|
|
)
|
||
|
|
controls.font_size = 14
|
||
|
|
controls.outline = 1
|
||
|
|
controls.outline_color = (0, 0, 0)
|
||
|
|
bg.children.append(controls)
|
||
|
|
|
||
|
|
# Version info
|
||
|
|
ver = mcrfpy.Caption(
|
||
|
|
text="Built with McRogueFace - C++ engine, Python gameplay",
|
||
|
|
pos=(250, 700), font=font,
|
||
|
|
fill_color=(60, 60, 80)
|
||
|
|
)
|
||
|
|
ver.font_size = 12
|
||
|
|
bg.children.append(ver)
|
||
|
|
|
||
|
|
self.scene.on_key = self.on_key
|
||
|
|
self.scene.activate()
|
||
|
|
|
||
|
|
# Blink timer for "Press ENTER"
|
||
|
|
self.blink_timer = mcrfpy.Timer("blink", self._blink, 600)
|
||
|
|
|
||
|
|
def _blink(self, timer, runtime):
|
||
|
|
self._blink_visible = not self._blink_visible
|
||
|
|
if self._blink_visible:
|
||
|
|
self._start_caption.fill_color = mcrfpy.Color(200, 200, 100)
|
||
|
|
else:
|
||
|
|
self._start_caption.fill_color = mcrfpy.Color(200, 200, 100, 60)
|
||
|
|
|
||
|
|
def on_key(self, key, state):
|
||
|
|
if state != mcrfpy.InputState.PRESSED:
|
||
|
|
return
|
||
|
|
if key in (mcrfpy.Key.ENTER, mcrfpy.Key.SPACE):
|
||
|
|
self.blink_timer.stop()
|
||
|
|
Game()
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Entry point
|
||
|
|
# =============================================================================
|
||
|
|
title = TitleScreen()
|