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>
1
.gitignore
vendored
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
BIN
combat/combat-web/portraits/boss.png
Normal file
|
After Width: | Height: | Size: 756 KiB |
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
game-wasm/src/monster_ai.rs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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** `1–5` 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||||
BIN
game-web/portraits/battlemage.png
Normal file
|
After Width: | Height: | Size: 721 KiB |
BIN
game-web/portraits/boss.png
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
game-web/portraits/duelist.png
Normal file
|
After Width: | Height: | Size: 688 KiB |
BIN
game-web/portraits/golem.png
Normal file
|
After Width: | Height: | Size: 808 KiB |
BIN
game-web/portraits/rat.png
Normal file
|
After Width: | Height: | Size: 724 KiB |
BIN
game-web/portraits/skeleton.png
Normal file
|
After Width: | Height: | Size: 757 KiB |
BIN
game-web/portraits/troll.png
Normal file
|
After Width: | Height: | Size: 708 KiB |
BIN
game-web/portraits/wraith.png
Normal file
|
After Width: | Height: | Size: 616 KiB |
384
game-web/style.css
Normal 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
|
|
@ -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
|
||||||
|
|
|
||||||
1
tools/out/atlas/59/manifest.json
Normal file
BIN
tools/out/atlas/59/r0_E.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
tools/out/atlas/59/r0_N.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tools/out/atlas/59/r0_S.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tools/out/atlas/59/r0_W.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
tools/out/atlas/59/r10_E.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
tools/out/atlas/59/r10_N.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tools/out/atlas/59/r10_S.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
tools/out/atlas/59/r10_W.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
tools/out/atlas/59/r11_E.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
tools/out/atlas/59/r11_N.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
tools/out/atlas/59/r11_S.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
tools/out/atlas/59/r11_W.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
tools/out/atlas/59/r12_E.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
tools/out/atlas/59/r12_N.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tools/out/atlas/59/r12_S.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
tools/out/atlas/59/r12_W.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tools/out/atlas/59/r13_E.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
tools/out/atlas/59/r13_N.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
tools/out/atlas/59/r13_S.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tools/out/atlas/59/r13_W.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
tools/out/atlas/59/r14_E.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
tools/out/atlas/59/r14_N.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
tools/out/atlas/59/r14_S.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
tools/out/atlas/59/r14_W.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
tools/out/atlas/59/r15_E.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
tools/out/atlas/59/r15_N.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
tools/out/atlas/59/r15_S.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
tools/out/atlas/59/r15_W.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
tools/out/atlas/59/r16_E.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
tools/out/atlas/59/r16_N.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
tools/out/atlas/59/r16_S.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tools/out/atlas/59/r16_W.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tools/out/atlas/59/r17_E.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tools/out/atlas/59/r17_N.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
tools/out/atlas/59/r17_S.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
tools/out/atlas/59/r17_W.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tools/out/atlas/59/r18_E.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tools/out/atlas/59/r18_N.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tools/out/atlas/59/r18_S.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
tools/out/atlas/59/r18_W.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
tools/out/atlas/59/r1_E.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tools/out/atlas/59/r1_N.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
tools/out/atlas/59/r1_S.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tools/out/atlas/59/r1_W.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tools/out/atlas/59/r2_E.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tools/out/atlas/59/r2_N.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
tools/out/atlas/59/r2_S.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
tools/out/atlas/59/r2_W.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tools/out/atlas/59/r3_E.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tools/out/atlas/59/r3_N.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tools/out/atlas/59/r3_S.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tools/out/atlas/59/r3_W.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
tools/out/atlas/59/r4_E.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
tools/out/atlas/59/r4_N.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
tools/out/atlas/59/r4_S.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
tools/out/atlas/59/r4_W.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
tools/out/atlas/59/r5_E.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tools/out/atlas/59/r5_N.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
tools/out/atlas/59/r5_S.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tools/out/atlas/59/r5_W.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
tools/out/atlas/59/r6_E.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
tools/out/atlas/59/r6_N.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
tools/out/atlas/59/r6_S.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
tools/out/atlas/59/r6_W.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tools/out/atlas/59/r7_E.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
tools/out/atlas/59/r7_N.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tools/out/atlas/59/r7_S.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tools/out/atlas/59/r7_W.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tools/out/atlas/59/r8_E.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tools/out/atlas/59/r8_N.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tools/out/atlas/59/r8_S.png
Normal file
|
After Width: | Height: | Size: 79 KiB |