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:
parent
c332772324
commit
d73a207535
3 changed files with 1237 additions and 2 deletions
|
|
@ -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)
|
# Playground mode - minimal scripts for web playground (REPL-focused)
|
||||||
option(MCRF_PLAYGROUND "Build with minimal playground scripts instead of full game" OFF)
|
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)
|
# 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)
|
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_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_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_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)
|
# Emscripten-specific link options (use ports for zlib, bzip2, sqlite3)
|
||||||
if(EMSCRIPTEN)
|
if(EMSCRIPTEN)
|
||||||
|
|
@ -365,8 +369,8 @@ if(EMSCRIPTEN)
|
||||||
-sALLOW_UNIMPLEMENTED_SYSCALLS=1
|
-sALLOW_UNIMPLEMENTED_SYSCALLS=1
|
||||||
# Preload Python stdlib into virtual filesystem at /lib/python3.14
|
# Preload Python stdlib into virtual filesystem at /lib/python3.14
|
||||||
--preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib
|
--preload-file=${CMAKE_SOURCE_DIR}/wasm_stdlib/lib@/lib
|
||||||
# Preload game scripts into /scripts (use playground scripts if MCRF_PLAYGROUND is set)
|
# Preload game scripts into /scripts (playground, demo, or full game)
|
||||||
--preload-file=$<IF:$<BOOL:${MCRF_PLAYGROUND}>,${MCRF_SCRIPTS_PLAYGROUND_DIR},${MCRF_SCRIPTS_DIR}>@/scripts
|
--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 assets
|
||||||
--preload-file=${MCRF_ASSETS_DIR}@/assets
|
--preload-file=${MCRF_ASSETS_DIR}@/assets
|
||||||
# Use custom HTML shell - game shell (fullscreen) or playground shell (REPL)
|
# Use custom HTML shell - game shell (fullscreen) or playground shell (REPL)
|
||||||
|
|
|
||||||
803
src/scripts_demo/game.py
Normal file
803
src/scripts_demo/game.py
Normal 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
428
web/index.html
Normal 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
|
||||||
|
— 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 & 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 — 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 — 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue