feat(game): REIKHELM: DESCENT — playable first-person roguelike

The pieces finally meet: procgen dungeons + the baked EoB atlas pipeline +
the Vigor combat engine become one game. Three fixed pre-baked floors
(seeds 7 → 69 → 59, 208 frames), monsters/loot/encounters rerolled per run
and composited over the empty scenes; the Ember Sovereign waits at the
bottom.

- game-wasm: run state machine in Rust (manifest nav graph as dungeon
  truth, seeded spawning by theme/depth, embedded combat-core Encounter,
  vigor/fatigue persistence between fights, rest/potions/relics/flee,
  permadeath + victory). 8 native tests incl. a whole-run bot and a
  ScriptedPlayer boss-winnability probe used for tuning.
- game-web: no-framework front-end — title/death/victory screens, baked
  frames with screen-blend monster portraits, combat overlay (dual vigor
  bars, cast telegraphs, cooldown sweeps, floaters), minimap with fog,
  all SFX synthesized in WebAudio.
- balance: floor-scaled levels with a hero's edge over trash, no packs on
  floor 1, boss tuned by sim sweep (~35%/55% win at skill 50/80).
- campaign atlases now versioned (tools/.gitignore exception); boss
  portrait added to the Z-Image roster.

Verified end-to-end in-browser: an autopilot beat the full game through
the real UI (10 fights, 2 flees, Sovereign down), and died plenty before
that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-06-11 23:30:50 -06:00
parent 57dba5e149
commit 5813ee3b5a
239 changed files with 3843 additions and 3 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
# WASM build output (regenerated by wasm-pack) # WASM build output (regenerated by wasm-pack)
reikhelm-web/pkg/ reikhelm-web/pkg/
game-web/pkg/
# Playwright MCP artifacts + iteration screenshots (scratch, not source) # Playwright MCP artifacts + iteration screenshots (scratch, not source)
.playwright-mcp/ .playwright-mcp/

19
Cargo.lock generated
View file

@ -56,6 +56,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combat-core"
version = "0.1.0"
dependencies = [
"rand_chacha",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@ -106,6 +115,16 @@ dependencies = [
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "game-wasm"
version = "0.1.0"
dependencies = [
"combat-core",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]] [[package]]
name = "glam" name = "glam"
version = "0.27.0" version = "0.27.0"

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["reikhelm-core", "reikhelm-viz", "reikhelm-wasm"] members = ["reikhelm-core", "reikhelm-viz", "reikhelm-wasm", "game-wasm"]
# A bare `cargo build`/`test`/`clippy` at the root operates only on the # A bare `cargo build`/`test`/`clippy` at the root operates only on the
# host-native crates; `reikhelm-wasm` is built deliberately for wasm32 via # host-native crates; `reikhelm-wasm` is built deliberately for wasm32 via
# wasm-pack, so it stays out of the default host set (but remains a member so it # wasm-pack, so it stays out of the default host set (but remains a member so it

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

View file

@ -58,6 +58,9 @@ ROSTER = {
"skeletal grasping hands reaching forward, incorporeal and dreadful", "skeletal grasping hands reaching forward, incorporeal and dreadful",
"battlemage": "a gaunt dark sorcerer, deep hooded robe, sunken shadowed eyes, crackling violet " "battlemage": "a gaunt dark sorcerer, deep hooded robe, sunken shadowed eyes, crackling violet "
"arcane lightning coiling around raised bony hands, glowing runic sigils, malice", "arcane lightning coiling around raised bony hands, glowing runic sigils, malice",
"boss": "an ancient undying sorcerer-king, a cracked obsidian crown fused to his brow, "
"ember-orange fire glowing through fissures in his grey withered skin, scorched regal "
"robes with molten gold trim, eyes of living flame, terrible majesty, the Ember Sovereign",
} }

17
game-wasm/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "game-wasm"
version = "0.1.0"
edition = "2021"
rust-version = "1.96"
description = "REIKHELM: DESCENT — the run state machine gluing the dungeon atlas to the Vigor combat engine, exposed to the browser via wasm-bindgen."
# `cdylib` is what wasm-pack/wasm-bindgen emit; `rlib` lets host tooling
# (cargo test/clippy) link the crate too — the whole game loop is testable natively.
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
combat-core = { path = "../combat/combat-core" }
wasm-bindgen = "0.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"

112
game-wasm/src/lib.rs Normal file
View file

@ -0,0 +1,112 @@
//! `game-wasm` — REIKHELM: DESCENT's browser bridge.
//!
//! The run logic lives in [`run::GameRun`] (pure Rust, natively tested); this
//! crate face is a thin `wasm-bindgen` wrapper in the same style as
//! `combat-wasm`: JSON strings in and out, every mutating call returning the
//! fresh state payload so the front-end re-renders from one source of truth.
pub mod manifest;
pub mod monster_ai;
pub mod run;
pub mod spawn;
pub mod views;
use run::GameRun;
use wasm_bindgen::prelude::*;
/// A live run the browser drives.
#[wasm_bindgen]
pub struct Game {
run: GameRun,
}
#[wasm_bindgen]
impl Game {
/// Start a run: `manifests_json` is a JSON array of atlas manifests in
/// floor order (fetched by the page from /out/atlas/<seed>/manifest.json),
/// `archetype` the player stat-block id, `seed` the run seed.
#[wasm_bindgen(constructor)]
pub fn new(
config_json: &str,
manifests_json: &str,
archetype: &str,
seed: u64,
) -> Result<Game, JsValue> {
GameRun::new(config_json, manifests_json, archetype, seed)
.map(|run| Game { run })
.map_err(|e| JsValue::from_str(&e))
}
/// Current state without advancing (initial render).
pub fn state(&mut self) -> String {
self.run.state_json()
}
/// Rotate facing by `delta` quarter-turns (+1 = right).
pub fn turn(&mut self, delta: i32) -> String {
self.run.turn(delta);
self.run.state_json()
}
/// Step to the adjacent room ahead (or behind when `forward` is false).
pub fn step(&mut self, forward: bool) -> String {
self.run.step(forward);
self.run.state_json()
}
/// Take the stairs down (only at the exit room, in explore mode).
pub fn descend(&mut self) -> String {
self.run.descend();
self.run.state_json()
}
/// Catch your breath without moving (hold to keep resting).
pub fn rest(&mut self) -> String {
self.run.rest();
self.run.state_json()
}
/// Drink a potion: `kind` is `"draught"` or `"tonic"`.
#[wasm_bindgen(js_name = useItem)]
pub fn use_item(&mut self, kind: &str) -> String {
self.run.use_item(kind);
self.run.state_json()
}
/// Advance the battle one tick (call at the config tick rate).
#[wasm_bindgen(js_name = combatTick)]
pub fn combat_tick(&mut self) -> String {
self.run.combat_tick();
self.run.state_json()
}
/// Queue an ability for the next combat decision. `target < 0` picks a
/// sensible default.
#[wasm_bindgen(js_name = useAbility)]
pub fn use_ability(&mut self, ability_id: &str, target: i32) {
self.run.use_ability(ability_id, target);
}
/// Toggle the passive swing.
#[wasm_bindgen(js_name = setAutoAttack)]
pub fn set_auto_attack(&mut self, on: bool) {
self.run.set_auto_attack(on);
}
/// Focus a specific enemy actor id.
#[wasm_bindgen(js_name = setTarget)]
pub fn set_target(&mut self, target: u32) {
self.run.set_target(target);
}
/// Break off the current fight (costs fatigue, returns to the previous room).
pub fn flee(&mut self) -> String {
self.run.flee();
self.run.state_json()
}
/// The player's slotted abilities (action bar), as JSON.
pub fn abilities(&self) -> String {
views::abilities_json(&self.run)
}
}

131
game-wasm/src/manifest.rs Normal file
View file

