pinch-points 0.8.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
//! Centre-screen announcements for the moments that change the round.
//!
//! The side feed reports everything, which makes it good for reading back
//! and poor for noticing: a lure or a tide event lands in a 14px line in
//! the corner while four players are watching the middle of the board. So
//! the roulette result goes across the centre of the screen, as the
//! original put it: big, brief, and colour-matched to the feed line that
//! records it.
//!
//! Render-only, and deliberately so: it never touches the sim or the clock,
//! so a peer that is a frame ahead still simulates the same round. Two
//! events landing together queue rather than overlap.

use crate::app::i18n::fill;
use crate::app::menu_ui;
use crate::app::palette;
use crate::app::settings::GameSettings;
use crate::app::sim_events::SimEvent;
use crate::sim::{PlayerId, TideEvent};
use bevy::prelude::*;
use std::collections::VecDeque;

/// How long one announcement holds the middle of the screen at full
/// strength, and the ramps at each end.
///
/// Two seconds of hold, not the one and a half it began with: the banner
/// carries a headline *and* a line explaining what the event does, and a
/// second and a half is enough to read the first and not the second. It can
/// be spent now the roulette has a cooldown ([`crate::sim::EVENT_COOLDOWN`])
/// and events no longer arrive in waves.
const HOLD: f32 = 2.0;
const FADE_IN: f32 = 0.15;
/// When a banner starts to fade, and the board under it can be seen again.
const HOLD_ENDS: f32 = FADE_IN + HOLD;
const FADE_OUT: f32 = 0.45;
/// The whole span, ramps included. Derived rather than written down, so a
/// change to either fade moves the total and leaves the *hold* - the part
/// that is actually read - exactly as long as it says it is.
const LIFE: f32 = FADE_IN + HOLD + FADE_OUT;

/// Never let a pile-up of events keep the middle of the screen busy for
/// the better part of ten seconds; the feed still has all of them.
///
/// The arithmetic is one banner more than it looks: `drive_announcements`
/// pops the queue *before* spawning, so one is on screen while this many
/// wait behind it, and the ceiling is `(MAX_QUEUED + 1) * LIFE`. At three,
/// with the hold at two seconds, that is 10.4 s; two keeps it at 7.8 s.
const MAX_QUEUED: usize = 2;

/// Something worth the centre of the screen.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Announcement {
    Tide(TideEvent),
    /// A molting crab banked: every loose crab now runs for this seat.
    Lure(PlayerId),
    /// The final scramble.
    Surge,
    /// A golden crab is about to come out of a spawner hole.
    Golden,
}

impl Announcement {
    /// Headline, the line under it, and the ink they share.
    ///
    /// Pure, so what the player reads at the loudest moment of the round is
    /// testable without a window. Takes the settings rather than the string
    /// table because a lure names a seat, and a seat may have been given a
    /// name of its own.
    pub fn lines(
        self,
        settings: &GameSettings,
        names: &crate::app::SeatNames,
    ) -> (String, String, Color) {
        let tr = settings.tr();
        match self {
            Announcement::Tide(event) => (
                tr.events[event.index()].to_string(),
                tr.event_blurbs[event.index()].to_string(),
                palette::INK_TIDE,
            ),
            Announcement::Lure(owner) => (
                tr.ann_lure.to_string(),
                fill(tr.ann_lure_sub, &[("p", &names.label(tr, owner))]),
                palette::INK_LURE,
            ),
            Announcement::Surge => (
                tr.ann_surge.to_string(),
                tr.ann_surge_sub.to_string(),
                palette::INK_SURGE,
            ),
            Announcement::Golden => (
                tr.ann_golden.to_string(),
                fill(
                    tr.ann_golden_sub,
                    &[("n", &crate::sim::GOLDEN_CALLED_VALUE.to_string())],
                ),
                palette::GOLD,
            ),
        }
    }
}

/// The announcement being shown and the ones waiting their turn.
#[derive(Resource, Default)]
pub struct Announcer {
    queue: VecDeque<Announcement>,
    /// Seconds the current banner has been up, once one is spawned.
    age: f32,
    /// Whether a banner is on screen.
    showing: bool,
}

impl Announcer {
    /// Whether a banner covers the middle of the board, or one is waiting
    /// to: what is happening there would be missed. Clear once the last
    /// one starts to fade.
    pub fn holds_the_board(&self) -> bool {
        !self.queue.is_empty() || (self.showing && self.age < HOLD_ENDS)
    }

