1use ponko::*;
23use serde::{Deserialize, Serialize};
24
25const ANTS_PER_NEST: usize = 5000;
27const NEST_AREA: Vec2 = vec2(1280.0, 720.0);
29const CELL: f32 = 4.0;
31const SPEED: f32 = 45.0;
32const SENSE_DIST: f32 = 10.0;
34const SENSE_ANGLE: f32 = 0.6;
35const SIGHT: f32 = 24.0;
37const NEST_RADIUS: f32 = 16.0;
38const FAINT: f32 = 0.05;
41const LIFESPAN: f32 = 90.0;
43
44#[derive(Serialize, Deserialize)]
45struct Ant {
46 home: Entity,
48 home_at: Vec2,
49 heading: f32,
50 carrying: bool,
51 since: f32,
54 energy: f32,
56 #[serde(skip)]
58 event: Event,
59}
60
61#[derive(Clone, Copy, Default, PartialEq)]
62enum Event {
63 #[default]
64 None,
65 Starved,
66 PickedUp(Entity),
67 Delivered,
68}
69
70#[derive(Serialize, Deserialize)]
71struct Nest {
72 stored: u32,
73 delivered: u64,
74 max_ants: u32,
75 ants: u32,
76}
77
78#[derive(Serialize, Deserialize)]
79struct Food {
80 amount: u32,
81}
82
83struct AntLook(Option<Texture>);
85
86#[derive(Default)]
89struct Field {
90 w: usize,
91 h: usize,
92 to_food: Vec<f32>,
93 to_home: Vec<f32>,
94}
95
96impl Field {
97 fn new(size: Vec2) -> Self {
98 let (w, h) = ((size.x / CELL) as usize, (size.y / CELL) as usize);
99 Self {
100 w,
101 h,
102 to_food: vec![0.0; w * h],
103 to_home: vec![0.0; w * h],
104 }
105 }
106
107 fn index(&self, p: Vec2) -> Option<usize> {
108 let (x, y) = ((p.x / CELL).floor(), (p.y / CELL).floor());
109 (x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h)
110 .then(|| y as usize * self.w + x as usize)
111 }
112}
113
114fn env_number(name: &str, default: f64) -> f64 {
115 std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
116}
117
118fn steer(bounds: Vec2) -> impl FnMut(&mut Ctx) {
123 let mut food_grid = SpatialGrid::new(SIGHT);
124 move |ctx: &mut Ctx| {
125 let (dt, random) = (ctx.dt(), ctx.random());
126 food_grid.rebuild_from(
127 ctx.world
128 .query::<(Entity, &Transform2D, &Food)>()
129 .iter()
130 .map(|(e, t, _)| (e, t.position)),
131 );
132 let field = ctx.world.remove_resource::<Field>().expect("field");
133 let (grid, f) = (&food_grid, &field);
134 ctx.world
135 .query::<(Entity, &mut Transform2D, &mut Ant)>()
136 .par_for_each(|(e, t, ant)| {
137 let mut rng = random.of(e);
138 let p = t.position;
139 let target = if ant.carrying {
140 (p.distance(ant.home_at) < SIGHT * 3.0).then_some(ant.home_at)
141 } else {
142 grid.nearest(p, SIGHT, |_| true).map(|food| food.position)
143 };
144 if let Some(target) = target {
145 ant.heading = (target - p).to_angle();
146 } else {
147 let layer = if ant.carrying { &f.to_home } else { &f.to_food };
148 let smell = |angle: f32| {
149 let at = p + Vec2::from_angle(ant.heading + angle) * SENSE_DIST;
150 f.index(at).map_or(-1.0, |i| layer[i])
151 };
152 let (left, ahead, right) = (smell(-SENSE_ANGLE), smell(0.0), smell(SENSE_ANGLE));
153 if ant.carrying && left.max(ahead).max(right) < FAINT {
154 let off = (ant.home_at - p).to_angle() - ant.heading;
157 let off = (off + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI;
158 ant.heading += off.clamp(-2.0 * dt, 2.0 * dt);
159 } else if left > ahead && left > right {
160 ant.heading -= 4.0 * dt;
161 } else if right > ahead && right > left {
162 ant.heading += 4.0 * dt;
163 }
164 }
165 ant.heading += rng.range(-2.5, 2.5) * dt;
166 let mut next = p + Vec2::from_angle(ant.heading) * SPEED * dt;
167 if next.x < 0.0 || next.x >= bounds.x {
169 ant.heading = std::f32::consts::PI - ant.heading;
170 next.x = next.x.clamp(0.0, bounds.x - 0.01);
171 }
172 if next.y < 0.0 || next.y >= bounds.y {
173 ant.heading = -ant.heading;
174 next.y = next.y.clamp(0.0, bounds.y - 0.01);
175 }
176 t.position = next;
177 t.rotation = ant.heading;
178 ant.since += dt;
179 ant.energy -= dt;
180 });
181 ctx.world.insert_resource(field);
182 }
183}
184
185fn lay_trails(ctx: &mut Ctx) {
188 let mut field = ctx.world.remove_resource::<Field>().expect("field");
189 ctx.world.query::<(&Transform2D, &Ant)>().for_each(|(t, ant)| {
190 if let Some(i) = field.index(t.position) {
191 let strength = (1.0 - ant.since / 30.0).max(0.0);
192 let layer = if ant.carrying {
193 &mut field.to_food
194 } else {
195 &mut field.to_home
196 };
197 layer[i] = layer[i].max(strength);
198 }
199 });
200 ctx.world.insert_resource(field);
201}
202
203fn evaporate(ctx: &mut Ctx) {
205 let keep = 0.985f32.powf(ctx.dt() * 60.0);
206 let field = ctx.world.resource_mut::<Field>();
207 for v in field.to_food.iter_mut().chain(field.to_home.iter_mut()) {
208 *v *= keep;
209 }
210}
211
212fn forage(ctx: &mut Ctx) {
216 let turn = ctx.turns(2);
217 let mut food_grid = SpatialGrid::new(8.0);
218 food_grid.rebuild_from(
219 ctx.world
220 .query::<(Entity, &Transform2D, &Food)>()
221 .iter()
222 .map(|(e, t, _)| (e, t.position)),
223 );
224 let grid = &food_grid;
225 ctx.world
226 .query::<(Entity, &Transform2D, &mut Ant)>()
227 .par_for_each(|(e, t, ant)| {
228 ant.event = Event::None;
229 if ant.energy <= 0.0 {
230 ant.event = Event::Starved;
231 return;
232 }
233 if !turn.includes(e) {
234 return;
235 }
236 let at_home = t.position.distance(ant.home_at) < NEST_RADIUS;
237 if ant.carrying {
238 if at_home {
239 ant.carrying = false;
240 ant.since = 0.0;
241 ant.energy = LIFESPAN;
242 ant.heading += std::f32::consts::PI;
243 ant.event = Event::Delivered;
244 }
245 } else if at_home {
246 ant.since = 0.0;
247 } else if let Some(f) = grid.nearest(t.position, 4.0, |_| true) {
248 ant.carrying = true;
249 ant.since = 0.0;
250 ant.heading += std::f32::consts::PI;
251 ant.event = Event::PickedUp(f.entity);
252 }
253 });
254
255 let (mut picked, mut delivered) = (Vec::new(), Vec::new());
256 let cmds = &mut ctx.commands;
257 ctx.world
258 .query::<(Entity, &Ant)>()
259 .for_each(|(e, ant)| match ant.event {
260 Event::None => {}
261 Event::Starved => cmds.despawn(e),
262 Event::PickedUp(food) => picked.push(food),
263 Event::Delivered => delivered.push(ant.home),
264 });
265 for f in picked {
266 if let Some(food) = ctx.world.get_mut::<Food>(f) {
267 food.amount = food.amount.saturating_sub(1);
268 if food.amount == 0 {
269 ctx.world.despawn(f);
270 }
271 }
272 }
273 for nest in delivered {
274 if let Some(n) = ctx.world.get_mut::<Nest>(nest) {
275 n.stored += 1;
276 n.delivered += 1;
277 }
278 }
279}
280
281fn grow(bounds: Vec2) -> impl FnMut(&mut Ctx) {
284 move |ctx: &mut Ctx| {
285 let mut rng = ctx.random().stream(0);
286 let sprite = ctx.world.resource::<AntLook>().0;
287 let mut counts = std::collections::HashMap::new();
288 ctx.world
289 .query::<&Ant>()
290 .for_each(|a| *counts.entry(a.home).or_insert(0u32) += 1);
291 let mut nests = Vec::new();
292 ctx.world
293 .query::<(Entity, &Transform2D, &mut Nest)>()
294 .for_each(|(e, t, n)| {
295 n.ants = counts.get(&e).copied().unwrap_or(0);
296 let born = n.stored.min(n.max_ants.saturating_sub(n.ants));
297 n.stored -= born;
298 nests.push((e, t.position, born));
299 });
300 for &(home, at, born) in &nests {
301 for _ in 0..born {
302 spawn_ant(&mut ctx.commands, sprite, home, at, rng.angle());
303 }
304 }
305 let piles = ctx.world.query::<&Food>().count();
306 let homes: Vec<Vec2> = nests.iter().map(|n| n.1).collect();
307 for _ in piles..food_piles(bounds) {
308 spawn_food(&mut ctx.commands, &mut rng, bounds, &homes);
309 }
310 }
311}
312
313fn food_piles(bounds: Vec2) -> usize {
316 ((bounds.x * bounds.y) / 60_000.0).ceil() as usize
317}
318
319fn spawn_ant(cmds: &mut Commands, sprite: Option<Texture>, home: Entity, at: Vec2, heading: f32) {
320 let ant = Ant {
321 home,
322 home_at: at,
323 heading,
324 carrying: false,
325 since: 0.0,
326 energy: LIFESPAN,
327 event: Event::None,
328 };
329 let t = Transform2D::at(at).rotated(heading);
330 match sprite {
331 Some(tex) => cmds.spawn((t, ant, Sprite::new(tex).with_color(Color::hex(0xD4553A)))),
332 None => cmds.spawn((t, ant)),
333 }
334}
335
336fn spawn_food(cmds: &mut Commands, rng: &mut Rng, bounds: Vec2, nests: &[Vec2]) {
337 let p = loop {
339 let p = vec2(rng.range(20.0, bounds.x - 20.0), rng.range(20.0, bounds.y - 20.0));
340 if nests.iter().all(|n| p.distance(*n) > 180.0) {
341 break p;
342 }
343 };
344 cmds.spawn((Transform2D::at(p), Food { amount: 400 }));
345}
346
347fn colonies(game: Game, bounds: Vec2) -> Game {
349 game.sim_system(steer(bounds))
350 .sim_system(lay_trails)
351 .sim_system(forage)
352 .sim_system_every(4, evaporate)
353 .sim_system_every(30, grow(bounds))
354}
355
356fn setup(ctx: &mut Ctx, bounds: Vec2, ants: usize, sprite: Option<Texture>) {
358 ctx.world.reflect::<Ant>();
359 ctx.world.reflect::<Nest>();
360 ctx.world.reflect::<Food>();
361 ctx.world.insert_resource(Field::new(bounds));
362 ctx.world.insert_resource(AntLook(sprite));
363 let mut rng = ctx.random().stream(1);
364 let (cols, rows) = ((bounds.x / NEST_AREA.x) as usize, (bounds.y / NEST_AREA.y) as usize);
365 let (cols, rows) = (cols.max(1), rows.max(1));
366 let per_nest = (ants / (cols * rows)).max(1);
367 let mut homes = Vec::new();
368 for row in 0..rows {
369 for col in 0..cols {
370 let at = (vec2(col as f32, row as f32) + 0.5) * bounds / vec2(cols as f32, rows as f32);
371 let nest = Nest {
372 stored: 0,
373 delivered: 0,
374 max_ants: per_nest as u32 * 2,
375 ants: per_nest as u32,
376 };
377 let home = ctx.world.spawn((Transform2D::at(at), nest));
378 homes.push(at);
379 for _ in 0..per_nest {
380 spawn_ant(&mut ctx.commands, sprite, home, at, rng.angle());
381 }
382 }
383 }
384 for _ in 0..food_piles(bounds) {
385 spawn_food(&mut ctx.commands, &mut rng, bounds, &homes);
386 }
387}
388
389fn delivered(world: &mut World) -> u64 {
390 world.query::<&Nest>().iter().map(|n| n.delivered).sum()
391}
392
393fn controls(ctx: &mut Ctx) {
396 if ctx.key_pressed(Key::Space) {
397 if ctx.is_paused() { ctx.resume() } else { ctx.pause() }
398 }
399 if ctx.key_pressed(Key::Period) {
400 ctx.step();
401 }
402 for (key, speed) in [(Key::Num1, 1.0), (Key::Num2, 4.0), (Key::Num3, 16.0), (Key::Num4, 64.0)] {
403 if ctx.key_pressed(key) {
404 ctx.set_sim_speed(speed);
405 }
406 }
407 if ctx.key_pressed(Key::Escape) {
408 ctx.quit();
409 }
410}
411
412fn hud(ctx: &mut Ctx) {
414 let ants = ctx.world.query::<&Ant>().count();
415 let nests = ctx.world.query::<&Nest>().count();
416 let food = delivered(&mut ctx.world);
417 let panel = Rect::new(12.0, 12.0, 260.0, 156.0);
418 ctx.ui_panel(panel);
419 let mut at = panel.min + ctx.ui_style.padding;
420 let speed = if ctx.is_paused() {
421 "paused".to_string()
422 } else {
423 format!("{}x", ctx.sim_speed())
424 };
425 let stats = format!(
426 "{ants} ants in {nests} colonies\n{food} food delivered\nspeed {speed}, {:.0} FPS",
427 ctx.fps()
428 );
429 at.y += ctx.ui_label(at, &stats).y + 8.0;
430 let pause = if ctx.is_paused() { "Run" } else { "Pause" };
431 if ctx.ui_button(Rect::new(at.x, at.y, 72.0, 30.0), pause) {
432 if ctx.is_paused() { ctx.resume() } else { ctx.pause() }
433 }
434 for (i, speed) in [1.0f32, 4.0, 16.0].into_iter().enumerate() {
435 let r = Rect::new(at.x + 80.0 + i as f32 * 54.0, at.y, 48.0, 30.0);
436 if ctx.ui_button(r, &format!("{speed}x")) {
437 ctx.set_sim_speed(speed);
438 ctx.resume();
439 }
440 }
441}
442
443fn draw_world(bounds: Vec2) -> impl FnMut(&mut Ctx) {
446 let mut pixels = Vec::new();
447 let mut dots: Vec<(Vec2, f32, Color)> = Vec::new();
448 let mut field_texture = None;
449 move |ctx: &mut Ctx| {
450 let (w, h) = {
451 let f = ctx.world.resource::<Field>();
452 (f.w, f.h)
453 };
454 let field_tex =
455 *field_texture.get_or_insert_with(|| ctx.create_texture(w as u32, h as u32, &vec![0; w * h * 4]));
456 let field = ctx.world.resource::<Field>();
457 pixels.clear();
458 pixels.extend(field.to_food.iter().zip(&field.to_home).flat_map(|(&food, &home)| {
459 let g = (food.sqrt() * 200.0) as u8;
460 let b = (home.sqrt() * 120.0) as u8;
461 [g / 3, g, b.max(g / 4), 255]
462 }));
463 ctx.update_texture(&field_tex, &pixels);
464 ctx.clear(Color::BLACK);
465 let scale = bounds / vec2(w as f32, h as f32);
466 ctx.draw_ex(
467 &field_tex,
468 bounds * 0.5,
469 DrawParams {
470 scale,
471 z: -10.0,
472 ..Default::default()
473 },
474 );
475
476 dots.clear();
477 ctx.world.query::<(&Transform2D, &Food)>().for_each(|(t, f)| {
478 dots.push((t.position, 2.0 + (f.amount as f32).sqrt() / 2.0, Color::hex(0x7BD389)));
479 });
480 ctx.world.query::<(&Transform2D, &Nest)>().for_each(|(t, _)| {
481 dots.push((t.position, NEST_RADIUS, Color::hex(0x8E5A3C)));
482 });
483 for &(p, radius, color) in &dots {
484 ctx.draw_circle(p, radius, color);
485 }
486 }
487}
488
489fn main() {
490 let ants = env_number("ANTS", ANTS_PER_NEST as f64) as usize;
491 let seed = env_number("SEED", DEFAULT_SEED as f64) as u64;
492 let nests = (ants as f32 / ANTS_PER_NEST as f32).max(1.0);
494 let side = nests.sqrt().ceil();
495 let bounds = NEST_AREA * vec2(side, (nests / side).ceil());
496
497 if env_number("HEADLESS", 0.0) != 0.0 {
498 let ticks = env_number("TICKS", 600.0) as u64;
499 let mut start = Instant::now();
500 let mut world = colonies(Game::new("ants", 1, 1).seed(seed), bounds).run_headless(ticks, |ctx| {
501 setup(ctx, bounds, ants, None);
502 start = Instant::now();
503 });
504 let secs = start.elapsed().as_secs_f64();
505 println!(
506 "{ticks} ticks: {:.2} ms/tick ({:.0} ticks/s). {} ants, {} food delivered",
507 secs * 1e3 / ticks as f64,
508 ticks as f64 / secs,
509 world.query::<&Ant>().count(),
510 delivered(&mut world)
511 );
512 return;
513 }
514
515 colonies(Game::new("Crabby Engine: ants", 1280, 720).seed(seed), bounds)
516 .system(controls)
517 .system(camera_controls)
518 .system(draw_world(bounds))
519 .system(hud)
520 .run(move |ctx| {
521 let sprite = ctx.create_texture(3, 2, &[255; 3 * 2 * 4]);
522 setup(ctx, bounds, ants, Some(sprite));
523 let fit = ctx.window_size() / bounds;
525 ctx.camera.zoom = fit.x.min(fit.y);
526 ctx.camera.look_at(bounds / 2.0);
527 let mut next = 1.0;
528 move |ctx: &mut Ctx| {
529 if ctx.time() >= next {
530 next += 1.0;
531 let sim_ms: f32 = ctx.system_timings().iter().filter(|t| t.sim).map(|t| t.avg_ms).sum();
532 println!(
533 "ants: {} delivered: {} fps: {:.0} sim: {sim_ms:.2} ms/frame tick: {}",
534 ctx.world.query::<&Ant>().count(),
535 delivered(&mut ctx.world),
536 ctx.fps(),
537 ctx.sim_tick()
538 );
539 }
540 }
541 });
542}