@ -0,0 +1,131 @@
//! Parse the atlas manifest the browser baked (`tools/out/atlas/<seed>/manifest.json`).
//!
//! The manifest is the game's *only* source of dungeon truth: it was written at
//! bake time by `explore.js::dungeonManifest`, so the nav graph here is exactly
//! the graph the pre-rendered frames depict. The game layer never re-runs
//! generation — if it did, a drifted config could disagree with the baked art.
use serde::Deserialize;
use std::collections::HashMap;
/// A cardinal facing. Order matches the JS explorer (`['N','E','S','W']`) so
/// turn arithmetic is index rotation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Dir {
N,
E,
S,
W,
}
pub const DIRS: [Dir; 4] = [Dir::N, Dir::E, Dir::S, Dir::W];
impl Dir {
pub fn index(self) -> usize {
DIRS.iter().position(|d| *d == self).unwrap()
}
pub fn turned(self, delta: i32) -> Dir {
let i = (self.index() as i32 + delta).rem_euclid(4) as usize;
DIRS[i]
}
pub fn opposite(self) -> Dir {
self.turned(2)
}
pub fn letter(self) -> &'static str {
match self {
Dir::N => "N",
Dir::E => "E",
Dir::S => "S",
Dir::W => "W",
}
}
}
/// Per-direction values as the manifest stores them (`{"N": .., "E": ..}`).
#[derive(Deserialize, Clone, Debug, Default)]
pub struct ByDir<T> {
#[serde(rename = "N")]
pub n: T,
#[serde(rename = "E")]
pub e: T,
#[serde(rename = "S")]
pub s: T,
#[serde(rename = "W")]
pub w: T,
}
impl<T: Copy> ByDir<T> {
pub fn get(&self, d: Dir) -> T {
match d {
Dir::N => self.n,
Dir::E => self.e,
Dir::S => self.s,
Dir::W => self.w,
}
}
}
/// One room node of the baked nav graph. Ids are region indices — sparse, and
/// also the atlas frame key (`r{id}_{dir}.png`).
#[derive(Deserialize, Clone, Debug)]
pub struct Room {
pub id: usize,
#[serde(default)]
pub theme: Option<String>,
pub nav: ByDir<Option<usize>>,
pub open: ByDir<bool>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Manifest {
pub seed: u64,
#[serde(rename = "startRoom")]
pub start_room: usize,
pub rooms: Vec<Room>,
}
impl Manifest {
pub fn parse(json: &str) -> Result<Manifest, String> {
serde_json::from_str(json).map_err(|e| format!("bad manifest json: {e}"))
}
pub fn by_id(&self) -> HashMap<usize, &Room> {
self.rooms.iter().map(|r| (r.id, r)).collect()
}
/// BFS distances over the nav graph from `from`. Unreachable rooms are absent.
pub fn distances(&self, from: usize) -> HashMap<usize, u32> {
let by_id = self.by_id();
let mut dist = HashMap::new();
let mut queue = std::collections::VecDeque::new();
dist.insert(from, 0u32);
queue.push_back(from);
while let Some(id) = queue.pop_front() {
let d = dist[&id];
let Some(room) = by_id.get(&id) else { continue };
for dir in DIRS {
if let Some(next) = room.nav.get(dir) {
if let std::collections::hash_map::Entry::Vacant(e) = dist.entry(next) {
e.insert(d + 1);
queue.push_back(next);
}
}
}
}
dist
}
/// The room the run's stairs (or the final floor's boss) occupy: the
/// reachable room farthest from the entrance — the deepest point of the
/// floor. Deterministic tie-break on the higher id.
pub fn exit_room(&self) -> usize {
let dist = self.distances(self.start_room);
dist.iter()
.max_by_key(|(id, d)| (**d, **id))
.map(|(id, _)| *id)
.unwrap_or(self.start_room)
}
}

View file

@ -0,0 +1,58 @@
//! The enemy brain, shared with combat-web's arena: fire the signature move on
//! a spaced-out coin flip, otherwise swing. Lives game-side, not in core, so
//! the engine stays policy-free.
use combat_core::ability::Ability;
use combat_core::controller::{Action, Controller, View};
use combat_core::rng::Rng;
#[derive(Debug, Default)]
pub struct MonsterAI;
impl MonsterAI {
fn usable(view: &View, ab: &Ability) -> bool {
ab.cost.fill <= view.me.vigor && !view.me.on_cooldown(&ab.id, view.tick)
}
}
impl Controller for MonsterAI {
fn decide(&mut self, view: &View, rng: &mut Rng) -> Action {
let Some(target) = view.weakest_enemy() else {
return Action::Idle;
};
// Signature move: priciest usable hostile ability that isn't the free
// auto-attack, used on a coin-flip so big hits space into a readable duel.
let use_signature = rng.chance_bp(5500);
let signature = view
.me
.loadout
.iter()
.filter(|a| a.is_offensive() && a.cost.fill > 0 && Self::usable(view, a))
.max_by_key(|a| a.cost.fill);
if let (true, Some(ab)) = (use_signature, signature) {
return Action::Use {
ability_id: ab.id.clone(),
targets: vec![target.id],
};
}
let auto = view
.me
.loadout
.iter()
.filter(|a| a.is_offensive())
.min_by_key(|a| a.cost.fill);
match auto {
Some(ab) if Self::usable(view, ab) => Action::Use {
ability_id: ab.id.clone(),
targets: vec![target.id],
},
_ => Action::Idle,
}
}
fn label(&self) -> &str {
"monster"
}
}

576
game-wasm/src/run.rs Normal file
View file

@ -0,0 +1,576 @@
//! The run: REIKHELM: DESCENT's whole game state machine.
//!
//! A run is three baked floors deep. The player occupies a (room, facing) on
//! the current floor and moves room-to-room over the manifest's nav graph —
//! exactly the moves the pre-baked atlas has frames for. Stepping into an
//! occupied room hands control to an embedded `combat_core::Encounter`; the
//! player's vigor and fatigue persist across fights as basis points of their
//! pool, so a run is one long attrition curve punctuated by duels. The deepest
//! room of the last floor holds the boss; killing it wins the run. Death is
//! permanent — that's what makes the drink-or-descend choices real.
use crate::manifest::{Dir, Manifest};
use crate::spawn::{balance, build_floor, Floor, Spawn};
use crate::views;
use combat_core::actor::compile_actor;
use combat_core::config::{CombatConfig, Rules};
use combat_core::controller::{Action, Controller, HumanController, HumanHandle};
use combat_core::engine::{Encounter, EncounterOptions};
use combat_core::event::Outcome;
use combat_core::rng::Rng;
use serde::Serialize;
const BP_ONE: i64 = 10_000;
/// splitmix64 — derives per-fight encounter seeds from run coordinates so a
/// re-fought room (after fleeing) rolls fresh variance.
fn mix(mut z: u64) -> u64 {
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Explore,
Combat,
Dead,
Victory,
}
impl Mode {
pub fn label(self) -> &'static str {
match self {
Mode::Explore => "explore",
Mode::Combat => "combat",
Mode::Dead => "dead",
Mode::Victory => "victory",
}
}
}
/// What just happened, drained into every state payload for the UI to narrate.
#[derive(Serialize, Clone, Debug)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum GameEvent {
Moved { room: usize },
Bump,
Encounter { monsters: Vec<String> },
Loot { gold: i64, item: Option<String> },
ItemUsed { kind: String, ok: bool },
Rested,
WinFight { gold: i64, drops: Vec<String> },
Fled,
Descend { floor: usize },
Relic { total: i32 },
PlayerDied { by: Option<String> },
Victory,
}
#[derive(Default, Serialize, Clone, Debug)]
pub struct RunStats {
pub fights: u32,
pub kills: u32,
pub gold_looted: i64,
pub rooms_explored: u32,
pub flees: u32,
pub potions_used: u32,
pub combat_ticks: u64,
pub deepest_floor: usize,
}
/// The hero, between fights. Vigor/fatigue live as bp of max so they survive
/// pool-size changes (a relic heals proportionally — finding one feels good).
#[derive(Debug)]
pub struct Player {
pub archetype: String,
pub vigor_bp: i64,
pub fatigue_bp: i64,
pub relics: i32,
pub gold: i64,
pub draughts: i32,
pub tonics: i32,
pub auto_attack: bool,
}
impl Player {
fn cap_bp(&self) -> i64 {
(BP_ONE - self.fatigue_bp).max(0)
}
fn clamp(&mut self) {
self.fatigue_bp = self.fatigue_bp.clamp(0, BP_ONE);
self.vigor_bp = self.vigor_bp.clamp(0, self.cap_bp());
}
}
/// A live battle: the encounter plus the player's intent handle and where it
/// is happening (so victory knows which room to clear).
pub struct Battle {
pub enc: Encounter,
pub human: HumanHandle,
pub room: usize,
}
pub struct GameRun {
pub config: CombatConfig,
pub rules: Rules,
pub run_seed: u64,
pub floors: Vec<Floor>,
pub floor_idx: usize,
pub room: usize,
pub prev_room: usize,
pub facing: Dir,
pub mode: Mode,
pub battle: Option<Battle>,
pub player: Player,
pub stats: RunStats,
pub events: Vec<GameEvent>,
/// Combat events drained on the most recent `combat_tick`, for floaters.
pub combat_events: Vec<combat_core::event::Event>,
/// Set on the tick a fight resolves so the final frame still renders the
/// battle (death animations, last hit floater) before `battle` drops.
pub last_outcome: Option<String>,
}
impl GameRun {
pub fn new(
config_json: &str,
manifests_json: &str,
archetype: &str,
run_seed: u64,
) -> Result<GameRun, String> {
let config: CombatConfig =
serde_json::from_str(config_json).map_err(|e| format!("bad config json: {e}"))?;
if !config.stat_blocks.contains_key(archetype) {
return Err(format!("unknown archetype {archetype:?}"));
}
let rules = config.compile();
let manifests: Vec<serde_json::Value> =
serde_json::from_str(manifests_json).map_err(|e| format!("bad manifests json: {e}"))?;
if manifests.is_empty() {
return Err("no floor manifests".to_string());
}
let root = Rng::from_seed(run_seed);
let mut floors = Vec::new();
let last = manifests.len() - 1;
for (i, m) in manifests.iter().enumerate() {
let manifest = Manifest::parse(&m.to_string())?;
let mut rng = root.fork(&format!("floor{i}:spawn"));
floors.push(build_floor(manifest, i, i == last, &config, &mut rng));
}
let start = floors[0].manifest.start_room;
let facing = first_open_facing(&floors[0], start);
Ok(GameRun {
config,
rules,
run_seed,
floors,
floor_idx: 0,
room: start,
prev_room: start,
facing,
mode: Mode::Explore,
battle: None,
player: Player {
archetype: archetype.to_string(),
vigor_bp: BP_ONE,
fatigue_bp: 0,
relics: 0,
gold: 0,
draughts: balance::START_DRAUGHTS,
tonics: balance::START_TONICS,
auto_attack: true,
},
stats: RunStats::default(),
events: Vec::new(),
combat_events: Vec::new(),
last_outcome: None,
})
}
pub fn floor(&self) -> &Floor {
&self.floors[self.floor_idx]
}
fn floor_mut(&mut self) -> &mut Floor {
&mut self.floors[self.floor_idx]
}
/// The player's effective level: hardened by one per floor descended.
pub fn player_level(&self) -> i32 {
let base = self.config.stat_blocks[&self.player.archetype].level;
base + self.floor_idx as i32
}
/// The player's pool size: archetype base widened by relics.
pub fn player_max_vigor(&self) -> i64 {
let base = self.config.stat_blocks[&self.player.archetype].max_vigor;
base + self.player.relics as i64 * balance::RELIC_MAX_VIGOR
}
// ----------------------------------------------------------------- //
// Exploration
// ----------------------------------------------------------------- //
pub fn turn(&mut self, delta: i32) {
if self.mode != Mode::Explore {
return;
}
self.facing = self.facing.turned(delta);
}
pub fn step(&mut self, forward: bool) {
if self.mode != Mode::Explore {
return;
}
let dir = if forward {
self.facing
} else {
self.facing.opposite()
};
let target = {
let by_id = self.floor().manifest.by_id();
by_id.get(&self.room).and_then(|r| r.nav.get(dir))
};
let Some(target) = target else {
self.events.push(GameEvent::Bump);
return;
};
self.prev_room = self.room;
self.room = target;
if self.floor_mut().visited.insert(target) {
self.stats.rooms_explored += 1;
}
self.events.push(GameEvent::Moved { room: target });
// Walking is rest: catch some breath, shed a little weariness.
self.player.vigor_bp += balance::STEP_FILL_BP;
self.player.fatigue_bp -= balance::STEP_FATIGUE_BP;
self.player.clamp();
if let Some(t) = self.floor_mut().treasure.remove(&target) {
self.player.gold += t.gold;
self.stats.gold_looted += t.gold;
match t.item {
Some("draught") => self.player.draughts += 1,
Some("tonic") => self.player.tonics += 1,
Some("relic") => {
self.player.relics += 1;
self.events.push(GameEvent::Relic {
total: self.player.relics,
});
}
_ => {}
}
self.events.push(GameEvent::Loot {
gold: t.gold,
item: t.item.map(|s| s.to_string()),
});
}
if self.floor().monsters.contains_key(&target) {
self.start_battle(target);
}
}
pub fn descend(&mut self) {
if self.mode != Mode::Explore || self.room != self.floor().exit_room {
return;
}
// The last floor's exit holds the boss, so standing here in Explore
// mode means it is already dead — but descend past it is meaningless.
if self.floor_idx + 1 >= self.floors.len() {
return;
}
self.floor_idx += 1;
self.stats.deepest_floor = self.stats.deepest_floor.max(self.floor_idx);
self.room = self.floor().manifest.start_room;
self.prev_room = self.room;
self.facing = first_open_facing(self.floor(), self.room);
let here = self.room;
self.floor_mut().visited.insert(here);
self.events.push(GameEvent::Descend {
floor: self.floor_idx,
});
}
/// Catch your breath where you stand: the same recovery as a step, no
/// movement. Holding the key is the crawler's campfire.
pub fn rest(&mut self) {
if self.mode != Mode::Explore {
return;
}
self.player.vigor_bp += balance::STEP_FILL_BP;
self.player.fatigue_bp -= balance::STEP_FATIGUE_BP;
self.player.clamp();
self.events.push(GameEvent::Rested);
}
pub fn use_item(&mut self, kind: &str) {
// Quaffing mid-melee isn't a thing; Second Wind is the in-combat verb.
if self.mode != Mode::Explore {
self.events.push(GameEvent::ItemUsed {
kind: kind.to_string(),
ok: false,
});
return;
}
let ok = match kind {
"draught" if self.player.draughts > 0 => {
self.player.draughts -= 1;
self.player.vigor_bp += balance::DRAUGHT_FILL_BP;
true
}
"tonic" if self.player.tonics > 0 => {
self.player.tonics -= 1;
self.player.fatigue_bp -= balance::TONIC_FATIGUE_BP;
true
}
_ => false,
};
if ok {
self.stats.potions_used += 1;
self.player.clamp();
}
self.events.push(GameEvent::ItemUsed {
kind: kind.to_string(),
ok,
});
}
// ----------------------------------------------------------------- //
// Combat
// ----------------------------------------------------------------- //
fn start_battle(&mut self, room: usize) {
let spawns: Vec<Spawn> = self.floor().monsters[&room].clone();
self.events.push(GameEvent::Encounter {
monsters: spawns.iter().map(|s| s.name.clone()).collect(),
});
// Player actor: archetype block at run-current level/pool/condition.
let mut psb = self.config.stat_blocks[&self.player.archetype].clone();
psb.team = 0;
psb.level = self.player_level();
psb.max_vigor = self.player_max_vigor();
psb.start_vigor_bp = self.player.vigor_bp;
psb.start_fatigue_bp = self.player.fatigue_bp;
let mut actors = Vec::new();
let mut controllers: Vec<Box<dyn Controller>> = Vec::new();
let (loadout, _) = self.rules.resolve_loadout(&psb.loadout);
actors.push(compile_actor(0, &psb, loadout, self.rules.tick_rate));
let human = HumanController::new();
let handle = human.handle();
{
let mut h = handle.borrow_mut();
h.target = Some(1);
h.auto_attack = self.player.auto_attack;
}
controllers.push(Box::new(human));
for (i, spawn) in spawns.iter().enumerate() {
let mut sb = self.config.stat_blocks[&spawn.archetype].clone();
sb.team = 1;
sb.level = spawn.level;
let (loadout, _) = self.rules.resolve_loadout(&sb.loadout);
actors.push(compile_actor(
(i + 1) as u32,
&sb,
loadout,
self.rules.tick_rate,
));
controllers.push(Box::new(crate::monster_ai::MonsterAI));
}
let seed = mix(self.run_seed
^ mix((self.floor_idx as u64) << 32 | room as u64)
^ mix(self.stats.fights as u64 + 1));
let opts = EncounterOptions {
max_ticks: 100_000,
log_events: true,
sample_every: 0,
};
let enc = Encounter::new(actors, controllers, self.rules.clone(), seed, opts);
self.stats.fights += 1;
self.battle = Some(Battle {
enc,
human: handle,
room,
});
self.mode = Mode::Combat;
self.last_outcome = None;
}
/// Advance the battle one tick (the front-end calls this at tick rate).
pub fn combat_tick(&mut self) {
if self.mode != Mode::Combat {
return;
}
let Some(battle) = self.battle.as_mut() else {
return;
};
let outcome = battle.enc.tick_once();
self.combat_events = battle.enc.drain_events();
self.stats.combat_ticks += 1;
if let Some(o) = outcome {
self.finish_battle(o);
}
}
fn finish_battle(&mut self, outcome: Outcome) {
let battle = self.battle.as_ref().unwrap();
let room = battle.room;
let won = matches!(outcome, Outcome::Win { team: 0 });
// Persist the hero's condition exactly as the fight left it.
let me = &battle.enc.actors[0];
let max = me.max_vigor.max(1);
self.player.vigor_bp = (me.vigor as i128 * BP_ONE as i128 / max as i128) as i64;
self.player.fatigue_bp = (me.fatigue as i128 * BP_ONE as i128 / max as i128) as i64;
self.player.clamp();
if won {
let slain = self.floor_mut().monsters.remove(&room).unwrap_or_default();
self.stats.kills += slain.len() as u32;
// Spoils: gold per kill plus a chance of a potion drop, all from a
// fight-keyed fork so outcomes don't disturb placement streams.
let mut rng = Rng::from_seed(self.run_seed).fork(&format!(
"loot:f{}:r{}:k{}",
self.floor_idx, room, self.stats.kills
));
let mut gold = 0;
let mut drops: Vec<String> = Vec::new();
for s in &slain {
gold += 6 + s.level as i64 + rng.range(0, 9) as i64;
if rng.chance_bp(2000) {
self.player.draughts += 1;
drops.push("draught".to_string());
} else if rng.chance_bp(1500) {
self.player.tonics += 1;
drops.push("tonic".to_string());
}
}
self.player.gold += gold;
self.stats.gold_looted += gold;
self.events.push(GameEvent::WinFight { gold, drops });
self.last_outcome = Some("win_player".to_string());
let boss_down = self.floor().has_boss && room == self.floor().exit_room;
if boss_down {
self.mode = Mode::Victory;
self.events.push(GameEvent::Victory);
} else {
self.mode = Mode::Explore;
}
} else {
let by = battle
.enc
.actors
.iter()
.find(|a| a.team == 1 && a.is_active())
.map(|a| a.name.clone());
self.last_outcome = Some("win_enemy".to_string());
self.mode = Mode::Dead;
self.events.push(GameEvent::PlayerDied { by });
}
// Keep `battle` alive for this one payload (final frame renders the
// killing blow); the next API call clears it.
}
/// Drop a finished battle before handling the next command.
pub fn settle(&mut self) {
if self.mode != Mode::Combat && self.last_outcome.is_some() {
self.battle = None;
}
}
pub fn use_ability(&mut self, ability_id: &str, target: i32) {
let Some(battle) = self.battle.as_mut() else {
return;
};
if self.mode != Mode::Combat {
return;
}
let Some(ability) = self.rules.abilities.get(ability_id).cloned() else {
return;
};
let targets = if ability.is_support() {
vec![0]
} else if target >= 0 {
vec![target as u32]
} else {
vec![battle.human.borrow().target.unwrap_or(1)]
};
battle.human.borrow_mut().queued = Some(Action::Use {
ability_id: ability_id.to_string(),
targets,
});
}
pub fn set_auto_attack(&mut self, on: bool) {
self.player.auto_attack = on;
if let Some(battle) = self.battle.as_mut() {
battle.human.borrow_mut().auto_attack = on;
}
}
pub fn set_target(&mut self, target: u32) {
if let Some(battle) = self.battle.as_mut() {
battle.human.borrow_mut().target = Some(target);
}
}
/// Break off the fight: back out the way you came, paying in ceiling.
pub fn flee(&mut self) {
if self.mode != Mode::Combat {
return;
}
let Some(battle) = self.battle.take() else {
return;
};
// Persist condition as the fight stands, then pay the price.
let me = &battle.enc.actors[0];
let max = me.max_vigor.max(1);
self.player.vigor_bp = (me.vigor as i128 * BP_ONE as i128 / max as i128) as i64;
self.player.fatigue_bp =
(me.fatigue as i128 * BP_ONE as i128 / max as i128) as i64 + balance::FLEE_FATIGUE_BP;
self.player.clamp();
self.stats.flees += 1;
self.room = self.prev_room;
self.mode = Mode::Explore;
self.last_outcome = None;
self.events.push(GameEvent::Fled);
}
// ----------------------------------------------------------------- //
// State payload
// ----------------------------------------------------------------- //
pub fn state_json(&mut self) -> String {
views::state_json(self)
}
}
fn first_open_facing(floor: &Floor, room: usize) -> Dir {
let by_id = floor.manifest.by_id();
if let Some(r) = by_id.get(&room) {
for d in crate::manifest::DIRS {
if r.nav.get(d).is_some() {
return d;
}
}
}
Dir::N
}

228
game-wasm/src/spawn.rs Normal file
View file

