From 210f17773a15c03fbca6f146f71cd4fcf8979bbb Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sat, 7 Nov 2026 23:04:34 +0100 Subject: [PATCH] Debug demo map: four allied masters with every room and creature against an endless hero gate Co-Authored-By: Claude Opus 5.5 --- lang/cs.lang | 1 + lang/da.lang | 1 + lang/de.lang | 1 + lang/el.lang | 1 + lang/en.lang | 1 + lang/es.lang | 1 + lang/fi.lang | 1 + lang/hr.lang | 1 + lang/hu.lang | 1 + lang/it.lang | 1 + lang/nl.lang | 1 + lang/no.lang | 1 + lang/pt.lang | 1 + lang/ro.lang | 1 + lang/ru.lang | 1 + lang/sk.lang | 1 + lang/sr.lang | 1 + lang/sv.lang | 1 + src/achievements.rs | 2 +- src/demo_map.rs | 471 ++++++++++++++++++++++++++++++++++++++++++++ src/game.rs | 6 + src/main.rs | 28 ++- src/menu.rs | 14 ++ 23 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 src/demo_map.rs diff --git a/lang/cs.lang b/lang/cs.lang index 7d46ce6..b19aa3a 100644 --- a/lang/cs.lang +++ b/lang/cs.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Zobrazit zóny spouštěčů set.debug_command_markers = Zobrazit ladicí značky rozkazů set.debug_path_lines = Zobrazit čáry cest set.debug_holiday = Svátek +set.debug_demo_map = Spustit ukázkovou mapu val.halloween = Halloween val.christmas = Vánoce val.easter = Velikonoce diff --git a/lang/da.lang b/lang/da.lang index 7f9abe8..c6bc679 100644 --- a/lang/da.lang +++ b/lang/da.lang @@ -389,6 +389,7 @@ set.debug_trigger_zones = Vis udløserzoner set.debug_command_markers = Vis debug-ordremarkører set.debug_path_lines = Vis stilinjer set.debug_holiday = Højtid +set.debug_demo_map = Start demokort val.halloween = Halloween val.christmas = Jul val.easter = Påske diff --git a/lang/de.lang b/lang/de.lang index b2c86d0..a9ba05e 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -389,6 +389,7 @@ set.debug_trigger_zones = Triggerzonen anzeigen set.debug_command_markers = Debug-Befehlsmarker anzeigen set.debug_path_lines = Pfadlinien anzeigen set.debug_holiday = Festzeit +set.debug_demo_map = Demokarte starten val.halloween = Halloween val.christmas = Weihnachten val.easter = Ostern diff --git a/lang/el.lang b/lang/el.lang index 48f1a4e..4bd3e1c 100644 --- a/lang/el.lang +++ b/lang/el.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Εμφάνιση ζωνών ενεργοποίησης set.debug_command_markers = Εμφάνιση δεικτών εντολών debug set.debug_path_lines = Εμφάνιση γραμμών διαδρομής set.debug_holiday = Γιορτή +set.debug_demo_map = Έναρξη χάρτη επίδειξης val.halloween = Halloween val.christmas = Χριστούγεννα val.easter = Πάσχα diff --git a/lang/en.lang b/lang/en.lang index a236162..e5c0f5f 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Show trigger zones set.debug_command_markers = Show debug command markers set.debug_path_lines = Show path lines set.debug_holiday = Holiday +set.debug_demo_map = Start demo map val.halloween = Halloween val.christmas = Christmas val.easter = Easter diff --git a/lang/es.lang b/lang/es.lang index 7325c19..3bb9474 100644 --- a/lang/es.lang +++ b/lang/es.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Mostrar zonas de activación set.debug_command_markers = Mostrar marcadores de órdenes set.debug_path_lines = Mostrar líneas de ruta set.debug_holiday = Festividad +set.debug_demo_map = Iniciar mapa de demostración val.halloween = Halloween val.christmas = Navidad val.easter = Pascua diff --git a/lang/fi.lang b/lang/fi.lang index cc81f9b..366e3fc 100644 --- a/lang/fi.lang +++ b/lang/fi.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Näytä laukaisualueet set.debug_command_markers = Näytä komentomerkit set.debug_path_lines = Näytä reittiviivat set.debug_holiday = Juhlakausi +set.debug_demo_map = Aloita esittelykartta val.halloween = Halloween val.christmas = Joulu val.easter = Pääsiäinen diff --git a/lang/hr.lang b/lang/hr.lang index 1e4ef8c..e3b3c62 100644 --- a/lang/hr.lang +++ b/lang/hr.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Prikaži zone okidača set.debug_command_markers = Prikaži debug oznake naredbi set.debug_path_lines = Prikaži linije putanja set.debug_holiday = Blagdan +set.debug_demo_map = Pokreni demo kartu val.halloween = Halloween val.christmas = Božić val.easter = Uskrs diff --git a/lang/hu.lang b/lang/hu.lang index 2747529..e6913ef 100644 --- a/lang/hu.lang +++ b/lang/hu.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Indítózónák mutatása set.debug_command_markers = Debug parancsjelölők mutatása set.debug_path_lines = Útvonalak mutatása set.debug_holiday = Ünnep +set.debug_demo_map = Bemutató térkép indítása val.halloween = Halloween val.christmas = Karácsony val.easter = Húsvét diff --git a/lang/it.lang b/lang/it.lang index c8bff4e..8c4b601 100644 --- a/lang/it.lang +++ b/lang/it.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Mostra zone trigger set.debug_command_markers = Mostra marcatori comandi set.debug_path_lines = Mostra percorsi set.debug_holiday = Festività +set.debug_demo_map = Avvia mappa demo val.halloween = Halloween val.christmas = Natale val.easter = Pasqua diff --git a/lang/nl.lang b/lang/nl.lang index 57dee52..4425947 100644 --- a/lang/nl.lang +++ b/lang/nl.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Triggerzones tonen set.debug_command_markers = Debug-commandomarkeringen tonen set.debug_path_lines = Padlijnen tonen set.debug_holiday = Feestdag +set.debug_demo_map = Demokaart starten val.halloween = Halloween val.christmas = Kerstmis val.easter = Pasen diff --git a/lang/no.lang b/lang/no.lang index 5eb3cea..faf2e74 100644 --- a/lang/no.lang +++ b/lang/no.lang @@ -389,6 +389,7 @@ set.debug_trigger_zones = Vis utløsersoner set.debug_command_markers = Vis debug-ordremarkører set.debug_path_lines = Vis stilinjer set.debug_holiday = Høytid +set.debug_demo_map = Start demokart val.halloween = Halloween val.christmas = Jul val.easter = Påske diff --git a/lang/pt.lang b/lang/pt.lang index 94d7efb..6c3ec12 100644 --- a/lang/pt.lang +++ b/lang/pt.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Mostrar zonas de ativação set.debug_command_markers = Mostrar marcadores de comando set.debug_path_lines = Mostrar linhas de caminho set.debug_holiday = Festividade +set.debug_demo_map = Iniciar mapa de demonstração val.halloween = Halloween val.christmas = Natal val.easter = Páscoa diff --git a/lang/ro.lang b/lang/ro.lang index 2d06dcb..04852e5 100644 --- a/lang/ro.lang +++ b/lang/ro.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Arată zonele declanșatoare set.debug_command_markers = Arată marcajele de comenzi (depanare) set.debug_path_lines = Arată liniile de traseu set.debug_holiday = Sărbătoare +set.debug_demo_map = Pornește harta demo val.halloween = Halloween val.christmas = Crăciun val.easter = Paște diff --git a/lang/ru.lang b/lang/ru.lang index 34ea756..1ad1e65 100644 --- a/lang/ru.lang +++ b/lang/ru.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Зоны триггеров set.debug_command_markers = Маркеры отладочных команд set.debug_path_lines = Линии путей set.debug_holiday = Праздник +set.debug_demo_map = Запустить демо-карту val.halloween = Хэллоуин val.christmas = Рождество val.easter = Пасха diff --git a/lang/sk.lang b/lang/sk.lang index b16b3c1..56e5736 100644 --- a/lang/sk.lang +++ b/lang/sk.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Zobraziť zóny spúšťačov set.debug_command_markers = Zobraziť ladiace značky rozkazov set.debug_path_lines = Zobraziť čiary ciest set.debug_holiday = Sviatok +set.debug_demo_map = Spustiť ukážkovú mapu val.halloween = Halloween val.christmas = Vianoce val.easter = Veľká noc diff --git a/lang/sr.lang b/lang/sr.lang index 50044ce..6a9d95b 100644 --- a/lang/sr.lang +++ b/lang/sr.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Прикажи зоне окидача set.debug_command_markers = Прикажи дебаг ознаке наредби set.debug_path_lines = Прикажи линије путања set.debug_holiday = Празник +set.debug_demo_map = Покрени демо мапу val.halloween = Ноћ вештица val.christmas = Божић val.easter = Ускрс diff --git a/lang/sv.lang b/lang/sv.lang index 3992c90..89226c5 100644 --- a/lang/sv.lang +++ b/lang/sv.lang @@ -390,6 +390,7 @@ set.debug_trigger_zones = Visa triggerzoner set.debug_command_markers = Visa debug-ordermarkörer set.debug_path_lines = Visa stiglinjer set.debug_holiday = Högtid +set.debug_demo_map = Starta demokarta val.halloween = Halloween val.christmas = Jul val.easter = Påsk diff --git a/src/achievements.rs b/src/achievements.rs index 4101094..eb63d43 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -153,7 +153,7 @@ impl Tracker { // called every frame; returns achievements unlocked just now pub fn update(&mut self, g: &Game, ctx: &Context, dt: f32, now: u64) -> Vec { - if g.demo.is_some() || ctx.editor_test { + if g.demo.is_some() || g.demo_map.is_some() || ctx.editor_test { self.last_time = 0.0; return Vec::new(); } diff --git a/src/demo_map.rs b/src/demo_map.rs new file mode 100644 index 0000000..c3a75bf --- /dev/null +++ b/src/demo_map.rs @@ -0,0 +1,471 @@ +// The demo map of the debug menu: all four dungeon masters (the player and three AI partners, all +// allied) each have a walled dungeon with every room kind and every dungeon creature kind. Their +// dungeons lie in the four corners around the humans' gate in the middle; the attack flag goes up +// on the gate now and then and all four armies march on it together, in between the creatures do +// their work in the rooms. The gate never falls and keeps sending heroes of every kind, the soul +// cores never fall, the treasuries never run dry. Meant to watch every animation; nothing of it +// counts for achievements or overwrites the autosave (see main.rs, achievements.rs). + +use crate::game::*; +use crate::map::*; +use crate::render3d::Cam; +use serde::{Deserialize, Serialize}; + +const W: usize = 74; +const H: usize = 74; +const PLAZA: (i32, i32) = (37, 37); // middle of the humans' plaza (the gate stands on it) +const PLAZA_R: f32 = 8.5; +const HALF: i32 = 10; // a dungeon's floor reaches this far from the middle of its soul core +// the masters and the middle of their dungeons: player north-west, then clockwise +pub const MASTERS: [(u8, (i32, i32)); 4] = [(0, (13, 13)), (2, (60, 13)), (3, (60, 60)), (4, (13, 60))]; +// every creature kind a dungeon can have (imps come with every master) +pub const CREATURES: [EK; 17] = [ + EK::Kobold, + EK::Spider, + EK::Mage, + EK::Ogre, + EK::Jailer, + EK::Priestess, + EK::Chainbreaker, + EK::Bat, + EK::Gargoyle, + EK::Owl, + EK::Wyrm, + EK::Cogling, + EK::Revenant, + EK::CaveBear, + EK::Beetle, + EK::Ram, + EK::Catapult, +]; +// the gate sends them in this order, over and over +pub const HEROES: [EK; 14] = [ + EK::Knight, + EK::Archer, + EK::Priest, + EK::Dwarf, + EK::Fairy, + EK::Thief, + EK::BattleMage, + EK::Monk, + EK::Paladin, + EK::Ranger, + EK::Giant, + EK::Inquisitor, + EK::Ram, + EK::Catapult, +]; +const HERO_EVERY: f32 = 1.2; // seconds between two heroes at the gate +const MAX_HEROES: usize = 30; // fighting heroes on the map at most +const RESTOCK_EVERY: f32 = 15.0; // seconds between two looks for creature kinds that died out +const ATTACK_FOR: f32 = 75.0; // the flag stays on the gate this long ... +const HOME_FOR: f32 = 40.0; // ... then the creatures are free for their rooms this long + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct DemoMap { + pub hero_t: f32, + pub next_hero: u32, + pub heroes_sent: u32, + pub restock_t: f32, + pub phase_t: f32, // seconds until the flag goes up (or down) + pub attacking: bool, +} + +// the 3x3 room slots of a dungeon around its core, one tile of corridor between them (5 x 5 +// slots, the middle one is the core): top-left corners +fn slots(c: (i32, i32)) -> Vec<(i32, i32)> { + let mut v = Vec::new(); + for ky in -2..=2 { + for kx in -2..=2 { + if kx != 0 || ky != 0 { + v.push((c.0 - 1 + 4 * kx, c.1 - 1 + 4 * ky)); + } + } + } + v +} + +fn in_square(c: (i32, i32), x: i32, y: i32, r: i32) -> bool { + (x - c.0).abs() <= r && (y - c.1).abs() <= r +} + +// the spot of the attack flag: on the plaza right before the gate +fn rally_tile() -> usize { + idx(PLAZA.0, PLAZA.1 + 3) +} + +impl Game { + pub fn new_demo_map(seed: u32) -> (Game, Cam) { + let opts = GenOpts { portals: Some(0), treasures: Some(0), gate: false, core_y: None }; + let mut map = Map::generate_with(seed, W, H, &opts); + let (wi, hi) = (W as i32, H as i32); + // solid earth everywhere, with a few gold veins between the dungeons + for y in 2..hi - 2 { + for x in 2..wi - 2 { + let t = &mut map.t[idx(x, y)]; + let var = t.var; + *t = Tile::new(Kind::Earth); + t.var = var; + t.hp = 6.0; + let h = ((x * 73 + y * 151) as u32).wrapping_mul(2654435761) >> 26; + let between = MASTERS.iter().all(|&(_, c)| !in_square(c, x, y, HALF + 1)) && ((x - PLAZA.0).pow(2) + (y - PLAZA.1).pow(2)) as f32 > (PLAZA_R + 2.0).powi(2); + if between && h % 17 == 0 { + t.kind = Kind::Gold; + t.gold = 400; + } + } + } + // the humans' plaza with the gate in the middle + for y in 2..hi - 2 { + for x in 2..wi - 2 { + if (((x - PLAZA.0).pow(2) + (y - PLAZA.1).pow(2)) as f32) <= PLAZA_R * PLAZA_R { + let t = &mut map.t[idx(x, y)]; + t.kind = Kind::Floor; + t.owner = Owner::Hero; + t.gold = 0; + } + } + } + let gate = (PLAZA.0 - 1, PLAZA.1 - 1); + for y in gate.1..gate.1 + 3 { + for x in gate.0..gate.0 + 3 { + let t = &mut map.t[idx(x, y)]; + t.kind = Kind::Gate; + t.owner = Owner::Hero; + } + } + map.gate = gate; + // the dungeons: a square of own floor in a reinforced wall, the core in the middle, the + // rooms around it + map.portals.clear(); + map.treasures.clear(); + for &(side, c) in MASTERS.iter() { + let owner = Owner::of_side(side); + for y in c.1 - HALF - 1..=c.1 + HALF + 1 { + for x in c.0 - HALF - 1..=c.0 + HALF + 1 { + let t = &mut map.t[idx(x, y)]; + t.owner = owner; + t.gold = 0; + if in_square(c, x, y, HALF) { + t.kind = Kind::Floor; + } else { + t.kind = Kind::Earth; + t.reinforced = true; + } + } + } + for y in c.1 - 1..=c.1 + 1 { + for x in c.0 - 1..=c.0 + 1 { + map.t[idx(x, y)].kind = Kind::Core; + } + } + // every room kind, more beds and food, the portal on the far side; slots left over + // stay plain floor for the master's own plans + let mut rooms: Vec = ROOM_KINDS.to_vec(); + rooms.extend([Kind::Lair, Kind::Lair, Kind::Farm, Kind::Aerie]); + let mut list = slots(c); + // the slots nearest the plaza first: the busy rooms are what the camera sees + list.sort_by_key(|&(x, y)| (x + 1 - PLAZA.0).pow(2) + (y + 1 - PLAZA.1).pow(2)); + let portal = list.pop().unwrap(); + for y in portal.1..portal.1 + 3 { + for x in portal.0..portal.0 + 3 { + map.t[idx(x, y)].kind = Kind::Portal; + } + } + map.portals.push(portal); + for (&(sx, sy), &kind) in list.iter().zip(rooms.iter()) { + for y in sy..sy + 3 { + for x in sx..sx + 3 { + map.t[idx(x, y)].kind = kind; + } + } + } + } + // corridors from every dungeon to the plaza (through the wall) + for &(side, c) in MASTERS.iter() { + let owner = Owner::of_side(side); + let (dx, dy) = ((PLAZA.0 - c.0) as f32, (PLAZA.1 - c.1) as f32); + let n = (dx.abs().max(dy.abs()) * 2.0) as i32; + for k in 0..=n { + let (px, py) = (c.0 as f32 + 0.5 + dx * k as f32 / n as f32, c.1 as f32 + 0.5 + dy * k as f32 / n as f32); + for y in (py - 1.5) as i32..=(py + 1.5) as i32 { + for x in (px - 1.5) as i32..=(px + 1.5) as i32 { + if (x as f32 + 0.5 - px).powi(2) + (y as f32 + 0.5 - py).powi(2) > 1.6 * 1.6 || x < 2 || y < 2 || x >= wi - 2 || y >= hi - 2 { + continue; + } + let t = &mut map.t[idx(x, y)]; + if in_square(c, x, y, HALF) || t.owner == Owner::Hero { + continue; // the dungeon's rooms and the plaza stay as they are + } + let wall = in_square(c, x, y, HALF + 1); + t.kind = Kind::Floor; + t.reinforced = false; + t.gold = 0; + t.owner = if wall { owner } else { Owner::None }; + } + } + } + } + let player = MASTERS[0].1; + map.core = (player.0 - 1, player.1 - 1); + let mut g = Game::from_map(map, seed); + // from_map put a treasury ring around the player's core, on the corridors between the + // rooms: back to floor (his treasury is one of the rooms, like everybody's) + for y in player.1 - 2..=player.1 + 2 { + for x in player.0 - 2..=player.0 + 2 { + let t = &mut g.map.t[idx(x, y)]; + if t.kind == Kind::Treasury && ((x - player.0).abs() == 2 || (y - player.1).abs() == 2) { + t.kind = Kind::Floor; + } + } + } + g.demo_map = Some(DemoMap { hero_t: 3.0, next_hero: 0, heroes_sent: 0, restock_t: RESTOCK_EVERY, phase_t: 15.0, attacking: false }); + g.gate_on = true; + g.gate_hp = GATE_HP; + g.wave_timer = f32::MAX; + g.coop = true; + g.ai_sides = vec![2, 3, 4]; + // the three AI partners: real masters with the brain of ai.rs (hard: they think fast) + for &(side, c) in MASTERS.iter().skip(1) { + let core = (c.0 - 1, c.1 - 1); + g.rivals.push(crate::rival::Rival { + side, + core, + hp: crate::rival::RIVAL_CORE_HP, + alive: true, + radius: HALF + 1, + grow_t: 20.0, + spawn_t: 5.0, + attack_t: 0.0, + difficulty: 2, + power: 0.0, + gift_t: crate::rival::PARTNER_GIFT_EVERY, + think_t: 2.0, + attacking: false, + army0: 0.0, + focus: None, + defending: false, + }); + g.adopt_master(side, core, 2, true); + } + // every creature kind for every master, gold in the treasuries + for &(side, _) in MASTERS.iter() { + g.switch_to(side); + g.recount(); + for &kind in CREATURES.iter() { + g.demo_map_creature(side, kind); + } + let fill = g.gold_cap() * 0.7 - g.gold; + g.add_gold(fill.max(0.0)); + g.mana = g.mana_max(); + g.wood = g.wood.max(200.0); + } + g.switch_to(0); + g.place_all_torches(); + g.msgs.clear(); + // the camera on the plaza, where the four armies meet + let mut cam = Cam::new(PLAZA.0 as f32 + 0.5, PLAZA.1 as f32 + 0.5); + cam.zoom = 30.0; + (g, cam) + } + + // a creature of `kind` for the master on `side` (the master being processed), somewhere on + // the floor of his dungeon + fn demo_map_creature(&mut self, side: u8, kind: EK) { + let Some(&(_, c)) = MASTERS.iter().find(|m| m.0 == side) else { return }; + let owner = Owner::of_side(side); + let spots: Vec = (0..self.map.t.len()) + .filter(|&i| { + let (x, y) = xy(i); + let t = &self.map.t[i]; + in_square(c, x, y, HALF) && t.owner == owner && t.walkable() && t.kind != Kind::Portal + }) + .collect(); + if spots.is_empty() { + return; + } + let i = spots[self.rng.next() as usize % spots.len()]; + let (x, y) = xy(i); + let level = 2 + self.rng.next() % 5; + let id = self.spawn(kind, false, x as f32 + 0.5, y as f32 + 0.5, level); + if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { + e.side = side; + e.settled = true; + e.happy = 1.0; + if crate::siege::siege(kind) { + e.home = Some((x as f32 + 0.5, y as f32 + 0.5)); + } + } + } + + // the demo map's rules, once per step from the first master's side: the gate and the cores + // never fall, gold never runs out, heroes keep coming, the flag goes up and down + pub(crate) fn demo_map_tick(&mut self, dt: f32) { + let Some(mut d) = self.demo_map.take() else { return }; + let back = self.me_side; + self.gate_hp = GATE_HP; + self.gate_stage = 0; + self.wave_timer = f32::MAX; + self.warned = true; + self.out_sides.clear(); + // the attack flag of the player (the partners follow it, see ai.rs) + d.phase_t -= dt; + if d.phase_t <= 0.0 { + d.attacking = !d.attacking; + d.phase_t = if d.attacking { ATTACK_FOR } else { HOME_FOR }; + } + d.restock_t -= dt; + let restock = d.restock_t <= 0.0; + if restock { + d.restock_t = RESTOCK_EVERY; + } + for &(side, _) in MASTERS.iter() { + self.switch_to(side); + self.core_hp = CORE_HP; + if self.gold < self.gold_cap() * 0.4 { + let fill = self.gold_cap() * 0.7 - self.gold; + self.add_gold(fill.max(0.0)); + } + if side == 0 { + self.flag = if d.attacking { Some(rally_tile()) } else { None }; + } + // kinds that died out (or left) come back + if restock { + for &kind in CREATURES.iter() { + if !self.ents.iter().any(|e| e.alive && !e.hero && e.side == side && e.kind == kind) { + self.demo_map_creature(side, kind); + } + } + } + } + for r in self.rivals.iter_mut() { + r.alive = true; + r.hp = crate::rival::RIVAL_CORE_HP; + } + self.switch_to(back); + // heroes of every kind from the gate: half of them guard the plaza, the others march on + // the dungeons + d.hero_t -= dt; + let fighting = self.ents.iter().filter(|e| e.alive && e.active_hero()).count(); + if d.hero_t <= 0.0 && fighting < MAX_HEROES { + d.hero_t = HERO_EVERY; + let n = d.next_hero; + d.next_hero += 1; + d.heroes_sent += 1; + let kind = HEROES[n as usize % HEROES.len()]; + let level = 3 + (n / HEROES.len() as u32) % 6; + let (gx, gy) = self.map.gate; + let x = gx as f32 + 0.5 + self.rng.f() * 2.0; + let y = gy as f32 + 3.2 + self.rng.f() * 0.6; + let id = self.spawn(kind, true, x, y, level); + let guard = n % 2 == 0 && !crate::siege::siege(kind); + let a = self.rng.f() * std::f32::consts::TAU; + let r = 2.5 + self.rng.f() * 3.0; + let home = (PLAZA.0 as f32 + 0.5 + a.cos() * r, PLAZA.1 as f32 + 0.5 + a.sin() * r); + let target = [0u8, 2, 3, 4][(n / 2) as usize % 4]; + if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { + if guard { + e.guard = true; + e.home = Some(home); + } else if target != 0 { + e.target = Some(target); + } + } + self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 0.6, 0xffffeecc); + } + self.demo_map = Some(d); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds_of(g: &Game, side: u8) -> Vec { + g.ents.iter().filter(|e| e.alive && !e.hero && e.side == side).map(|e| e.kind).collect() + } + + #[test] + fn four_allied_masters_with_every_room_and_creature() { + let (mut g, _) = Game::new_demo_map(5); + for y in 0..H as i32 { + let row: String = (0..W as i32) + .map(|x| { + let t = g.map.t[idx(x, y)]; + match t.kind { + Kind::Gate => 'G', + Kind::Core => 'C', + Kind::Portal => 'P', + Kind::Gold => '$', + Kind::Floor => if t.owner == Owner::None { '.' } else { ',' }, + k if room_index(k).is_some() => (b'a' + room_index(k).unwrap() as u8) as char, + _ if t.solid() => if t.reinforced { '#' } else { ' ' }, + _ => '?', + } + }) + .collect(); + println!("{}", row); + } + assert_eq!(g.master_sides(), vec![0, 2, 3, 4]); + for &(a, _) in MASTERS.iter() { + for &(b, _) in MASTERS.iter() { + assert!(g.allied(a, b), "{a} and {b} are allies"); + } + assert!(!g.allied(a, 1), "the heroes are the enemy"); + } + for &(side, _) in MASTERS.iter() { + g.switch_to(side); + g.recount(); + for (r, &k) in ROOM_KINDS.iter().enumerate() { + assert!(g.counts[r] >= 9, "side {side} has a {:?}", k); + } + assert!(g.portals_owned() > 0, "side {side} has a portal"); + assert!(g.gold > 1000.0, "side {side} has gold"); + let have = kinds_of(&g, side); + for k in CREATURES.iter().chain([EK::Worker].iter()) { + assert!(have.contains(k), "side {side} has a {:?}", k); + } + } + g.switch_to(0); + assert!(g.has_gate() && g.gate_hp > 0.0); + assert!(g.is_partner(2) && g.is_partner(3) && g.is_partner(4)); + // every dungeon reaches the gate + let gate = rally_tile(); + for &(_, c) in MASTERS.iter() { + assert!(g.map.path_to(idx(c.0 + 2, c.1 + 2), gate).is_some(), "the way from {:?} to the gate", c); + } + } + + #[test] + fn the_gate_never_falls_and_keeps_sending_heroes() { + let (mut g, _) = Game::new_demo_map(5); + let mut kinds = Vec::new(); + let mut rallied = false; + for k in 0..(20 * 120) { + g.update(0.05); + if k % (20 * 20) == 0 { + let c: Vec = MASTERS.iter().map(|m| g.ents.iter().filter(|e| e.alive && !e.hero && e.side == m.0).count()).collect(); + let h = g.ents.iter().filter(|e| e.alive && e.active_hero()).count(); + let rally = g.ents.iter().filter(|e| e.alive && e.job == Job::Rally).count(); + println!("{:>3} s: creatures {:?}, heroes {}, rallying {}, gate {}", k / 20, c, h, rally, g.gate_hp); + } + for e in g.ents.iter().filter(|e| e.alive && e.hero) { + if !kinds.contains(&e.kind) { + kinds.push(e.kind); + } + } + rallied |= g.ents.iter().any(|e| e.alive && !e.hero && e.side != 0 && e.job == Job::Rally); + assert_eq!(g.state, 0, "the demo map never ends"); + } + let d = g.demo_map.as_ref().unwrap(); + assert!(d.heroes_sent >= 20, "heroes keep coming: {}", d.heroes_sent); + assert!(kinds.len() >= 12, "every kind of hero: {:?}", kinds); + assert!(g.gate_hp > 0.0 && g.has_gate()); + assert_eq!(g.master_sides(), vec![0, 2, 3, 4]); + assert!(g.rivals.iter().all(|r| r.alive), "no master falls"); + assert!(rallied, "the partners' creatures follow the flag to the gate"); + for &(side, _) in MASTERS.iter() { + assert!(g.core_hp_of(side) > 0.0); + } + } +} diff --git a/src/game.rs b/src/game.rs index 67058fa..454ff7c 100644 --- a/src/game.rs +++ b/src/game.rs @@ -652,6 +652,8 @@ pub struct Game { #[serde(skip)] pub demo: Option, // the endless bridge battle behind the title menu #[serde(default)] + pub demo_map: Option, // the debug demo map: four allied masters against an endless gate + #[serde(default)] pub dwarf_caves: Vec, #[serde(default)] pub bridgework: crate::bridges::BridgeWork, // burning and worn bridges @@ -1001,6 +1003,7 @@ impl Game { springs: 0, freed: 0, demo: None, + demo_map: None, traps: Vec::new(), rollers: Vec::new(), clouds: Vec::new(), @@ -1843,6 +1846,9 @@ impl Game { self.update_wood(dt); self.update_events(dt); self.update_traders(dt); + if self.demo_map.is_some() { + self.demo_map_tick(dt); + } // fallen hearts of a match are shared state: every client marks them in the same step self.update_fallen_masters(); // the end of the game and the fog are about the master at this computer diff --git a/src/main.rs b/src/main.rs index cecdcdb..18ab1df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod campaign_doc; mod cloud; mod commands; mod demo; +mod demo_map; mod editor; mod events; mod dwarves; @@ -1660,6 +1661,28 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i); ui.show_help = false; autosave_t = 0.0; } + MenuAction::DemoMap => { + // debug: four allied masters with every room and creature against an + // endless gate (never autosaved, counts for no achievement) + if app.match_mode || app.game.in_match() { + app.leave_match(); + } + app.seed = next_seed(app.seed); + (app.game, app.cam) = Game::new_demo_map(app.seed); + apply_game_settings(&mut app.game, &app.settings); + app.coop = false; + app.recorded = false; + app.tutorial = false; + app.custom_campaign = None; + app.custom_progress = None; + app.custom_mission_index = None; + app.custom_next_index = None; + menu.page = None; + r3d.invalidate(); + ui.log.clear(); + ui.show_help = false; + autosave_t = 0.0; + } MenuAction::OpenRepo => platform::open_url(menu::REPO_URL), MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL), MenuAction::OpenOpenCode => platform::open_url(menu::OPENCODE_URL), @@ -1700,7 +1723,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i); app.tutorial = false; if app.match_mode || app.game.in_match() { app.leave_match(); - } else if app.game.state == 0 { + } else if app.game.state == 0 && app.game.demo_map.is_none() { platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json()); menu.has_autosave = true; } @@ -1980,7 +2003,8 @@ app.pending_briefing = briefing_of(&campaign, i); // the world runs in fixed steps: the speed setting only feeds more time into them app.game.update(dt * app.settings.game_speed.max(1.0)); // autosave: snapshot for leaving the tab + regular autosave - if app.game.state == 0 && !app.match_mode { + // (the debug demo map never replaces the autosave) + if app.game.state == 0 && !app.match_mode && app.game.demo_map.is_none() { cache_t += dt; autosave_t += dt; if cache_t >= AUTOSAVE_CACHE_EVERY { diff --git a/src/menu.rs b/src/menu.rs index 47dcbda..4e5604e 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -252,6 +252,7 @@ enum Btn { Start, Key(usize), ResetKeys, + DemoMap, // debug tab: start the demo map SaveNew, Slot(usize), // load / overwrite Delete(usize), @@ -512,6 +513,7 @@ pub enum MenuAction { OpenRepo, OpenClaudeCode, Tutorial, // start the tutorial mission + DemoMap, // debug: the demo map with all four masters against an endless gate BackToEditor, OpenOpenCode, Login(String, String), @@ -854,6 +856,10 @@ impl Menu { self.page = Some(Page::KeyCapture { action: a, from_title }); MenuAction::Consumed } + Some(Btn::DemoMap) => { + self.page = None; + MenuAction::DemoMap + } Some(Btn::ResetKeys) => { settings.reset_keys(); MenuAction::SettingsChanged @@ -1523,6 +1529,14 @@ impl Menu { self.buttons.push((right, Btn::Change(i, 1))); self.buttons.push((mid, Btn::Change(i, 1))); } + if tab == 4 { + // the demo map: all four masters with every room and creature against an endless gate + let label = tr("set.debug_demo_map"); + let rw = gfx.measure(&label, S()) + CW() * 4.0; + let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.0); + button(gfx, rr, &label, rr.contains(vec2(mx, my)), FRAME); + self.buttons.push((rr, Btn::DemoMap)); + } } let bw = gfx.measure(&tr("menu.back"), S()) + CW() * 6.0; let br = Rect::new(x + CW() * 2.0, y + (rows - 4) as f32 * CH(), bw, CH() * 2.0);