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