@ -0,0 +1,228 @@
//! Seeded population of a baked floor: which rooms hold monsters, what they
//! are, and where the loot sits. This is the roguelike half of the design —
//! the floor *geometry* is fixed (it was baked to pixel art ahead of time),
//! but every run rolls its own inhabitants from the run seed.
//!
//! All randomness flows through `combat_core::rng::Rng` forks keyed by floor,
//! so placement is bit-reproducible per (run_seed, floor) and adding a roll to
//! one floor never reshuffles another.
use crate::manifest::Manifest;
use combat_core::config::CombatConfig;
use combat_core::rng::Rng;
use std::collections::{BTreeMap, BTreeSet};
/// Tunables that aren't combat-table balance (those live in config.json).
/// Basis points are of the player's max vigor unless noted.
pub mod balance {
/// Fill regained per room stepped into — walking is catching your breath.
pub const STEP_FILL_BP: i64 = 1200;
/// Fatigue recovered per room stepped into — slow attrition relief so a
/// deep run wears you down without death-spiralling.
pub const STEP_FATIGUE_BP: i64 = 350;
/// Vigor draught: restores fill on the spot (out of combat).
pub const DRAUGHT_FILL_BP: i64 = 4500;
/// Stamina tonic: clears fatigue, raising the ceiling back up.
pub const TONIC_FATIGUE_BP: i64 = 5000;
/// Breaking off a fight costs ceiling — you escape, but it marks you.
pub const FLEE_FATIGUE_BP: i64 = 800;
/// Each relic permanently widens the pool (displayed units).
pub const RELIC_MAX_VIGOR: i64 = 15;
pub const START_DRAUGHTS: i32 = 2;
pub const START_TONICS: i32 = 1;
/// Monster levels shift with depth; the player gains a level per floor
/// descended, holding a hero's edge over trash — depth pressure comes
/// from nastier kits and packs, not stat inflation.
pub const FLOOR_LEVEL_DELTA: [i32; 3] = [-2, -1, 0];
}
/// A monster standing in a room, resolved against the combat config at
/// placement time so views don't need a config lookup.
#[derive(Clone, Debug)]
pub struct Spawn {
pub archetype: String,
pub name: String,
pub level: i32,
}
#[derive(Clone, Debug)]
pub struct Treasure {
pub gold: i64,
/// `"draught" | "tonic" | "relic"` — or None for plain coin.
pub item: Option<&'static str>,
}
/// A populated floor: baked geometry + this run's inhabitants.
#[derive(Debug)]
pub struct Floor {
pub manifest: Manifest,
pub exit_room: usize,
pub monsters: BTreeMap<usize, Vec<Spawn>>,
pub treasure: BTreeMap<usize, Treasure>,
pub visited: BTreeSet<usize>,
pub has_boss: bool,
}
/// Chance (bp) that a room of this theme houses monsters. The entrance
/// (threshold) is always safe ground; dens are almost never empty.
fn monster_chance_bp(theme: Option<&str>) -> i64 {
match theme.unwrap_or("stone") {
"threshold" => 0,
"den" => 8000,
"crypt" => 6000,
"throne" => 6000,
"forge" => 5500,
"vault" => 4500,
"library" => 4500,
_ => 5000, // stone, hall, cistern
}
}
/// Chance (bp) that a room of this theme holds treasure.
fn treasure_chance_bp(theme: Option<&str>) -> i64 {
match theme.unwrap_or("stone") {
"threshold" => 0,
"vault" => 9000,
"throne" => 7500,
"library" => 6000,
"crypt" => 4500,
"den" => 2500,
_ => 3000,
}
}
/// Weighted archetype pools per floor index. Weights are relative ints.
fn pool(floor_idx: usize) -> &'static [(&'static str, i32)] {
match floor_idx {
// Floor 1 is vermin and bones — the curse-slinging wraith debuts deeper.
0 => &[("rat", 55), ("skeleton", 45)],
1 => &[("skeleton", 30), ("wraith", 30), ("troll", 30), ("rat", 10)],
_ => &[
("troll", 30),
("wraith", 30),
("golem", 25),
("skeleton", 15),
],
}
}
/// Theme nudges: a crypt leans undead, a den leans beasts. Multiplies the
/// base weight when the archetype suits the room.
fn theme_weight(theme: Option<&str>, archetype: &str) -> i32 {
match (theme.unwrap_or("stone"), archetype) {
("crypt", "skeleton") | ("crypt", "wraith") => 3,
("den", "rat") | ("den", "troll") => 3,
("vault", "golem") | ("throne", "golem") | ("forge", "golem") => 3,
_ => 1,
}
}
fn pick_archetype(rng: &mut Rng, floor_idx: usize, theme: Option<&str>) -> &'static str {
let weighted: Vec<(&str, i32)> = pool(floor_idx)
.iter()
.map(|(a, w)| (*a, w * theme_weight(theme, a)))
.collect();
let total: i32 = weighted.iter().map(|(_, w)| w).sum();
let mut roll = rng.range(0, total);
for (a, w) in &weighted {
roll -= w;
if roll < 0 {
return a;
}
}
weighted.last().unwrap().0
}
fn spawn_of(config: &CombatConfig, archetype: &str, level_delta: i32) -> Option<Spawn> {
let sb = config.stat_blocks.get(archetype)?;
Some(Spawn {
archetype: archetype.to_string(),
name: sb.name.clone(),
level: sb.level + level_delta,
})
}
/// Populate a baked floor for this run. `rng` must be a fork keyed by the
/// floor so floors roll independently.
pub fn build_floor(
manifest: Manifest,
floor_idx: usize,
is_last: bool,
config: &CombatConfig,
rng: &mut Rng,
) -> Floor {
let exit_room = manifest.exit_room();
let start = manifest.start_room;
let delta = *balance::FLOOR_LEVEL_DELTA
.get(floor_idx)
.unwrap_or(balance::FLOOR_LEVEL_DELTA.last().unwrap());
let mut monsters: BTreeMap<usize, Vec<Spawn>> = BTreeMap::new();
let mut treasure: BTreeMap<usize, Treasure> = BTreeMap::new();
// Rooms in id order so the roll sequence is stable.
let mut rooms: Vec<_> = manifest.rooms.clone();
rooms.sort_by_key(|r| r.id);
for room in &rooms {
if room.id == start {
continue;
}
let theme = room.theme.as_deref();
// The final floor's deepest room belongs to the boss alone.
if is_last && room.id == exit_room {
if let Some(boss) = spawn_of(config, "boss", 0) {
monsters.insert(room.id, vec![boss]);
}
continue;
}
if rng.chance_bp(monster_chance_bp(theme)) {
let arch = pick_archetype(rng, floor_idx, theme);
let mut pack = Vec::new();
if let Some(s) = spawn_of(config, arch, delta) {
pack.push(s);
}
// Sometimes a straggler joins — small fry so 2v1 stays fair.
// Never on floor 1: a pack as your opening fight is a noob-killer;
// the action-economy lesson waits for the Drowned Halls.
let straggler = if floor_idx < 2 { "rat" } else { "skeleton" };
if floor_idx > 0 && arch != straggler && rng.chance_bp(2200) {
if let Some(s) = spawn_of(config, straggler, delta) {
pack.push(s);
}
}
if !pack.is_empty() {
monsters.insert(room.id, pack);
}
}
if rng.chance_bp(treasure_chance_bp(theme)) {
let gold = 8 + (floor_idx as i64) * 8 + rng.range(0, 13) as i64;
let roll = rng.range(0, 10_000);
let item = if roll < 3000 {
Some("draught")
} else if roll < 5000 {
Some("tonic")
} else if roll < 6200 {
Some("relic")
} else {
None
};
treasure.insert(room.id, Treasure { gold, item });
}
}
let mut visited = BTreeSet::new();
visited.insert(start);
Floor {
manifest,
exit_room,
monsters,
treasure,
visited,
has_boss: is_last,
}
}

467
game-wasm/src/views.rs Normal file
View file

@ -0,0 +1,467 @@
//! Serialization of the run for the browser: one JSON payload carrying
//! everything the front-end renders, with per-call event streams drained into
//! it. The combat views mirror combat-web's shapes so that UI carries over.
use crate::manifest::DIRS;
use crate::run::{GameEvent, GameRun, RunStats};
use combat_core::ability::Ability;
use combat_core::actor::{Actor, StaggerState};
use combat_core::effect::EffectKind;
use combat_core::event::Event;
use serde::Serialize;
/// `1000` milli-units == 1 displayed unit.
fn to_units(milli: i64) -> f64 {
milli as f64 / 1000.0
}
// --------------------------------------------------------------------- //
// Combat views (combat-web's contract).
// --------------------------------------------------------------------- //
#[derive(Serialize)]
struct CastView {
ability: String,
remaining: u64,
}
#[derive(Serialize)]
struct CooldownView {
id: String,
remaining: u64,
}
#[derive(Serialize)]
struct ActorView {
id: u32,
name: String,
archetype: Option<String>,
team: u8,
level: i32,
vigor: f64,
cap: f64,
fatigue: f64,
max_vigor: f64,
stagger: String,
casting: Option<CastView>,
statuses: Vec<String>,
cooldowns: Vec<CooldownView>,
}
#[derive(Serialize)]
#[serde(tag = "t", rename_all = "snake_case")]
enum EvView {
Hit {
src: u32,
tgt: u32,
dmg: f64,
outcome: String,
},
Ceiling {
src: u32,
tgt: u32,
fatigue: f64,
},
Recovery {
tgt: u32,
heal: f64,
revived: bool,
},
Cast {
actor: u32,
ability: String,
},
Fizzle {
actor: u32,
ability: String,
},
Interrupt {
actor: u32,
ability: String,
},
Death {
actor: u32,
},
}
#[derive(Serialize)]
struct CombatView {
tick: u64,
outcome: Option<String>,
actors: Vec<ActorView>,
events: Vec<EvView>,
}
#[derive(Serialize)]
pub struct AbilityView {
id: String,
name: String,
school: String,
fill_cost: f64,
fatigue_cost: f64,
cast_time: u32,
cooldown: u32,
kind: String,
magnitude_pct: f64,
}
// --------------------------------------------------------------------- //
// Game state view.
// --------------------------------------------------------------------- //
#[derive(Serialize)]
struct PlayerView {
archetype: String,
name: String,
level: i32,
vigor: f64,
cap: f64,
fatigue: f64,
max_vigor: f64,
gold: i64,
draughts: i32,
tonics: i32,
relics: i32,
auto_attack: bool,
}
#[derive(Serialize)]
struct MonsterPeek {
archetype: String,
name: String,
level: i32,
}
#[derive(Serialize)]
struct StateView {
mode: &'static str,
floor: usize,
floors_total: usize,
floor_seed: u64,
room: usize,
facing: &'static str,
ahead_open: bool,
exit_room: usize,
at_exit: bool,
visited: Vec<usize>,
/// Rooms the player has seen that still hold live monsters (fled fights).
known_monsters: Vec<usize>,
/// Living monsters left on this floor (HUD flavor).
monsters_left: usize,
/// A live monster waits in an adjacent room — audio/UI dread hook.
adjacent_danger: bool,
player: PlayerView,
room_monsters: Vec<MonsterPeek>,
events: Vec<GameEvent>,
stats: RunStats,
combat: Option<CombatView>,
}
pub fn state_json(run: &mut GameRun) -> String {
let events: Vec<GameEvent> = run.events.drain(..).collect();
let combat_events: Vec<Event> = run.combat_events.drain(..).collect();
let floor = run.floor();
let by_id = floor.manifest.by_id();
let here = by_id.get(&run.room);
let ahead_open = here
.map(|r| r.nav.get(run.facing).is_some())
.unwrap_or(false);
let adjacent_danger = here
.map(|r| {
DIRS.iter().any(|d| {
r.nav
.get(*d)
.map(|n| floor.monsters.contains_key(&n))
.unwrap_or(false)
})
})
.unwrap_or(false);
let known_monsters: Vec<usize> = floor
.monsters
.keys()
.filter(|id| floor.visited.contains(id))
.copied()
.collect();
let room_monsters: Vec<MonsterPeek> = floor
.monsters
.get(&run.room)
.map(|pack| {
pack.iter()
.map(|s| MonsterPeek {
archetype: s.archetype.clone(),
name: s.name.clone(),
level: s.level,
})
.collect()
})
.unwrap_or_default();
let sb = &run.config.stat_blocks[&run.player.archetype];
let max_vigor = run.player_max_vigor() as f64;
let cap_bp = (10_000 - run.player.fatigue_bp).max(0);
let player = PlayerView {
archetype: run.player.archetype.clone(),
name: sb.name.clone(),
level: run.player_level(),
vigor: max_vigor * run.player.vigor_bp as f64 / 10_000.0,
cap: max_vigor * cap_bp as f64 / 10_000.0,
fatigue: max_vigor * run.player.fatigue_bp as f64 / 10_000.0,
max_vigor,
gold: run.player.gold,
draughts: run.player.draughts,
tonics: run.player.tonics,
relics: run.player.relics,
auto_attack: run.player.auto_attack,
};
let combat = run.battle.as_ref().map(|b| CombatView {
tick: b.enc.current_tick(),
outcome: run.last_outcome.clone(),
actors: b
.enc
.actors
.iter()
.map(|a| actor_view(a, b.enc.current_tick(), run))
.collect(),
events: combat_events.iter().filter_map(ev_view).collect(),
});
let view = StateView {
mode: run.mode.label(),
floor: run.floor_idx,
floors_total: run.floors.len(),
floor_seed: floor.manifest.seed,
room: run.room,
facing: run.facing.letter(),
ahead_open,
exit_room: floor.exit_room,
at_exit: run.room == floor.exit_room,
visited: floor.visited.iter().copied().collect(),
known_monsters,
monsters_left: floor.monsters.len(),
adjacent_danger,
player,
room_monsters,
events,
stats: run.stats.clone(),
combat,
};
// A finished battle has now rendered its final frame; release it so the
// next payload is clean exploration state.
run.settle();
serde_json::to_string(&view).unwrap_or_else(|_| "{}".into())
}
/// The fight's actor → view, tagging enemies with their archetype so the
/// front-end can pick portraits.
fn actor_view(a: &Actor, tick: u64, run: &GameRun) -> ActorView {
let stagger = match a.stagger {
StaggerState::Normal => "normal",
StaggerState::Dazed { .. } => "dazed",
StaggerState::Unconscious => "unconscious",
StaggerState::Dead => "dead",
}
.to_string();
let casting = a.casting.as_ref().map(|c| CastView {
ability: c.ability_id.clone(),
remaining: c.completes_at.saturating_sub(tick),
});
let statuses = a
.statuses
.iter()
.map(|s| status_label(&s.kind).to_string())
.collect();
let cooldowns = a
.cooldowns
.iter()
.filter(|(_, &ready)| ready > tick)
.map(|(id, &ready)| CooldownView {
id: id.clone(),
remaining: ready - tick,
})
.collect();
// Actor 0 is the player; enemies are battle spawns in slot order.
let archetype = if a.team == 0 {
Some(run.player.archetype.clone())
} else {
run.battle.as_ref().and_then(|b| {
run.floor()
.monsters
.get(&b.room)
.and_then(|pack| pack.get((a.id as usize).saturating_sub(1)))
.map(|s| s.archetype.clone())
})
};
ActorView {
id: a.id,
name: a.name.clone(),
archetype,
team: a.team,
level: a.level,
vigor: to_units(a.vigor),
cap: to_units(a.cap()),
fatigue: to_units(a.fatigue),
max_vigor: to_units(a.max_vigor),
stagger,
casting,
statuses,
cooldowns,
}
}
fn status_label(kind: &combat_core::actor::StatusKind) -> &'static str {
use combat_core::actor::StatusKind::*;
match kind {
RegenSabotage { .. } => "regen_sabotage",
FatigueAmp { .. } => "fatigue_amp",
Dot { .. } => "dot",
Hot { .. } => "hot",
Mitigation { .. } => "mitigation",
}
}
fn ev_view(e: &Event) -> Option<EvView> {
Some(match e {
Event::Hit {
source,
target,
landed,
outcome,
..
} => EvView::Hit {
src: *source,
tgt: *target,
dmg: to_units(*landed),
outcome: format!("{outcome:?}").to_lowercase(),
},
Event::Ceiling {
source,
target,
fatigue_added,
..
} => EvView::Ceiling {
src: *source,
tgt: *target,
fatigue: to_units(*fatigue_added),
},
Event::Recovery {
target,
fatigue_healed,
revived,
..
} => EvView::Recovery {
tgt: *target,
heal: to_units(*fatigue_healed),
revived: *revived,
},
Event::CastStarted { actor, ability, .. } => EvView::Cast {
actor: *actor,
ability: ability.clone(),
},
Event::Fail {
actor,
ability,
reason,
..
} => {
use combat_core::event::FailReason::*;
match reason {
CompetencyFizzle => EvView::Fizzle {
actor: *actor,
ability: ability.clone(),
},
Interrupted => EvView::Interrupt {
actor: *actor,
ability: ability.clone(),
},
}
}
Event::Death { actor, .. } => EvView::Death { actor: *actor },
_ => return None,
})
}
/// The player's slotted abilities, for the action bar.
pub fn abilities_json(run: &GameRun) -> String {
let sb = &run.config.stat_blocks[&run.player.archetype];
let (loadout, _) = run.rules.resolve_loadout(&sb.loadout);
let views: Vec<AbilityView> = loadout.iter().map(ability_view).collect();
serde_json::to_string(&views).unwrap_or_else(|_| "[]".into())
}
fn ability_view(ab: &Ability) -> AbilityView {
let kind = classify(ab).to_string();
let magnitude_pct = ab
.effects
.iter()
.filter_map(|e| match e.kind {
EffectKind::FillDamage { magnitude_bp } => Some(magnitude_bp),
EffectKind::CeilingDamage { magnitude_bp } => Some(magnitude_bp),
EffectKind::Recovery { magnitude_bp } => Some(magnitude_bp),
_ => None,
})
.map(|bp| bp as f64 / 100.0)
.next()
.unwrap_or(0.0);
AbilityView {
id: ab.id.clone(),
name: ab.name.clone(),
school: ab.school.clone(),
fill_cost: to_units(ab.cost.fill),
fatigue_cost: to_units(ab.cost.fatigue),
cast_time: ab.cast_time,
cooldown: ab.cooldown,
kind,
magnitude_pct,
}
}
fn classify(ab: &Ability) -> &'static str {
if ab.is_support() {
if ab
.effects
.iter()
.any(|e| matches!(e.kind, EffectKind::MitigationBuff { .. }))
{
return "defense";
}
return "recovery";
}
if ab
.effects
.iter()
.any(|e| matches!(e.kind, EffectKind::CeilingDamage { .. }))
{
return "curse";
}
if ab.effects.iter().any(|e| {
matches!(
e.kind,
EffectKind::RegenSabotage { .. } | EffectKind::FatigueAmp { .. }
)
}) {
return "hex";
}
if ab
.effects
.iter()
.any(|e| matches!(e.kind, EffectKind::Dot { .. }))
{
return "dot";
}
if ab.cost.fill == 0 {
return "auto";
}
if ab.cast_time > 0 {
return "spell";
}
"skill"
}

