From f359671dacd620a9364f6f9c32a5c322ca5a20ab Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 15:51:35 +0100 Subject: [PATCH] Title menu background: an endless battle at a bridge over an underground river - dungeon creatures against knights, balanced reinforcements for the weaker side - towers on both banks; heavy units go for them, destroyed towers are rebuilt after 25 s - the camera slowly circles the bridge; battle sounds stay muted; the demo is never saved Co-Authored-By: Claude Opus 5.5 --- src/demo.rs | 312 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/game.rs | 41 ++++--- src/main.rs | 14 ++- 3 files changed, 351 insertions(+), 16 deletions(-) create mode 100644 src/demo.rs diff --git a/src/demo.rs b/src/demo.rs new file mode 100644 index 0000000..c12762f --- /dev/null +++ b/src/demo.rs @@ -0,0 +1,312 @@ +// The endless battle behind the title menu: the dungeon's creatures and the knights fight at +// a bridge over an underground river. Both sides get balanced reinforcements, destroyed towers +// are rebuilt after a while. Nothing here is ever saved. + +use crate::game::*; +use crate::map::*; +use crate::render3d::Cam; + +const W: usize = 72; +const H: usize = 48; +const RIVER_X: i32 = 34; // first water column (four tiles wide) +const BRIDGE_Y: i32 = 23; // first bridge row (three tiles wide) +const PER_SIDE: usize = 9; // at most this many units per side +const REBUILD: f32 = 25.0; // seconds until a destroyed tower stands again + +pub struct Demo { + spawn_t: f32, + towers: Vec<(i32, i32, TowerKind, bool, f32)>, // slot: position, kind, knight tower, time since lost + rng: Rng, +} + +const CREATURES: [(EK, u32); 9] = [(EK::Kobold, 4), (EK::Spider, 3), (EK::Mage, 2), (EK::Ogre, 2), (EK::Jailer, 1), (EK::Priestess, 1), (EK::Bat, 2), (EK::Gargoyle, 1), (EK::Owl, 1)]; +const KNIGHTS: [(EK, u32); 5] = [(EK::Knight, 5), (EK::Archer, 3), (EK::Priest, 2), (EK::Dwarf, 2), (EK::Paladin, 1)]; + +fn pick(rng: &mut Rng, pool: &[(EK, u32)]) -> EK { + let total: u32 = pool.iter().map(|p| p.1).sum(); + let mut r = rng.next() % total; + for &(k, w) in pool { + if r < w { + return k; + } + r -= w; + } + pool[0].0 +} + +impl Game { + pub fn new_demo(seed: u32) -> (Game, Cam) { + let opts = GenOpts { portals: Some(0), treasures: Some(0), gate: false, core_y: Some(H as i32 / 2 - 1) }; + let map = Map::generate_with(seed, W, H, &opts); + let mut g = Game::from_map(map, seed); + g.ents.clear(); + let mut rng = Rng(seed | 1); + // a wide cave on both banks: the dungeon's ground on the left, knight ground on the right + for y in 3..H as i32 - 3 { + for x in 3..W as i32 - 3 { + let cy = (y - H as i32 / 2) as f32 / (H as f32 * 0.42); + let cx = (x - W as i32 / 2) as f32 / (W as f32 * 0.47); + let edge = 1.0 + (rng.f() - 0.5) * 0.12; + if cx * cx + cy * cy > edge { + continue; + } + let t = &mut g.map.t[idx(x, y)]; + if matches!(t.kind, Kind::Core | Kind::Treasury) { + continue; + } + t.kind = Kind::Floor; + t.gold = 0; + t.owner = if x < RIVER_X { Owner::Player } else if x >= RIVER_X + 4 { Owner::Hero } else { Owner::None }; + } + } + // the river from top to bottom, and the bridge across + for y in 2..H as i32 - 2 { + for x in RIVER_X..RIVER_X + 4 { + let t = &mut g.map.t[idx(x, y)]; + t.owner = Owner::None; + t.gold = 0; + t.kind = if (BRIDGE_Y..BRIDGE_Y + 3).contains(&y) { Kind::Bridge } else { Kind::Water }; + } + } + for y in BRIDGE_Y..BRIDGE_Y + 3 { + for x in RIVER_X..RIVER_X + 4 { + g.map.t[idx(x, y)].owner = Owner::Player; + } + } + // a few rooms on the dungeon side for the look of it + for (x0, y0, x1, y1, k) in [(8, 13, 13, 17, Kind::Lair), (8, 30, 13, 34, Kind::Training), (17, 12, 21, 15, Kind::Farm)] { + for y in y0..=y1 { + for x in x0..=x1 { + let t = &mut g.map.t[idx(x, y)]; + if t.kind == Kind::Floor && t.owner == Owner::Player { + t.kind = k; + } + } + } + } + let towers = vec![ + (RIVER_X - 4, BRIDGE_Y - 3, TowerKind::Arrow, false, 0.0), + (RIVER_X - 4, BRIDGE_Y + 5, TowerKind::Cannon, false, 0.0), + (RIVER_X - 8, BRIDGE_Y + 1, TowerKind::Magic, false, 0.0), + (RIVER_X + 7, BRIDGE_Y - 3, TowerKind::Arrow, true, 0.0), + (RIVER_X + 7, BRIDGE_Y + 5, TowerKind::Cannon, true, 0.0), + (RIVER_X + 11, BRIDGE_Y + 1, TowerKind::Arrow, true, 0.0), + ]; + g.demo = Some(Demo { spawn_t: 0.0, towers, rng: Rng(seed.wrapping_mul(747796405) | 1) }); + g.gate_hp = 0.0; + g.gate_on = false; + g.mana = MANA_MAX; + g.place_all_towers_of_demo(); + g.place_all_torches(); + // start with a fight already going on + for _ in 0..6 { + g.demo_spawn(true); + g.demo_spawn(false); + } + let mut cam = Cam::new(RIVER_X as f32 + 2.0, BRIDGE_Y as f32 + 1.5); + cam.zoom = 16.0; + (g, cam) + } + + fn place_all_towers_of_demo(&mut self) { + let Some(d) = self.demo.as_ref() else { return }; + let slots: Vec<(i32, i32, TowerKind, bool)> = d.towers.iter().map(|s| (s.0, s.1, s.2, s.3)).collect(); + for (x, y, kind, hero) in slots { + self.demo_tower(x, y, kind, hero); + } + } + + fn demo_tower(&mut self, x: i32, y: i32, kind: TowerKind, hero: bool) { + let i = idx(x, y); + if self.towers.iter().any(|t| t.tile == i) { + return; + } + let s = tower_stats(kind); + let t = &mut self.map.t[i]; + t.kind = Kind::Tower; + t.owner = if hero { Owner::Hero } else { Owner::Player }; + let id = self.next_id; + self.next_id += 1; + let level = 1 + (self.demo.as_mut().map_or(0, |d| d.rng.next() % 2)); + let mut tw = Tower { id, kind, tile: i, level, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero, rune_final: false }; + tw.hp = tw.max_hp(); + self.towers.push(tw); + self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff40d0ff); + } + + // a new unit at the far end of its side + fn demo_spawn(&mut self, creature: bool) { + let Some(d) = self.demo.as_mut() else { return }; + let kind = pick(&mut d.rng, if creature { &CREATURES } else { &KNIGHTS }); + let level = 2 + d.rng.next() % 6; + let y = BRIDGE_Y as f32 + 1.5 + (d.rng.f() - 0.5) * 14.0; + let x = if creature { 9.0 + d.rng.f() * 6.0 } else { W as f32 - 15.0 + d.rng.f() * 6.0 }; + if !self.map.walk(x as i32, y as i32) { + return; + } + let id = self.spawn(kind, !creature, x, y, level); + if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { + e.happy = 1.0; + e.settled = true; + } + } + + // the demo instead of the normal game: units, towers, reinforcements, rebuilding + pub fn demo_step(&mut self, dt: f32) { + self.time += dt; + self.core_hp = CORE_HP; + self.mana = MANA_MAX; + self.update_units(dt); + self.update_towers(dt); + let Some(mut d) = self.demo.take() else { return }; + // reinforcements: the weaker side gets the next unit + d.spawn_t -= dt; + let mut want = None; + if d.spawn_t <= 0.0 { + d.spawn_t = 1.0; + let side = |hero: bool| -> (usize, f32) { + let list: Vec<(EK, u32)> = self.ents.iter().filter(|e| e.alive && e.hero == hero && e.captive == 0).map(|e| (e.kind, e.level)).collect(); + (list.len(), Game::squad_value(&list)) + }; + let (nc, vc) = side(false); + let (nk, vk) = side(true); + if nc < PER_SIDE && (vc <= vk || nk >= PER_SIDE) { + want = Some(true); + } else if nk < PER_SIDE { + want = Some(false); + } + } + // destroyed towers come back after a while + let mut rebuild = Vec::new(); + for s in d.towers.iter_mut() { + let i = idx(s.0, s.1); + if self.towers.iter().any(|t| t.tile == i) { + s.4 = 0.0; + } else { + s.4 += dt; + if s.4 >= REBUILD { + s.4 = 0.0; + rebuild.push((s.0, s.1, s.2, s.3)); + } + } + } + self.demo = Some(d); + if let Some(creature) = want { + self.demo_spawn(creature); + } + for (x, y, kind, hero) in rebuild { + // nobody may stand on the spot + let blocked = self.ents.iter().any(|e| e.alive && e.tile() == idx(x, y)); + if !blocked { + self.demo_tower(x, y, kind, hero); + } + } + // the battle never ends + self.state = 0; + self.msgs.clear(); + } + + // demo units: fight what is close, tear down enemy towers, otherwise push over the bridge + pub fn demo_ai(&mut self, e: &mut Ent, dt: f32) { + if e.captive != 0 { + e.alive = false; + return; + } + if e.think <= 0.0 { + e.think = 0.5; + // the heavy ones go for the towers first + let breaker = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Dwarf | EK::Gargoyle); + if !matches!(e.job, Job::Attack(_) | Job::AttackTower(_)) { + let enemy = if breaker { None } else { self.nearest_enemy(e, 7.0) }; + if let Some(id) = enemy { + e.job = Job::Attack(id); + e.path.clear(); + e.repath = 0.0; + } else { + let (x, y) = (e.x, e.y); + let tower = self.towers.iter().filter(|t| t.hero != e.hero).min_by(|a, b| { + let da = (a.pos().0 - x).powi(2) + (a.pos().1 - y).powi(2); + let db = (b.pos().0 - x).powi(2) + (b.pos().1 - y).powi(2); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }); + if let Some(t) = tower { + let (tx, ty) = t.pos(); + if (tx - x).powi(2) + (ty - y).powi(2) < 8.0 * 8.0 { + e.job = Job::AttackTower(t.id); + e.path.clear(); + e.repath = 0.0; + } + } + if breaker && e.job == Job::None { + if let Some(id) = self.nearest_enemy(e, 7.0) { + e.job = Job::Attack(id); + e.path.clear(); + e.repath = 0.0; + } + } + } + } + } + match e.job { + Job::Attack(id) => { + if self.attack_job(e, id, dt) { + return; + } + e.job = Job::None; + } + Job::AttackTower(id) => { + if self.attack_tower(e, id, dt) { + return; + } + e.job = Job::None; + } + _ => {} + } + // march towards the far bank + if e.path.is_empty() && e.repath <= 0.0 { + e.repath = 2.0; + let d = self.demo.as_mut(); + let r = d.map_or(0.5, |d| d.rng.f()); + let tx = if e.hero { 12 + (r * 8.0) as i32 } else { W as i32 - 20 + (r * 8.0) as i32 }; + let ty = BRIDGE_Y + 1 + ((r * 97.0).fract() * 12.0) as i32 - 6; + if self.map.walk(tx, ty) { + let p = if flies(e.kind) { self.map.fly_path_to(e.tile(), idx(tx, ty)) } else { self.map.path_to(e.tile(), idx(tx, ty)) }; + if let Some(p) = p { + e.path = p; + } + } + } + self.step_path(e, dt); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_battle_goes_on() { + let (mut g, _) = Game::new_demo(7); + let mut kills = 0; + let mut rebuilt = false; + let mut lost = false; + for k in 0..(20 * 240) { + let before = g.ents.len(); + let towers = g.towers.len(); + g.update(0.05); + kills += before.saturating_sub(g.ents.len()); + lost |= g.towers.len() < 6; + rebuilt |= lost && g.towers.len() > towers; + if k % (20 * 30) == 0 { + let c = g.ents.iter().filter(|e| e.alive && !e.hero).count(); + let h = g.ents.iter().filter(|e| e.alive && e.hero).count(); + println!("{:>3} s: {} creatures, {} knights, {} towers, {} fallen", k / 20, c, h, g.towers.len(), kills); + } + } + assert_eq!(g.state, 0); + assert!(kills > 10, "they fight: {} fallen", kills); + assert!(g.ents.iter().any(|e| e.alive && e.hero) && g.ents.iter().any(|e| e.alive && !e.hero), "both sides are still there"); + println!("towers lost {}, rebuilt {}", lost, rebuilt); + assert!(rebuilt, "lost towers are rebuilt"); + } +} diff --git a/src/game.rs b/src/game.rs index be2757d..09699cf 100644 --- a/src/game.rs +++ b/src/game.rs @@ -445,6 +445,8 @@ pub struct Game { pub gate_on: bool, // free game: the humans' gate exists #[serde(skip)] pub springs: usize, // claimed mana springs (recounted) + #[serde(skip)] + pub demo: Option, // the endless bridge battle behind the title menu #[serde(default)] pub dwarf_caves: Vec, #[serde(default)] @@ -603,6 +605,7 @@ impl Game { gate_on: true, springs: 0, freed: 0, + demo: None, dwarf_caves: Vec::new(), dwarf_t: 30.0, rune_free: 0, @@ -964,7 +967,25 @@ impl Game { self.fx.retain(|f| f.t < f.max); } + pub(crate) fn update_units(&mut self, dt: f32) { + for i in 0..self.ents.len() { + if !self.ents[i].alive || self.ents[i].held { + continue; + } + let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy()); + let (x0, y0) = (e.x, e.y); + self.update_ent(&mut e, dt); + self.turn(&mut e, x0, y0); + self.ents[i] = e; + } + self.ents.retain(|e| e.alive); + } + fn step(&mut self, dt: f32) { + if self.demo.is_some() { + self.demo_step(dt); + return; + } self.time += dt; self.recount(); self.mana = (self.mana + (1.0 + SPRING_MANA * self.springs as f32) * dt).min(self.mana_max()); @@ -1014,17 +1035,7 @@ impl Game { self.reinforce_corners(); } self.update_contested(); - for i in 0..self.ents.len() { - if !self.ents[i].alive || self.ents[i].held { - continue; - } - let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy()); - let (x0, y0) = (e.x, e.y); - self.update_ent(&mut e, dt); - self.turn(&mut e, x0, y0); - self.ents[i] = e; - } - self.ents.retain(|e| e.alive); + self.update_units(dt); self.update_towers(dt); if !self.rivals.is_empty() { self.update_rivals(dt); @@ -1611,6 +1622,8 @@ impl Game { self.sfx(Sfx::Convert, e.x, e.y); self.msg("msg.creature_freed", vec![format!("@{}", st(e.kind).name)]); } + } else if self.demo.is_some() { + self.demo_ai(e, dt); } else if e.hero && e.captive != 0 { self.captive_update(e, dt); } else if e.rival() { @@ -1934,7 +1947,7 @@ impl Game { cap.saturating_sub(used) } - fn kill(&mut self, j: usize) { + pub(crate) fn kill(&mut self, j: usize) { let e = &self.ents[j]; if e.hero && e.captive == 0 && self.prison_space() > 0 { // knocked out instead of killed: imps carry the hero to the torture chamber @@ -4064,7 +4077,7 @@ impl Game { } } - fn update_towers(&mut self, dt: f32) { + pub(crate) fn update_towers(&mut self, dt: f32) { for k in 0..self.towers.len() { if k >= self.towers.len() { break; @@ -4227,7 +4240,7 @@ impl Game { } // a hero attacks a tower; false = tower gone or unreachable - fn attack_tower(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool { + pub(crate) fn attack_tower(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool { let Some(k) = self.towers.iter().position(|t| t.id == id) else { return false }; let (tx, ty) = self.towers[k].pos(); let hit_h = self.towers[k].top() * 0.6; diff --git a/src/main.rs b/src/main.rs index c66c843..6693c3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod audio; mod campaign; mod cloud; +mod demo; mod dwarves; mod fortress; mod game; @@ -374,7 +375,8 @@ async fn main() { menu.has_autosave = platform::storage_get(save::AUTOSAVE_KEY).is_some(); let seed = (miniquad::date::now() * 1000.0) as u32 | 1; - let (game, cam) = new_game(seed, &settings); + // behind the title menu: the endless bridge battle + let (game, cam) = Game::new_demo(seed); let mut app = App { game, cam, settings, seed, status: None, recorded: false }; menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); app.game.hints = app.settings.hints; @@ -814,6 +816,8 @@ async fn main() { menu.has_autosave = true; } menu.page = Some(Page::Title); + app.seed = next_seed(app.seed); + (app.game, app.cam) = Game::new_demo(app.seed); } MenuAction::None | MenuAction::Consumed => {} } @@ -948,7 +952,13 @@ async fn main() { app.game.hints = app.settings.hints; } - // ---------------- update (the world stands still while menus or dialogs are open) + // ---------------- update (the world stands still while menus or dialogs are open; + // the bridge battle behind the title menu goes on, the camera slowly circles it) + if app.game.demo.is_some() && !playing { + app.game.update(dt); + app.cam.yaw += dt * 0.03; + app.game.events.retain(|ev| ev.pos.is_none()); + } if playing { for _ in 0..(app.settings.game_speed.round().max(1.0) as usize) { app.game.update(dt);