diff --git a/.gitignore b/.gitignore index a4455a3..1cfe39a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # WASM build output (regenerated by wasm-pack) reikhelm-web/pkg/ +game-web/pkg/ # Playwright MCP artifacts + iteration screenshots (scratch, not source) .playwright-mcp/ diff --git a/Cargo.lock b/Cargo.lock index 8245be4..a3bb806 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "combat-core" +version = "0.1.0" +dependencies = [ + "rand_chacha", + "serde", + "serde_json", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -106,6 +115,16 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "game-wasm" +version = "0.1.0" +dependencies = [ + "combat-core", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "glam" version = "0.27.0" diff --git a/Cargo.toml b/Cargo.toml index 6441bd0..0ccc89c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] 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 # 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 diff --git a/combat/combat-web/portraits/boss.png b/combat/combat-web/portraits/boss.png new file mode 100644 index 0000000..ff16f35 Binary files /dev/null and b/combat/combat-web/portraits/boss.png differ diff --git a/combat/tools/portraits.py b/combat/tools/portraits.py index 24628d9..90d4141 100644 --- a/combat/tools/portraits.py +++ b/combat/tools/portraits.py @@ -58,6 +58,9 @@ ROSTER = { "skeletal grasping hands reaching forward, incorporeal and dreadful", "battlemage": "a gaunt dark sorcerer, deep hooded robe, sunken shadowed eyes, crackling violet " "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", } diff --git a/game-wasm/Cargo.toml b/game-wasm/Cargo.toml new file mode 100644 index 0000000..9dbb6fd --- /dev/null +++ b/game-wasm/Cargo.toml @@ -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" diff --git a/game-wasm/src/lib.rs b/game-wasm/src/lib.rs new file mode 100644 index 0000000..aad3a43 --- /dev/null +++ b/game-wasm/src/lib.rs @@ -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//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 { + 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) + } +} diff --git a/game-wasm/src/manifest.rs b/game-wasm/src/manifest.rs new file mode 100644 index 0000000..ca2f670 --- /dev/null +++ b/game-wasm/src/manifest.rs @@ -0,0 +1,131 @@ +//! Parse the atlas manifest the browser baked (`tools/out/atlas//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 { + #[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 ByDir { + 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, + pub nav: ByDir>, + pub open: ByDir, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct Manifest { + pub seed: u64, + #[serde(rename = "startRoom")] + pub start_room: usize, + pub rooms: Vec, +} + +impl Manifest { + pub fn parse(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| format!("bad manifest json: {e}")) + } + + pub fn by_id(&self) -> HashMap { + 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 { + 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) + } +} diff --git a/game-wasm/src/monster_ai.rs b/game-wasm/src/monster_ai.rs new file mode 100644 index 0000000..7011596 --- /dev/null +++ b/game-wasm/src/monster_ai.rs @@ -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" + } +} diff --git a/game-wasm/src/run.rs b/game-wasm/src/run.rs new file mode 100644 index 0000000..1a1a873 --- /dev/null +++ b/game-wasm/src/run.rs @@ -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 }, + Loot { gold: i64, item: Option }, + ItemUsed { kind: String, ok: bool }, + Rested, + WinFight { gold: i64, drops: Vec }, + Fled, + Descend { floor: usize }, + Relic { total: i32 }, + PlayerDied { by: Option }, + 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, + pub floor_idx: usize, + pub room: usize, + pub prev_room: usize, + pub facing: Dir, + pub mode: Mode, + pub battle: Option, + pub player: Player, + pub stats: RunStats, + pub events: Vec, + /// Combat events drained on the most recent `combat_tick`, for floaters. + pub combat_events: Vec, + /// 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, +} + +impl GameRun { + pub fn new( + config_json: &str, + manifests_json: &str, + archetype: &str, + run_seed: u64, + ) -> Result { + 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::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 = 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> = 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 = 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 +} diff --git a/game-wasm/src/spawn.rs b/game-wasm/src/spawn.rs new file mode 100644 index 0000000..3492192 --- /dev/null +++ b/game-wasm/src/spawn.rs @@ -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>, + pub treasure: BTreeMap, + pub visited: BTreeSet, + 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 { + 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> = BTreeMap::new(); + let mut treasure: BTreeMap = 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, + } +} diff --git a/game-wasm/src/views.rs b/game-wasm/src/views.rs new file mode 100644 index 0000000..645b0d2 --- /dev/null +++ b/game-wasm/src/views.rs @@ -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, + team: u8, + level: i32, + vigor: f64, + cap: f64, + fatigue: f64, + max_vigor: f64, + stagger: String, + casting: Option, + statuses: Vec, + cooldowns: Vec, +} + +#[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, + actors: Vec, + events: Vec, +} + +#[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, + /// Rooms the player has seen that still hold live monsters (fled fights). + known_monsters: Vec, + /// 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, + events: Vec, + stats: RunStats, + combat: Option, +} + +pub fn state_json(run: &mut GameRun) -> String { + let events: Vec = run.events.drain(..).collect(); + let combat_events: Vec = 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 = floor + .monsters + .keys() + .filter(|id| floor.visited.contains(id)) + .copied() + .collect(); + + let room_monsters: Vec = 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 { + 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 = 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" +} diff --git a/game-wasm/tests/run.rs b/game-wasm/tests/run.rs new file mode 100644 index 0000000..b914c7e --- /dev/null +++ b/game-wasm/tests/run.rs @@ -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 = 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 { + 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> { + let by_id = run.floor().manifest.by_id(); + let mut prev: HashMap = 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> = + 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" + ); +} diff --git a/game-web/README.md b/game-web/README.md new file mode 100644 index 0000000..043fd30 --- /dev/null +++ b/game-web/README.md @@ -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// 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. diff --git a/game-web/audio.js b/game-web/audio.js new file mode 100644 index 0000000..a60a404 --- /dev/null +++ b/game-web/audio.js @@ -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; + }, +}; diff --git a/game-web/config.json b/game-web/config.json new file mode 100644 index 0000000..f081a11 --- /dev/null +++ b/game-web/config.json @@ -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" + ] + } + } +} \ No newline at end of file diff --git a/game-web/game.js b/game-web/game.js new file mode 100644 index 0000000..f60392f --- /dev/null +++ b/game-web/game.js @@ -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 ${ROMAN[st.floor]} · ${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 = ``; + 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 = ` +
${f.name}lvl ${f.level}
+
+
+
`; + 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('DAZED'); + for (const s of f.statuses) chips.push(`${s.replace('_', ' ')}`); + 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 = `${i + 1}${ab.name}${cost}
`; + 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]) => `
${v}
${k}
`) + .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 = `
boot failed: ${e}\n${e.stack || ''}
`; +}); diff --git a/game-web/index.html b/game-web/index.html new file mode 100644 index 0000000..b024463 --- /dev/null +++ b/game-web/index.html @@ -0,0 +1,134 @@ + + + + + +REIKHELM: DESCENT + + + + + +
+
+

REIKHELM

+
— D E S C E N T —
+

Three floors down sits the Ember Sovereign.
Take the crown, or join the bones.

+ +
+ + +
+ +
+ fate + + +
+
choose your champion to begin
+
+
+ + +
+
+ +
+
+
+ + +
+ + +
+ + + + + +
+
+
+
+ + +
+ + +
+
+
+
+ + +
▼ the stair descends — [enter]
+
· resting ·
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 + 🜪 0G + 🜍 0T + 0 +
+
+
+ + +
+
+ +
+ +
w/s move · a/d turn · r rest · g/t drink · enter stairs · 1-5 abilities
+
+
+ + +
+
+

THE DESCENT CLAIMS YOU

+
+
+
+ +
+
+
+ +
+
+ +

THE EMBER CROWN IS YOURS

+
the Sovereign's fire gutters out beneath you
+
+
+ +
+
+
+ + + + diff --git a/game-web/portraits/battlemage.png b/game-web/portraits/battlemage.png new file mode 100644 index 0000000..a4a277b Binary files /dev/null and b/game-web/portraits/battlemage.png differ diff --git a/game-web/portraits/boss.png b/game-web/portraits/boss.png new file mode 100644 index 0000000..ff16f35 Binary files /dev/null and b/game-web/portraits/boss.png differ diff --git a/game-web/portraits/duelist.png b/game-web/portraits/duelist.png new file mode 100644 index 0000000..e147311 Binary files /dev/null and b/game-web/portraits/duelist.png differ diff --git a/game-web/portraits/golem.png b/game-web/portraits/golem.png new file mode 100644 index 0000000..3c552d5 Binary files /dev/null and b/game-web/portraits/golem.png differ diff --git a/game-web/portraits/rat.png b/game-web/portraits/rat.png new file mode 100644 index 0000000..306c08f Binary files /dev/null and b/game-web/portraits/rat.png differ diff --git a/game-web/portraits/skeleton.png b/game-web/portraits/skeleton.png new file mode 100644 index 0000000..787bea4 Binary files /dev/null and b/game-web/portraits/skeleton.png differ diff --git a/game-web/portraits/troll.png b/game-web/portraits/troll.png new file mode 100644 index 0000000..a258fab Binary files /dev/null and b/game-web/portraits/troll.png differ diff --git a/game-web/portraits/wraith.png b/game-web/portraits/wraith.png new file mode 100644 index 0000000..e86aa08 Binary files /dev/null and b/game-web/portraits/wraith.png differ diff --git a/game-web/style.css b/game-web/style.css new file mode 100644 index 0000000..9f809a1 --- /dev/null +++ b/game-web/style.css @@ -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); } diff --git a/tools/.gitignore b/tools/.gitignore index 04784c3..045c87a 100644 --- a/tools/.gitignore +++ b/tools/.gitignore @@ -7,8 +7,16 @@ __pycache__/ .DS_Store # All pipeline outputs live here — depth maps, renders, dither, baked atlases, -# and the local gallery of keepers. Regenerable; organized; never committed. -out/ +# and the local gallery of keepers. Regenerable; organized; not committed… +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/. comfy-spike/out_*.png diff --git a/tools/out/atlas/59/manifest.json b/tools/out/atlas/59/manifest.json new file mode 100644 index 0000000..50a97f6 --- /dev/null +++ b/tools/out/atlas/59/manifest.json @@ -0,0 +1 @@ +{"seed":59,"width":64,"height":40,"startRoom":0,"rooms":[{"id":0,"theme":"threshold","vantage":[7,4],"centroid":[7,4],"bounds":{"x":3,"y":2,"w":9,"h":5},"nav":{"N":null,"E":2,"S":null,"W":null},"open":{"N":false,"E":true,"S":false,"W":false}},{"id":1,"theme":"crypt","vantage":[36,4],"centroid":[36,4],"bounds":{"x":33,"y":2,"w":6,"h":5},"nav":{"N":null,"E":11,"S":3,"W":null},"open":{"N":false,"E":true,"S":true,"W":false}},{"id":2,"theme":"crypt","vantage":[15,14],"centroid":[15,14],"bounds":{"x":12,"y":12,"w":7,"h":5},"nav":{"N":0,"E":null,"S":5,"W":null},"open":{"N":true,"E":false,"S":true,"W":false}},{"id":3,"theme":"den","vantage":[34,14],"centroid":[34,14],"bounds":{"x":31,"y":12,"w":6,"h":5},"nav":{"N":1,"E":12,"S":6,"W":null},"open":{"N":true,"E":true,"S":true,"W":false}},{"id":4,"theme":"stone","vantage":[9,22],"centroid":[9,22],"bounds":{"x":7,"y":21,"w":4,"h":4},"nav":{"N":null,"E":5,"S":5,"W":7},"open":{"N":false,"E":true,"S":true,"W":true}},{"id":5,"theme":"library","vantage":[19,23],"centroid":[19,23],"bounds":{"x":15,"y":19,"w":9,"h":9},"nav":{"N":2,"E":null,"S":9,"W":4},"open":{"N":true,"E":false,"S":true,"W":true}},{"id":6,"theme":"forge","vantage":[34,23],"centroid":[35,23],"bounds":{"x":31,"y":19,"w":8,"h":9},"nav":{"N":3,"E":null,"S":10,"W":null},"open":{"N":true,"E":false,"S":true,"W":false}},{"id":7,"theme":"crypt","vantage":[4,32],"centroid":[4,32],"bounds":{"x":1,"y":30,"w":7,"h":5},"nav":{"N":4,"E":8,"S":null,"W":null},"open":{"N":true,"E":true,"S":false,"W":false}},{"id":8,"theme":"crypt","vantage":[13,33],"centroid":[14,34],"bounds":{"x":10,"y":30,"w":8,"h":8},"nav":{"N":null,"E":9,"S":null,"W":7},"open":{"N":false,"E":true,"S":false,"W":true}},{"id":9,"theme":"crypt","vantage":[23,33],"centroid":[24,33],"bounds":{"x":20,"y":31,"w":8,"h":5},"nav":{"N":null,"E":null,"S":10,"W":5},"open":{"N":false,"E":false,"S":true,"W":true}},{"id":10,"theme":"den","vantage":[34,36],"centroid":[34,36],"bounds":{"x":30,"y":34,"w":9,"h":5},"nav":{"N":6,"E":14,"S":null,"W":9},"open":{"N":true,"E":true,"S":false,"W":true}},{"id":11,"theme":"stone","vantage":[46,4],"centroid":[46,5],"bounds":{"x":44,"y":1,"w":5,"h":8},"nav":{"N":null,"E":15,"S":12,"W":1},"open":{"N":false,"E":true,"S":true,"W":true}},{"id":12,"theme":"den","vantage":[45,15],"centroid":[46,15],"bounds":{"x":43,"y":13,"w":6,"h":5},"nav":{"N":11,"E":null,"S":null,"W":3},"open":{"N":true,"E":false,"S":false,"W":true}},{"id":13,"theme":"crypt","vantage":[48,28],"centroid":[48,28],"bounds":{"x":45,"y":26,"w":7,"h":5},"nav":{"N":17,"E":17,"S":14,"W":null},"open":{"N":true,"E":true,"S":true,"W":false}},{"id":14,"theme":"den","vantage":[47,35],"centroid":[47,36],"bounds":{"x":43,"y":33,"w":9,"h":6},"nav":{"N":13,"E":18,"S":null,"W":10},"open":{"N":true,"E":true,"S":false,"W":true}},{"id":15,"theme":"stone","vantage":[58,7],"centroid":[58,7],"bounds":{"x":54,"y":3,"w":9,"h":8},"nav":{"N":null,"E":null,"S":16,"W":11},"open":{"N":false,"E":false,"S":true,"W":true}},{"id":16,"theme":"vault","vantage":[58,17],"centroid":[58,18],"bounds":{"x":54,"y":15,"w":9,"h":6},"nav":{"N":15,"E":null,"S":17,"W":null},"open":{"N":true,"E":false,"S":true,"W":false}},{"id":17,"theme":"hall","vantage":[58,27],"centroid":[59,27],"bounds":{"x":55,"y":24,"w":8,"h":7},"nav":{"N":16,"E":null,"S":18,"W":13},"open":{"N":true,"E":false,"S":true,"W":true}},{"id":18,"theme":"throne","vantage":[59,35],"centroid":[59,36],"bounds":{"x":55,"y":33,"w":8,"h":6},"nav":{"N":17,"E":null,"S":null,"W":14},"open":{"N":true,"E":false,"S":false,"W":true}}],"tiles":[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0],[0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0],[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,5,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0],[0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,5,5,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0],[0,0,0,0,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,5,5,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0],[0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,5,5,5,5,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,5,5,5,5,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,1,3,0,0,0,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0],[0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0],[0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0],[0,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0],[0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0],[0,0,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0],[0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]]} \ No newline at end of file diff --git a/tools/out/atlas/59/r0_E.png b/tools/out/atlas/59/r0_E.png new file mode 100644 index 0000000..7368fa4 Binary files /dev/null and b/tools/out/atlas/59/r0_E.png differ diff --git a/tools/out/atlas/59/r0_N.png b/tools/out/atlas/59/r0_N.png new file mode 100644 index 0000000..369db9e Binary files /dev/null and b/tools/out/atlas/59/r0_N.png differ diff --git a/tools/out/atlas/59/r0_S.png b/tools/out/atlas/59/r0_S.png new file mode 100644 index 0000000..369db9e Binary files /dev/null and b/tools/out/atlas/59/r0_S.png differ diff --git a/tools/out/atlas/59/r0_W.png b/tools/out/atlas/59/r0_W.png new file mode 100644 index 0000000..8310d2c Binary files /dev/null and b/tools/out/atlas/59/r0_W.png differ diff --git a/tools/out/atlas/59/r10_E.png b/tools/out/atlas/59/r10_E.png new file mode 100644 index 0000000..81517e7 Binary files /dev/null and b/tools/out/atlas/59/r10_E.png differ diff --git a/tools/out/atlas/59/r10_N.png b/tools/out/atlas/59/r10_N.png new file mode 100644 index 0000000..a97b364 Binary files /dev/null and b/tools/out/atlas/59/r10_N.png differ diff --git a/tools/out/atlas/59/r10_S.png b/tools/out/atlas/59/r10_S.png new file mode 100644 index 0000000..c280df2 Binary files /dev/null and b/tools/out/atlas/59/r10_S.png differ diff --git a/tools/out/atlas/59/r10_W.png b/tools/out/atlas/59/r10_W.png new file mode 100644 index 0000000..57b48e3 Binary files /dev/null and b/tools/out/atlas/59/r10_W.png differ diff --git a/tools/out/atlas/59/r11_E.png b/tools/out/atlas/59/r11_E.png new file mode 100644 index 0000000..39e239b Binary files /dev/null and b/tools/out/atlas/59/r11_E.png differ diff --git a/tools/out/atlas/59/r11_N.png b/tools/out/atlas/59/r11_N.png new file mode 100644 index 0000000..bc48a56 Binary files /dev/null and b/tools/out/atlas/59/r11_N.png differ diff --git a/tools/out/atlas/59/r11_S.png b/tools/out/atlas/59/r11_S.png new file mode 100644 index 0000000..80c6fa1 Binary files /dev/null and b/tools/out/atlas/59/r11_S.png differ diff --git a/tools/out/atlas/59/r11_W.png b/tools/out/atlas/59/r11_W.png new file mode 100644 index 0000000..b0c8ede Binary files /dev/null and b/tools/out/atlas/59/r11_W.png differ diff --git a/tools/out/atlas/59/r12_E.png b/tools/out/atlas/59/r12_E.png new file mode 100644 index 0000000..e2b15f9 Binary files /dev/null and b/tools/out/atlas/59/r12_E.png differ diff --git a/tools/out/atlas/59/r12_N.png b/tools/out/atlas/59/r12_N.png new file mode 100644 index 0000000..3b96e90 Binary files /dev/null and b/tools/out/atlas/59/r12_N.png differ diff --git a/tools/out/atlas/59/r12_S.png b/tools/out/atlas/59/r12_S.png new file mode 100644 index 0000000..c280df2 Binary files /dev/null and b/tools/out/atlas/59/r12_S.png differ diff --git a/tools/out/atlas/59/r12_W.png b/tools/out/atlas/59/r12_W.png new file mode 100644 index 0000000..3b96e90 Binary files /dev/null and b/tools/out/atlas/59/r12_W.png differ diff --git a/tools/out/atlas/59/r13_E.png b/tools/out/atlas/59/r13_E.png new file mode 100644 index 0000000..f261523 Binary files /dev/null and b/tools/out/atlas/59/r13_E.png differ diff --git a/tools/out/atlas/59/r13_N.png b/tools/out/atlas/59/r13_N.png new file mode 100644 index 0000000..50ea007 Binary files /dev/null and b/tools/out/atlas/59/r13_N.png differ diff --git a/tools/out/atlas/59/r13_S.png b/tools/out/atlas/59/r13_S.png new file mode 100644 index 0000000..b7788ab Binary files /dev/null and b/tools/out/atlas/59/r13_S.png differ diff --git a/tools/out/atlas/59/r13_W.png b/tools/out/atlas/59/r13_W.png new file mode 100644 index 0000000..54a4894 Binary files /dev/null and b/tools/out/atlas/59/r13_W.png differ diff --git a/tools/out/atlas/59/r14_E.png b/tools/out/atlas/59/r14_E.png new file mode 100644 index 0000000..06f98b2 Binary files /dev/null and b/tools/out/atlas/59/r14_E.png differ diff --git a/tools/out/atlas/59/r14_N.png b/tools/out/atlas/59/r14_N.png new file mode 100644 index 0000000..e595473 Binary files /dev/null and b/tools/out/atlas/59/r14_N.png differ diff --git a/tools/out/atlas/59/r14_S.png b/tools/out/atlas/59/r14_S.png new file mode 100644 index 0000000..0780b41 Binary files /dev/null and b/tools/out/atlas/59/r14_S.png differ diff --git a/tools/out/atlas/59/r14_W.png b/tools/out/atlas/59/r14_W.png new file mode 100644 index 0000000..d1138ca Binary files /dev/null and b/tools/out/atlas/59/r14_W.png differ diff --git a/tools/out/atlas/59/r15_E.png b/tools/out/atlas/59/r15_E.png new file mode 100644 index 0000000..824d9ae Binary files /dev/null and b/tools/out/atlas/59/r15_E.png differ diff --git a/tools/out/atlas/59/r15_N.png b/tools/out/atlas/59/r15_N.png new file mode 100644 index 0000000..9312777 Binary files /dev/null and b/tools/out/atlas/59/r15_N.png differ diff --git a/tools/out/atlas/59/r15_S.png b/tools/out/atlas/59/r15_S.png new file mode 100644 index 0000000..158f832 Binary files /dev/null and b/tools/out/atlas/59/r15_S.png differ diff --git a/tools/out/atlas/59/r15_W.png b/tools/out/atlas/59/r15_W.png new file mode 100644 index 0000000..5d76053 Binary files /dev/null and b/tools/out/atlas/59/r15_W.png differ diff --git a/tools/out/atlas/59/r16_E.png b/tools/out/atlas/59/r16_E.png new file mode 100644 index 0000000..61a62d0 Binary files /dev/null and b/tools/out/atlas/59/r16_E.png differ diff --git a/tools/out/atlas/59/r16_N.png b/tools/out/atlas/59/r16_N.png new file mode 100644 index 0000000..61b6ba2 Binary files /dev/null and b/tools/out/atlas/59/r16_N.png differ diff --git a/tools/out/atlas/59/r16_S.png b/tools/out/atlas/59/r16_S.png new file mode 100644 index 0000000..905905a Binary files /dev/null and b/tools/out/atlas/59/r16_S.png differ diff --git a/tools/out/atlas/59/r16_W.png b/tools/out/atlas/59/r16_W.png new file mode 100644 index 0000000..1aba061 Binary files /dev/null and b/tools/out/atlas/59/r16_W.png differ diff --git a/tools/out/atlas/59/r17_E.png b/tools/out/atlas/59/r17_E.png new file mode 100644 index 0000000..4821dc2 Binary files /dev/null and b/tools/out/atlas/59/r17_E.png differ diff --git a/tools/out/atlas/59/r17_N.png b/tools/out/atlas/59/r17_N.png new file mode 100644 index 0000000..3a07113 Binary files /dev/null and b/tools/out/atlas/59/r17_N.png differ diff --git a/tools/out/atlas/59/r17_S.png b/tools/out/atlas/59/r17_S.png new file mode 100644 index 0000000..71ce382 Binary files /dev/null and b/tools/out/atlas/59/r17_S.png differ diff --git a/tools/out/atlas/59/r17_W.png b/tools/out/atlas/59/r17_W.png new file mode 100644 index 0000000..739b96d Binary files /dev/null and b/tools/out/atlas/59/r17_W.png differ diff --git a/tools/out/atlas/59/r18_E.png b/tools/out/atlas/59/r18_E.png new file mode 100644 index 0000000..20efa97 Binary files /dev/null and b/tools/out/atlas/59/r18_E.png differ diff --git a/tools/out/atlas/59/r18_N.png b/tools/out/atlas/59/r18_N.png new file mode 100644 index 0000000..f58451a Binary files /dev/null and b/tools/out/atlas/59/r18_N.png differ diff --git a/tools/out/atlas/59/r18_S.png b/tools/out/atlas/59/r18_S.png new file mode 100644 index 0000000..e76967f Binary files /dev/null and b/tools/out/atlas/59/r18_S.png differ diff --git a/tools/out/atlas/59/r18_W.png b/tools/out/atlas/59/r18_W.png new file mode 100644 index 0000000..b34459c Binary files /dev/null and b/tools/out/atlas/59/r18_W.png differ diff --git a/tools/out/atlas/59/r1_E.png b/tools/out/atlas/59/r1_E.png new file mode 100644 index 0000000..3a326a9 Binary files /dev/null and b/tools/out/atlas/59/r1_E.png differ diff --git a/tools/out/atlas/59/r1_N.png b/tools/out/atlas/59/r1_N.png new file mode 100644 index 0000000..2b49f6f Binary files /dev/null and b/tools/out/atlas/59/r1_N.png differ diff --git a/tools/out/atlas/59/r1_S.png b/tools/out/atlas/59/r1_S.png new file mode 100644 index 0000000..d3be55d Binary files /dev/null and b/tools/out/atlas/59/r1_S.png differ diff --git a/tools/out/atlas/59/r1_W.png b/tools/out/atlas/59/r1_W.png new file mode 100644 index 0000000..e5bfb7a Binary files /dev/null and b/tools/out/atlas/59/r1_W.png differ diff --git a/tools/out/atlas/59/r2_E.png b/tools/out/atlas/59/r2_E.png new file mode 100644 index 0000000..2c1964a Binary files /dev/null and b/tools/out/atlas/59/r2_E.png differ diff --git a/tools/out/atlas/59/r2_N.png b/tools/out/atlas/59/r2_N.png new file mode 100644 index 0000000..0ca24da Binary files /dev/null and b/tools/out/atlas/59/r2_N.png differ diff --git a/tools/out/atlas/59/r2_S.png b/tools/out/atlas/59/r2_S.png new file mode 100644 index 0000000..e89786c Binary files /dev/null and b/tools/out/atlas/59/r2_S.png differ diff --git a/tools/out/atlas/59/r2_W.png b/tools/out/atlas/59/r2_W.png new file mode 100644 index 0000000..2c1964a Binary files /dev/null and b/tools/out/atlas/59/r2_W.png differ diff --git a/tools/out/atlas/59/r3_E.png b/tools/out/atlas/59/r3_E.png new file mode 100644 index 0000000..a97b364 Binary files /dev/null and b/tools/out/atlas/59/r3_E.png differ diff --git a/tools/out/atlas/59/r3_N.png b/tools/out/atlas/59/r3_N.png new file mode 100644 index 0000000..e5a3e8f Binary files /dev/null and b/tools/out/atlas/59/r3_N.png differ diff --git a/tools/out/atlas/59/r3_S.png b/tools/out/atlas/59/r3_S.png new file mode 100644 index 0000000..5afb787 Binary files /dev/null and b/tools/out/atlas/59/r3_S.png differ diff --git a/tools/out/atlas/59/r3_W.png b/tools/out/atlas/59/r3_W.png new file mode 100644 index 0000000..e2b15f9 Binary files /dev/null and b/tools/out/atlas/59/r3_W.png differ diff --git a/tools/out/atlas/59/r4_E.png b/tools/out/atlas/59/r4_E.png new file mode 100644 index 0000000..e68b5ab Binary files /dev/null and b/tools/out/atlas/59/r4_E.png differ diff --git a/tools/out/atlas/59/r4_N.png b/tools/out/atlas/59/r4_N.png new file mode 100644 index 0000000..b773e54 Binary files /dev/null and b/tools/out/atlas/59/r4_N.png differ diff --git a/tools/out/atlas/59/r4_S.png b/tools/out/atlas/59/r4_S.png new file mode 100644 index 0000000..84e611a Binary files /dev/null and b/tools/out/atlas/59/r4_S.png differ diff --git a/tools/out/atlas/59/r4_W.png b/tools/out/atlas/59/r4_W.png new file mode 100644 index 0000000..79e8e50 Binary files /dev/null and b/tools/out/atlas/59/r4_W.png differ diff --git a/tools/out/atlas/59/r5_E.png b/tools/out/atlas/59/r5_E.png new file mode 100644 index 0000000..6c98d44 Binary files /dev/null and b/tools/out/atlas/59/r5_E.png differ diff --git a/tools/out/atlas/59/r5_N.png b/tools/out/atlas/59/r5_N.png new file mode 100644 index 0000000..63c18a1 Binary files /dev/null and b/tools/out/atlas/59/r5_N.png differ diff --git a/tools/out/atlas/59/r5_S.png b/tools/out/atlas/59/r5_S.png new file mode 100644 index 0000000..4113a64 Binary files /dev/null and b/tools/out/atlas/59/r5_S.png differ diff --git a/tools/out/atlas/59/r5_W.png b/tools/out/atlas/59/r5_W.png new file mode 100644 index 0000000..f91e06a Binary files /dev/null and b/tools/out/atlas/59/r5_W.png differ diff --git a/tools/out/atlas/59/r6_E.png b/tools/out/atlas/59/r6_E.png new file mode 100644 index 0000000..c5e663d Binary files /dev/null and b/tools/out/atlas/59/r6_E.png differ diff --git a/tools/out/atlas/59/r6_N.png b/tools/out/atlas/59/r6_N.png new file mode 100644 index 0000000..c7d5975 Binary files /dev/null and b/tools/out/atlas/59/r6_N.png differ diff --git a/tools/out/atlas/59/r6_S.png b/tools/out/atlas/59/r6_S.png new file mode 100644 index 0000000..e86b194 Binary files /dev/null and b/tools/out/atlas/59/r6_S.png differ diff --git a/tools/out/atlas/59/r6_W.png b/tools/out/atlas/59/r6_W.png new file mode 100644 index 0000000..d8f69c3 Binary files /dev/null and b/tools/out/atlas/59/r6_W.png differ diff --git a/tools/out/atlas/59/r7_E.png b/tools/out/atlas/59/r7_E.png new file mode 100644 index 0000000..4486135 Binary files /dev/null and b/tools/out/atlas/59/r7_E.png differ diff --git a/tools/out/atlas/59/r7_N.png b/tools/out/atlas/59/r7_N.png new file mode 100644 index 0000000..9973cd9 Binary files /dev/null and b/tools/out/atlas/59/r7_N.png differ diff --git a/tools/out/atlas/59/r7_S.png b/tools/out/atlas/59/r7_S.png new file mode 100644 index 0000000..e6fa426 Binary files /dev/null and b/tools/out/atlas/59/r7_S.png differ diff --git a/tools/out/atlas/59/r7_W.png b/tools/out/atlas/59/r7_W.png new file mode 100644 index 0000000..2c1964a Binary files /dev/null and b/tools/out/atlas/59/r7_W.png differ diff --git a/tools/out/atlas/59/r8_E.png b/tools/out/atlas/59/r8_E.png new file mode 100644 index 0000000..277bd61 Binary files /dev/null and b/tools/out/atlas/59/r8_E.png differ diff --git a/tools/out/atlas/59/r8_N.png b/tools/out/atlas/59/r8_N.png new file mode 100644 index 0000000..2d2aff5 Binary files /dev/null and b/tools/out/atlas/59/r8_N.png differ diff --git a/tools/out/atlas/59/r8_S.png b/tools/out/atlas/59/r8_S.png new file mode 100644 index 0000000..69a8560 Binary files /dev/null and b/tools/out/atlas/59/r8_S.png differ diff --git a/tools/out/atlas/59/r8_W.png b/tools/out/atlas/59/r8_W.png new file mode 100644 index 0000000..c066508 Binary files /dev/null and b/tools/out/atlas/59/r8_W.png differ diff --git a/tools/out/atlas/59/r9_E.png b/tools/out/atlas/59/r9_E.png new file mode 100644 index 0000000..0a6837f Binary files /dev/null and b/tools/out/atlas/59/r9_E.png differ diff --git a/tools/out/atlas/59/r9_N.png b/tools/out/atlas/59/r9_N.png new file mode 100644 index 0000000..e6fa426 Binary files /dev/null and b/tools/out/atlas/59/r9_N.png differ diff --git a/tools/out/atlas/59/r9_S.png b/tools/out/atlas/59/r9_S.png new file mode 100644 index 0000000..b8469f2 Binary files /dev/null and b/tools/out/atlas/59/r9_S.png differ diff --git a/tools/out/atlas/59/r9_W.png b/tools/out/atlas/59/r9_W.png new file mode 100644 index 0000000..2af21de Binary files /dev/null and b/tools/out/atlas/59/r9_W.png differ diff --git a/tools/out/atlas/69/manifest.json b/tools/out/atlas/69/manifest.json new file mode 100644 index 0000000..bd71176 --- /dev/null +++ b/tools/out/atlas/69/manifest.json @@ -0,0 +1 @@ +{"seed":69,"width":64,"height":40,"startRoom":3,"rooms":[{"id":0,"theme":"den","vantage":[3,5],"centroid":[3,6],"bounds":{"x":1,"y":1,"w":5,"h":10},"nav":{"N":null,"E":3,"S":1,"W":null},"open":{"N":false,"E":true,"S":true,"W":false}},{"id":1,"theme":"den","vantage":[4,22],"centroid":[4,22],"bounds":{"x":1,"y":17,"w":6,"h":11},"nav":{"N":0,"E":4,"S":2,"W":null},"open":{"N":true,"E":true,"S":true,"W":false}},{"id":2,"theme":"stone","vantage":[4,35],"centroid":[4,35],"bounds":{"x":1,"y":32,"w":6,"h":7},"nav":{"N":1,"E":5,"S":null,"W":null},"open":{"N":true,"E":true,"S":false,"W":false}},{"id":3,"theme":"threshold","vantage":[15,5],"centroid":[15,5],"bounds":{"x":13,"y":3,"w":5,"h":5},"nav":{"N":null,"E":6,"S":4,"W":0},"open":{"N":false,"E":true,"S":true,"W":true}},{"id":4,"theme":"cistern","vantage":[16,18],"centroid":[16,18],"bounds":{"x":12,"y":13,"w":9,"h":11},"nav":{"N":3,"E":9,"S":null,"W":1},"open":{"N":true,"E":true,"S":false,"W":true}},{"id":5,"theme":"hall","vantage":[16,34],"centroid":[16,35],"bounds":{"x":13,"y":31,"w":7,"h":8},"nav":{"N":12,"E":12,"S":null,"W":2},"open":{"N":true,"E":true,"S":false,"W":true}},{"id":6,"theme":"cistern","vantage":[27,4],"centroid":[28,5],"bounds":{"x":24,"y":1,"w":8,"h":8},"nav":{"N":null,"E":null,"S":9,"W":3},"open":{"N":false,"E":false,"S":true,"W":true}},{"id":7,"theme":"stone","vantage":[48,5],"centroid":[48,5],"bounds":{"x":45,"y":2,"w":6,"h":7},"nav":{"N":null,"E":8,"S":10,"W":10},"open":{"N":false,"E":true,"S":true,"W":true}},{"id":8,"theme":"stone","vantage":[58,5],"centroid":[58,5],"bounds":{"x":56,"y":3,"w":5,"h":5},"nav":{"N":null,"E":11,"S":11,"W":7},"open":{"N":false,"E":true,"S":true,"W":true}},{"id":9,"theme":"crypt","vantage":[27,20],"centroid":[27,20],"bounds":{"x":25,"y":18,"w":5,"h":5},"nav":{"N":6,"E":null,"S":12,"W":4},"open":{"N":true,"E":false,"S":true,"W":true}},{"id":10,"theme":"library","vantage":[46,16],"centroid":[46,16],"bounds":{"x":44,"y":12,"w":5,"h":9},"nav":{"N":7,"E":11,"S":14,"W":null},"open":{"N":true,"E":true,"S":true,"W":false}},{"id":11,"theme":"library","vantage":[58,19],"centroid":[59,19],"bounds":{"x":56,"y":15,"w":6,"h":9},"nav":{"N":8,"E":null,"S":null,"W":10},"open":{"N":true,"E":false,"S":false,"W":true}},{"id":12,"theme":"stone","vantage":[26,31],"centroid":[27,31],"bounds":{"x":24,"y":29,"w":6,"h":5},"nav":{"N":9,"E":13,"S":null,"W":5},"open":{"N":true,"E":true,"S":false,"W":true}},{"id":13,"theme":"vault","vantage":[36,31],"centroid":[36,31],"bounds":{"x":33,"y":29,"w":7,"h":5},"nav":{"N":null,"E":14,"S":null,"W":12},"open":{"N":false,"E":true,"S":false,"W":true}},{"id":14,"theme":"stone","vantage":[46,34],"centroid":[46,34],"bounds":{"x":44,"y":31,"w":5,"h":7},"nav":{"N":10,"E":15,"S":null,"W":13},"open":{"N":true,"E":true,"S":false,"W":true}},{"id":15,"theme":"throne","vantage":[54,35],"centroid":[54,36],"bounds":{"x":51,"y":33,"w":7,"h":6},"nav":{"N":null,"E":null,"S":null,"W":14},"open":{"N":false,"E":false,"S":false,"W":true}}],"tiles":[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,4,4,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0],[0,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,1,1,4,4,4,4,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,1,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,2,1,1,4,4,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,0,0,0],[0,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,1,1,4,4,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,2,1,0,0,0,0],[0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,1,0,0,0,0,0,0,0,0,1,1,4,4,4,4,4,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0],[0,0,0,1,0,0,0,0,0,0,0,0,1,1,4,4,4,4,4,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0],[0,0,0,1,1,0,0,0,0,0,0,0,1,1,4,4,4,4,4,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0],[0,0,1,1,1,1,0,0,0,0,0,0,1,1,4,4,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,4,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0],[0,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,1,2,1,1,1,1,2,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,2,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0],[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,2,1,1,1,1,1,1,1,0,0,0,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0],[0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]]} \ No newline at end of file diff --git a/tools/out/atlas/69/r0_E.png b/tools/out/atlas/69/r0_E.png new file mode 100644 index 0000000..7502368 Binary files /dev/null and b/tools/out/atlas/69/r0_E.png differ diff --git a/tools/out/atlas/69/r0_N.png b/tools/out/atlas/69/r0_N.png new file mode 100644 index 0000000..94f1ca1 Binary files /dev/null and b/tools/out/atlas/69/r0_N.png differ diff --git a/tools/out/atlas/69/r0_S.png b/tools/out/atlas/69/r0_S.png new file mode 100644 index 0000000..d352871 Binary files /dev/null and b/tools/out/atlas/69/r0_S.png differ diff --git a/tools/out/atlas/69/r0_W.png b/tools/out/atlas/69/r0_W.png new file mode 100644 index 0000000..c280df2 Binary files /dev/null and b/tools/out/atlas/69/r0_W.png differ diff --git a/tools/out/atlas/69/r10_E.png b/tools/out/atlas/69/r10_E.png new file mode 100644 index 0000000..4fa8e11 Binary files /dev/null and b/tools/out/atlas/69/r10_E.png differ diff --git a/tools/out/atlas/69/r10_N.png b/tools/out/atlas/69/r10_N.png new file mode 100644 index 0000000..188e235 Binary files /dev/null and b/tools/out/atlas/69/r10_N.png differ diff --git a/tools/out/atlas/69/r10_S.png b/tools/out/atlas/69/r10_S.png new file mode 100644 index 0000000..e39cf32 Binary files /dev/null and b/tools/out/atlas/69/r10_S.png differ diff --git a/tools/out/atlas/69/r10_W.png b/tools/out/atlas/69/r10_W.png new file mode 100644 index 0000000..4bf9da7 Binary files /dev/null and b/tools/out/atlas/69/r10_W.png differ diff --git a/tools/out/atlas/69/r11_E.png b/tools/out/atlas/69/r11_E.png new file mode 100644 index 0000000..9558ad0 Binary files /dev/null and b/tools/out/atlas/69/r11_E.png differ diff --git a/tools/out/atlas/69/r11_N.png b/tools/out/atlas/69/r11_N.png new file mode 100644 index 0000000..d9c101c Binary files /dev/null and b/tools/out/atlas/69/r11_N.png differ diff --git a/tools/out/atlas/69/r11_S.png b/tools/out/atlas/69/r11_S.png new file mode 100644 index 0000000..3d756ba Binary files /dev/null and b/tools/out/atlas/69/r11_S.png differ diff --git a/tools/out/atlas/69/r11_W.png b/tools/out/atlas/69/r11_W.png new file mode 100644 index 0000000..05dc4ef Binary files /dev/null and b/tools/out/atlas/69/r11_W.png differ diff --git a/tools/out/atlas/69/r12_E.png b/tools/out/atlas/69/r12_E.png new file mode 100644 index 0000000..04b041e Binary files /dev/null and b/tools/out/atlas/69/r12_E.png differ diff --git a/tools/out/atlas/69/r12_N.png b/tools/out/atlas/69/r12_N.png new file mode 100644 index 0000000..7f969ed Binary files /dev/null and b/tools/out/atlas/69/r12_N.png differ diff --git a/tools/out/atlas/69/r12_S.png b/tools/out/atlas/69/r12_S.png new file mode 100644 index 0000000..177ae92 Binary files /dev/null and b/tools/out/atlas/69/r12_S.png differ diff --git a/tools/out/atlas/69/r12_W.png b/tools/out/atlas/69/r12_W.png new file mode 100644 index 0000000..0ecbfe3 Binary files /dev/null and b/tools/out/atlas/69/r12_W.png differ diff --git a/tools/out/atlas/69/r13_E.png b/tools/out/atlas/69/r13_E.png new file mode 100644 index 0000000..c7d6e2a Binary files /dev/null and b/tools/out/atlas/69/r13_E.png differ diff --git a/tools/out/atlas/69/r13_N.png b/tools/out/atlas/69/r13_N.png new file mode 100644 index 0000000..a092788 Binary files /dev/null and b/tools/out/atlas/69/r13_N.png differ diff --git a/tools/out/atlas/69/r13_S.png b/tools/out/atlas/69/r13_S.png new file mode 100644 index 0000000..a092788 Binary files /dev/null and b/tools/out/atlas/69/r13_S.png differ diff --git a/tools/out/atlas/69/r13_W.png b/tools/out/atlas/69/r13_W.png new file mode 100644 index 0000000..ddb01e5 Binary files /dev/null and b/tools/out/atlas/69/r13_W.png differ diff --git a/tools/out/atlas/69/r14_E.png b/tools/out/atlas/69/r14_E.png new file mode 100644 index 0000000..ec2d73f Binary files /dev/null and b/tools/out/atlas/69/r14_E.png differ diff --git a/tools/out/atlas/69/r14_N.png b/tools/out/atlas/69/r14_N.png new file mode 100644 index 0000000..156c567 Binary files /dev/null and b/tools/out/atlas/69/r14_N.png differ diff --git a/tools/out/atlas/69/r14_S.png b/tools/out/atlas/69/r14_S.png new file mode 100644 index 0000000..8ec91bb Binary files /dev/null and b/tools/out/atlas/69/r14_S.png differ diff --git a/tools/out/atlas/69/r14_W.png b/tools/out/atlas/69/r14_W.png new file mode 100644 index 0000000..f4231a9 Binary files /dev/null and b/tools/out/atlas/69/r14_W.png differ diff --git a/tools/out/atlas/69/r15_E.png b/tools/out/atlas/69/r15_E.png new file mode 100644 index 0000000..20efa97 Binary files /dev/null and b/tools/out/atlas/69/r15_E.png differ diff --git a/tools/out/atlas/69/r15_N.png b/tools/out/atlas/69/r15_N.png new file mode 100644 index 0000000..6319168 Binary files /dev/null and b/tools/out/atlas/69/r15_N.png differ diff --git a/tools/out/atlas/69/r15_S.png b/tools/out/atlas/69/r15_S.png new file mode 100644 index 0000000..ddc1ec3 Binary files /dev/null and b/tools/out/atlas/69/r15_S.png differ diff --git a/tools/out/atlas/69/r15_W.png b/tools/out/atlas/69/r15_W.png new file mode 100644 index 0000000..a01102c Binary files /dev/null and b/tools/out/atlas/69/r15_W.png differ diff --git a/tools/out/atlas/69/r1_E.png b/tools/out/atlas/69/r1_E.png new file mode 100644 index 0000000..12cf1f9 Binary files /dev/null and b/tools/out/atlas/69/r1_E.png differ diff --git a/tools/out/atlas/69/r1_N.png b/tools/out/atlas/69/r1_N.png new file mode 100644 index 0000000..1efdcad Binary files /dev/null and b/tools/out/atlas/69/r1_N.png differ diff --git a/tools/out/atlas/69/r1_S.png b/tools/out/atlas/69/r1_S.png new file mode 100644 index 0000000..1626b5c Binary files /dev/null and b/tools/out/atlas/69/r1_S.png differ diff --git a/tools/out/atlas/69/r1_W.png b/tools/out/atlas/69/r1_W.png new file mode 100644 index 0000000..476a9d9 Binary files /dev/null and b/tools/out/atlas/69/r1_W.png differ diff --git a/tools/out/atlas/69/r2_E.png b/tools/out/atlas/69/r2_E.png new file mode 100644 index 0000000..1edc2e8 Binary files /dev/null and b/tools/out/atlas/69/r2_E.png differ diff --git a/tools/out/atlas/69/r2_N.png b/tools/out/atlas/69/r2_N.png new file mode 100644 index 0000000..2639397 Binary files /dev/null and b/tools/out/atlas/69/r2_N.png differ diff --git a/tools/out/atlas/69/r2_S.png b/tools/out/atlas/69/r2_S.png new file mode 100644 index 0000000..7820d2f Binary files /dev/null and b/tools/out/atlas/69/r2_S.png differ diff --git a/tools/out/atlas/69/r2_W.png b/tools/out/atlas/69/r2_W.png new file mode 100644 index 0000000..bce2d60 Binary files /dev/null and b/tools/out/atlas/69/r2_W.png differ diff --git a/tools/out/atlas/69/r3_E.png b/tools/out/atlas/69/r3_E.png new file mode 100644 index 0000000..fed7c3d Binary files /dev/null and b/tools/out/atlas/69/r3_E.png differ diff --git a/tools/out/atlas/69/r3_N.png b/tools/out/atlas/69/r3_N.png new file mode 100644 index 0000000..0a7db14 Binary files /dev/null and b/tools/out/atlas/69/r3_N.png differ diff --git a/tools/out/atlas/69/r3_S.png b/tools/out/atlas/69/r3_S.png new file mode 100644 index 0000000..8c40717 Binary files /dev/null and b/tools/out/atlas/69/r3_S.png differ diff --git a/tools/out/atlas/69/r3_W.png b/tools/out/atlas/69/r3_W.png new file mode 100644 index 0000000..6b8f1c7 Binary files /dev/null and b/tools/out/atlas/69/r3_W.png differ diff --git a/tools/out/atlas/69/r4_E.png b/tools/out/atlas/69/r4_E.png new file mode 100644 index 0000000..5719c43 Binary files /dev/null and b/tools/out/atlas/69/r4_E.png differ diff --git a/tools/out/atlas/69/r4_N.png b/tools/out/atlas/69/r4_N.png new file mode 100644 index 0000000..27309e3 Binary files /dev/null and b/tools/out/atlas/69/r4_N.png differ diff --git a/tools/out/atlas/69/r4_S.png b/tools/out/atlas/69/r4_S.png new file mode 100644 index 0000000..32ce65f Binary files /dev/null and b/tools/out/atlas/69/r4_S.png differ diff --git a/tools/out/atlas/69/r4_W.png b/tools/out/atlas/69/r4_W.png new file mode 100644 index 0000000..d56abdd Binary files /dev/null and b/tools/out/atlas/69/r4_W.png differ diff --git a/tools/out/atlas/69/r5_E.png b/tools/out/atlas/69/r5_E.png new file mode 100644 index 0000000..0feecb9 Binary files /dev/null and b/tools/out/atlas/69/r5_E.png differ diff --git a/tools/out/atlas/69/r5_N.png b/tools/out/atlas/69/r5_N.png new file mode 100644 index 0000000..9b837a2 Binary files /dev/null and b/tools/out/atlas/69/r5_N.png differ diff --git a/tools/out/atlas/69/r5_S.png b/tools/out/atlas/69/r5_S.png new file mode 100644 index 0000000..ce42400 Binary files /dev/null and b/tools/out/atlas/69/r5_S.png differ diff --git a/tools/out/atlas/69/r5_W.png b/tools/out/atlas/69/r5_W.png new file mode 100644 index 0000000..4f38320 Binary files /dev/null and b/tools/out/atlas/69/r5_W.png differ diff --git a/tools/out/atlas/69/r6_E.png b/tools/out/atlas/69/r6_E.png new file mode 100644 index 0000000..2c2ff43 Binary files /dev/null and b/tools/out/atlas/69/r6_E.png differ diff --git a/tools/out/atlas/69/r6_N.png b/tools/out/atlas/69/r6_N.png new file mode 100644 index 0000000..1687dee Binary files /dev/null and b/tools/out/atlas/69/r6_N.png differ diff --git a/tools/out/atlas/69/r6_S.png b/tools/out/atlas/69/r6_S.png new file mode 100644 index 0000000..e39d736 Binary files /dev/null and b/tools/out/atlas/69/r6_S.png differ diff --git a/tools/out/atlas/69/r6_W.png b/tools/out/atlas/69/r6_W.png new file mode 100644 index 0000000..c7777ea Binary files /dev/null and b/tools/out/atlas/69/r6_W.png differ diff --git a/tools/out/atlas/69/r7_E.png b/tools/out/atlas/69/r7_E.png new file mode 100644 index 0000000..c264626 Binary files /dev/null and b/tools/out/atlas/69/r7_E.png differ diff --git a/tools/out/atlas/69/r7_N.png b/tools/out/atlas/69/r7_N.png new file mode 100644 index 0000000..fae11f2 Binary files /dev/null and b/tools/out/atlas/69/r7_N.png differ diff --git a/tools/out/atlas/69/r7_S.png b/tools/out/atlas/69/r7_S.png new file mode 100644 index 0000000..e5bcf0b Binary files /dev/null and b/tools/out/atlas/69/r7_S.png differ diff --git a/tools/out/atlas/69/r7_W.png b/tools/out/atlas/69/r7_W.png new file mode 100644 index 0000000..129ccb3 Binary files /dev/null and b/tools/out/atlas/69/r7_W.png differ diff --git a/tools/out/atlas/69/r8_E.png b/tools/out/atlas/69/r8_E.png new file mode 100644 index 0000000..84e611a Binary files /dev/null and b/tools/out/atlas/69/r8_E.png differ diff --git a/tools/out/atlas/69/r8_N.png b/tools/out/atlas/69/r8_N.png new file mode 100644 index 0000000..8d96a31 Binary files /dev/null and b/tools/out/atlas/69/r8_N.png differ diff --git a/tools/out/atlas/69/r8_S.png b/tools/out/atlas/69/r8_S.png new file mode 100644 index 0000000..1c915bd Binary files /dev/null and b/tools/out/atlas/69/r8_S.png differ diff --git a/tools/out/atlas/69/r8_W.png b/tools/out/atlas/69/r8_W.png new file mode 100644 index 0000000..51eda89 Binary files /dev/null and b/tools/out/atlas/69/r8_W.png differ diff --git a/tools/out/atlas/69/r9_E.png b/tools/out/atlas/69/r9_E.png new file mode 100644 index 0000000..95d9492 Binary files /dev/null and b/tools/out/atlas/69/r9_E.png differ diff --git a/tools/out/atlas/69/r9_N.png b/tools/out/atlas/69/r9_N.png new file mode 100644 index 0000000..a5c44e3 Binary files /dev/null and b/tools/out/atlas/69/r9_N.png differ diff --git a/tools/out/atlas/69/r9_S.png b/tools/out/atlas/69/r9_S.png new file mode 100644 index 0000000..6c6116b Binary files /dev/null and b/tools/out/atlas/69/r9_S.png differ diff --git a/tools/out/atlas/69/r9_W.png b/tools/out/atlas/69/r9_W.png new file mode 100644 index 0000000..247a208 Binary files /dev/null and b/tools/out/atlas/69/r9_W.png differ diff --git a/tools/out/atlas/7/manifest.json b/tools/out/atlas/7/manifest.json new file mode 100644 index 0000000..537713d --- /dev/null +++ b/tools/out/atlas/7/manifest.json @@ -0,0 +1 @@ +{"seed":7,"width":64,"height":40,"startRoom":0,"rooms":[{"id":0,"theme":"threshold","vantage":[7,4],"centroid":[7,4],"bounds":{"x":2,"y":1,"w":11,"h":6},"nav":{"N":null,"E":null,"S":1,"W":null},"open":{"N":false,"E":false,"S":true,"W":false}},{"id":1,"theme":"vault","vantage":[19,10],"centroid":[19,11],"bounds":{"x":17,"y":8,"w":5,"h":6},"nav":{"N":null,"E":2,"S":7,"W":0},"open":{"N":false,"E":true,"S":true,"W":true}},{"id":2,"theme":"stone","vantage":[26,9],"centroid":[26,10],"bounds":{"x":24,"y":6,"w":5,"h":8},"nav":{"N":null,"E":3,"S":null,"W":1},"open":{"N":false,"E":true,"S":false,"W":true}},{"id":3,"theme":"vault","vantage":[35,7],"centroid":[36,7],"bounds":{"x":32,"y":5,"w":8,"h":5},"nav":{"N":null,"E":4,"S":null,"W":2},"open":{"N":false,"E":true,"S":false,"W":true}},{"id":4,"theme":"stone","vantage":[47,6],"centroid":[48,7],"bounds":{"x":44,"y":3,"w":8,"h":8},"nav":{"N":null,"E":5,"S":11,"W":3},"open":{"N":false,"E":true,"S":true,"W":true}},{"id":5,"theme":"den","vantage":[58,7],"centroid":[58,8],"bounds":{"x":54,"y":4,"w":9,"h":8},"nav":{"N":null,"E":null,"S":12,"W":4},"open":{"N":false,"E":false,"S":true,"W":true}},{"id":6,"theme":"forge","vantage":[4,21],"centroid":[5,22],"bounds":{"x":1,"y":17,"w":8,"h":10},"nav":{"N":null,"E":7,"S":8,"W":null},"open":{"N":false,"E":true,"S":true,"W":false}},{"id":7,"theme":"library","vantage":[17,23],"centroid":[17,24],"bounds":{"x":15,"y":20,"w":5,"h":8},"nav":{"N":1,"E":10,"S":null,"W":9},"open":{"N":true,"E":true,"S":false,"W":true}},{"id":8,"theme":"hall","vantage":[4,34],"centroid":[4,34],"bounds":{"x":1,"y":30,"w":7,"h":9},"nav":{"N":6,"E":9,"S":null,"W":null},"open":{"N":true,"E":true,"S":false,"W":false}},{"id":9,"theme":"crypt","vantage":[13,34],"centroid":[14,34],"bounds":{"x":11,"y":30,"w":6,"h":9},"nav":{"N":7,"E":14,"S":null,"W":8},"open":{"N":true,"E":true,"S":false,"W":true}},{"id":10,"theme":"crypt","vantage":[32,23],"centroid":[32,23],"bounds":{"x":30,"y":20,"w":5,"h":7},"nav":{"N":null,"E":11,"S":null,"W":7},"open":{"N":false,"E":true,"S":false,"W":true}},{"id":11,"theme":"crypt","vantage":[48,22],"centroid":[48,22],"bounds":{"x":46,"y":20,"w":5,"h":5},"nav":{"N":12,"E":null,"S":13,"W":10},"open":{"N":true,"E":false,"S":true,"W":true}},{"id":12,"theme":"den","vantage":[59,18],"centroid":[59,19],"bounds":{"x":56,"y":16,"w":7,"h":6},"nav":{"N":5,"E":null,"S":13,"W":11},"open":{"N":true,"E":false,"S":true,"W":true}},{"id":13,"theme":"library","vantage":[56,26],"centroid":[57,26],"bounds":{"x":54,"y":24,"w":6,"h":5},"nav":{"N":12,"E":16,"S":16,"W":11},"open":{"N":true,"E":true,"S":true,"W":true}},{"id":14,"theme":"vault","vantage":[24,35],"centroid":[25,36],"bounds":{"x":22,"y":33,"w":6,"h":6},"nav":{"N":null,"E":15,"S":null,"W":9},"open":{"N":false,"E":true,"S":false,"W":true}},{"id":15,"theme":"stone","vantage":[36,35],"centroid":[36,36],"bounds":{"x":33,"y":33,"w":7,"h":6},"nav":{"N":null,"E":null,"S":null,"W":14},"open":{"N":false,"E":false,"S":false,"W":true}},{"id":16,"theme":"throne","vantage":[58,35],"centroid":[58,36],"bounds":{"x":56,"y":33,"w":5,"h":6},"nav":{"N":13,"E":null,"S":null,"W":null},"open":{"N":true,"E":false,"S":false,"W":false}}],"tiles":[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,0,0,0],[0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,0,0],[0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,0],[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,2,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,0,0],[0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0],[0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0],[0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,0],[0,1,1,5,5,5,5,1,1,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,0],[0,1,1,5,5,5,5,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,1,1,1,0],[0,1,1,5,5,5,5,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,0],[0,1,1,5,5,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,0,0,0,0,0,0,0,0,2,0,0,0,0],[0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0],[0,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,0,1,0,0,0,0],[0,0,1,1,1,1,1,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,1,1,0,2,0,0,0,0],[0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0],[0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0],[0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0],[0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,1,1,1,1,3,1,1,2,1,2,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0],[0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0],[0,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0],[0,0,1,1,1,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,0,0,0],[0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]]} \ No newline at end of file diff --git a/tools/out/atlas/7/r0_E.png b/tools/out/atlas/7/r0_E.png new file mode 100644 index 0000000..442dc40 Binary files /dev/null and b/tools/out/atlas/7/r0_E.png differ diff --git a/tools/out/atlas/7/r0_N.png b/tools/out/atlas/7/r0_N.png new file mode 100644 index 0000000..f1c5036 Binary files /dev/null and b/tools/out/atlas/7/r0_N.png differ diff --git a/tools/out/atlas/7/r0_S.png b/tools/out/atlas/7/r0_S.png new file mode 100644 index 0000000..ceb3fef Binary files /dev/null and b/tools/out/atlas/7/r0_S.png differ diff --git a/tools/out/atlas/7/r0_W.png b/tools/out/atlas/7/r0_W.png new file mode 100644 index 0000000..d993fe2 Binary files /dev/null and b/tools/out/atlas/7/r0_W.png differ diff --git a/tools/out/atlas/7/r10_E.png b/tools/out/atlas/7/r10_E.png new file mode 100644 index 0000000..8ecd97d Binary files /dev/null and b/tools/out/atlas/7/r10_E.png differ diff --git a/tools/out/atlas/7/r10_N.png b/tools/out/atlas/7/r10_N.png new file mode 100644 index 0000000..2c1964a Binary files /dev/null and b/tools/out/atlas/7/r10_N.png differ diff --git a/tools/out/atlas/7/r10_S.png b/tools/out/atlas/7/r10_S.png new file mode 100644 index 0000000..2c1964a Binary files /dev/null and b/tools/out/atlas/7/r10_S.png differ diff --git a/tools/out/atlas/7/r10_W.png b/tools/out/atlas/7/r10_W.png new file mode 100644 index 0000000..8ecd97d Binary files /dev/null and b/tools/out/atlas/7/r10_W.png differ diff --git a/tools/out/atlas/7/r11_E.png b/tools/out/atlas/7/r11_E.png new file mode 100644 index 0000000..95d9492 Binary files /dev/null and b/tools/out/atlas/7/r11_E.png differ diff --git a/tools/out/atlas/7/r11_N.png b/tools/out/atlas/7/r11_N.png new file mode 100644 index 0000000..1e2df2e Binary files /dev/null and b/tools/out/atlas/7/r11_N.png differ diff --git a/tools/out/atlas/7/r11_S.png b/tools/out/atlas/7/r11_S.png new file mode 100644 index 0000000..7faf9ac Binary files /dev/null and b/tools/out/atlas/7/r11_S.png differ diff --git a/tools/out/atlas/7/r11_W.png b/tools/out/atlas/7/r11_W.png new file mode 100644 index 0000000..f9cb999 Binary files /dev/null and b/tools/out/atlas/7/r11_W.png differ diff --git a/tools/out/atlas/7/r12_E.png b/tools/out/atlas/7/r12_E.png new file mode 100644 index 0000000..fde8c2c Binary files /dev/null and b/tools/out/atlas/7/r12_E.png differ diff --git a/tools/out/atlas/7/r12_N.png b/tools/out/atlas/7/r12_N.png new file mode 100644 index 0000000..752947e Binary files /dev/null and b/tools/out/atlas/7/r12_N.png differ diff --git a/tools/out/atlas/7/r12_S.png b/tools/out/atlas/7/r12_S.png new file mode 100644 index 0000000..04f1086 Binary files /dev/null and b/tools/out/atlas/7/r12_S.png differ diff --git a/tools/out/atlas/7/r12_W.png b/tools/out/atlas/7/r12_W.png new file mode 100644 index 0000000..3754188 Binary files /dev/null and b/tools/out/atlas/7/r12_W.png differ diff --git a/tools/out/atlas/7/r13_E.png b/tools/out/atlas/7/r13_E.png new file mode 100644 index 0000000..69e46c6 Binary files /dev/null and b/tools/out/atlas/7/r13_E.png differ diff --git a/tools/out/atlas/7/r13_N.png b/tools/out/atlas/7/r13_N.png new file mode 100644 index 0000000..02b6cc0 Binary files /dev/null and b/tools/out/atlas/7/r13_N.png differ diff --git a/tools/out/atlas/7/r13_S.png b/tools/out/atlas/7/r13_S.png new file mode 100644 index 0000000..b96131a Binary files /dev/null and b/tools/out/atlas/7/r13_S.png differ diff --git a/tools/out/atlas/7/r13_W.png b/tools/out/atlas/7/r13_W.png new file mode 100644 index 0000000..0b8058d Binary files /dev/null and b/tools/out/atlas/7/r13_W.png differ diff --git a/tools/out/atlas/7/r14_E.png b/tools/out/atlas/7/r14_E.png new file mode 100644 index 0000000..a800fc8 Binary files /dev/null and b/tools/out/atlas/7/r14_E.png differ diff --git a/tools/out/atlas/7/r14_N.png b/tools/out/atlas/7/r14_N.png new file mode 100644 index 0000000..f557efe Binary files /dev/null and b/tools/out/atlas/7/r14_N.png differ diff --git a/tools/out/atlas/7/r14_S.png b/tools/out/atlas/7/r14_S.png new file mode 100644 index 0000000..cb98e25 Binary files /dev/null and b/tools/out/atlas/7/r14_S.png differ diff --git a/tools/out/atlas/7/r14_W.png b/tools/out/atlas/7/r14_W.png new file mode 100644 index 0000000..5d0102f Binary files /dev/null and b/tools/out/atlas/7/r14_W.png differ diff --git a/tools/out/atlas/7/r15_E.png b/tools/out/atlas/7/r15_E.png new file mode 100644 index 0000000..b14e3b8 Binary files /dev/null and b/tools/out/atlas/7/r15_E.png differ diff --git a/tools/out/atlas/7/r15_N.png b/tools/out/atlas/7/r15_N.png new file mode 100644 index 0000000..2762486 Binary files /dev/null and b/tools/out/atlas/7/r15_N.png differ diff --git a/tools/out/atlas/7/r15_S.png b/tools/out/atlas/7/r15_S.png new file mode 100644 index 0000000..8ec91bb Binary files /dev/null and b/tools/out/atlas/7/r15_S.png differ diff --git a/tools/out/atlas/7/r15_W.png b/tools/out/atlas/7/r15_W.png new file mode 100644 index 0000000..a170d52 Binary files /dev/null and b/tools/out/atlas/7/r15_W.png differ diff --git a/tools/out/atlas/7/r16_E.png b/tools/out/atlas/7/r16_E.png new file mode 100644 index 0000000..90c78a9 Binary files /dev/null and b/tools/out/atlas/7/r16_E.png differ diff --git a/tools/out/atlas/7/r16_N.png b/tools/out/atlas/7/r16_N.png new file mode 100644 index 0000000..4c5eff1 Binary files /dev/null and b/tools/out/atlas/7/r16_N.png differ diff --git a/tools/out/atlas/7/r16_S.png b/tools/out/atlas/7/r16_S.png new file mode 100644 index 0000000..ddc1ec3 Binary files /dev/null and b/tools/out/atlas/7/r16_S.png differ diff --git a/tools/out/atlas/7/r16_W.png b/tools/out/atlas/7/r16_W.png new file mode 100644 index 0000000..327f848 Binary files /dev/null and b/tools/out/atlas/7/r16_W.png differ diff --git a/tools/out/atlas/7/r1_E.png b/tools/out/atlas/7/r1_E.png new file mode 100644 index 0000000..0df6ca4 Binary files /dev/null and b/tools/out/atlas/7/r1_E.png differ diff --git a/tools/out/atlas/7/r1_N.png b/tools/out/atlas/7/r1_N.png new file mode 100644 index 0000000..f355138 Binary files /dev/null and b/tools/out/atlas/7/r1_N.png differ diff --git a/tools/out/atlas/7/r1_S.png b/tools/out/atlas/7/r1_S.png new file mode 100644 index 0000000..ee77918 Binary files /dev/null and b/tools/out/atlas/7/r1_S.png differ diff --git a/tools/out/atlas/7/r1_W.png b/tools/out/atlas/7/r1_W.png new file mode 100644 index 0000000..80e5960 Binary files /dev/null and b/tools/out/atlas/7/r1_W.png differ diff --git a/tools/out/atlas/7/r2_E.png b/tools/out/atlas/7/r2_E.png new file mode 100644 index 0000000..b3974dd Binary files /dev/null and b/tools/out/atlas/7/r2_E.png differ diff --git a/tools/out/atlas/7/r2_N.png b/tools/out/atlas/7/r2_N.png new file mode 100644 index 0000000..8ec91bb Binary files /dev/null and b/tools/out/atlas/7/r2_N.png differ diff --git a/tools/out/atlas/7/r2_S.png b/tools/out/atlas/7/r2_S.png new file mode 100644 index 0000000..c9b10d0 Binary files /dev/null and b/tools/out/atlas/7/r2_S.png differ diff --git a/tools/out/atlas/7/r2_W.png b/tools/out/atlas/7/r2_W.png new file mode 100644 index 0000000..5d8f6ae Binary files /dev/null and b/tools/out/atlas/7/r2_W.png differ diff --git a/tools/out/atlas/7/r3_E.png b/tools/out/atlas/7/r3_E.png new file mode 100644 index 0000000..fea2762 Binary files /dev/null and b/tools/out/atlas/7/r3_E.png differ diff --git a/tools/out/atlas/7/r3_N.png b/tools/out/atlas/7/r3_N.png new file mode 100644 index 0000000..04a1d14 Binary files /dev/null and b/tools/out/atlas/7/r3_N.png differ diff --git a/tools/out/atlas/7/r3_S.png b/tools/out/atlas/7/r3_S.png new file mode 100644 index 0000000..504418f Binary files /dev/null and b/tools/out/atlas/7/r3_S.png differ diff --git a/tools/out/atlas/7/r3_W.png b/tools/out/atlas/7/r3_W.png new file mode 100644 index 0000000..7ce2e27 Binary files /dev/null and b/tools/out/atlas/7/r3_W.png differ diff --git a/tools/out/atlas/7/r4_E.png b/tools/out/atlas/7/r4_E.png new file mode 100644 index 0000000..a840cf8 Binary files /dev/null and b/tools/out/atlas/7/r4_E.png differ diff --git a/tools/out/atlas/7/r4_N.png b/tools/out/atlas/7/r4_N.png new file mode 100644 index 0000000..066641e Binary files /dev/null and b/tools/out/atlas/7/r4_N.png differ diff --git a/tools/out/atlas/7/r4_S.png b/tools/out/atlas/7/r4_S.png new file mode 100644 index 0000000..ffe6591 Binary files /dev/null and b/tools/out/atlas/7/r4_S.png differ diff --git a/tools/out/atlas/7/r4_W.png b/tools/out/atlas/7/r4_W.png new file mode 100644 index 0000000..679ac64 Binary files /dev/null and b/tools/out/atlas/7/r4_W.png differ diff --git a/tools/out/atlas/7/r5_E.png b/tools/out/atlas/7/r5_E.png new file mode 100644 index 0000000..ee354a5 Binary files /dev/null and b/tools/out/atlas/7/r5_E.png differ diff --git a/tools/out/atlas/7/r5_N.png b/tools/out/atlas/7/r5_N.png new file mode 100644 index 0000000..e2b15f9 Binary files /dev/null and b/tools/out/atlas/7/r5_N.png differ diff --git a/tools/out/atlas/7/r5_S.png b/tools/out/atlas/7/r5_S.png new file mode 100644 index 0000000..ace7cc1 Binary files /dev/null and b/tools/out/atlas/7/r5_S.png differ diff --git a/tools/out/atlas/7/r5_W.png b/tools/out/atlas/7/r5_W.png new file mode 100644 index 0000000..3e3ad26 Binary files /dev/null and b/tools/out/atlas/7/r5_W.png differ diff --git a/tools/out/atlas/7/r6_E.png b/tools/out/atlas/7/r6_E.png new file mode 100644 index 0000000..9ddfe69 Binary files /dev/null and b/tools/out/atlas/7/r6_E.png differ diff --git a/tools/out/atlas/7/r6_N.png b/tools/out/atlas/7/r6_N.png new file mode 100644 index 0000000..f8b82a3 Binary files /dev/null and b/tools/out/atlas/7/r6_N.png differ diff --git a/tools/out/atlas/7/r6_S.png b/tools/out/atlas/7/r6_S.png new file mode 100644 index 0000000..717ce69 Binary files /dev/null and b/tools/out/atlas/7/r6_S.png differ diff --git a/tools/out/atlas/7/r6_W.png b/tools/out/atlas/7/r6_W.png new file mode 100644 index 0000000..e049e82 Binary files /dev/null and b/tools/out/atlas/7/r6_W.png differ diff --git a/tools/out/atlas/7/r7_E.png b/tools/out/atlas/7/r7_E.png new file mode 100644 index 0000000..799e75e Binary files /dev/null and b/tools/out/atlas/7/r7_E.png differ diff --git a/tools/out/atlas/7/r7_N.png b/tools/out/atlas/7/r7_N.png new file mode 100644 index 0000000..c085040 Binary files /dev/null and b/tools/out/atlas/7/r7_N.png differ diff --git a/tools/out/atlas/7/r7_S.png b/tools/out/atlas/7/r7_S.png new file mode 100644 index 0000000..a1bec75 Binary files /dev/null and b/tools/out/atlas/7/r7_S.png differ diff --git a/tools/out/atlas/7/r7_W.png b/tools/out/atlas/7/r7_W.png new file mode 100644 index 0000000..ae139a2 Binary files /dev/null and b/tools/out/atlas/7/r7_W.png differ diff --git a/tools/out/atlas/7/r8_E.png b/tools/out/atlas/7/r8_E.png new file mode 100644 index 0000000..fb274a9 Binary files /dev/null and b/tools/out/atlas/7/r8_E.png differ diff --git a/tools/out/atlas/7/r8_N.png b/tools/out/atlas/7/r8_N.png new file mode 100644 index 0000000..e652c3d Binary files /dev/null and b/tools/out/atlas/7/r8_N.png differ diff --git a/tools/out/atlas/7/r8_S.png b/tools/out/atlas/7/r8_S.png new file mode 100644 index 0000000..e5ccd80 Binary files /dev/null and b/tools/out/atlas/7/r8_S.png differ diff --git a/tools/out/atlas/7/r8_W.png b/tools/out/atlas/7/r8_W.png new file mode 100644 index 0000000..907ab77 Binary files /dev/null and b/tools/out/atlas/7/r8_W.png differ diff --git a/tools/out/atlas/7/r9_E.png b/tools/out/atlas/7/r9_E.png new file mode 100644 index 0000000..584c2b6 Binary files /dev/null and b/tools/out/atlas/7/r9_E.png differ diff --git a/tools/out/atlas/7/r9_N.png b/tools/out/atlas/7/r9_N.png new file mode 100644 index 0000000..bfcf179 Binary files /dev/null and b/tools/out/atlas/7/r9_N.png differ diff --git a/tools/out/atlas/7/r9_S.png b/tools/out/atlas/7/r9_S.png new file mode 100644 index 0000000..b9e70ae Binary files /dev/null and b/tools/out/atlas/7/r9_S.png differ diff --git a/tools/out/atlas/7/r9_W.png b/tools/out/atlas/7/r9_W.png new file mode 100644 index 0000000..989f6f0 Binary files /dev/null and b/tools/out/atlas/7/r9_W.png differ