399
game-wasm/tests/run.rs Normal file
View file

@ -0,0 +1,399 @@
//! Integration tests for the run state machine, driven against the REAL baked
//! campaign: game-web/config.json + the three atlas manifests under
//! tools/out/atlas/. A scripted bot plays whole runs headlessly — the same
//! loop the browser drives, minus the pixels.
use game_wasm::manifest::{Dir, DIRS};
use game_wasm::run::{GameRun, Mode};
use std::collections::{HashMap, VecDeque};
use std::fs;
use std::path::Path;
const CAMPAIGN: [&str; 3] = ["7", "69", "59"];
fn repo() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap()
}
fn config_json() -> String {
fs::read_to_string(repo().join("game-web/config.json")).expect("game-web/config.json")
}
fn manifests_json() -> String {
let parts: Vec<String> = CAMPAIGN
.iter()
.map(|s| {
fs::read_to_string(repo().join(format!("tools/out/atlas/{s}/manifest.json")))
.unwrap_or_else(|_| panic!("manifest for seed {s} — bake the campaign first"))
})
.collect();
format!("[{}]", parts.join(","))
}
fn new_run(archetype: &str, seed: u64) -> GameRun {
GameRun::new(&config_json(), &manifests_json(), archetype, seed).expect("run builds")
}
// ------------------------------------------------------------------ //
// Placement invariants
// ------------------------------------------------------------------ //
#[test]
fn placement_invariants_across_seeds() {
for seed in [1u64, 2, 3, 42, 1337, 9001] {
let run = new_run("duelist", seed);
assert_eq!(run.floors.len(), 3);
for (i, floor) in run.floors.iter().enumerate() {
let start = floor.manifest.start_room;
assert!(
!floor.monsters.contains_key(&start),
"start room must be safe"
);
assert!(
!floor.treasure.contains_key(&start),
"no loot at the entrance"
);
assert_ne!(floor.exit_room, start, "exit is somewhere deeper");
assert!(
floor
.manifest
.distances(start)
.contains_key(&floor.exit_room),
"exit reachable from start"
);
let fights = floor.monsters.len();
assert!(
(3..=14).contains(&fights),
"floor {i} seed {seed}: {fights} fights is out of the sane band"
);
if i == 2 {
let boss = &floor.monsters[&floor.exit_room];
assert_eq!(boss.len(), 1);
assert_eq!(
boss[0].archetype, "boss",
"the deepest room belongs to the boss"
);
} else {
assert!(floor
.monsters
.get(&floor.exit_room)
.map(|p| p.iter().all(|s| s.archetype != "boss"))
.unwrap_or(true));
}
}
}
}
#[test]
fn dir_arithmetic() {
assert_eq!(Dir::N.turned(1), Dir::E);
assert_eq!(Dir::N.turned(-1), Dir::W);
assert_eq!(Dir::S.opposite(), Dir::N);
assert_eq!(Dir::W.turned(4), Dir::W);
}
// ------------------------------------------------------------------ //
// Determinism
// ------------------------------------------------------------------ //
#[test]
fn identical_runs_emit_identical_state() {
let script = |run: &mut GameRun| -> Vec<String> {
let mut out = vec![run.state_json()];
for _ in 0..6 {
run.step(true);
out.push(run.state_json());
// Tick any battle to a fixed depth so combat state is compared too.
for _ in 0..40 {
run.combat_tick();
}
out.push(run.state_json());
run.turn(1);
}
out
};
let a = script(&mut new_run("duelist", 777));
let b = script(&mut new_run("duelist", 777));
assert_eq!(
a, b,
"same seed + same commands must replay bit-identically"
);
}
// ------------------------------------------------------------------ //
// Combat handoff + persistence
// ------------------------------------------------------------------ //
/// March the player into the first monster room (BFS), returns rooms walked.
fn walk_into_trouble(run: &mut GameRun) -> bool {
for _ in 0..64 {
if run.mode == Mode::Combat {
return true;
}
let target = {
let floor = run.floor();
nearest(run, |r| floor.monsters.contains_key(&r))
};
let Some(path) = target else { return false };
step_along(run, &path);
}
run.mode == Mode::Combat
}
/// BFS path from the current room to the nearest room satisfying `want`.
fn nearest(run: &GameRun, want: impl Fn(usize) -> bool) -> Option<Vec<usize>> {
let by_id = run.floor().manifest.by_id();
let mut prev: HashMap<usize, usize> = HashMap::new();
let mut q = VecDeque::from([run.room]);
let mut seen = std::collections::HashSet::from([run.room]);
while let Some(at) = q.pop_front() {
if at != run.room && want(at) {
let mut path = vec![at];
let mut cur = at;
while let Some(&p) = prev.get(&cur) {
if p == run.room {
break;
}
path.push(p);
cur = p;
}
path.reverse();
return Some(path);
}
if let Some(room) = by_id.get(&at) {
for d in DIRS {
if let Some(n) = room.nav.get(d) {
if seen.insert(n) {
prev.insert(n, at);
q.push_back(n);
}
}
}
}
}
None
}
/// Walk the first hop of `path` (sets facing directly; turn() is unit-tested).
fn step_along(run: &mut GameRun, path: &[usize]) {
let Some(&next) = path.first() else { return };
let dir = {
let by_id = run.floor().manifest.by_id();
DIRS.into_iter()
.find(|d| by_id[&run.room].nav.get(*d) == Some(next))
};
if let Some(dir) = dir {
run.facing = dir;
run.step(true);
}
}
/// Fight the current battle to resolution with a simple but active policy:
/// heavies on cooldown cadence, second wind when the ceiling sags.
fn fight_out(run: &mut GameRun) {
let mut t = 0u64;
while run.mode == Mode::Combat && t < 30_000 {
if t % 50 == 5 {
run.use_ability("heavy_strike", -1);
}
if t % 80 == 30 {
run.use_ability("second_wind", -1);
}
run.combat_tick();
t += 1;
}
assert!(t < 30_000, "fight failed to resolve in 30k ticks");
}
#[test]
fn fights_wound_and_wounds_persist() {
let mut run = new_run("duelist", 4242);
assert!(
walk_into_trouble(&mut run),
"campaign floor 1 must offer a fight"
);
fight_out(&mut run);
let _ = run.state_json(); // settles the finished battle
if run.mode == Mode::Explore {
// Swinging costs fatigue; winning a fight always leaves a mark.
assert!(
run.player.fatigue_bp > 0 || run.player.vigor_bp < 10_000,
"a won fight must leave the player worn"
);
assert!(run.stats.kills > 0);
} else {
assert_eq!(run.mode, Mode::Dead, "only other resolution is death");
}
}
#[test]
fn fleeing_returns_and_costs_ceiling() {
let mut run = new_run("duelist", 31337);
assert!(walk_into_trouble(&mut run));
let battle_room = run.room;
let before = run.prev_room;
for _ in 0..20 {
run.combat_tick();
}
run.flee();
assert_eq!(run.mode, Mode::Explore);
assert_eq!(run.room, before, "flee backs out the way you came");
assert!(run.player.fatigue_bp >= 800, "flee marks the ceiling");
assert!(
run.floor().monsters.contains_key(&battle_room),
"the monsters don't forget"
);
}
#[test]
fn potions_apply_out_of_combat_only() {
let mut run = new_run("duelist", 99);
run.player.draughts = 1;
run.player.tonics = 1;
run.player.vigor_bp = 4_000;
run.player.fatigue_bp = 3_000;
run.use_item("draught");
assert_eq!(run.player.vigor_bp, 7_000 - 0, "draught restores fill");
run.use_item("tonic");
assert_eq!(run.player.fatigue_bp, 0, "tonic clears fatigue");
assert_eq!(run.player.draughts, 0);
assert_eq!(run.player.tonics, 0);
run.use_item("draught");
let v = run.player.vigor_bp;
assert_eq!(v, 7_000, "empty belt does nothing");
}
// ------------------------------------------------------------------ //
// Boss tuning probe — pure combat-core sim, no dungeon.
// ------------------------------------------------------------------ //
#[test]
fn boss_is_winnable_by_skilled_play() {
use combat_core::actor::compile_actor;
use combat_core::config::CombatConfig;
use combat_core::controller::{Controller, ScriptedPlayer};
use combat_core::engine::{Encounter, EncounterOptions};
use combat_core::event::Outcome;
use game_wasm::monster_ai::MonsterAI;
let config: CombatConfig = serde_json::from_str(&config_json()).unwrap();
let rules = config.compile();
let mut report = Vec::new();
let mut best = 0;
for skill in [50i64, 80, 100] {
let mut wins = 0;
let trials = 40;
for seed in 0..trials {
// The hero as they'd plausibly reach the bottom: level 12 (two
// descents), a couple of relics, rested but not pristine.
let mut hero = config.stat_blocks["duelist"].clone();
hero.team = 0;
hero.level = 12;
hero.max_vigor = 180;
hero.start_vigor_bp = 9_500;
hero.start_fatigue_bp = 500;
let mut boss = config.stat_blocks["boss"].clone();
boss.team = 1;
let (hl, _) = rules.resolve_loadout(&hero.loadout);
let (bl, _) = rules.resolve_loadout(&boss.loadout);
let actors = vec![
compile_actor(0, &hero, hl, rules.tick_rate),
compile_actor(1, &boss, bl, rules.tick_rate),
];
let controllers: Vec<Box<dyn Controller>> =
vec![Box::new(ScriptedPlayer::new(skill)), Box::new(MonsterAI)];
let opts = EncounterOptions {
max_ticks: 6_000,
log_events: false,
sample_every: 0,
};
let mut enc = Encounter::new(actors, controllers, rules.clone(), seed, opts);
if matches!(enc.run().outcome, Outcome::Win { team: 0 }) {
wins += 1;
}
}
best = best.max(wins);
report.push(format!("skill {skill}: {wins}/{trials}"));
}
println!("boss win rates — {}", report.join(", "));
assert!(
best >= 8,
"even perfect play rarely beats the boss: {}",
report.join(", ")
);
}
// ------------------------------------------------------------------ //
// Whole-run bot
// ------------------------------------------------------------------ //
#[test]
fn bot_plays_whole_runs_to_a_verdict() {
let mut verdicts = Vec::new();
for seed in [11u64, 22, 33, 44, 55, 66] {
let mut run = new_run("duelist", seed);
let mut budget = 4_000usize;
loop {
budget -= 1;
assert!(budget > 0, "seed {seed}: run did not terminate in budget");
match run.mode {
Mode::Dead | Mode::Victory => break,
Mode::Combat => fight_out(&mut run),
Mode::Explore => {
let _ = run.state_json();
// A patient crawler rests up before opening the next door.
if run.player.vigor_bp < 9_000 {
run.rest();
continue;
}
if run.player.fatigue_bp > 4_000 && run.player.tonics > 0 {
run.use_item("tonic");
}
if run.player.vigor_bp < 4_500 && run.player.draughts > 0 {
run.use_item("draught");
}
let at_exit = run.room == run.floor().exit_room;
let last = run.floor_idx + 1 == run.floors.len();
if at_exit && !last {
run.descend();
continue;
}
let goal = {
let floor = run.floor();
let exit = floor.exit_room;
nearest(&run, |r| {
floor.treasure.contains_key(&r)
|| floor.monsters.contains_key(&r)
|| r == exit
})
};
match goal {
Some(path) => step_along(&mut run, &path),
None => break, // nothing left and no exit path — shouldn't happen
}
}
}
}
let v = format!(
"seed {seed}: {} on floor {} — kills {}, gold {}, relics {}, fled {}, explored {}",
run.mode.label(),
run.floor_idx + 1,
run.stats.kills,
run.stats.gold_looted,
run.player.relics,
run.stats.flees,
run.stats.rooms_explored,
);
println!("{v}");
verdicts.push((run.mode, run.floor_idx));
assert!(matches!(run.mode, Mode::Dead | Mode::Victory));
}
// The campaign must be survivable past floor 1 for a mediocre bot, or the
// tuning is off for humans too.
assert!(
verdicts.iter().any(|(_, floor)| *floor >= 1),
"no bot run escaped floor 1 — too brutal"
);
}

60
game-web/README.md Normal file
View file

@ -0,0 +1,60 @@
# REIKHELM: DESCENT
A first-person dungeon-crawler roguelike, three floors deep. Eye of the Beholder
presentation over the reikhelm procgen + AI-render pipeline, with the Vigor
combined-pool combat system as the fight engine. Pure Rust→WASM + vanilla JS —
no game engine.
## Play
```bash
wasm-pack build game-wasm --target web --out-dir ../game-web/pkg --no-typescript
python3 tools/serve.py --root game-web --port 8001
# open http://127.0.0.1:8001
```
**Explore** `W/S` step · `A/D` turn · `R` rest (hold) · `G` drink draught ·
`T` drink tonic · `Enter` take the stairs
**Combat** `15` abilities · `Space` toggle auto-attack · `Tab` switch target ·
`F` flee (costs ceiling, the monsters remember)
## The shape of a run
- Three fixed floors (atlas seeds **7 → 69 → 59**), pre-baked to pixel art by
`tools/bake_atlas.py`; monsters, treasure, and loot reroll **per run** from the
fate seed. The baked scenes are empty ruins by design — inhabitants are
composited at runtime.
- Your **vigor pool is health, mana, and stamina at once** (the Vigor system,
see `combat/`). Damage and effort drain the same pool; fatigue lowers its
ceiling and persists **between** fights. Resting restores fill quickly and
fatigue slowly; tonics clear fatigue; relics widen the pool for the run.
- The deepest room of floor III holds **the Ember Sovereign**. Kill him to win.
Death is permanent.
## Architecture
```
game-wasm/ the whole game state machine (Rust, natively tested)
src/manifest.rs parses the baked atlas manifests (nav graph = source of truth)
src/spawn.rs seeded monster/treasure placement + balance constants
src/run.rs explore/combat/dead/victory state machine, persistence
src/views.rs one JSON payload per interaction; combat views match combat-web
src/monster_ai.rs the shared enemy brain
game-web/ thin browser view (no framework)
game.js input → wasm call → render from returned state
audio.js all SFX synthesized in WebAudio, zero assets
config.json the combat table: combat/configs/default.json + the boss
tools/out/atlas/<seed>/ the baked campaign art (committed; _work/ is not)
```
The dungeon geometry is read from the atlas **manifest**, never regenerated —
the graph the game walks is exactly the graph the baked frames depict.
## Balancing notes
- Tuned via `cargo test -p game-wasm -- --nocapture`: a whole-run bot plays the
real campaign headlessly, and a `ScriptedPlayer` probe sweeps boss win rates
(target: ~35% at skill 50, ~55% at skill 80, with potions/flee on top).
- Floor 1 spawns no packs; the wraith debuts on floor 2; player gains a level
per descent so trash stays beneath a hero's edge — depth pressure comes from
kits, packs, and fatigue attrition.

