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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 15:51:35 +01:00
1 parent d06ce2edb5
commit f359671dac
3 files changed
+351 -16

No files matched your search

+312
View File
@@ -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");
}
}