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 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-10 00:41:57 -04:00
commit d73a207535
3 changed files with 1237 additions and 2 deletions

View file

@ -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=$<IF:$<BOOL:${MCRF_PLAYGROUND}>,${MCRF_SCRIPTS_PLAYGROUND_DIR},${MCRF_SCRIPTS_DIR}>@/scripts
# Preload game scripts into /scripts (playground, demo, or full game)
--preload-file=$<IF:$<BOOL:${MCRF_PLAYGROUND}>,${MCRF_SCRIPTS_PLAYGROUND_DIR},$<IF:$<BOOL:${MCRF_DEMO}>,${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)

803
src/scripts_demo/game.py Normal file
View file

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

428
web/index.html Normal file
View file

@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>McRogueFace - Web Demo</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-dark: #0c0a14;
--bg-card: #16132a;
--bg-surface: #1e1a36;
--accent: #e94560;
--accent-glow: #e9456040;
--text: #e8e6f0;
--text-dim: #8886a0;
--text-muted: #5a587a;
--gold: #f0c040;
--green: #40d080;
}
html, body {
width: 100%;
min-height: 100vh;
background: var(--bg-dark);
color: var(--text);
font-family: 'JetBrains Mono', 'Courier New', monospace;
overflow-x: hidden;
}
.hero {
text-align: center;
padding: 40px 20px 20px;
background: linear-gradient(180deg, #14102a 0%, var(--bg-dark) 100%);
}
.hero h1 {
font-size: 3rem;
font-weight: 700;
color: var(--accent);
text-shadow: 0 0 40px var(--accent-glow);
margin-bottom: 8px;
letter-spacing: 2px;
}
.hero .subtitle {
font-size: 1.1rem;
color: var(--text-dim);
margin-bottom: 16px;
}
.hero .tagline {
font-size: 0.85rem;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto 20px;
line-height: 1.5;
}
.canvas-wrapper {
display: flex;
justify-content: center;
padding: 0 20px 30px;
}
.canvas-frame {
position: relative;
background: #000;
border: 2px solid #2a2648;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6), 0 0 60px var(--accent-glow);
/* fixed aspect ratio container for 1024x768 */
width: min(1024px, calc(100vw - 40px));
aspect-ratio: 1024 / 768;
}
#canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
outline: none;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(12, 10, 20, 0.95);
z-index: 10;
transition: opacity 0.5s ease;
}
.loading-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #2a2648;
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#status {
margin-top: 16px;
font-size: 0.85rem;
color: var(--text-dim);
}
.info-section {
max-width: 1024px;
margin: 0 auto;
padding: 30px 20px 50px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
@media (max-width: 700px) {
.info-grid { grid-template-columns: 1fr; }
.hero h1 { font-size: 2rem; }
}
.info-card {
background: var(--bg-card);
border: 1px solid #2a2648;
border-radius: 8px;
padding: 20px;
}
.info-card h3 {
color: var(--gold);
font-size: 1rem;
margin-bottom: 10px;
}
.info-card p, .info-card ul {
font-size: 0.8rem;
color: var(--text-dim);
line-height: 1.6;
}
.info-card ul {
list-style: none;
padding: 0;
}
.info-card ul li::before {
content: "> ";
color: var(--green);
}
.links {
text-align: center;
padding: 20px;
}
.links a {
display: inline-block;
margin: 0 12px;
padding: 10px 24px;
background: var(--bg-surface);
color: var(--text);
text-decoration: none;
border: 1px solid #2a2648;
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.links a:hover {
border-color: var(--accent);
box-shadow: 0 0 20px var(--accent-glow);
}
.footer {
text-align: center;
padding: 20px;
font-size: 0.75rem;
color: var(--text-muted);
border-top: 1px solid #1a1830;
}
.click-to-focus {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(12, 10, 20, 0.7);
z-index: 5;
cursor: pointer;
transition: opacity 0.3s ease;
}
.click-to-focus.hidden {
opacity: 0;
pointer-events: none;
}
.click-to-focus span {
font-size: 1.1rem;
color: var(--text);
padding: 12px 24px;
background: var(--bg-surface);
border: 1px solid var(--accent);
border-radius: 6px;
}
</style>
</head>
<body>
<div class="hero">
<h1>McRogueFace</h1>
<p class="subtitle">A Python-Powered Roguelike Engine</p>
<p class="tagline">
C++ engine with Python scripting, compiled to WebAssembly.
BSP dungeons, Wang tile autotiling, field of view, and turn-based combat
&mdash; all running in your browser.
</p>
</div>
<div class="canvas-wrapper">
<div class="canvas-frame">
<canvas id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
<div class="loading-overlay" id="loading">
<div class="spinner"></div>
<p id="status">Loading...</p>
</div>
<div class="click-to-focus hidden" id="focus-prompt">
<span>Click to play</span>
</div>
</div>
</div>
<div class="info-section">
<div class="info-grid">
<div class="info-card">
<h3>Controls</h3>
<ul>
<li>Arrow keys or WASD to move</li>
<li>Bump into enemies to attack</li>
<li>Walk over potions to heal</li>
<li>Find stairs to descend deeper</li>
<li>Period (.) to wait a turn</li>
<li>R to restart after death</li>
</ul>
</div>
<div class="info-card">
<h3>Engine Features</h3>
<ul>
<li>BSP dungeon generation (libtcod)</li>
<li>Wang tile autotiling (Tiled .tsx)</li>
<li>Field of view &amp; fog of war</li>
<li>Entity/Grid system with layers</li>
<li>Python 3.14 scripting (full interpreter)</li>
<li>SDL2 + OpenGL ES 2 rendering</li>
</ul>
</div>
<div class="info-card">
<h3>About This Demo</h3>
<p>
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 &mdash; the engine
handles rendering, input, and the tile/entity system.
</p>
</div>
<div class="info-card">
<h3>Tech Stack</h3>
<ul>
<li>C++17 game engine core</li>
<li>Python 3.14 (cross-compiled to WASM)</li>
<li>SDL2 + OpenGL ES 2 (via Emscripten)</li>
<li>libtcod (BSP, FOV, pathfinding)</li>
<li>Kenney Tiny Dungeon tileset</li>
<li>~16 MB total download</li>
</ul>
</div>
</div>
<div class="links">
<a href="https://github.com/jmccardle/McRogueFace">GitHub</a>
<a href="https://gamedev.ffwf.net/gitea/john/McRogueFace">Gitea</a>
<a href="https://gamedev.ffwf.net/gitea/john/McRogueFace/wiki">Documentation</a>
</div>
</div>
<div class="footer">
McRogueFace &mdash; Created for 7DRL 2023, actively developed.
Engine by John McCardle. Tiles by Kenney.nl.
</div>
<script>
var canvasElement = document.getElementById('canvas');
var loadingElement = document.getElementById('loading');
var statusElement = document.getElementById('status');
var focusPrompt = document.getElementById('focus-prompt');
var runtimeReady = false;
var hasFocus = false;
function updateCanvasSize() {
// Match canvas backing buffer to its display size
var rect = canvasElement.getBoundingClientRect();
var w = Math.floor(rect.width);
var h = Math.floor(rect.height);
if (runtimeReady) {
Module.ccall('notify_canvas_resize', null, ['number', 'number'], [w, h]);
} else {
canvasElement.width = w;
canvasElement.height = h;
}
}
updateCanvasSize();
window.addEventListener('resize', updateCanvasSize);
// Focus management
canvasElement.addEventListener('click', function() {
canvasElement.focus();
hasFocus = true;
focusPrompt.classList.add('hidden');
});
canvasElement.addEventListener('mousedown', function() {
if (document.activeElement !== canvasElement) {
canvasElement.focus();
hasFocus = true;
focusPrompt.classList.add('hidden');
}
});
canvasElement.addEventListener('blur', function() {
if (runtimeReady) {
hasFocus = false;
focusPrompt.classList.remove('hidden');
}
});
focusPrompt.addEventListener('click', function() {
canvasElement.focus();
hasFocus = true;
focusPrompt.classList.add('hidden');
});
var Module = {
preRun: [function() {
FS.mkdir('/save');
FS.mount(IDBFS, {}, '/save');
Module.addRunDependency('idbfs-restore');
FS.syncfs(true, function(err) {
if (err) console.error('Failed to restore /save/:', err);
Module.removeRunDependency('idbfs-restore');
});
}],
print: function(text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
console.log(text);
},
printErr: function(text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
console.error(text);
},
canvas: canvasElement,
setStatus: function(text) {
if (!Module.setStatus.last) Module.setStatus.last = { time: Date.now(), text: '' };
if (text === Module.setStatus.last.text) return;
var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
var now = Date.now();
if (m && now - Module.setStatus.last.time < 30) return;
Module.setStatus.last.time = now;
Module.setStatus.last.text = text;
if (m) text = m[1];
statusElement.textContent = text;
},
totalDependencies: 0,
monitorRunDependencies: function(left) {
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(left
? 'Preparing... (' + (this.totalDependencies - left) + '/' + this.totalDependencies + ')'
: 'All downloads complete.');
},
onRuntimeInitialized: function() {
runtimeReady = true;
loadingElement.classList.add('hidden');
canvasElement.focus();
hasFocus = true;
window.dispatchEvent(new Event('resize'));
}
};
Module.setStatus('Downloading...');
window.onerror = function(event) {
Module.setStatus('Error! See browser console.');
};
// Emscripten symbol resolver shim
if (typeof resolveGlobalSymbol === 'undefined') {
window.resolveGlobalSymbol = function(name, direct) {
return {
sym: Module['_' + name] || Module[name],
type: 'function'
};
};
}
</script>
<script async src="mcrogueface.js"></script>
</body>
</html>