From d73a2075352ce1e2f7d7d51bbe2d2210ed570eac Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 10 Apr 2026 00:41:57 -0400 Subject: [PATCH] Add web-playable WASM demo with BSP dungeon crawler - Create self-contained demo game script (src/scripts_demo/game.py) showcasing: BSP dungeon generation, Wang tile autotiling, FOV with fog of war, turn-based bump combat, enemy AI, items/treasure, title screen - Add MCRF_DEMO CMake option for building with demo scripts - Add web/index.html landing page with dark theme, controls reference, feature list, and links to GitHub/Gitea - Build with: emcmake cmake -DMCRF_SDL2=ON -DMCRF_DEMO=ON -DMCRF_GAME_SHELL=ON Note: Makefile wasm-demo/serve-demo targets also added locally but Makefile is gitignored. Use CMake directly or force-add the Makefile to track it. Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 8 +- src/scripts_demo/game.py | 803 +++++++++++++++++++++++++++++++++++++++ web/index.html | 428 +++++++++++++++++++++ 3 files changed, 1237 insertions(+), 2 deletions(-) create mode 100644 src/scripts_demo/game.py create mode 100644 web/index.html diff --git a/CMakeLists.txt b/CMakeLists.txt index c00a047..326c181 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,9 @@ option(MCRF_SDL2 "Build with SDL2+OpenGL ES 2 backend instead of SFML" OFF) # Playground mode - minimal scripts for web playground (REPL-focused) option(MCRF_PLAYGROUND "Build with minimal playground scripts instead of full game" OFF) +# Demo mode - self-contained demo game for web showcase +option(MCRF_DEMO "Build with demo scripts (web showcase)" OFF) + # Game shell mode - fullscreen canvas, no REPL chrome (for itch.io / standalone web games) option(MCRF_GAME_SHELL "Use minimal game-only HTML shell (no REPL)" OFF) @@ -343,6 +346,7 @@ endif() set(MCRF_ASSETS_DIR "${CMAKE_SOURCE_DIR}/assets" CACHE PATH "Assets directory for WASM preloading") set(MCRF_SCRIPTS_DIR "${CMAKE_SOURCE_DIR}/src/scripts" CACHE PATH "Scripts directory for WASM preloading") set(MCRF_SCRIPTS_PLAYGROUND_DIR "${CMAKE_SOURCE_DIR}/src/scripts_playground" CACHE PATH "Playground scripts for WASM") +set(MCRF_SCRIPTS_DEMO_DIR "${CMAKE_SOURCE_DIR}/src/scripts_demo" CACHE PATH "Demo scripts for WASM showcase") # Emscripten-specific link options (use ports for zlib, bzip2, sqlite3) if(EMSCRIPTEN) @@ -365,8 +369,8 @@ if(EMSCRIPTEN) -sALLOW_UNIMPLEMENTED_SYSCALLS=1 # Preload Python stdlib into virtual filesystem at /lib/python3.14 --preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib - # Preload game scripts into /scripts (use playground scripts if MCRF_PLAYGROUND is set) - --preload-file=$,${MCRF_SCRIPTS_PLAYGROUND_DIR},${MCRF_SCRIPTS_DIR}>@/scripts + # Preload game scripts into /scripts (playground, demo, or full game) + --preload-file=$,${MCRF_SCRIPTS_PLAYGROUND_DIR},$,${MCRF_SCRIPTS_DEMO_DIR},${MCRF_SCRIPTS_DIR}>>@/scripts # Preload assets --preload-file=${MCRF_ASSETS_DIR}@/assets # Use custom HTML shell - game shell (fullscreen) or playground shell (REPL) diff --git a/src/scripts_demo/game.py b/src/scripts_demo/game.py new file mode 100644 index 0000000..5db32a3 --- /dev/null +++ b/src/scripts_demo/game.py @@ -0,0 +1,803 @@ +"""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() diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e4b66fe --- /dev/null +++ b/web/index.html @@ -0,0 +1,428 @@ + + + + + + McRogueFace - Web Demo + + + + +
+

McRogueFace

+

A Python-Powered Roguelike Engine

+

+ C++ engine with Python scripting, compiled to WebAssembly. + BSP dungeons, Wang tile autotiling, field of view, and turn-based combat + — all running in your browser. +

+
+ +
+
+ +
+
+

Loading...

+
+ +
+
+ +
+
+
+

Controls

+
    +
  • Arrow keys or WASD to move
  • +
  • Bump into enemies to attack
  • +
  • Walk over potions to heal
  • +
  • Find stairs to descend deeper
  • +
  • Period (.) to wait a turn
  • +
  • R to restart after death
  • +
+
+
+

Engine Features

+
    +
  • BSP dungeon generation (libtcod)
  • +
  • Wang tile autotiling (Tiled .tsx)
  • +
  • Field of view & fog of war
  • +
  • Entity/Grid system with layers
  • +
  • Python 3.14 scripting (full interpreter)
  • +
  • SDL2 + OpenGL ES 2 rendering
  • +
+
+
+

About This Demo

+

+ This entire game is written in Python, running on the McRogueFace + C++ engine compiled to WebAssembly via Emscripten. The game logic, + dungeon generation, AI, and UI are all Python — the engine + handles rendering, input, and the tile/entity system. +

+
+
+

Tech Stack

+
    +
  • C++17 game engine core
  • +
  • Python 3.14 (cross-compiled to WASM)
  • +
  • SDL2 + OpenGL ES 2 (via Emscripten)
  • +
  • libtcod (BSP, FOV, pathfinding)
  • +
  • Kenney Tiny Dungeon tileset
  • +
  • ~16 MB total download
  • +
+
+
+ + +
+ + + + + + + +