Skip to main content

pinch_points/sim/board/
gulls.rs

1//! Gulls (spec §3.5): spawning, walking, flight, castle raids, and the
2//! crab-eating collision pass.
3
4use super::*;
5
6impl Board {
7    /// Whether a gull can be put down on `(x, y)`: sand, not rock and not
8    /// kelp, which a gull's feet cannot reach through. The one rule every
9    /// placer asks (`spawn_gull` asserts it) so a level file, a pasted
10    /// code and the editor's brush cannot disagree with the sim about
11    /// where a bird may stand.
12    pub fn gull_may_stand(&self, x: u8, y: u8) -> bool {
13        self.in_bounds(i32::from(x), i32::from(y))
14            && !matches!(self.tile_at(x, y), TileKind::Rock | TileKind::Kelp)
15    }
16
17    /// Drop a gull onto the board, walking. Used by level setup and the
18    /// periodic edge spawner.
19    pub fn spawn_gull(&mut self, x: u8, y: u8, dir: Direction) {
20        assert!(
21            self.in_bounds(i32::from(x), i32::from(y)),
22            "gull off the board"
23        );
24        assert!(self.gull_may_stand(x, y), "gull on a rock or in kelp");
25        let tile = self.index(i32::from(x), i32::from(y));
26        let id = self.next_gull_id;
27        self.next_gull_id += 1;
28        let handed = self.roll_handedness();
29        let takeoff_in = self.roll_takeoff();
30        let mut gull = Gull {
31            id,
32            tile,
33            dir,
34            progress: 0,
35            prev: Pose {
36                tile,
37                dir,
38                progress: 0,
39            },
40            handed,
41            state: GullState::Walking,
42            takeoff_in,
43        };
44        self.resolve_walls_for(tile, &mut gull.dir, handed, Walker::Gull);
45        self.gulls.push(gull);
46    }
47
48    /// Auto-spawn a gull at the edge every `gull_period` ticks (double rate
49    /// during the final-scramble surge), at a PRNG perimeter tile, facing
50    /// into the board.
51    pub(super) fn run_gull_spawner(&mut self) {
52        if self.rules.gull_period == 0 {
53            return;
54        }
55        let period = if self.in_surge() {
56            (self.rules.gull_period / 2).max(1)
57        } else {
58            self.rules.gull_period
59        };
60        if !self.tick.is_multiple_of(u64::from(period)) {
61            return;
62        }
63        // Balance: the ambient flock is capped so the late round stays
64        // playable (raiders leaving keeps the population cycling). Tide
65        // events (GullMania, GullAttack) deliberately bypass the cap.
66        if self.gulls.len() >= GULL_CAP {
67            return;
68        }
69        let (w, h) = (u32::from(self.grid.width), u32::from(self.grid.height));
70        let perimeter = if w > 1 && h > 1 {
71            2 * w + 2 * h - 4
72        } else {
73            w * h
74        };
75        let k = self.rng.next_u32() % perimeter;
76        let (x, y, dir) = if k < w {
77            (k, 0, Direction::Down) // top edge
78        } else if k < 2 * w {
79            (k - w, h - 1, Direction::Up) // bottom edge
80        } else if k < 2 * w + (h - 2) {
81            (0, k - 2 * w + 1, Direction::Right) // left edge
82        } else {
83            (w - 1, k - 2 * w - (h - 2) + 1, Direction::Left) // right edge
84        };
85        let tile = self.index(x as i32, y as i32);
86        if matches!(
87            self.grid.tiles[tile as usize],
88            TileKind::Rock | TileKind::Kelp
89        ) {
90            return; // unlucky roll; the flock circles and tries again later
91        }
92        self.spawn_gull(x as u8, y as u8, dir);
93    }
94
95    fn roll_takeoff(&mut self) -> u32 {
96        TAKEOFF_MIN + self.rng.next_u32() % (TAKEOFF_MAX - TAKEOFF_MIN + 1)
97    }
98
99    pub(super) fn move_gulls(&mut self) {
100        let mut departed: Vec<usize> = Vec::new();
101        for i in 0..self.gulls.len() {
102            let mut gull = self.gulls[i];
103            gull.prev = gull.pose();
104            let raided = match gull.state {
105                GullState::Walking => self.walk_gull(&mut gull),
106                GullState::Flying { .. } => self.fly_gull(&mut gull),
107            };
108            if raided {
109                // Balance: a successful raider hauls its loot back to the
110                // flock and leaves the beach, so gull pressure regulates
111                // itself instead of compounding all round.
112                departed.push(i);
113            } else {
114                self.gulls[i] = gull;
115            }
116        }
117        for &i in departed.iter().rev() {
118            self.gulls.remove(i);
119        }
120    }
121
122    /// Returns true if the gull raided a castle (it departs with the loot).
123    fn walk_gull(&mut self, gull: &mut Gull) -> bool {
124        // Takeoff timer runs only while walking (spec §3.5: per-gull timer).
125        if gull.takeoff_in == 0 {
126            let distance =
127                FLIGHT_MIN + (self.rng.next_u32() % u32::from(FLIGHT_MAX - FLIGHT_MIN + 1)) as u8;
128            gull.state = GullState::Flying {
129                remaining: distance,
130            };
131            return self.fly_gull(gull);
132        }
133        gull.takeoff_in -= 1;
134        if !self.passable_for(gull.tile, gull.dir, Walker::Gull) {
135            return false; // sealed in; waits like a crab would
136        }
137        gull.progress += self.walk_step(gull.tile, GULL_WALK_SPEED);
138        while gull.progress >= SUBUNITS_PER_TILE {
139            gull.progress -= SUBUNITS_PER_TILE;
140            gull.tile = self.neighbor(gull.tile, gull.dir);
141            if self.gull_arrival(gull) {
142                return true;
143            }
144        }
145        false
146    }
147
148    /// Walking-gull arrival: raid a castle it reaches (returning true, and
149    /// the raider then leaves the beach with its loot), obey and degrade any
150    /// signpost it crosses, wall-resolve.
151    fn gull_arrival(&mut self, gull: &mut Gull) -> bool {
152        let t = gull.tile as usize;
153        if let TileKind::Castle(owner) = self.grid.tiles[t] {
154            if self.rules.castle_raids {
155                self.damage_castle(owner, gull.tile);
156            }
157            return true;
158        }
159        if self.turnstile_deflect(gull.tile, &mut gull.dir, gull.handed, Walker::Gull) {
160            return false;
161        }
162        if let Some(mut sp) = self.signposts[t] {
163            gull.dir = sp.dir;
164            self.signposts[t] = match sp.health {
165                SignpostHealth::Full => {
166                    sp.health = SignpostHealth::Worn;
167                    Some(sp)
168                }
169                SignpostHealth::Worn => None,
170            };
171        }
172        self.resolve_walls_for(gull.tile, &mut gull.dir, gull.handed, Walker::Gull);
173        false
174    }
175
176    /// Flying: hop tile-by-tile in the current direction, ignoring walls and
177    /// signposts, bouncing off the board edge, landing only on a tile a
178    /// creature can stand on (spec §3.5). Returns true on a landing raid.
179    fn fly_gull(&mut self, gull: &mut Gull) -> bool {
180        let GullState::Flying { mut remaining } = gull.state else {
181            return false;
182        };
183        let mut landed = false;
184        gull.progress += self.tempo_speed(GULL_FLY_SPEED);
185        while gull.progress >= SUBUNITS_PER_TILE {
186            gull.progress -= SUBUNITS_PER_TILE;
187            let (x, y) = self.coords(gull.tile);
188            let (dx, dy) = gull.dir.offset();
189            if !self.wrap && !self.in_bounds(x + dx, y + dy) {
190                gull.dir = gull.dir.reverse();
191                let (rx, ry) = gull.dir.offset();
192                if !self.in_bounds(x + rx, y + ry) {
193                    // A board one tile long on this axis: nowhere to fly.
194                    // Land where we are.
195                    gull.state = GullState::Walking;
196                    gull.takeoff_in = self.roll_takeoff();
197                    return false;
198                }
199            }
200            gull.tile = self.neighbor(gull.tile, gull.dir);
201            remaining = remaining.saturating_sub(1);
202            if remaining == 0 {
203                if matches!(
204                    self.grid.tiles[gull.tile as usize],
205                    TileKind::Rock | TileKind::Kelp
206                ) {
207                    remaining = 1; // glide one more tile to somewhere landable
208                } else {
209                    landed = true;
210                    gull.state = GullState::Walking;
211                    gull.takeoff_in = self.roll_takeoff();
212                    // Landing is not an arrival: no signpost effect (§3.5),
213                    // but never leave the gull facing a wall.
214                    self.resolve_walls_for(gull.tile, &mut gull.dir, gull.handed, Walker::Gull);
215                    // A gull that lands on a castle raids it on the spot and
216                    // departs with the loot, like a walking raid.
217                    if let TileKind::Castle(owner) = self.grid.tiles[gull.tile as usize] {
218                        if self.rules.castle_raids {
219                            self.damage_castle(owner, gull.tile);
220                        }
221                        return true;
222                    }
223                    break;
224                }
225            }
226        }
227        if !landed && let GullState::Flying { remaining: r } = &mut gull.state {
228            *r = remaining;
229        }
230        false
231    }
232
233    /// Spec §3.4: a gull reaching a castle carries off **half** the banked
234    /// crabs (score halves, rounding the loss up). A raid on a fat castle is
235    /// devastating and legible from across the room, and the tier drop falls
236    /// out of the score loss. At most [`SPILL_CAP`] of the lost crabs respawn
237    /// as live crabs in the surrounding tiles; the rest are carried off by
238    /// the flock.
239    pub(super) fn damage_castle(&mut self, owner: PlayerId, castle_tile: u16) {
240        let score = self.scores[owner as usize];
241        let target = score / 2;
242        let spill = (score - target).min(SPILL_CAP);
243        self.scores[owner as usize] = target;
244
245        let (cx, cy) = self.coords(castle_tile);
246        for (nx, ny, ox, oy) in self
247            .ring_openings(cx, cy, &CASTLE_RING)
248            .into_iter()
249            .take(spill as usize)
250        {
251            // Scatter away from the castle: dominant axis of the offset.
252            let dir = Direction::toward(ox, oy);
253            let handed = self.roll_handedness();
254            self.spawn_crab(nx as u8, ny as u8, dir, handed, CrabKind::Common);
255        }
256    }
257
258    /// Where a creature actually stands, in board subunits: the tile it is
259    /// filed under, plus how far it has walked out of that tile's centre.
260    ///
261    /// Measuring against the tile alone is what let a gull and a crab walk
262    /// through each other. Two creatures approaching head-on cross the gap
263    /// between two tile centres while still filed under *different* tiles,
264    /// so a same-tile test never sees the contact, and by the time they do
265    /// share a tile they have passed and their offsets point apart.
266    fn sub_position(&self, tile: u16, dir: Direction, progress: u16) -> (i32, i32) {
267        let (x, y) = self.coords(tile);
268        let (dx, dy) = sub_offset(dir, progress);
269        (
270            x * i32::from(SUBUNITS_PER_TILE) + dx,
271            y * i32::from(SUBUNITS_PER_TILE) + dy,
272        )
273    }
274
275    /// Manhattan distance between two sub-positions, in subunits. On a
276    /// wrapping board the short way round counts: two creatures meeting
277    /// head-on through the seam are a tile apart on the sand and a whole
278    /// board apart in raw coordinates, and measuring the latter let them
279    /// walk through each other there, the failure `sub_position` exists to
280    /// rule out everywhere else.
281    fn sub_distance(&self, a: (i32, i32), b: (i32, i32)) -> u32 {
282        let (mut dx, mut dy) = ((a.0 - b.0).unsigned_abs(), (a.1 - b.1).unsigned_abs());
283        if self.wrap {
284            let sub = u32::from(SUBUNITS_PER_TILE);
285            let (w, h) = (
286                u32::from(self.width()) * sub,
287                u32::from(self.height()) * sub,
288            );
289            dx %= w;
290            dy %= h;
291            dx = dx.min(w - dx);
292            dy = dy.min(h - dy);
293        }
294        dx + dy
295    }
296
297    /// Fixed-order collision pass: each gull, in index order, eats every crab
298    /// within [`EAT_RANGE`] subunits of it (Manhattan distance across the
299    /// board, spec §4.3). Flying gulls eat nothing.
300    pub(super) fn gulls_eat(&mut self) {
301        for g in 0..self.gulls.len() {
302            let gull = self.gulls[g];
303            if gull.state != GullState::Walking {
304                continue;
305            }
306            let at = self.sub_position(gull.tile, gull.dir, gull.progress);
307            let mut c = 0;
308            while c < self.crabs.len() {
309                let crab = self.crabs[c];
310                let crab_at = self.sub_position(crab.tile, crab.dir, crab.progress);
311                if self.sub_distance(at, crab_at) <= u32::from(EAT_RANGE) {
312                    self.crabs.remove(c);
313                    continue;
314                }
315                c += 1;
316            }
317        }
318    }
319}