1use ponko::*;
22use serde::{Deserialize, Serialize};
23
24const CELL: f32 = 4.0;
26const 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;
35const COOLDOWN: f32 = 0.6;
37const SIGHT: f32 = 18.0;
39const REACH: f32 = 5.0;
40const 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 cooldown: f32,
75 engaged: bool,
77 #[serde(skip)]
79 push: Vec2,
80 #[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 budget: f32,
98}
99
100struct Battle {
102 nav: NavGrid,
103 fields: [FlowField; 2],
104 rally: [Vec2; 2],
105 bases: [Vec2; 2],
106 base_entities: [Entity; 2],
107 cap: usize,
109 field_ms: f32,
110}
111
112#[derive(Serialize, Deserialize, Default)]
115struct Score {
116 alive: [usize; 2],
117 rounds_won: [u32; 2],
118 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#[derive(Serialize)]
134struct Fallen {
135 team: Team,
136 at: Vec2,
137}
138
139struct 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
146fn 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 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
209fn fight() -> impl FnMut(&mut Ctx) {
219 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 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 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
312fn 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
369fn 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 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
403fn 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
417fn 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
472fn 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 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
499fn 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
508fn keep_neighbors_close(ctx: &mut Ctx) {
512 ctx.world.sort_by::<Transform2D>(|t| spatial_key(t.position, 64.0));
513}
514
515fn 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
550fn 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 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 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}