    /// Queue an announcement, unless the same one is already waiting (a
    /// second gull surge cannot happen, but a repeated tide event can).
    pub fn push(&mut self, announcement: Announcement) {
        if self.queue.contains(&announcement) || self.queue.len() >= MAX_QUEUED {
            return;
        }
        self.queue.push_back(announcement);
    }

    #[cfg(test)]
    pub fn pending(&self) -> usize {
        self.queue.len()
    }
}

/// Alpha over one announcement's life: a quick ramp in, a hold, a slower
/// fade out. Colour-only, so it never re-shapes the text under it.
fn envelope(age: f32) -> f32 {
    if age < FADE_IN {
        (age / FADE_IN).clamp(0.0, 1.0)
    } else if age > LIFE - FADE_OUT {
        ((LIFE - age) / FADE_OUT).clamp(0.0, 1.0)
    } else {
        1.0
    }
}

/// The whole banner, so it can be despawned in one call.
#[derive(Component)]
pub struct Banner;

/// A piece of the banner and the colours it fades from: text and border take
/// `ink`, the card's panel takes `fill`.
#[derive(Component)]
pub struct BannerPart {
    ink: Color,
    fill: Option<Color>,
}

/// Fold the loud sim events into the queue. Everything else stays in the
/// feed: an announcement that fires every few seconds is wallpaper.
pub fn collect_announcements(
    mut events: MessageReader<SimEvent>,
    mut announcer: ResMut<Announcer>,
) {
    for event in events.read() {
        match event {
            SimEvent::TideEventFired { event, .. } => {
                announcer.push(Announcement::Tide(*event));
            }
            SimEvent::LureStarted { owner } => announcer.push(Announcement::Lure(*owner)),
            SimEvent::SurgeStarted => announcer.push(Announcement::Surge),
            SimEvent::GoldenCalled { .. } => announcer.push(Announcement::Golden),
            SimEvent::CrabBanked { .. }
            | SimEvent::CrabEaten { .. }
            | SimEvent::CrabSpawned { .. }
            | SimEvent::CastleRaided { .. }
            | SimEvent::GullArrived
            | SimEvent::GullTookOff
            | SimEvent::GullLanded { .. }
            | SimEvent::GullShooed { .. }
            | SimEvent::GullFlewOff { .. }
            | SimEvent::SignpostPlaced { .. }
            | SimEvent::SignpostRemoved { .. }
            | SimEvent::SignpostEvicted { .. }
            | SimEvent::TierUp { .. }
            | SimEvent::WheelLocked { .. }
            | SimEvent::GoldenOut { .. }
            | SimEvent::RoundEnded => {}
        }
    }
}

/// The three colours a banner piece can wear: its text, its edge, its panel.
/// A piece has whichever of them it was spawned with.
type BannerColors<'w> = (
    &'w BannerPart,
    Option<&'w mut TextColor>,
    Option<&'w mut BorderColor>,
    Option<&'w mut BackgroundColor>,
);

/// Show the queue one at a time: age the banner that is up, fade it, retire
/// it, then raise the next one.
pub fn drive_announcements(
    mut commands: Commands,
    time: Res<Time>,
    settings: Res<GameSettings>,
    names: Res<crate::app::SeatNames>,
    mut announcer: ResMut<Announcer>,
    banners: Query<Entity, With<Banner>>,
    mut parts: Query<BannerColors>,
) {
    if let Ok(banner) = banners.single() {
        announcer.age += time.delta_secs();
        if announcer.age >= LIFE {
            commands.entity(banner).despawn();
            announcer.age = 0.0;
            announcer.showing = false;
            return;
        }
        let alpha = envelope(announcer.age);
        for (part, text, border, fill) in &mut parts {
            let ink = part.ink.with_alpha(part.ink.alpha() * alpha);
            if let Some(mut color) = text {
                menu_ui::set_color(&mut color, ink);
            }
            if let Some(mut color) = border
                && color.top != ink
            {
                *color = BorderColor::all(ink);
            }
            if let (Some(mut color), Some(base)) = (fill, part.fill) {
                menu_ui::set_bg(&mut color, base.with_alpha(base.alpha() * alpha));
            }
        }
        return;
    }
    let Some(next) = announcer.queue.pop_front() else {
        return;
    };
    announcer.age = 0.0;
    announcer.showing = true;
    spawn_banner(&mut commands, next, &settings, &names);
}