189
game-web/audio.js Normal file
View file

@ -0,0 +1,189 @@
// audio.js — procedural sound for REIKHELM: DESCENT. No assets: every cue is
// synthesized on a shared AudioContext (oscillators + filtered noise), so the
// game ships as code only. Context unlocks on the first user gesture.
let ctx = null;
let master = null;
let ambient = null;
function ac() {
if (!ctx) {
ctx = new (window.AudioContext || window.webkitAudioContext)();
master = ctx.createGain();
master.gain.value = 0.5;
master.connect(ctx.destination);
}
if (ctx.state === 'suspended') ctx.resume();
return ctx;
}
// One reusable noise buffer (1s of white noise).
let noiseBuf = null;
function noise() {
const c = ac();
if (!noiseBuf) {
noiseBuf = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
const src = c.createBufferSource();
src.buffer = noiseBuf;
return src;
}
// A tone with an exponential decay envelope.
function tone({ freq = 440, type = 'sine', dur = 0.2, vol = 0.3, when = 0, slide = 0 }) {
const c = ac();
const t0 = c.currentTime + when;
const osc = c.createOscillator();
const g = c.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t0 + dur);
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g).connect(master);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
// A burst of filtered noise with a decay envelope.
function thump({ cutoff = 400, dur = 0.15, vol = 0.4, when = 0, q = 1, type = 'lowpass' }) {
const c = ac();
const t0 = c.currentTime + when;
const src = noise();
const f = c.createBiquadFilter();
f.type = type;
f.frequency.value = cutoff;
f.Q.value = q;
const g = c.createGain();
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(f).connect(g).connect(master);
src.start(t0);
src.stop(t0 + dur + 0.02);
}
export const sfx = {
unlock() { ac(); },
step() {
thump({ cutoff: 220 + Math.random() * 80, dur: 0.12, vol: 0.35 });
thump({ cutoff: 150, dur: 0.08, vol: 0.2, when: 0.05 });
},
bump() {
thump({ cutoff: 120, dur: 0.2, vol: 0.5 });
tone({ freq: 65, type: 'triangle', dur: 0.18, vol: 0.3 });
},
turn() {
thump({ cutoff: 500, dur: 0.05, vol: 0.12 });
},
rest() {
tone({ freq: 196, type: 'sine', dur: 0.4, vol: 0.06, slide: 30 });
},
loot() {
tone({ freq: 880, type: 'triangle', dur: 0.1, vol: 0.2 });
tone({ freq: 1320, type: 'triangle', dur: 0.15, vol: 0.2, when: 0.07 });
},
relic() {
[523, 659, 784, 1046].forEach((f, i) => tone({ freq: f, type: 'triangle', dur: 0.3, vol: 0.18, when: i * 0.09 }));
},
potion() {
tone({ freq: 300, type: 'sine', dur: 0.25, vol: 0.2, slide: 260 });
},
encounter() {
tone({ freq: 110, type: 'sawtooth', dur: 0.5, vol: 0.25, slide: -45 });
thump({ cutoff: 800, dur: 0.4, vol: 0.25, when: 0.05 });
tone({ freq: 55, type: 'triangle', dur: 0.7, vol: 0.3, when: 0.1 });
},
growl() {
tone({ freq: 70, type: 'sawtooth', dur: 0.5, vol: 0.07, slide: -20 });
},
hit() {
thump({ cutoff: 900, dur: 0.1, vol: 0.4, type: 'bandpass', q: 1.5 });
tone({ freq: 180, type: 'square', dur: 0.07, vol: 0.12 });
},
heavyHit() {
thump({ cutoff: 500, dur: 0.25, vol: 0.55, type: 'bandpass' });
tone({ freq: 90, type: 'sawtooth', dur: 0.3, vol: 0.3, slide: -30 });
},
playerHit() {
thump({ cutoff: 300, dur: 0.2, vol: 0.5 });
tone({ freq: 140, type: 'square', dur: 0.12, vol: 0.15, slide: -40 });
},
cast() {
tone({ freq: 440, type: 'sine', dur: 0.35, vol: 0.12, slide: 320 });
},
fizzle() {
tone({ freq: 600, type: 'sawtooth', dur: 0.25, vol: 0.15, slide: -480 });
},
daze() {
tone({ freq: 660, type: 'triangle', dur: 0.6, vol: 0.2, slide: -200 });
tone({ freq: 663, type: 'triangle', dur: 0.6, vol: 0.2, slide: -210 });
},
guard() {
thump({ cutoff: 2500, dur: 0.08, vol: 0.2, type: 'highpass' });
tone({ freq: 520, type: 'square', dur: 0.06, vol: 0.08 });
},
recovery() {
tone({ freq: 392, type: 'sine', dur: 0.4, vol: 0.18, slide: 130 });
},
death() {
tone({ freq: 220, type: 'sawtooth', dur: 1.4, vol: 0.3, slide: -170 });
thump({ cutoff: 200, dur: 1.0, vol: 0.4, when: 0.1 });
},
monsterDeath() {
tone({ freq: 160, type: 'sawtooth', dur: 0.7, vol: 0.25, slide: -110 });
thump({ cutoff: 350, dur: 0.5, vol: 0.3, when: 0.05 });
},
fled() {
[400, 320, 260].forEach((f, i) => thump({ cutoff: f, dur: 0.1, vol: 0.3, when: i * 0.09 }));
},
descend() {
[200, 160, 120, 90].forEach((f, i) => {
thump({ cutoff: f * 2, dur: 0.25, vol: 0.35, when: i * 0.22 });
tone({ freq: f, type: 'triangle', dur: 0.3, vol: 0.15, when: i * 0.22 });
});
},
victory() {
[392, 523, 659, 784, 1046].forEach((f, i) =>
tone({ freq: f, type: 'triangle', dur: 0.5, vol: 0.2, when: i * 0.13 }));
thump({ cutoff: 4000, dur: 1.2, vol: 0.08, when: 0.6, type: 'highpass' });
},
// A faint dungeon bed: low drone + slow filtered rumble. Idempotent.
ambientOn() {
const c = ac();
if (ambient) return;
const g = c.createGain();
g.gain.value = 0.05;
const drone = c.createOscillator();
drone.type = 'sine';
drone.frequency.value = 55;
const drone2 = c.createOscillator();
drone2.type = 'sine';
drone2.frequency.value = 55.7; // beat frequency shimmer
const n = noise();
n.loop = true;
const f = c.createBiquadFilter();
f.type = 'lowpass';
f.frequency.value = 90;
const ng = c.createGain();
ng.gain.value = 0.4;
drone.connect(g);
drone2.connect(g);
n.connect(f).connect(ng).connect(g);
g.connect(master);
drone.start();
drone2.start();
n.start();
ambient = { g, drone, drone2, n };
},
ambientOff() {
if (!ambient) return;
const { g, drone, drone2, n } = ambient;
g.gain.linearRampToValueAtTime(0, ac().currentTime + 0.6);
setTimeout(() => { drone.stop(); drone2.stop(); n.stop(); }, 700);
ambient = null;
},
};

374
game-web/config.json Normal file
View file

@ -0,0 +1,374 @@
{
"_comment": "REIKHELM: DESCENT combat table - combat/configs/default.json plus the run boss. Magnitudes are displayed units; ratios are basis points; durations are ticks.",
"tick_rate": 10,
"global_cooldown": 15,
"damage_variance_bp": 1500,
"regen": {
"dazed_regen_bp": 5000,
"floor_per_sec": 1
},
"stagger": {
"daze_threshold_bp": 13000,
"unconscious_threshold_bp": 20000,
"daze_duration": 8,
"revive_daze": 12,
"fatigue_ratio_bp": 2300
},
"competency": {
"level_per_point_bp": 200
},
"fizzle": {
"base_bp": 200,
"per_gap_bp": 150,
"max_bp": 9000
},
"cost_model": {
"base_fill_self": 0,
"base_fill_touch": 1,
"base_fill_melee": 1,
"base_fill_projectile": 2,
"base_fill_beam": 3,
"base_fill_aoe": 4,
"fatigue_ratio_bp": 2300,
"difficulty_per_fill_bp": 10000
},
"con_curve": [
{
"delta": -8,
"mult_bp": 4000
},
{
"delta": -5,
"mult_bp": 6000
},
{
"delta": -2,
"mult_bp": 8500
},
{
"delta": 0,
"mult_bp": 10000
},
{
"delta": 2,
"mult_bp": 11500
},
{
"delta": 5,
"mult_bp": 15000
},
{
"delta": 8,
"mult_bp": 19000
}
],
"abilities": [
{
"id": "auto_attack",
"name": "Auto-Attack",
"school": "martial",
"delivery": "melee",
"effects": [
{
"kind": "fill_damage",
"magnitude_bp": 560,
"cost_fill": 0
}
],
"cooldown": 10,
"cost_override": {
"fill": 0,
"fatigue": 1
}
},
{
"id": "quick_jab",
"name": "Quick Jab",
"school": "martial",
"delivery": "melee",
"effects": [
{
"kind": "fill_damage",
"magnitude_bp": 1100,
"cost_fill": 5
}
],
"cooldown": 20
},
{
"id": "heavy_strike",
"name": "Heavy Strike",
"school": "martial",
"delivery": "melee",
"effects": [
{
"kind": "fill_damage",
"magnitude_bp": 2800,
"cost_fill": 14
}
],
"cooldown": 45
},
{
"id": "fireball",
"name": "Fireball",
"school": "invocation",
"delivery": "projectile",
"effects": [
{
"kind": "fill_damage",
"magnitude_bp": 2800,
"cost_fill": 14
}
],
"potency_bp": 10000,
"cast_time": 12,
"cooldown": 40
},
{
"id": "curse_of_weariness",
"name": "Curse of Weariness",
"school": "affliction",
"delivery": "projectile",
"effects": [
{
"kind": "ceiling_damage",
"magnitude_bp": 1250,
"cost_fill": 10
}
],
"cast_time": 8,
"cooldown": 40
},
{
"id": "venom",
"name": "Venom",
"school": "affliction",
"delivery": "touch",
"effects": [
{
"kind": "dot",
"per_tick_bp": 260,
"duration": 20,
"cost_fill": 10
}
],
"cooldown": 30
},
{
"id": "enervate",
"name": "Enervate",
"school": "affliction",
"delivery": "projectile",
"effects": [
{
"kind": "regen_sabotage",
"reduce_bp": 6000,
"duration": 30,
"cost_fill": 10
}
],
"cast_time": 6,
"cooldown": 35
},
{
"id": "second_wind",
"name": "Second Wind",
"school": "restoration",
"delivery": "self_cast",
"effects": [
{
"kind": "recovery",
"magnitude_bp": 700,
"cost_fill": 3
}
],
"cooldown": 65
},
{
"id": "mend",
"name": "Mend",
"school": "restoration",
"delivery": "self_cast",
"effects": [
{
"kind": "hot",
"per_tick_bp": 70,
"duration": 15,
"cost_fill": 6
}
],
"cast_time": 10,
"cooldown": 45
},
{
"id": "guard",
"name": "Guard",
"school": "martial",
"delivery": "self_cast",
"effects": [
{
"kind": "mitigation_buff",
"stage": "active_defense",
"amount_bp": 8000,
"duration": 3,
"cost_fill": 0
}
],
"cooldown": 15,
"cost_override": {
"fill": 0,
"fatigue": 2
}
}
],
"stat_blocks": {
"duelist": {
"name": "Duelist",
"team": 0,
"level": 10,
"max_vigor": 150,
"regen_idle_per_sec": 9,
"regen_combat_per_sec": 5,
"armor_bp": 2500,
"competency": {
"martial": 50,
"restoration": 30
},
"loadout": [
"auto_attack",
"quick_jab",
"heavy_strike",
"second_wind",
"guard"
]
},
"battlemage": {
"name": "Battlemage",
"team": 0,
"level": 10,
"max_vigor": 100,
"regen_idle_per_sec": 12,
"regen_combat_per_sec": 7,
"armor_bp": 500,
"competency": {
"invocation": 50,
"affliction": 40,
"restoration": 25,
"martial": 15
},
"loadout": [
"auto_attack",
"fireball",
"curse_of_weariness",
"second_wind",
"guard"
]
},
"golem": {
"name": "Golem",
"team": 1,
"level": 10,
"max_vigor": 260,
"regen_idle_per_sec": 6,
"regen_combat_per_sec": 3,
"armor_bp": 3500,
"competency": {
"martial": 40
},
"loadout": [
"auto_attack",
"heavy_strike"
]
},
"rat": {
"name": "Dire Rat",
"team": 1,
"level": 8,
"max_vigor": 45,
"regen_idle_per_sec": 16,
"regen_combat_per_sec": 9,
"armor_bp": 0,
"competency": {
"martial": 20
},
"loadout": [
"auto_attack",
"quick_jab"
]
},
"skeleton": {
"name": "Skeleton Knight",
"team": 1,
"level": 10,
"max_vigor": 90,
"regen_idle_per_sec": 8,
"regen_combat_per_sec": 4,
"armor_bp": 1000,
"competency": {
"martial": 35
},
"loadout": [
"auto_attack",
"quick_jab",
"heavy_strike"
]
},
"troll": {
"name": "Cave Troll",
"team": 1,
"level": 10,
"max_vigor": 190,
"regen_idle_per_sec": 8,
"regen_combat_per_sec": 4,
"armor_bp": 2000,
"competency": {
"martial": 30,
"restoration": 20
},
"loadout": [
"auto_attack",
"heavy_strike",
"second_wind"
]
},
"wraith": {
"name": "Wraith",
"team": 1,
"level": 10,
"max_vigor": 110,
"regen_idle_per_sec": 12,
"regen_combat_per_sec": 7,
"armor_bp": 500,
"competency": {
"affliction": 45,
"martial": 15
},
"loadout": [
"auto_attack",
"curse_of_weariness",
"enervate",
"venom"
]
},
"boss": {
"name": "The Ember Sovereign",
"team": 1,
"level": 12,
"max_vigor": 220,
"regen_idle_per_sec": 6,
"regen_combat_per_sec": 4,
"armor_bp": 1000,
"competency": {
"martial": 45,
"invocation": 40,
"restoration": 25
},
"loadout": [
"auto_attack",
"heavy_strike",
"fireball"
]
}
}
}

677
game-web/game.js Normal file
View file

