McRogueFace/src/scripts_demo/game.py

803 lines
27 KiB
Python
Raw Normal View History

"""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()