fn spawn_banner(
    commands: &mut Commands,
    announcement: Announcement,
    settings: &GameSettings,
    names: &crate::app::SeatNames,
) {
    let (headline, blurb, ink) = announcement.lines(settings, names);
    // Opaque, like the pause card: a translucent panel over a busy board
    // showed the crabs through the letters, which is the opposite of the
    // point. It only holds the middle for two seconds.
    let card_fill = palette::CARD_BG;
    commands
        .spawn((
            Banner,
            GlobalZIndex(menu_ui::layer::BANNER),
            menu_ui::centred_overlay(),
        ))
        .with_children(|wrap| {
            wrap.spawn((
                BannerPart {
                    ink: ink.with_alpha(0.9),
                    fill: Some(card_fill),
                },
                Node {
                    flex_direction: FlexDirection::Column,
                    align_items: AlignItems::Center,
                    row_gap: Val::Px(4.0),
                    padding: UiRect::axes(Val::Px(40.0), Val::Px(22.0)),
                    border: UiRect::all(Val::Px(2.0)),
                    border_radius: BorderRadius::all(Val::Px(18.0)),
                    ..default()
                },
                BackgroundColor(card_fill.with_alpha(0.0)),
                BorderColor::all(ink.with_alpha(0.0)),
            ))
            .with_children(|card| {
                card.spawn((
                    BannerPart { ink, fill: None },
                    Text::new(headline),
                    menu_ui::display_font(46.0),
                    TextColor(ink.with_alpha(0.0)),
                ));
                card.spawn((
                    BannerPart {
                        ink: palette::PARCHMENT,
                        fill: None,
                    },
                    Text::new(blurb),
                    TextFont {
                        font_size: FontSize::Px(menu_ui::type_scale::ROW),
                        ..default()
                    },
                    TextColor(palette::PARCHMENT.with_alpha(0.0)),
                ));
            });
        });
}