@ -0,0 +1,677 @@
// game.js — REIKHELM: DESCENT front-end. A thin view over game-wasm: every
// input calls into the Game and re-renders from the returned state payload;
// the only client-side state is presentation (timers, animations, the log).
import init, { Game } from './pkg/game_wasm.js';
import { sfx } from './audio.js';
const CAMPAIGN = [
{ seed: 7, name: 'The Hollow Threshold' },
{ seed: 69, name: 'The Drowned Halls' },
{ seed: 59, name: 'The Burning Deep' },
];
const ROMAN = ['I', 'II', 'III'];
const TICK_MS = 100;
// ------------------------------------------------------------------ //
// DOM
// ------------------------------------------------------------------ //
const $ = (id) => document.getElementById(id);
const screens = { title: $('title'), game: $('game'), death: $('death'), victory: $('victory') };
const frame = $('frame');
const frameFade = $('frame-fade');
const sprites = $('sprites');
const floaters = $('floaters');
const banner = $('banner');
const enemiesBox = $('enemies');
const logBox = $('log');
const actionbar = $('actionbar');
const minimap = $('minimap');
const mctx = minimap.getContext('2d');
// ------------------------------------------------------------------ //
// Session
// ------------------------------------------------------------------ //
let configText = null;
let manifests = []; // parsed, in floor order (minimap needs tiles)
let manifestsText = null; // raw JSON array string for the wasm constructor
let game = null; // wasm Game
let st = null; // latest state payload
let abilities = []; // player loadout views
let seed = roll();
let lastArch = 'duelist';
let ticker = null;
let target = 1;
let castMax = {}; // actor id -> longest cast seen (telegraph scale)
let lastGrowl = 0;
function roll() { return 1 + Math.floor(Math.random() * 999_999); }
function show(name) {
for (const k in screens) screens[k].classList.toggle('show', k === name);
}
// ------------------------------------------------------------------ //
// Boot
// ------------------------------------------------------------------ //
async function boot() {
await init();
configText = await (await fetch('config.json')).text();
manifests = await Promise.all(
CAMPAIGN.map(async (f) => (await fetch(`/out/atlas/${f.seed}/manifest.json`)).json()),
);
manifestsText = JSON.stringify(manifests);
$('seed-label').textContent = String(seed).padStart(6, '0');
$('reroll').onclick = () => {
seed = roll();
$('seed-label').textContent = String(seed).padStart(6, '0');
sfx.unlock(); sfx.turn();
};
document.querySelectorAll('.arch-card').forEach((card) => {
card.onclick = () => { sfx.unlock(); startRun(card.dataset.arch); };
});
$('btn-retry').onclick = () => { seed = roll(); startRun(lastArch); };
$('btn-again').onclick = () => { seed = roll(); startRun(lastArch); };
$('btn-auto').onclick = () => toggleAuto();
$('btn-flee').onclick = () => flee();
document.body.dataset.ready = '1';
}
function startRun(arch) {
lastArch = arch;
stopTicker();
game = new Game(configText, manifestsText, arch, BigInt(seed));
abilities = JSON.parse(game.abilities());
buildActionbar();
logBox.innerHTML = '';
show('game');
sfx.ambientOn();
floorCard(0);
log(`the descent begins — fate ${String(seed).padStart(6, '0')}`);
apply(game.state());
}
// ------------------------------------------------------------------ //
// State pump: every interaction funnels through apply()
// ------------------------------------------------------------------ //
function apply(json) {
st = JSON.parse(json);
for (const ev of st.events) onEvent(ev);
render();
}
function onEvent(ev) {
switch (ev.t) {
case 'moved': sfx.step(); dip(110); break;
case 'bump': sfx.bump(); break;
case 'rested': sfx.rest(); showRest(); break;
case 'loot': {
sfx.loot();
flash('belt-gold');
let s = `you find ${ev.gold} gold`;
if (ev.item === 'draught') { s += ' and a vigor draught'; flash('belt-draught'); }
if (ev.item === 'tonic') { s += ' and a stamina tonic'; flash('belt-tonic'); }
if (ev.item === 'relic') s += '…';
log(s, 'good');
break;
}
case 'relic':
sfx.relic();
flash('belt-relic');
log(`☥ a RELIC of the old kings — your pool widens (${ev.total})`, 'good');
break;
case 'item_used':
if (ev.ok) { sfx.potion(); log(ev.kind === 'draught' ? 'the draught burns going down' : 'the tonic steadies your limbs', 'good'); }
else if (st.mode === 'combat') log('no drinking while something wants you dead', 'bad');
else log(`no ${ev.kind} left on your belt`, 'bad');
break;
case 'encounter': {
sfx.encounter();
bannerShow(ev.monsters.join(' & '));
log(`${ev.monsters.join(' and ')} bars the way!`, 'bad');
// let the lunge land before the swing timers start
setTimeout(() => { if (st && st.mode === 'combat') startTicker(); }, 750);
break;
}
case 'win_fight': {
sfx.monsterDeath();
let s = `the room falls silent — ${ev.gold} gold`;
if (ev.drops.length) s += `, dropped: ${ev.drops.join(', ')}`;
log(s, 'good');
flash('belt-gold');
break;
}
case 'fled':
sfx.fled();
log('you break and run — the effort marks you', 'bad');
break;
case 'descend':
sfx.descend();
floorCard(ev.floor);
break;
case 'player_died':
sfx.death();
setTimeout(() => endDeath(ev.by), 1700);
break;
case 'victory':
sfx.victory();
setTimeout(endVictory, 1900);
break;
}
}
// ------------------------------------------------------------------ //
// Render
// ------------------------------------------------------------------ //
function render() {
const floorMeta = CAMPAIGN[st.floor];
$('floor-label').innerHTML = `floor <b>${ROMAN[st.floor]}</b> · ${floorMeta.name}`;
$('compass').textContent = `${st.facing}`;
setFrame(`/out/atlas/${st.floor_seed}/r${st.room}_${st.facing}.png`);
preloadAround();
renderPlayer();
renderBelt();
drawMinimap();
const inCombat = st.mode === 'combat' || (st.combat && st.combat.outcome);
renderSprites(inCombat);
renderEnemies(inCombat);
actionbar.classList.toggle('on', inCombat);
$('combat-controls').classList.toggle('on', inCombat);
if (st.combat) renderCombat();
$('descend-prompt').classList.toggle(
'show',
st.mode === 'explore' && st.at_exit && st.floor + 1 < st.floors_total,
);
$('danger-vignette').classList.toggle('on', st.mode === 'explore' && st.adjacent_danger);
if (st.mode === 'explore' && st.adjacent_danger && Date.now() - lastGrowl > 6000) {
lastGrowl = Date.now();
sfx.growl();
}
}
let frameUrl = null;
function setFrame(url) {
if (frameUrl === url) return;
frameUrl = url;
frame.src = url;
}
function dip(ms) {
frameFade.classList.add('dip');
setTimeout(() => frameFade.classList.remove('dip'), ms);
}
const preloaded = new Set();
function preloadAround() {
const m = manifests[st.floor];
const here = m.rooms.find((r) => r.id === st.room);
const want = [st.room];
if (here) for (const d of ['N', 'E', 'S', 'W']) if (here.nav[d] != null) want.push(here.nav[d]);
for (const id of want) {
for (const d of ['N', 'E', 'S', 'W']) {
const u = `/out/atlas/${st.floor_seed}/r${id}_${d}.png`;
if (!preloaded.has(u)) { preloaded.add(u); new Image().src = u; }
}
}
}
// ---------------- player HUD ---------------- //
function setVbar(el, a) {
const capPct = (a.cap / a.max_vigor) * 100;
el.querySelector('.vbar-cap').style.width = `${capPct}%`;
el.querySelector('.vbar-fill').style.width = `${(a.vigor / a.max_vigor) * 100}%`;
el.querySelector('.vbar-ceiling').style.left = `calc(${capPct}% - 1px)`;
el.querySelector('.vbar-num').textContent =
`${Math.round(a.vigor)} · cap ${Math.round(a.cap)} / ${Math.round(a.max_vigor)}`;
}
function renderPlayer() {
const me = st.combat ? st.combat.actors.find((a) => a.id === 0) : st.player;
setVbar($('player-vbar'), me);
const cast = st.combat && st.combat.actors[0].casting;
const bar = $('player-cast');
bar.classList.toggle('on', !!cast);
bar.classList.add('player');
if (cast) {
castMax[0] = Math.max(castMax[0] || 1, cast.remaining);
bar.firstElementChild.style.width = `${(1 - cast.remaining / castMax[0]) * 100}%`;
} else castMax[0] = 1;
}
function renderBelt() {
$('belt-gold').querySelector('b').textContent = st.player.gold;
$('belt-draught').querySelector('b').textContent = st.player.draughts;
$('belt-tonic').querySelector('b').textContent = st.player.tonics;
$('belt-relic').querySelector('b').textContent = st.player.relics;
}
function flash(id) {
const el = $(id);
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
// ---------------- monsters ---------------- //
let spriteIds = null; // signature of current sprite set
function renderSprites(inCombat) {
if (!inCombat) {
if (sprites.childElementCount) { sprites.innerHTML = ''; spriteIds = null; }
return;
}
const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1) : [];
const sig = foes.map((f) => f.id).join(',');
if (sig !== spriteIds) {
spriteIds = sig;
sprites.innerHTML = '';
const lanes = foes.length === 1 ? [50] : [37, 63];
foes.forEach((f, i) => {
const div = document.createElement('div');
div.className = `sprite enter slot-${i}`;
div.style.left = `${lanes[i]}%`;
div.dataset.actor = f.id;
div.innerHTML = `<img src="portraits/${f.archetype || 'skeleton'}.png" alt=""><span class="tgt">▼</span>`;
div.onclick = () => setTarget(f.id);
sprites.appendChild(div);
setTimeout(() => div.classList.remove('enter'), 500);
});
}
for (const f of foes) {
const div = sprites.querySelector(`[data-actor="${f.id}"]`);
if (!div) continue;
div.classList.toggle('dazed', f.stagger === 'dazed');
if ((f.stagger === 'dead' || f.stagger === 'unconscious') && !div.classList.contains('dead')) {
div.classList.add('dead');
}
div.classList.toggle('targeted', f.id === target && f.stagger !== 'dead');
}
}
let panelIds = null;
function renderEnemies(inCombat) {
if (!inCombat) {
if (enemiesBox.childElementCount) { enemiesBox.innerHTML = ''; panelIds = null; }
return;
}
const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1) : [];
const sig = foes.map((f) => f.id).join(',');
if (sig !== panelIds) {
panelIds = sig;
enemiesBox.innerHTML = '';
for (const f of foes) {
const div = document.createElement('div');
div.className = 'epanel';
div.dataset.actor = f.id;
div.innerHTML = `
<div class="ehead"><span class="ename">${f.name}</span><span class="elvl">lvl ${f.level}</span></div>
<div class="vbar"><div class="vbar-cap"></div><div class="vbar-fill"></div><div class="vbar-ceiling"></div><div class="vbar-num"></div></div>
<div class="castbar"><div></div></div>
<div class="estatus"></div>`;
div.onclick = () => setTarget(f.id);
enemiesBox.appendChild(div);
}
}
for (const f of foes) {
const panel = enemiesBox.querySelector(`[data-actor="${f.id}"]`);
if (!panel) continue;
setVbar(panel.querySelector('.vbar'), f);
panel.classList.toggle('dead', f.stagger === 'dead' || f.stagger === 'unconscious');
panel.classList.toggle('targeted', f.id === target && f.stagger !== 'dead');
const cb = panel.querySelector('.castbar div');
if (f.casting) {
castMax[f.id] = Math.max(castMax[f.id] || 1, f.casting.remaining);
cb.style.width = `${(1 - f.casting.remaining / castMax[f.id]) * 100}%`;
} else { cb.style.width = '0%'; castMax[f.id] = 1; }
const chips = [];
if (f.stagger === 'dazed') chips.push('<span class="chip daze">DAZED</span>');
for (const s of f.statuses) chips.push(`<span class="chip">${s.replace('_', ' ')}</span>`);
panel.querySelector('.estatus').innerHTML = chips.join('');
}
}
// ---------------- combat ---------------- //
function startTicker() {
if (ticker) return;
ticker = setInterval(() => {
if (!game) return;
apply(game.combatTick());
}, TICK_MS);
}
function stopTicker() {
if (ticker) { clearInterval(ticker); ticker = null; }
}
function renderCombat() {
// retarget if the focus dropped
const foes = st.combat.actors.filter((a) => a.team === 1);
const focus = foes.find((f) => f.id === target);
if (!focus || focus.stagger === 'dead' || focus.stagger === 'unconscious') {
const alive = foes.find((f) => f.stagger === 'normal' || f.stagger === 'dazed');
if (alive) setTarget(alive.id, true);
}
for (const ev of st.combat.events) onCombatEvent(ev);
updateActionbar();
$('btn-auto').classList.toggle('lit', st.player.auto_attack);
if (st.combat.outcome) stopTicker();
}
function onCombatEvent(ev) {
switch (ev.t) {
case 'hit': {
const big = ev.dmg >= 18;
if (ev.tgt === 0) { sfx.playerHit(); floatAt('player', `-${ev.dmg.toFixed(0)}`, 'ouch'); }
else {
(big ? sfx.heavyHit : sfx.hit)();
floatAt(ev.tgt, `${ev.dmg.toFixed(0)}`, big ? 'big' : 'dmg');
hurt(ev.tgt);
}
if (ev.outcome === 'dazed') { sfx.daze(); floatAt(ev.tgt, 'DAZED', 'curse'); }
if (ev.tgt === 0) flashBar();
break;
}
case 'ceiling':
floatAt(ev.tgt === 0 ? 'player' : ev.tgt, `${ev.fatigue.toFixed(0)}`, 'curse');
break;
case 'recovery':
sfx.recovery();
floatAt(ev.tgt === 0 ? 'player' : ev.tgt, `+${ev.heal.toFixed(0)}`, 'heal');
break;
case 'cast': {
sfx.cast();
const name = (abilities.find((a) => a.id === ev.ability) || {}).name || ev.ability.replace(/_/g, ' ');
if (ev.actor !== 0) floatAt(ev.actor, name, 'info');
break;
}
case 'fizzle':
sfx.fizzle();
floatAt(ev.actor === 0 ? 'player' : ev.actor, 'fizzle!', 'info');
break;
case 'interrupt':
floatAt(ev.actor === 0 ? 'player' : ev.actor, 'interrupted!', 'info');
break;
case 'death':
if (ev.actor !== 0) sfx.monsterDeath();
break;
}
}
function hurt(actorId) {
const div = sprites.querySelector(`[data-actor="${actorId}"]`);
if (!div || div.classList.contains('dead')) return;
div.classList.remove('hurt');
void div.offsetWidth;
div.classList.add('hurt');
}
function flashBar() {
const bar = $('player-vbar');
bar.classList.remove('hurtflash');
void bar.offsetWidth;
bar.classList.add('hurtflash');
}
function floatAt(where, text, cls) {
const f = document.createElement('div');
f.className = `floater ${cls}`;
f.textContent = text;
if (where === 'player') {
f.style.left = `${44 + Math.random() * 12}%`;
f.style.bottom = '24%';
} else {
const div = sprites.querySelector(`[data-actor="${where}"]`);
const lane = div ? parseFloat(div.style.left) : 50;
f.style.left = `${lane - 4 + Math.random() * 8}%`;
f.style.bottom = `${46 + Math.random() * 8}%`;
}
floaters.appendChild(f);
setTimeout(() => f.remove(), 1150);
}
function setTarget(id, silent) {
target = id;
if (game) game.setTarget(id);
if (!silent) sfx.turn();
renderSprites(true);
if (st && st.combat) renderEnemies(true);
}
// ---------------- action bar ---------------- //
function buildActionbar() {
actionbar.innerHTML = '';
abilities.forEach((ab, i) => {
const b = document.createElement('button');
b.className = 'slot';
b.dataset.kind = ab.kind;
b.dataset.id = ab.id;
const cost = ab.fill_cost > 0 ? `${ab.fill_cost}` : ab.fatigue_cost > 0 ? `${ab.fatigue_cost}` : 'free';
b.innerHTML = `<span class="num">${i + 1}</span><span class="nm">${ab.name}</span><span class="cost">${cost}</span><div class="cd"></div>`;
b.onclick = () => useAbility(ab.id);
actionbar.appendChild(b);
});
}
function updateActionbar() {
const me = st.combat.actors[0];
const cds = Object.fromEntries(me.cooldowns.map((c) => [c.id, c.remaining]));
for (const slot of actionbar.children) {
const ab = abilities.find((a) => a.id === slot.dataset.id);
const remaining = cds[ab.id] || 0;
slot.querySelector('.cd').style.transform = `scaleY(${ab.cooldown ? Math.min(1, remaining / ab.cooldown) : 0})`;
slot.classList.toggle('locked', me.vigor < ab.fill_cost || me.stagger !== 'normal');
}
}
function useAbility(id) {
if (!game || !st || st.mode !== 'combat') return;
game.useAbility(id, target);
}
function toggleAuto() {
if (!game) return;
game.setAutoAttack(!st.player.auto_attack);
st.player.auto_attack = !st.player.auto_attack;
$('btn-auto').classList.toggle('lit', st.player.auto_attack);
}
function flee() {
if (!game || !st || st.mode !== 'combat') return;
stopTicker();
apply(game.flee());
}
// ---------------- banner / floor card / log ---------------- //
function bannerShow(text) {
banner.textContent = text;
banner.classList.remove('show');
void banner.offsetWidth;
banner.classList.add('show');
}
function floorCard(idx) {
$('floorcard-depth').textContent = `· floor ${ROMAN[idx]} ·`;
$('floorcard-name').textContent = CAMPAIGN[idx].name;
const fc = $('floorcard');
fc.classList.remove('show');
void fc.offsetWidth;
fc.classList.add('show');
log(`floor ${ROMAN[idx]}${CAMPAIGN[idx].name}`);
}
function log(text, cls = '') {
const line = document.createElement('div');
line.className = `line ${cls}`;
line.textContent = text;
logBox.appendChild(line);
while (logBox.childElementCount > 4) logBox.firstElementChild.remove();
}
// ---------------- minimap ---------------- //
function drawMinimap() {
const m = manifests[st.floor];
const { tiles, width, height } = m;
const s = Math.max(2, Math.floor(Math.min(196 / width, 124 / height)));
minimap.width = width * s;
minimap.height = height * s;
const visited = new Set(st.visited);
const danger = new Set(st.known_monsters);
// ghost layer: the bones of the floor, barely visible
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (tiles[y][x] !== 0) {
mctx.fillStyle = 'rgba(70, 64, 78, 0.16)';
mctx.fillRect(x * s, y * s, s, s);
}
}
}
// visited rooms: lit
for (const r of m.rooms) {
if (!visited.has(r.id)) continue;
for (let y = r.bounds.y; y < r.bounds.y + r.bounds.h; y++) {
for (let x = r.bounds.x; x < r.bounds.x + r.bounds.w; x++) {
const t = tiles[y] && tiles[y][x];
if (t == null || t === 0) continue;
mctx.fillStyle =
t === 2 ? 'rgba(202, 162, 74, 0.85)' :
t === 4 ? 'rgba(70, 120, 140, 0.8)' :
t === 5 ? 'rgba(190, 90, 40, 0.85)' :
'rgba(120, 112, 100, 0.55)';
mctx.fillRect(x * s, y * s, s, s);
}
}
}
// markers
for (const r of m.rooms) {
if (!visited.has(r.id)) continue;
const [vx, vy] = r.vantage;
if (r.id === st.exit_room) {
mctx.fillStyle = '#d4a54a';
mctx.font = `${s * 3}px monospace`;
mctx.fillText('▼', vx * s - s, vy * s + s * 1.5);
}
if (danger.has(r.id) && r.id !== st.room) {
mctx.fillStyle = '#b03a2e';
mctx.fillRect(vx * s - 1, vy * s - 1, s + 2, s + 2);
}
}
// the player
const here = m.rooms.find((r) => r.id === st.room);
if (here) {
const [vx, vy] = here.vantage;
const cx = vx * s + s / 2, cy = vy * s + s / 2;
mctx.fillStyle = '#e8dcc8';
mctx.beginPath();
mctx.arc(cx, cy, Math.max(2, s * 0.8), 0, Math.PI * 2);
mctx.fill();
const vec = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }[st.facing];
mctx.strokeStyle = '#d4a54a';
mctx.lineWidth = Math.max(1.5, s * 0.5);
mctx.beginPath();
mctx.moveTo(cx, cy);
mctx.lineTo(cx + vec[0] * s * 2.2, cy + vec[1] * s * 2.2);
mctx.stroke();
}
}
// ---------------- end screens ---------------- //
function statsHTML() {
const s = st.stats;
const rows = [
[s.kills, 'slain'],
[s.gold_looted, 'gold'],
[st.floor + 1, 'floors'],
[s.rooms_explored, 'rooms'],
[s.fights, 'battles'],
[`${Math.round(s.combat_ticks / 10)}s`, 'in combat'],
];
return rows
.map(([v, k]) => `<div class="stat"><div class="v">${v}</div><div class="k">${k}</div></div>`)
.join('');
}
function endDeath(by) {
stopTicker();
sfx.ambientOff();
$('death-by').textContent = by ? `${by} stands over your body on floor ${ROMAN[st.floor]}` : `floor ${ROMAN[st.floor]} keeps you`;
$('death-stats').innerHTML = statsHTML();
show('death');
}
function endVictory() {
stopTicker();
sfx.ambientOff();
$('victory-stats').innerHTML = statsHTML();
show('victory');
}
// ------------------------------------------------------------------ //
// Input
// ------------------------------------------------------------------ //
let lastRest = 0;
let restHide = null;
const restOverlay = $('rest-overlay');
// Shown by the rested event, self-hiding — robust even without a keyup.
function showRest() {
restOverlay.classList.add('show');
clearTimeout(restHide);
restHide = setTimeout(() => restOverlay.classList.remove('show'), 400);
}
window.addEventListener('keydown', (e) => {
if (!game || !st) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key.toLowerCase();
if (st.mode === 'explore') {
if (k === 'w' || k === 'arrowup') apply(game.step(true));
else if (k === 's' || k === 'arrowdown') apply(game.step(false));
else if (k === 'a' || k === 'arrowleft') { sfx.turn(); apply(game.turn(-1)); }
else if (k === 'd' || k === 'arrowright') { sfx.turn(); apply(game.turn(1)); }
else if (k === 'r') {
const now = Date.now();
if (now - lastRest > 130) { lastRest = now; apply(game.rest()); }
} else if (k === 'g') apply(game.useItem('draught'));
else if (k === 't') apply(game.useItem('tonic'));
else if (k === 'enter') apply(game.descend());
else return;
e.preventDefault();
return;
}
if (st.mode === 'combat') {
const n = parseInt(k, 10);
if (n >= 1 && n <= abilities.length) useAbility(abilities[n - 1].id);
else if (k === ' ') toggleAuto();
else if (k === 'f') flee();
else if (k === 'tab') {
const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1 && a.stagger !== 'dead') : [];
if (foes.length > 1) {
const i = foes.findIndex((f) => f.id === target);
setTarget(foes[(i + 1) % foes.length].id);
}
} else return;
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
if (e.key.toLowerCase() === 'r') restOverlay.classList.remove('show');
});
// Scripting/debug handle (same spirit as the playground's window.reikhelm).
window.descent = {
get st() { return st; },
cmd(name, ...args) { if (game) apply(game[name](...args)); },
start(arch, s) { if (s != null) seed = s; startRun(arch); },
};
boot().catch((e) => {
document.body.innerHTML = `<pre style="color:#b03a2e;padding:40px">boot failed: ${e}\n${e.stack || ''}</pre>`;
});

