Skip to main content

battle/
battle.rs

1//! Two crab armies fight over a map of rocks, lakes and mud. Each base
2//! keeps sending units; units march along their side's flow field, fight
3//! whatever enemy they meet, and tear down the enemy base when they reach
4//! it. You command red: click to set the rally point. Blue attacks.
5//!
6//!   `cargo run --release --example battle`
7//!   `UNITS=200000 cargo run --release --example battle`
8//!   `HEADLESS=1 UNITS=1000000 TICKS=600 cargo run --release --example battle`
9//!   `SEED=3 ...` picks a different (but reproducible) map.
10//!
11//! Mouse: left click sets red's rally point; drag with the right button to
12//! pan, scroll to zoom. Keys: A attack (rally on blue's base), D defend
13//! (rally at home), Space pause, 1-3 speed, R restart, Escape quit.
14//!
15//! Shows: flow fields shared by a whole army (`NavGrid`, `FlowField`), a
16//! spatial grid with data (the team of every unit) for targeting on all
17//! cores, decide-in-parallel then apply-in-order combat, deterministic
18//! seeded randomness, and reflection, so `ponko-mcp` agents can inspect a
19//! running battle (the `Score` resource says who's winning) or change any unit.
20
21use ponko::*;
22use serde::{Deserialize, Serialize};
23
24/// World units per nav cell.
25const CELL: f32 = 4.0;
26/// Map size (in cells) for `UNITS_PER_MAP` units; bigger armies get a
27/// bigger map, so the crowd density stays the same.
28const MAP: UVec2 = uvec2(400, 225);
29const UNITS_PER_MAP: f32 = 20_000.0;
30const MUD: u8 = 5;
31
32const SPEED: f32 = 34.0;
33const HP: f32 = 10.0;
34const DAMAGE: f32 = 2.0;
35/// Seconds between attacks.
36const COOLDOWN: f32 = 0.6;
37/// Units see enemies this far away, and hit them this close.
38const SIGHT: f32 = 18.0;
39const REACH: f32 = 5.0;
40/// Friends closer than this push each other apart.
41const PERSONAL_SPACE: f32 = 3.5;
42const BASE_RADIUS: f32 = 22.0;
43
44#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
45enum Team {
46    Red,
47    Blue,
48}
49
50impl Team {
51    fn index(self) -> usize {
52        self as usize
53    }
54    fn enemy(self) -> Team {
55        match self {
56            Team::Red => Team::Blue,
57            Team::Blue => Team::Red,
58        }
59    }
60    fn color(self) -> Color {
61        match self {
62            Team::Red => Color::hex(0xFF6B5B),
63            Team::Blue => Color::hex(0x5BB8FF),
64        }
65    }
66}
67
68#[derive(Serialize, Deserialize)]
69struct Unit {
70    team: Team,
71    hp: f32,
72    vel: Vec2,
73    /// Seconds until the next attack.
74    cooldown: f32,
75    /// An enemy was in sight at the last look: look again every tick.
76    engaged: bool,
77    /// Away from crowding friends; refreshed every other tick.
78    #[serde(skip)]
79    push: Vec2,
80    /// What this unit hit this tick, decided in parallel, applied after.
81    #[serde(skip)]
82    hit: Option<Hit>,
83}
84
85#[derive(Clone, Copy)]
86enum Hit {
87    Unit(Entity),
88    Base,
89}
90
91#[derive(Serialize, Deserialize)]
92struct Base {
93    team: Team,
94    hp: f32,
95    max_hp: f32,
96    /// Units this base may still send (fractional, refilled each tick).
97    budget: f32,
98}
99
100/// The map, one flow field per team, and where each team is headed.
101struct Battle {
102    nav: NavGrid,
103    fields: [FlowField; 2],
104    rally: [Vec2; 2],
105    bases: [Vec2; 2],
106    base_entities: [Entity; 2],
107    /// Units per team the bases keep alive.
108    cap: usize,
109    field_ms: f32,
110}
111
112/// Who's winning, reflected so tools can read it (`ponko-mcp`'s
113/// `get_resource`, `watch`) and change it. Pairs are `[red, blue]`.
114#[derive(Serialize, Deserialize, Default)]
115struct Score {
116    alive: [usize; 2],
117    rounds_won: [u32; 2],
118    /// Set when a base falls; the round is over until a restart.
119    winner: Option<Team>,
120}
121
122impl Battle {
123    fn set_rally(&mut self, team: Team, at: Vec2) {
124        let start = Instant::now();
125        self.rally[team.index()] = at;
126        self.fields[team.index()].compute(&self.nav, &[at]);
127        self.field_ms = start.elapsed().as_secs_f32() * 1000.0;
128    }
129}
130
131/// A unit died: sent by `resolve`, reflected so tools can watch the
132/// fighting (`ponko-mcp`'s `subscribe_events` / `take_events`).
133#[derive(Serialize)]
134struct Fallen {
135    team: Team,
136    at: Vec2,
137}
138
139/// What new units look like: a sprite when there's a window, nothing headless.
140struct Look(Option<Texture>);
141
142fn env_number(name: &str, default: f64) -> f64 {
143    std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
144}
145
146// ---------------------------------------------------------------------- map
147
148/// Rocks and lakes (walls) and mud, in blobs, with a clear spot for each
149/// base and a border. The same seed makes the same map.
150fn make_map(size: UVec2, seed: u64) -> (NavGrid, [Vec2; 2]) {
151    let mut nav = NavGrid::new(size.x, size.y, CELL);
152    let mut rng = Rng::new(seed);
153    let scale = size.as_vec2() / MAP.as_vec2();
154    let area = scale.x * scale.y;
155    let mut blob = |nav: &mut NavGrid, cost: u8, radius: f32| {
156        let c = vec2(rng.range(0.0, size.x as f32), rng.range(0.0, size.y as f32));
157        let r = radius * rng.range(0.6, 1.4);
158        for y in (c.y - r).max(0.0) as u32..((c.y + r) as u32).min(size.y) {
159            for x in (c.x - r).max(0.0) as u32..((c.x + r) as u32).min(size.x) {
160                if vec2(x as f32, y as f32).distance(c) < r {
161                    nav.set_cost(x, y, cost);
162                }
163            }
164        }
165    };
166    for _ in 0..(16.0 * area) as usize {
167        blob(&mut nav, MUD, 18.0);
168    }
169    for _ in 0..(70.0 * area) as usize {
170        blob(&mut nav, NavGrid::WALL, 7.0);
171    }
172    for _ in 0..(6.0 * area) as usize {
173        blob(&mut nav, NavGrid::WALL, 16.0);
174    }
175    let world = size.as_vec2() * CELL;
176    let bases = [vec2(0.08, 0.5) * world, vec2(0.92, 0.5) * world];
177    for base in bases {
178        let c = base / CELL;
179        let r = BASE_RADIUS * 3.0 / CELL;
180        for y in (c.y - r) as u32..(c.y + r) as u32 {
181            for x in (c.x - r) as u32..(c.x + r) as u32 {
182                nav.set_cost(x, y, 1);
183            }
184        }
185    }
186    for x in 0..size.x {
187        nav.set_cost(x, 0, NavGrid::WALL);
188        nav.set_cost(x, size.y - 1, NavGrid::WALL);
189    }
190    for y in 0..size.y {
191        nav.set_cost(0, y, NavGrid::WALL);
192        nav.set_cost(size.x - 1, y, NavGrid::WALL);
193    }
194    // If the blobs cut the bases off from each other, dig a road between them.
195    let mut flow = FlowField::new();
196    flow.compute(&nav, &[bases[1]]);
197    if flow.distance_at(bases[0]).is_none() {
198        let y = (bases[0].y / CELL) as u32;
199        for x in 1..size.x - 1 {
200            for dy in 0..3 {
201                let c = nav.cost(x, y + dy - 1).unwrap();
202                nav.set_cost(x, y + dy - 1, c.max(1));
203            }
204        }
205    }
206    (nav, bases)
207}
208
209// ------------------------------------------------------------------ systems
210
211/// Every tick, on all cores: each unit finds the nearest enemy it can see
212/// and keeps a little room from its friends. With an enemy in reach it
213/// attacks; in sight, it charges; otherwise it follows its team's field.
214///
215/// Most units are marching, not fighting, so they look for enemies only
216/// on their turn (every 4th tick); units in a fight look every tick.
217/// Crowding is refreshed every other tick.
218fn fight() -> impl FnMut(&mut Ctx) {
219    // Per team: a fine grid for friends (only the closest few matter) and a
220    // coarse one for enemies (seen from afar). Marching armies far from the
221    // enemy then cost almost nothing to search, however packed they are.
222    let mut near = [
223        SpatialGrid::new(PERSONAL_SPACE * 2.0),
224        SpatialGrid::new(PERSONAL_SPACE * 2.0),
225    ];
226    let mut far = [SpatialGrid::new(SIGHT), SpatialGrid::new(SIGHT)];
227    let mut positions: Vec<(Entity, Vec2, Team)> = Vec::new();
228    move |ctx: &mut Ctx| {
229        let (dt, random, look) = (ctx.dt(), ctx.random(), ctx.turns(4));
230        let crowding = ctx.sim_tick().is_multiple_of(2);
231        ctx.world
232            .query::<(Entity, &Transform2D, &Unit)>()
233            .par_collect_into(&mut positions, |(e, t, u)| (e, t.position, u.team));
234        // The four grids are independent: build them at the same time.
235        let [near_red, near_blue] = &mut near;
236        let [far_red, far_blue] = &mut far;
237        let all = &positions;
238        let of = |team: Team| all.iter().filter(move |p| p.2 == team).map(|&(e, at, _)| (e, at));
239        rayon::scope(|s| {
240            if crowding {
241                s.spawn(|_| near_red.rebuild_from(of(Team::Red)));
242                s.spawn(|_| near_blue.rebuild_from(of(Team::Blue)));
243            }
244            s.spawn(|_| far_red.rebuild_from(of(Team::Red)));
245            s.spawn(|_| far_blue.rebuild_from(of(Team::Blue)));
246        });
247        let over = ctx.world.resource::<Score>().winner.is_some();
248        let battle = ctx.world.remove_resource::<Battle>().expect("battle");
249        let (near, far, b) = (&near, &far, &battle);
250        ctx.world
251            .query::<(Entity, &mut Transform2D, &mut Unit)>()
252            .par_for_each(|(e, t, unit)| {
253                let mut rng = random.of(e);
254                let me = t.position;
255                unit.hit = None;
256                unit.cooldown -= dt;
257                if crowding {
258                    let mut push = Vec2::ZERO;
259                    near[unit.team.index()].for_each_in_radius(me, PERSONAL_SPACE, |other| {
260                        if other.entity != e {
261                            let away = me - other.position;
262                            push += away / (away.length_squared() + 0.01);
263                        }
264                    });
265                    unit.push = push.clamp_length_max(1.0);
266                }
267                let mut nearest = None;
268                if unit.engaged || look.includes(e) {
269                    let enemies = &far[unit.team.enemy().index()];
270                    nearest = enemies
271                        .nearest(me, REACH, |_| true)
272                        .or_else(|| enemies.nearest(me, SIGHT, |_| true))
273                        .map(|g| (g.position.distance_squared(me), g.entity, g.position));
274                    unit.engaged = nearest.is_some();
275                }
276                let enemy_base = b.bases[unit.team.enemy().index()];
277                let mut want = match nearest {
278                    Some((d2, target, _)) if d2 < REACH * REACH => {
279                        if unit.cooldown <= 0.0 {
280                            unit.cooldown = COOLDOWN * rng.range(0.8, 1.2);
281                            unit.hit = Some(Hit::Unit(target));
282                        }
283                        Vec2::ZERO
284                    }
285                    Some((_, _, at)) => (at - me).normalize_or_zero() * SPEED,
286                    None if me.distance(enemy_base) < BASE_RADIUS + REACH => {
287                        if unit.cooldown <= 0.0 && !over {
288                            unit.cooldown = COOLDOWN;
289                            unit.hit = Some(Hit::Base);
290                        }
291                        Vec2::ZERO
292                    }
293                    None => b.fields[unit.team.index()].direction_at(me) * SPEED,
294                };
295                let slow = b.nav.cell_at(me).and_then(|c| b.nav.cost(c.x, c.y)).unwrap_or(1).max(1) as f32;
296                want = want / slow + unit.push * SPEED;
297                unit.vel = unit.vel.lerp(want, 0.2) + rng.unit_vec2() * 2.0;
298                // Slide along walls instead of walking into them.
299                let next = me + unit.vel * dt;
300                if b.nav.walkable(next) {
301                    t.position = next;
302                } else if b.nav.walkable(vec2(next.x, me.y)) {
303                    t.position.x = next.x;
304                } else if b.nav.walkable(vec2(me.x, next.y)) {
305                    t.position.y = next.y;
306                }
307            });
308        ctx.world.insert_resource(battle);
309    }
310}
311
312/// Applies this tick's hits in order (so the result doesn't depend on
313/// thread timing), removes the dead, and checks for a fallen base.
314fn resolve() -> impl FnMut(&mut Ctx) {
315    let mut hits: Vec<(Team, Hit)> = Vec::new();
316    move |ctx: &mut Ctx| {
317        hits.clear();
318        ctx.world.query::<&Unit>().for_each(|u| {
319            if let Some(hit) = u.hit {
320                hits.push((u.team, hit));
321            }
322        });
323        let battle = ctx.world.resource::<Battle>();
324        let bases = battle.base_entities;
325        for &(team, hit) in &hits {
326            match hit {
327                Hit::Unit(target) => {
328                    if let Some(u) = ctx.world.get_mut::<Unit>(target) {
329                        u.hp -= DAMAGE;
330                    }
331                }
332                Hit::Base => {
333                    if let Some(base) = ctx.world.get_mut::<Base>(bases[team.enemy().index()]) {
334                        base.hp = (base.hp - DAMAGE).max(0.0);
335                    }
336                }
337            }
338        }
339        let mut alive = [0; 2];
340        let (cmds, events) = (&mut ctx.commands, &mut ctx.events);
341        ctx.world
342            .query::<(Entity, &Transform2D, &Unit)>()
343            .for_each(|(e, t, u)| {
344                if u.hp <= 0.0 {
345                    cmds.despawn(e);
346                    events.send(Fallen {
347                        team: u.team,
348                        at: t.position,
349                    });
350                } else {
351                    alive[u.team.index()] += 1;
352                }
353            });
354        let mut fallen = None;
355        ctx.world.query::<&Base>().for_each(|b| {
356            if b.hp <= 0.0 {
357                fallen = Some(b.team);
358            }
359        });
360        let score = ctx.world.resource_mut::<Score>();
361        score.alive = alive;
362        if let (Some(team), None) = (fallen, score.winner) {
363            score.winner = Some(team.enemy());
364            score.rounds_won[team.enemy().index()] += 1;
365        }
366    }
367}
368
369/// Bases send new units (a few per tick, up to the cap) while they stand.
370fn reinforce(ctx: &mut Ctx) {
371    let mut rng = ctx.random().stream(1);
372    let sprite = ctx.world.resource::<Look>().0;
373    let cap = ctx.world.resource::<Battle>().cap;
374    let (alive, winner) = {
375        let s = ctx.world.resource::<Score>();
376        (s.alive, s.winner)
377    };
378    if winner.is_some() {
379        return;
380    }
381    // A full army rebuilds in about 40 seconds.
382    let per_tick = cap as f32 / (40.0 * 60.0);
383    let mut spawns = Vec::new();
384    ctx.world.query::<(&Transform2D, &mut Base)>().for_each(|(t, base)| {
385        let room = cap.saturating_sub(alive[base.team.index()]);
386        base.budget = (base.budget + per_tick).min(room as f32);
387        let n = base.budget as usize;
388        base.budget -= n as f32;
389        spawns.push((base.team, t.position, n));
390    });
391    for (team, at, n) in spawns {
392        for _ in 0..n {
393            spawn_unit(
394                &mut ctx.commands,
395                sprite,
396                team,
397                at + rng.unit_vec2() * rng.range(0.0, BASE_RADIUS),
398            );
399        }
400    }
401}
402
403/// Blue's general: attack red's base, or defend when outnumbered 2 to 1.
404fn blue_ai(ctx: &mut Ctx) {
405    let alive = ctx.world.resource::<Score>().alive;
406    let b = ctx.world.resource_mut::<Battle>();
407    let goal = if alive[1] * 2 < alive[0] {
408        b.bases[1]
409    } else {
410        b.bases[0]
411    };
412    if b.rally[1] != goal {
413        b.set_rally(Team::Blue, goal);
414    }
415}
416
417// -------------------------------------------------------------------- setup
418
419fn spawn_unit(cmds: &mut Commands, sprite: Option<Texture>, team: Team, at: Vec2) {
420    let unit = Unit {
421        team,
422        hp: HP,
423        vel: Vec2::ZERO,
424        cooldown: 0.0,
425        engaged: false,
426        push: Vec2::ZERO,
427        hit: None,
428    };
429    match sprite {
430        Some(tex) => cmds.spawn((Transform2D::at(at), Sprite::new(tex).with_color(team.color()), unit)),
431        None => cmds.spawn((Transform2D::at(at), unit)),
432    }
433}
434
435fn setup(ctx: &mut Ctx, units: usize, seed: u64, sprite: Option<Texture>) {
436    let scale = (units as f32 / UNITS_PER_MAP).max(1.0).sqrt();
437    let size = (MAP.as_vec2() * scale).as_uvec2();
438    let (nav, bases) = make_map(size, seed);
439    ctx.world.reflect::<Unit>();
440    ctx.world.reflect::<Base>();
441    let max_hp = 400.0 * (units as f32 / 1000.0).sqrt();
442    let base_entities = [Team::Red, Team::Blue].map(|team| {
443        ctx.world.spawn((
444            Transform2D::at(bases[team.index()]),
445            Base {
446                team,
447                hp: max_hp,
448                max_hp,
449                budget: 0.0,
450            },
451        ))
452    });
453    let mut battle = Battle {
454        nav,
455        fields: [FlowField::new(), FlowField::new()],
456        rally: bases,
457        bases,
458        base_entities,
459        cap: units / 2,
460        field_ms: 0.0,
461    };
462    battle.set_rally(Team::Red, bases[1]);
463    battle.set_rally(Team::Blue, bases[0]);
464    ctx.world.insert_resource(battle);
465    ctx.world.reflect_resource::<Score>();
466    ctx.events.reflect::<Fallen>();
467    ctx.world.insert_resource(Score::default());
468    ctx.world.insert_resource(Look(sprite));
469    start_round(ctx);
470}
471
472/// Both armies at full strength around their bases, bases repaired.
473fn start_round(ctx: &mut Ctx) {
474    let mut rng = ctx.random().stream(ctx.sim_tick());
475    let cmds = &mut ctx.commands;
476    ctx.world.query::<(Entity, &Unit)>().for_each(|(e, _)| cmds.despawn(e));
477    ctx.world.query::<&mut Base>().for_each(|b| b.hp = b.max_hp);
478    let sprite = ctx.world.resource::<Look>().0;
479    ctx.world.resource_mut::<Score>().winner = None;
480    let battle = ctx.world.resource_mut::<Battle>();
481    let (cap, bases) = (battle.cap, battle.bases);
482    let nav = &battle.nav;
483    // Spread each army over the walkable ground around its base.
484    let mut spots = Vec::with_capacity(cap * 2);
485    for team in [Team::Red, Team::Blue] {
486        let spread = (cap as f32).sqrt() * 2.2;
487        while spots.len() < cap * (team.index() + 1) {
488            let p = bases[team.index()] + rng.unit_vec2() * rng.range(0.0, 1.0).sqrt() * spread;
489            if nav.walkable(p) {
490                spots.push((team, p));
491            }
492        }
493    }
494    for (team, at) in spots {
495        spawn_unit(&mut ctx.commands, sprite, team, at);
496    }
497}
498
499/// The systems both the window and headless runs use.
500fn battle_systems(game: Game) -> Game {
501    game.named_sim_system("fight", 1, fight())
502        .named_sim_system("resolve", 1, resolve())
503        .sim_system(reinforce)
504        .sim_system_every(30, blue_ai)
505        .sim_system_every(60, keep_neighbors_close)
506}
507
508/// Once a second, units are reordered in memory by where they stand, so
509/// neighbors on the map are neighbors in memory: grid rebuilds, flow field
510/// lookups and searches then mostly hit the cache.
511fn keep_neighbors_close(ctx: &mut Ctx) {
512    ctx.world.sort_by::<Transform2D>(|t| spatial_key(t.position, 64.0));
513}
514
515// ------------------------------------------------------------------ playing
516
517fn controls(ctx: &mut Ctx) {
518    let home = ctx.world.resource::<Battle>().bases;
519    let rally = if ctx.mouse_pressed(MouseButton::Left) && !ctx.mouse_over_ui() {
520        Some(ctx.mouse_world())
521    } else if ctx.key_pressed(Key::A) {
522        Some(home[1])
523    } else if ctx.key_pressed(Key::D) {
524        Some(home[0])
525    } else {
526        None
527    };
528    if let Some(at) = rally {
529        let b = ctx.world.resource_mut::<Battle>();
530        if b.nav.walkable(at) {
531            b.set_rally(Team::Red, at);
532        }
533    }
534    if ctx.key_pressed(Key::R) {
535        start_round(ctx);
536    }
537    if ctx.key_pressed(Key::Space) {
538        if ctx.is_paused() { ctx.resume() } else { ctx.pause() }
539    }
540    for (key, speed) in [(Key::Num1, 1.0), (Key::Num2, 2.0), (Key::Num3, 4.0)] {
541        if ctx.key_pressed(key) {
542            ctx.set_sim_speed(speed);
543        }
544    }
545    if ctx.key_pressed(Key::Escape) {
546        ctx.quit();
547    }
548}
549
550/// The map as one texture (a pixel per cell), the bases with their health,
551/// and red's rally point. Units are sprites, drawn by the engine.
552fn draw_map(ctx: &mut Ctx) -> impl FnMut(&mut Ctx) + use<> {
553    let size = ctx.world.resource::<Battle>().nav.size();
554    let mut pixels = vec![0u8; (size.x * size.y * 4) as usize];
555    {
556        let nav = &ctx.world.resource::<Battle>().nav;
557        for y in 0..size.y {
558            for x in 0..size.x {
559                // A little per-cell noise so the ground doesn't look flat.
560                let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 9) as u8;
561                let px = match nav.cost(x, y).unwrap() {
562                    NavGrid::WALL => [52 + n, 56 + n, 66 + n, 255],
563                    MUD => [74 + n, 56 + n, 34, 255],
564                    _ => [30 + n, 46 + n, 32, 255],
565                };
566                pixels[((y * size.x + x) * 4) as usize..][..4].copy_from_slice(&px);
567            }
568        }
569    }
570    let tex = ctx.create_texture(size.x, size.y, &pixels);
571    ctx.set_texture_filter(&tex, TextureFilter::Nearest);
572    move |ctx: &mut Ctx| {
573        ctx.clear(Color::BLACK);
574        let world = size.as_vec2() * CELL;
575        ctx.draw_ex(
576            &tex,
577            world / 2.0,
578            DrawParams {
579                scale: Vec2::splat(CELL),
580                z: -10.0,
581                ..Default::default()
582            },
583        );
584        let rally = ctx.world.resource::<Battle>().rally[0];
585        let mut bases = [(Vec2::ZERO, 0.0, Team::Red); 2];
586        ctx.world.query::<(&Transform2D, &Base)>().for_each(|(t, b)| {
587            bases[b.team.index()] = (t.position, b.hp / b.max_hp, b.team);
588        });
589        for (at, health, team) in bases {
590            ctx.draw_circle(at, BASE_RADIUS, team.color().with_alpha(0.35));
591            ctx.draw_circle(at, BASE_RADIUS * 0.4, team.color());
592            let bar = Rect::new(at.x - BASE_RADIUS, at.y - BASE_RADIUS - 8.0, BASE_RADIUS * 2.0, 3.0);
593            ctx.draw_rect(bar, Color::rgba(0.0, 0.0, 0.0, 0.6));
594            ctx.draw_rect(
595                Rect::new(bar.min.x, bar.min.y, bar.size().x * health, 3.0),
596                team.color(),
597            );
598        }
599        ctx.draw_circle(rally, 5.0, Color::rgba(1.0, 0.9, 0.3, 0.9));
600    }
601}
602
603fn hud(ctx: &mut Ctx) {
604    let field_ms = ctx.world.resource::<Battle>().field_ms;
605    let s = ctx.world.resource::<Score>();
606    let (alive, won, winner) = (s.alive, s.rounds_won, s.winner);
607    let sim_ms: f32 = ctx.system_timings().iter().filter(|t| t.sim).map(|t| t.avg_ms).sum();
608    let speed = if ctx.is_paused() {
609        "paused".to_string()
610    } else {
611        format!("{}x", ctx.sim_speed())
612    };
613    let panel = Rect::new(12.0, 12.0, 300.0, 150.0);
614    ctx.ui_panel(panel);
615    let at = panel.min + ctx.ui_style.padding;
616    let text = format!(
617        "red {} vs blue {}   (rounds {}-{})\nsim {sim_ms:.2} ms/tick, {:.0} FPS, {speed}\nfield {field_ms:.2} ms\nclick: rally  A: attack  D: defend\nR: restart  1-3: speed",
618        alive[0],
619        alive[1],
620        won[0],
621        won[1],
622        ctx.fps()
623    );
624    ctx.ui_label(at, &text);
625    if let Some(team) = winner {
626        let msg = if team == Team::Red {
627            "Red wins! R for another round"
628        } else {
629            "Blue wins. R to try again"
630        };
631        let center = ctx.window_size() / 2.0;
632        let size = ctx.measure_text(msg, 32.0);
633        ctx.draw_text(msg, center - size / 2.0, 32.0, team.color());
634    }
635}
636
637fn main() {
638    let units = env_number("UNITS", 20_000.0) as usize;
639    let seed = env_number("SEED", 1.0) as u64;
640    if env_number("HEADLESS", 0.0) != 0.0 {
641        let ticks = env_number("TICKS", 600.0) as u64;
642        let mut start = Instant::now();
643        let world = battle_systems(Game::new("battle", 1, 1).seed(seed))
644            .sim_system(|ctx: &mut Ctx| {
645                // Headless rounds restart on their own.
646                if ctx.world.resource::<Score>().winner.is_some() {
647                    start_round(ctx);
648                }
649                if ctx.sim_tick().is_multiple_of(120) {
650                    let b = ctx.world.resource::<Score>();
651                    let timings: Vec<String> = ctx
652                        .system_timings()
653                        .iter()
654                        .filter(|t| t.sim && t.avg_ms >= 0.01)
655                        .map(|t| format!("{} {:.2} ms", t.name.rsplit("::").next().unwrap_or(t.name), t.avg_ms))
656                        .collect();
657                    println!(
658                        "tick {}: red {} blue {}, rounds {}-{}  [{}]",
659                        ctx.sim_tick(),
660                        b.alive[0],
661                        b.alive[1],
662                        b.rounds_won[0],
663                        b.rounds_won[1],
664                        timings.join(", ")
665                    );
666                }
667            })
668            .run_headless(ticks, |ctx| {
669                setup(ctx, units, seed, None);
670                start = Instant::now();
671            });
672        let secs = start.elapsed().as_secs_f64();
673        let b = world.resource::<Battle>();
674        println!(
675            "{units} units, {}x{} cells, {ticks} ticks: {:.2} ms/tick ({:.0} ticks/s)",
676            b.nav.size().x,
677            b.nav.size().y,
678            secs * 1e3 / ticks as f64,
679            ticks as f64 / secs
680        );
681        return;
682    }
683
684    battle_systems(Game::new("Crabby Engine: battle", 1280, 720).seed(seed))
685        .system(controls)
686        .system(camera_controls)
687        .system(hud)
688        .run(move |ctx| {
689            let dot = ctx.create_texture(2, 2, &[255; 2 * 2 * 4]);
690            setup(ctx, units, seed, Some(dot));
691            let world = ctx.world.resource::<Battle>().nav.size().as_vec2() * CELL;
692            ctx.camera.zoom = (ctx.camera.viewport() / world).min_element();
693            ctx.camera.look_at(world / 2.0);
694            draw_map(ctx)
695        });
696}