/// Leaving the round takes the banner and anything queued behind it.
pub fn clear_announcements(
    mut commands: Commands,
    mut announcer: ResMut<Announcer>,
    banners: Query<Entity, With<Banner>>,
) {
    for banner in &banners {
        commands.entity(banner).despawn();
    }
    announcer.queue.clear();
    announcer.showing = false;
    announcer.age = 0.0;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::i18n::{ALL_LANGS, EN};

    /// The envelope ramps in, holds, and reaches zero at the end. A banner
    /// that never quite disappears would sit on the board forever.
    #[test]
    fn the_envelope_opens_and_closes() {
        assert_eq!(envelope(0.0), 0.0);
        assert_eq!(envelope(FADE_IN), 1.0);
        assert_eq!(
            envelope(FADE_IN + HOLD),
            1.0,
            "full strength for the whole of the hold, to its last instant"
        );
        assert_eq!(envelope(LIFE / 2.0), 1.0, "holds through the middle");
        assert_eq!(envelope(LIFE), 0.0);
        assert!(envelope(LIFE - FADE_OUT / 2.0) < 1.0, "on the way out");
        assert_eq!(envelope(LIFE + 5.0), 0.0, "never negative");
    }

    /// The queue serializes announcements and drops a duplicate of one
    /// already waiting, so a double event cannot double the banner.
    #[test]
    fn the_queue_serializes_and_dedupes() {
        let mut announcer = Announcer::default();
        announcer.push(Announcement::Surge);
        announcer.push(Announcement::Surge);
        assert_eq!(announcer.pending(), 1);
        announcer.push(Announcement::Tide(TideEvent::CrabMania));
        announcer.push(Announcement::Lure(2));
        // Counted off the cap rather than written as a number, so moving
        // the cap moves the test with it instead of pinning the old value.
        assert_eq!(announcer.pending(), MAX_QUEUED, "everything up to the cap");
        announcer.push(Announcement::Tide(TideEvent::FreshSand));
        assert_eq!(announcer.pending(), MAX_QUEUED, "the pile-up is capped");
    }

    /// The wiring, end to end in a headless App: a tide event raises a
    /// banner that says what happened, one at a time, and it retires itself.
    #[test]
    fn an_event_raises_a_banner_that_retires_itself() {
        use crate::app::sim_events::SimEvent;
        use std::time::Duration;

        let mut app = App::new();
        app.init_resource::<Time>();
        app.add_message::<SimEvent>();
        app.init_resource::<Announcer>();
        app.insert_resource(GameSettings::default());
        app.init_resource::<crate::app::SeatNames>();
        app.add_systems(Update, (collect_announcements, drive_announcements).chain());

        app.world_mut().write_message(SimEvent::TideEventFired {
            event: TideEvent::CastleSwap,
            by: None,
        });
        app.world_mut().write_message(SimEvent::SurgeStarted);
        app.update();

        let headlines = |app: &mut App| {
            app.world_mut()
                .query::<&Text>()
                .iter(app.world())
                .map(|text| text.0.clone())
                .collect::<Vec<_>>()
        };
        let shown = headlines(&mut app);
        assert!(
            shown.iter().any(|line| line == EN.events[7]),
            "the first event took the screen: {shown:?}"
        );
        assert!(
            !shown.iter().any(|line| line == EN.ann_surge),
            "the second one waits its turn: {shown:?}"
        );
        assert_eq!(app.world().resource::<Announcer>().pending(), 1);

        // Run past its life: it retires, then the queued one takes over on
        // the frame after (the despawn is a command, and the next banner
        // waits for it to land).
        //
        // Counted off `LIFE` rather than written as a number of frames:
        // four 800 ms steps was exactly enough at 2.1 s, so lengthening the
        // hold left a test that never saw the second banner.
        let step = Duration::from_millis(200);
        let frames = (LIFE / step.as_secs_f32()).ceil() as u32 + 2;
        for _ in 0..frames {
            app.world_mut().resource_mut::<Time>().advance_by(step);
            app.update();
        }
        let shown = headlines(&mut app);
        assert!(
            shown.iter().any(|line| line == EN.ann_surge),
            "the surge got its turn: {shown:?}"
        );
        assert_eq!(app.world().resource::<Announcer>().pending(), 0);
    }

    /// The board is held while any banner covers it or waits to, through a
    /// queue of two, and let go as the last one starts to fade: what a
    /// Crab Monopoly's sweep waits for before its crabs fly.
    #[test]
    fn the_board_is_held_until_the_last_banner_fades() {
        use crate::app::sim_events::SimEvent;
        use std::time::Duration;

        let mut app = App::new();
        app.init_resource::<Time>();
        app.add_message::<SimEvent>();
        app.init_resource::<Announcer>();
        app.insert_resource(GameSettings::default());
        app.init_resource::<crate::app::SeatNames>();
        app.add_systems(Update, (collect_announcements, drive_announcements).chain());
        let held = |app: &App| app.world().resource::<Announcer>().holds_the_board();
        assert!(!held(&app), "nothing to hold for");

        app.world_mut()
            .write_message(SimEvent::LureStarted { owner: 1 });
        app.world_mut().write_message(SimEvent::TideEventFired {
            event: TideEvent::Monopoly,
            by: Some(0),
        });
        let step = Duration::from_millis(50);
        let mut seconds = 0.0;
        let mut released = None;
        while seconds < 3.0 * LIFE {
            app.update();
            if !held(&app) && released.is_none() {
                released = Some(seconds);
            }
            app.world_mut().resource_mut::<Time>().advance_by(step);
            seconds += step.as_secs_f32();
        }
        let released = released.expect("the board was never let go");
        // The lure's whole life, a frame for its despawn to land, and the
        // Monopoly banner's hold.
        assert!(
            (LIFE + HOLD_ENDS..=LIFE + HOLD_ENDS + 0.2).contains(&released),
            "let go at {released} s"
        );
    }

    /// Every announcement has a headline and a line explaining it, in every
    /// language: a "Fresh Sand" banner that does not say your signposts just
    /// washed away is a colour, not information.
    #[test]
    fn every_announcement_says_what_happened() {
        let mut all = vec![Announcement::Surge, Announcement::Lure(0)];
        all.extend(TideEvent::ALL.map(Announcement::Tide));
        for lang in ALL_LANGS {
            for announcement in &all {
                let settings = GameSettings {
                    language: lang,
                    ..GameSettings::default()
                };
                let (head, blurb, _) =
                    announcement.lines(&settings, &crate::app::SeatNames(settings.names.clone()));
                assert!(!head.is_empty(), "{announcement:?} in {lang:?}");
                assert!(blurb.len() > head.len(), "{announcement:?} in {lang:?}");
            }
        }
        // The lure names the seat that pulled it off.
        let (_, blurb, ink) = Announcement::Lure(3)
            .lines(&GameSettings::default(), &crate::app::SeatNames::default());
        assert!(blurb.contains('4'), "{blurb}");
        assert_eq!(ink, palette::INK_LURE, "the feed line's colour");
    }
}