134
game-web/index.html Normal file
View file

@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>REIKHELM: DESCENT</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ======================= TITLE ======================= -->
<section id="title" class="screen show">
<div class="title-wrap">
<h1 class="wordmark">REIKHELM</h1>
<div class="subtitle">— D E S C E N T —</div>
<p class="tagline">Three floors down sits the Ember Sovereign.<br>Take the crown, or join the bones.</p>
<div class="archetypes">
<button class="arch-card" data-arch="duelist">
<img src="portraits/duelist.png" alt="">
<span class="arch-name">The Duelist</span>
<span class="arch-blurb">Steel, grit, and a wide pool.<br>Guard the blows. Answer harder.</span>
</button>
<button class="arch-card" data-arch="battlemage">
<img src="portraits/battlemage.png" alt="">
<span class="arch-name">The Sorcerer</span>
<span class="arch-blurb">A thin frame, a deep art.<br>Curse, burn, and never get hit.</span>
</button>
</div>
<div class="seed-row">
<span class="dim">fate</span>
<span id="seed-label" class="seed"></span>
<button id="reroll" title="reroll fate"></button>
</div>
<div class="hint dim">choose your champion to begin</div>
</div>
</section>
<!-- ======================= GAME ======================= -->
<section id="game" class="screen">
<div id="stage">
<img id="frame" alt="" draggable="false">
<div id="frame-fade"></div>
<div id="vignette"></div>
<div id="danger-vignette"></div>
<!-- monsters composited over the empty baked scene -->
<div id="sprites"></div>
<!-- floating combat numbers -->
<div id="floaters"></div>
<!-- encounter banner -->
<div id="banner"></div>
<!-- floor transition card -->
<div id="floorcard">
<div id="floorcard-depth"></div>
<div id="floorcard-name"></div>
</div>
<!-- enemy panels (combat) -->
<div id="enemies"></div>
<!-- top HUD -->
<div id="hud-top">
<div id="floor-label"></div>
<div id="compass"></div>
</div>
<!-- descend prompt -->
<div id="descend-prompt" class="prompt">▼ the stair descends — <b>[enter]</b></div>
<div id="rest-overlay">· resting ·</div>
<!-- bottom HUD -->
<div id="hud">
<div id="log"></div>
<div id="player-panel">
<div id="player-bars">
<div class="vbar" id="player-vbar">
<div class="vbar-cap"></div>
<div class="vbar-fill"></div>
<div class="vbar-ceiling"></div>
<div class="vbar-num"></div>
</div>
<div id="player-cast" class="castbar"><div></div></div>
</div>
<div id="belt">
<span class="belt-item" id="belt-gold" title="gold"><b>0</b></span>
<span class="belt-item key" id="belt-draught" title="vigor draught — restores fill [G]">🜪 <b>0</b><span class="kb">G</span></span>
<span class="belt-item key" id="belt-tonic" title="stamina tonic — clears fatigue [T]">🜍 <b>0</b><span class="kb">T</span></span>
<span class="belt-item" id="belt-relic" title="relics — widen your pool"><b>0</b></span>
</div>
<div id="actionbar"></div>
<div id="combat-controls">
<button id="btn-auto" class="ctl">AUTO <span class="kb">space</span></button>
<button id="btn-flee" class="ctl">FLEE <span class="kb">F</span></button>
</div>
</div>
<canvas id="minimap"></canvas>
</div>
<div id="keys-hint" class="dim">w/s move · a/d turn · r rest · g/t drink · enter stairs · 1-5 abilities</div>
</div>
</section>
<!-- ======================= END SCREENS ======================= -->
<section id="death" class="screen">
<div class="end-wrap">
<h1 class="end-title doom">THE DESCENT CLAIMS YOU</h1>
<div id="death-by" class="end-sub"></div>
<div id="death-stats" class="stats"></div>
<div class="end-buttons">
<button id="btn-retry" class="big-btn">descend again</button>
</div>
</div>
</section>
<section id="victory" class="screen">
<div class="end-wrap">
<img id="victory-boss" src="portraits/boss.png" alt="">
<h1 class="end-title gold">THE EMBER CROWN IS YOURS</h1>
<div class="end-sub">the Sovereign's fire gutters out beneath you</div>
<div id="victory-stats" class="stats"></div>
<div class="end-buttons">
<button id="btn-again" class="big-btn">descend again</button>
</div>
</div>
</section>
<script type="module" src="game.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

BIN
game-web/portraits/boss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

BIN
game-web/portraits/rat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

384
game-web/style.css Normal file
View file

@ -0,0 +1,384 @@
/* REIKHELM: DESCENT torchlit retro UI over the Primordyn-dithered frames.
Palette pulls from the baked atlas: deep near-black stone, warm torch gold,
ember orange, bone parchment, and a cold cistern teal for accents. */
:root {
--void: #0a0908;
--stone: #15131a;
--stone-2: #221e26;
--line: #3a3340;
--bone: #e8dcc8;
--dim: #8d8273;
--gold: #d4a54a;
--ember: #e06a28;
--blood: #b03a2e;
--teal: #5a9aa8;
--vigor: #d8b95e;
--vigor-deep: #7a6228;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--void);
color: var(--bone);
font-family: "Menlo", "Consolas", "Courier New", monospace;
font-size: 14px;
overflow: hidden;
user-select: none;
}
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
.dim { color: var(--dim); }
.kb {
font-size: 10px; color: var(--dim); border: 1px solid var(--line);
border-radius: 3px; padding: 0 4px; margin-left: 6px; letter-spacing: 1px;
}
/* ---------------- screens ---------------- */
.screen {
position: absolute; inset: 0; display: none;
align-items: center; justify-content: center;
}
.screen.show { display: flex; }
/* ---------------- title ---------------- */
#title { background: radial-gradient(ellipse at 50% 35%, #1b1410 0%, var(--void) 70%); }
.title-wrap { text-align: center; animation: rise 0.9s ease-out; }
@keyframes rise { from { opacity: 0; transform: translateY(18px); } }
.wordmark {
font-size: 72px; letter-spacing: 26px; margin-left: 26px; font-weight: 700;
color: var(--gold);
text-shadow: 0 0 18px rgba(224, 106, 40, 0.45), 0 2px 0 #6b4a17, 0 5px 14px #000;
}
.subtitle { letter-spacing: 10px; color: var(--ember); margin-top: 6px; font-size: 15px; }
.tagline { margin: 26px 0 30px; color: var(--dim); line-height: 1.7; }
.archetypes { display: flex; gap: 26px; justify-content: center; }
.arch-card {
width: 240px; padding: 16px 16px 18px;
background: linear-gradient(180deg, var(--stone-2), var(--stone));
border: 1px solid var(--line); border-radius: 6px;
display: flex; flex-direction: column; align-items: center; gap: 10px;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.arch-card:hover {
transform: translateY(-4px); border-color: var(--gold);
box-shadow: 0 8px 30px rgba(212, 165, 74, 0.18);
}
.arch-card img {
width: 168px; height: 168px; object-fit: cover; border-radius: 4px;
border: 1px solid var(--line); image-rendering: auto;
}
.arch-name { color: var(--gold); letter-spacing: 3px; font-size: 16px; }
.arch-blurb { color: var(--dim); font-size: 12px; line-height: 1.6; }
.seed-row { margin-top: 30px; display: flex; gap: 10px; justify-content: center; align-items: center; }
.seed { color: var(--teal); letter-spacing: 2px; }
#reroll { color: var(--dim); font-size: 18px; }
#reroll:hover { color: var(--gold); }
.hint { margin-top: 14px; font-size: 12px; letter-spacing: 2px; }
/* ---------------- stage ---------------- */
#game { background: var(--void); }
#stage {
position: relative;
width: min(100vw, calc(100vh * 16 / 9));
aspect-ratio: 16 / 9;
background: #000;
overflow: hidden;
box-shadow: 0 0 80px rgba(0,0,0,0.9);
}
#frame {
position: absolute; inset: 0; width: 100%; height: 100%;
image-rendering: pixelated;
object-fit: cover;
}
#frame-fade {
position: absolute; inset: 0; background: #000; opacity: 0; pointer-events: none;
transition: opacity 0.1s linear;
}
#frame-fade.dip { opacity: 1; transition: none; }
#vignette {
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(ellipse at 50% 46%, transparent 52%, rgba(0,0,0,0.55) 100%);
}
#danger-vignette {
position: absolute; inset: 0; pointer-events: none; opacity: 0;
background: radial-gradient(ellipse at 50% 50%, transparent 58%, rgba(140, 30, 20, 0.35) 100%);
transition: opacity 1.2s;
}
#danger-vignette.on { opacity: 1; animation: dangerpulse 2.6s ease-in-out infinite; }
@keyframes dangerpulse { 50% { opacity: 0.45; } }
/* ---------------- monsters ---------------- */
#sprites { position: absolute; inset: 0; pointer-events: none; }
.sprite {
position: absolute; bottom: 16%; left: 50%; pointer-events: auto; cursor: pointer;
transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center;
}
.sprite img {
width: 30vmin; height: 30vmin; object-fit: cover;
mix-blend-mode: screen; /* portraits sit on near-black — melts into the scene */
filter: drop-shadow(0 18px 26px rgba(0,0,0,0.8)) contrast(1.04);
animation: sway 3.2s ease-in-out infinite;
image-rendering: auto;
/* feather the square portrait into the scene */
-webkit-mask-image: radial-gradient(ellipse 50% 50% at 50% 46%, #000 58%, transparent 82%);
mask-image: radial-gradient(ellipse 50% 50% at 50% 46%, #000 58%, transparent 82%);
}
.sprite.slot-1 img { animation-delay: -1.6s; }
.sprite.enter img { animation: lunge 0.45s ease-out, sway 3.2s ease-in-out 0.45s infinite; }
@keyframes lunge {
from { opacity: 0; transform: scale(0.6) translateY(8%); }
60% { opacity: 1; transform: scale(1.06); }
to { transform: scale(1); }
}
@keyframes sway { 50% { transform: translateY(-1.6%) scale(1.012); } }
.sprite.hurt img { animation: hurtflash 0.28s ease-out, sway 3.2s ease-in-out 0.28s infinite; }
@keyframes hurtflash {
0% { filter: brightness(2.4) sepia(1) hue-rotate(-28deg) saturate(3); transform: translateX(-2%); }
50% { transform: translateX(2%); }
}
.sprite.dazed img { filter: drop-shadow(0 18px 26px rgba(0,0,0,0.8)) saturate(0.6) brightness(0.85); animation: dazedwobble 1.4s ease-in-out infinite; }
@keyframes dazedwobble { 25% { transform: rotate(-2.4deg); } 75% { transform: rotate(2.4deg); } }
.sprite.dead img {
animation: collapse 0.9s ease-in forwards; mix-blend-mode: screen;
}
@keyframes collapse {
to { opacity: 0; transform: translateY(24%) scale(0.82); filter: saturate(0) brightness(0.4); }
}
.sprite .tgt {
margin-top: 4px; color: var(--gold); font-size: 16px; opacity: 0;
text-shadow: 0 0 8px var(--ember);
}
.sprite.targeted .tgt { opacity: 1; animation: tgtbob 1s ease-in-out infinite; }
@keyframes tgtbob { 50% { transform: translateY(-3px); } }
/* ---------------- floaters ---------------- */
#floaters { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.floater {
position: absolute; font-weight: 700; font-size: 22px;
text-shadow: 0 2px 0 #000, 0 0 12px rgba(0,0,0,0.8);
animation: floatup 1.1s ease-out forwards;
}
.floater.dmg { color: var(--bone); }
.floater.big { color: var(--ember); font-size: 30px; }
.floater.ouch { color: var(--blood); }
.floater.heal { color: #7fc06a; }
.floater.curse { color: #b07ad0; }
.floater.info { color: var(--teal); font-size: 16px; font-weight: 400; }
@keyframes floatup {
from { opacity: 0; transform: translateY(10px) scale(0.8); }
20% { opacity: 1; transform: translateY(0) scale(1.06); }
to { opacity: 0; transform: translateY(-58px) scale(1); }
}
/* ---------------- banner / floor card ---------------- */
#banner {
position: absolute; top: 24%; left: 0; right: 0; text-align: center;
font-size: 30px; letter-spacing: 8px; color: var(--blood); font-weight: 700;
text-shadow: 0 0 18px rgba(176, 58, 46, 0.7), 0 2px 0 #000;
opacity: 0; pointer-events: none;
}
#banner.show { animation: bannerin 1.6s ease-out; }
@keyframes bannerin { 8% { opacity: 1; } 70% { opacity: 1; } 100% { opacity: 0; } }
#floorcard {
position: absolute; inset: 0; background: #000;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
opacity: 0; pointer-events: none;
}
#floorcard.show { animation: floorcard 2.4s ease-in-out; }
@keyframes floorcard { 12% { opacity: 1; } 78% { opacity: 1; } 100% { opacity: 0; } }
#floorcard-depth { color: var(--dim); letter-spacing: 6px; font-size: 14px; }
#floorcard-name { color: var(--gold); letter-spacing: 8px; font-size: 30px; }
/* ---------------- enemy panels ---------------- */
#enemies {
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
display: flex; gap: 14px;
}
.epanel {
width: 280px; padding: 8px 10px;
background: rgba(10, 9, 8, 0.82);
border: 1px solid var(--line); border-radius: 5px;
cursor: pointer;
transition: border-color 0.15s;
}
.epanel.targeted { border-color: var(--gold); }
.epanel .ehead { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 5px; }
.epanel .ename { color: var(--bone); letter-spacing: 1px; font-size: 13px; }
.epanel .elvl { color: var(--dim); font-size: 11px; }
.epanel.dead { opacity: 0.45; filter: saturate(0); }
.estatus { margin-top: 4px; font-size: 10px; height: 13px; color: var(--dim); letter-spacing: 1px; }
.estatus .chip { color: #b07ad0; margin-right: 7px; }
.estatus .chip.daze { color: var(--gold); }
/* ---------------- vigor bars ---------------- */
.vbar {
position: relative; height: 16px;
background: #060505;
border: 1px solid var(--line); border-radius: 3px; overflow: hidden;
}
.vbar-cap { position: absolute; inset: 0; width: 100%; background: #241f14; }
.vbar-fill {
position: absolute; top: 0; bottom: 0; left: 0; width: 100%;
background: linear-gradient(180deg, var(--vigor), var(--vigor-deep));
transition: width 0.18s ease-out;
}
.vbar-ceiling {
position: absolute; top: 0; bottom: 0; width: 2px; background: var(--gold);
box-shadow: 0 0 6px var(--gold); transition: left 0.3s;
}
.vbar-num {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-size: 10px; color: rgba(10, 8, 4, 0.9); font-weight: 700; letter-spacing: 1px;
text-shadow: 0 0 4px rgba(232, 220, 200, 0.35);
}
.vbar.hurtflash { animation: barflash 0.25s; }
@keyframes barflash { from { box-shadow: 0 0 16px var(--blood); } }
.castbar {
height: 5px; margin-top: 3px; border-radius: 2px; background: #100d12; overflow: hidden;
}
.castbar div {
height: 100%; width: 0%;
background: linear-gradient(90deg, #6c4f8f, #b07ad0);
}
.castbar.player div { background: linear-gradient(90deg, #2e6f7e, var(--teal)); }
/* ---------------- HUD ---------------- */
#hud-top {
position: absolute; top: 10px; left: 14px; right: 14px;
display: flex; justify-content: space-between; pointer-events: none;
text-shadow: 0 1px 0 #000, 0 0 10px #000;
}
#floor-label { color: var(--dim); letter-spacing: 3px; font-size: 12px; }
#floor-label b { color: var(--bone); }
#compass { color: var(--gold); letter-spacing: 2px; font-size: 13px; }
#hud {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: flex-end; justify-content: space-between;
padding: 10px 12px; gap: 12px;
background: linear-gradient(180deg, transparent, rgba(5, 4, 4, 0.88) 55%);
}
#log { width: 300px; font-size: 11.5px; line-height: 1.65; min-height: 56px; pointer-events: none; }
#log .line { color: var(--dim); text-shadow: 0 1px 0 #000; }
#log .line:last-child { color: var(--bone); }
#log .line.good { color: var(--gold); }
#log .line.bad { color: var(--blood); }
#player-panel { flex: 1; max-width: 560px; }
#player-vbar { height: 20px; }
#player-cast.castbar { display: none; }
#player-cast.castbar.on { display: block; }
#belt { display: flex; gap: 16px; margin-top: 7px; justify-content: center; font-size: 13px; }
.belt-item { color: var(--dim); }
.belt-item b { color: var(--bone); }
#belt-gold { color: var(--gold); }
#belt-gold b { color: var(--gold); }
.belt-item.flash { animation: beltflash 0.5s; }
@keyframes beltflash { from { text-shadow: 0 0 14px var(--gold); transform: scale(1.25); } }
/* ---------------- action bar ---------------- */
#actionbar { display: none; gap: 8px; margin-top: 8px; justify-content: center; }
#actionbar.on { display: flex; }
.slot {
position: relative; width: 92px; padding: 7px 4px 5px;
background: linear-gradient(180deg, var(--stone-2), var(--stone));
border: 1px solid var(--line); border-radius: 4px;
text-align: center; overflow: hidden;
transition: border-color 0.1s, transform 0.06s;
}
.slot:hover { border-color: var(--dim); }
.slot:active { transform: translateY(1px); }
.slot .nm { font-size: 10px; letter-spacing: 0.5px; display: block; color: var(--bone); white-space: nowrap; }
.slot .cost { font-size: 9px; color: var(--dim); display: block; margin-top: 2px; }
.slot .num {
position: absolute; top: 2px; left: 5px; font-size: 9px; color: var(--dim);
}
.slot .cd {
position: absolute; inset: 0; background: rgba(5, 4, 6, 0.78);
transform-origin: bottom; transform: scaleY(0); pointer-events: none;
}
.slot[data-kind="auto"] .nm { color: var(--dim); }
.slot[data-kind="skill"] .nm { color: var(--vigor); }
.slot[data-kind="spell"] .nm { color: var(--ember); }
.slot[data-kind="curse"] .nm { color: #b07ad0; }
.slot[data-kind="recovery"] .nm { color: #7fc06a; }
.slot[data-kind="defense"] .nm { color: var(--teal); }
.slot.locked { opacity: 0.4; }
#combat-controls { display: none; gap: 10px; margin-top: 7px; justify-content: center; }
#combat-controls.on { display: flex; }
.ctl {
font-size: 11px; letter-spacing: 2px; color: var(--dim);
border: 1px solid var(--line); border-radius: 4px; padding: 4px 12px;
}
.ctl:hover { color: var(--bone); border-color: var(--dim); }
.ctl.lit { color: var(--gold); border-color: var(--gold); }
/* ---------------- minimap ---------------- */
#minimap {
width: 196px; height: 124px;
border: 1px solid var(--line); border-radius: 4px;
background: rgba(8, 7, 8, 0.85);
image-rendering: pixelated;
}
/* ---------------- prompts ---------------- */
.prompt {
position: absolute; bottom: 26%; left: 0; right: 0; text-align: center;
color: var(--gold); letter-spacing: 2px; font-size: 16px;
text-shadow: 0 0 14px rgba(212, 165, 74, 0.6), 0 2px 0 #000;
opacity: 0; pointer-events: none; transition: opacity 0.3s;
}
.prompt.show { opacity: 1; animation: promptpulse 2s ease-in-out infinite; }
@keyframes promptpulse { 50% { transform: translateY(-3px); } }
#rest-overlay {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.45); color: var(--teal); letter-spacing: 6px; font-size: 18px;
opacity: 0; pointer-events: none; transition: opacity 0.25s;
}
#rest-overlay.show { opacity: 1; }
#keys-hint {
position: absolute; bottom: 2px; left: 0; right: 0; text-align: center;
font-size: 10px; letter-spacing: 1.5px; opacity: 0.4; pointer-events: none;
}
/* ---------------- end screens ---------------- */
#death { background: radial-gradient(ellipse at 50% 40%, #1a0c0a 0%, var(--void) 75%); }
#victory { background: radial-gradient(ellipse at 50% 38%, #241307 0%, var(--void) 75%); }
.end-wrap { text-align: center; animation: rise 1.1s ease-out; max-width: 640px; }
.end-title { font-size: 38px; letter-spacing: 10px; margin-bottom: 12px; }
.end-title.doom { color: var(--blood); text-shadow: 0 0 24px rgba(176, 58, 46, 0.5), 0 3px 0 #000; }
.end-title.gold { color: var(--gold); text-shadow: 0 0 24px rgba(224, 106, 40, 0.6), 0 3px 0 #000; }
.end-sub { color: var(--dim); margin-bottom: 28px; letter-spacing: 1px; }
#victory-boss {
width: 200px; height: 200px; object-fit: cover; border-radius: 6px;
border: 1px solid var(--gold); margin-bottom: 22px;
box-shadow: 0 0 50px rgba(224, 106, 40, 0.35);
filter: saturate(0.8) brightness(0.9);
}
.stats {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px 30px;
margin: 0 auto 30px; width: fit-content;
}
.stats .stat { text-align: center; }
.stats .v { font-size: 24px; color: var(--bone); }
.stats .k { font-size: 10px; color: var(--dim); letter-spacing: 2px; margin-top: 3px; }
.big-btn {
border: 1px solid var(--gold); color: var(--gold); border-radius: 4px;
padding: 12px 34px; letter-spacing: 4px; font-size: 14px;
transition: background 0.15s, box-shadow 0.15s;
}
.big-btn:hover { background: rgba(212, 165, 74, 0.12); box-shadow: 0 0 24px rgba(212, 165, 74, 0.25); }

12
tools/.gitignore vendored
View file

@ -7,8 +7,16 @@ __pycache__/
.DS_Store .DS_Store
# All pipeline outputs live here — depth maps, renders, dither, baked atlases, # All pipeline outputs live here — depth maps, renders, dither, baked atlases,
# and the local gallery of keepers. Regenerable; organized; never committed. # and the local gallery of keepers. Regenerable; organized; not committed…
out/ out/*
# …EXCEPT the DESCENT campaign atlases: they are the game's shipped art
# (regenerating needs the LAN render box, so the keepers are versioned).
!out/atlas/
out/atlas/*
!out/atlas/7/
!out/atlas/69/
!out/atlas/59/
out/atlas/*/_work/
# Legacy ad-hoc outputs (pre-tools/out/ layout). Curated keepers live in pixelart/samples/. # Legacy ad-hoc outputs (pre-tools/out/ layout). Curated keepers live in pixelart/samples/.
comfy-spike/out_*.png comfy-spike/out_*.png

File diff suppressed because one or more lines are too long

BIN
tools/out/atlas/59/r0_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
tools/out/atlas/59/r0_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
tools/out/atlas/59/r0_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
tools/out/atlas/59/r0_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
tools/out/atlas/59/r1_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
tools/out/atlas/59/r1_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
tools/out/atlas/59/r1_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
tools/out/atlas/59/r1_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
tools/out/atlas/59/r2_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
tools/out/atlas/59/r2_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
tools/out/atlas/59/r2_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
tools/out/atlas/59/r2_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
tools/out/atlas/59/r3_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
tools/out/atlas/59/r3_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
tools/out/atlas/59/r3_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
tools/out/atlas/59/r3_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
tools/out/atlas/59/r4_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
tools/out/atlas/59/r4_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
tools/out/atlas/59/r4_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
tools/out/atlas/59/r4_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
tools/out/atlas/59/r5_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
tools/out/atlas/59/r5_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
tools/out/atlas/59/r5_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
tools/out/atlas/59/r5_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
tools/out/atlas/59/r6_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
tools/out/atlas/59/r6_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
tools/out/atlas/59/r6_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
tools/out/atlas/59/r6_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
tools/out/atlas/59/r7_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
tools/out/atlas/59/r7_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
tools/out/atlas/59/r7_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
tools/out/atlas/59/r7_W.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
tools/out/atlas/59/r8_E.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
tools/out/atlas/59/r8_N.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
tools/out/atlas/59/r8_S.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Some files were not shown because too many files have changed in this diff Show more