Skip to main content

pinch_points/app/
mod.rs

1//! The Bevy shell: windowing, rendering, input, and the fixed-timestep bridge
2//! into the headless simulation. Nothing in `crate::sim` may depend on this.
3//!
4//! This file is the shell's shared vocabulary: the resources, the states,
5//! the messages. When each of them runs is [`schedule`]'s business.
6
7mod achievements;
8pub(crate) mod announce;
9mod art;
10mod audio;
11mod awards;
12mod binds;
13mod board_render;
14mod boot;
15mod bot_seats;
16pub mod campaign;
17pub(crate) mod clock;
18mod codes;
19/// Whether `screen` stands on the shared beach postcard. One list, asked
20/// by the run condition and by the backdrop tender, so a new screen
21/// decides once where it stands.
22pub(crate) fn postcard_screen(screen: Screen) -> bool {
23    match screen {
24        Screen::Menu
25        | Screen::StageSelect
26        | Screen::Settings
27        | Screen::Controls
28        | Screen::MatchSetup
29        | Screen::Achievements
30        | Screen::Replays
31        | Screen::Lobby
32        | Screen::Language
33        | Screen::Interlude
34        | Screen::NewVersion => true,
35        Screen::Puzzle | Screen::Versus | Screen::Editor => false,
36    }
37}
38
39mod conditions;
40mod controls;
41mod countdown;
42mod creatures;
43mod cursor;
44mod cycle;
45mod daily;
46mod dev;
47mod editor;
48mod effects;
49mod embedded;
50mod failsafe;
51mod gamepad;
52mod hint;
53mod hud;
54pub(crate) mod i18n;
55mod keycaps;
56mod keymap;
57mod language;
58pub mod layout;
59mod lobby;
60mod play_input;
61// Public for the integration tests: a launched round's board is built
62// here, and the wire tests have to build it the way the game does.
63pub mod match_setup;
64mod menu_scene;
65mod menu_ui;
66pub mod net;
67mod open;
68pub mod palette;
69pub(crate) mod paths;
70mod pause;
71pub mod progress;
72pub mod replays;
73mod results;
74mod schedule;
75mod session;
76mod settings;
77mod side_panels;
78mod sim_events;
79mod spectators;
80mod stage_select;
81mod suspend;
82mod teams;
83mod tournament;
84mod typing;
85pub mod update;
86mod watch;
87
88pub use campaign::{Campaign, CampaignKind, Coop};
89pub use daily::Daily;
90pub use schedule::run;
91
92/// Open the game on one replay file (`pinch-points watch FILE`).
93pub fn watch_file(replay: Replay) {
94    schedule::run_with(watch::file(replay));
95}
96
97/// Open the game on an arena's games as they are played (`arena --watch`).
98pub fn watch_live(feed: std::sync::mpsc::Receiver<crate::bots::game::Feed>) {
99    schedule::run_with(watch::live(feed));
100}
101
102use crate::app::i18n::fill;
103use crate::sim::{
104    Board, BotLevel, Level, MAX_PLAYERS, PlayerAction, PuzzleOutcome, Replay, bot_action,
105    castle_spots, classic_arena, classic_arena_seeded, generate_arena,
106};
107use bevy::app::{TaskPoolOptions, TaskPoolPlugin, TaskPoolThreadAssignmentPolicy};
108use bevy::prelude::*;
109use bevy::render::RenderPlugin;
110use bevy::render::settings::{InstanceFlags, RenderCreation, WgpuSettings};
111
112/// Where the last finished versus round's replay is written (spec §7.7).
113pub fn replay_path() -> std::path::PathBuf {
114    replays::library_dir().join("last.txt")
115}
116/// Where that round's highlight reel lands (see [`crate::highlight`]).
117pub fn highlight_path() -> std::path::PathBuf {
118    replays::library_dir().join("highlight.gif")
119}
120
121/// Where the finished round's highlight reel was written, so the results
122/// card can say so. Cleared when a round has no replay to build one from.
123#[derive(Resource, Default)]
124pub struct Highlight(pub Option<String>);
125
126/// The authoritative simulation, wrapped for Bevy. Systems read it freely;
127/// mutation happens in `advance_sim` (ticks) and, during puzzle setup only,
128/// in the placement input system.
129#[derive(Resource)]
130pub struct Sim(pub Board);
131
132/// Player actions accumulated from input since the last fixed tick, in the
133/// shape rollback netcode will feed. Taken (and reset) by `advance_sim`
134/// each tick.
135#[derive(Resource, Default)]
136pub struct PendingActions(pub [PlayerAction; MAX_PLAYERS]);
137
138#[derive(Resource, Default)]
139pub struct Paused(pub bool);
140
141/// Records the running versus round for the replay file (spec §7.7).
142#[derive(Resource, Default)]
143pub struct Recorder(pub Option<Replay>);
144
145/// What decides for a seat: the one description of who sits where, which
146/// the fixed tick reads to fill each seat's action (`docs/bot-seats.md`).
147#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
148pub enum SeatController {
149    /// A person at this machine, on the keyboard or a pad, through a cursor.
150    #[default]
151    Local,
152    /// A seat another peer owns: its inputs arrive over the wire, and this
153    /// machine never decides for it.
154    Remote,
155    /// The game's AI at a difficulty. A pure function of the board, so
156    /// online every peer computes it and its moves never cross the wire.
157    Ai(BotLevel),
158    /// A bot connected to this machine over the bot protocol. Unlike the
159    /// AI it is owned by the one game it connected to, as a person's seat
160    /// is, and its actions travel like a keyboard's.
161    Bot,
162}
163
164impl SeatController {
165    /// The AI's level, when the AI holds the seat.
166    pub fn ai(self) -> Option<BotLevel> {
167        match self {
168            SeatController::Ai(level) => Some(level),
169            SeatController::Local | SeatController::Remote | SeatController::Bot => None,
170        }
171    }
172}
173
174/// Who decides for every seat this round.
175#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
176pub struct Controllers(pub [SeatController; MAX_PLAYERS]);
177
178impl Controllers {
179    /// The AI's level at `seat`, if the AI holds it.
180    pub fn ai(&self, seat: usize) -> Option<BotLevel> {
181        self.0.get(seat).copied().and_then(SeatController::ai)
182    }
183
184    /// How many seats the AI holds.
185    pub fn ai_count(&self) -> usize {
186        self.0.iter().filter(|c| c.ai().is_some()).count()
187    }
188
189    /// How many seats play themselves, the AI's and the bots': the seats
190    /// nobody at this machine's keys has a cursor in.
191    pub fn machine_count(&self) -> usize {
192        self.0
193            .iter()
194            .filter(|c| c.ai().is_some() || **c == SeatController::Bot)
195            .count()
196    }
197
198    /// The AI seats and nothing else, the shape a saved round keeps. A
199    /// bot's seat is saved as the AI that stands in for a bot, since the
200    /// bot is not there when the round is picked up again.
201    pub fn levels(&self) -> [Option<BotLevel>; MAX_PLAYERS] {
202        self.0.map(|c| match c {
203            SeatController::Bot => Some(bot_seats::STAND_IN),
204            SeatController::Ai(level) => Some(level),
205            SeatController::Local | SeatController::Remote => None,
206        })
207    }
208
209    /// A table from the AI seats alone: everyone else plays at this machine.
210    pub fn from_levels(levels: [Option<BotLevel>; MAX_PLAYERS]) -> Controllers {
211        Controllers(levels.map(|level| level.map_or(SeatController::Local, SeatController::Ai)))
212    }
213}
214
215/// A loaded replay being watched, and the next input index to feed.
216#[derive(Resource, Default)]
217pub struct Playback(pub Option<(Replay, usize)>);
218
219/// Top-level mode select.
220#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
221pub enum Screen {
222    #[default]
223    Menu,
224    /// Tide Pool (spec §5.1).
225    Puzzle,
226    /// Turf War (spec §5.3).
227    Versus,
228    /// Driftwood (spec §5.4).
229    Editor,
230    /// LAN matchmaking for online Turf War.
231    Lobby,
232    Settings,
233    /// Per-key rebinding, reached from Settings.
234    Controls,
235    /// Local versus configuration (players, bots, map, ...).
236    MatchSetup,
237    /// Lifetime stats and trophies.
238    Achievements,
239    /// The stage list a puzzle campaign is entered through.
240    StageSelect,
241    /// The kept rounds, and which one to watch.
242    Replays,
243    /// Between-rounds breather in a series.
244    Interlude,
245    /// The very first screen of the very first run: which language the
246    /// game should speak. Never reached again once a settings file
247    /// exists, since the dial in Settings takes over from there.
248    Language,
249    /// A newer release is out: its notes and one question. Reached from
250    /// the menu when the start-up check comes back with one, and left
251    /// for the menu either way.
252    NewVersion,
253}
254
255/// Fixed interface a board must not slide under, in unscaled pixels.
256///
257/// Three floats in a row, two of them the same unit and one not, is a thing
258/// to get the wrong way round: the camera reads `top` and `bottom` to
259/// centre the board on the gap between the bars.
260#[derive(Clone, Copy)]
261pub struct Chrome {
262    /// The sidebars, both of them together.
263    pub width: f32,
264    /// The header bar.
265    pub top: f32,
266    /// The prompt line, which runs to two rows in the wordier languages,
267    /// and the crab legend above it.
268    pub bottom: f32,
269}
270
271impl Chrome {
272    const fn of(width: f32, top: f32, bottom: f32) -> Chrome {
273        Chrome { width, top, bottom }
274    }
275}
276
277impl Screen {
278    /// Every screen, so anything that must cover all of them can iterate
279    /// rather than be remembered.
280    pub const ALL: [Screen; 14] = [
281        Screen::Menu,
282        Screen::Puzzle,
283        Screen::Versus,
284        Screen::Editor,
285        Screen::Lobby,
286        Screen::Settings,
287        Screen::Controls,
288        Screen::MatchSetup,
289        Screen::Achievements,
290        Screen::StageSelect,
291        Screen::Replays,
292        Screen::Interlude,
293        Screen::Language,
294        Screen::NewVersion,
295    ];
296
297    /// The fixed interface this screen puts around a board.
298    ///
299    /// Top and bottom are separate because they are not equal: the header
300    /// is one line and the prompt runs to two in the wordier languages, and
301    /// a single height centres the board on the window rather than on the
302    /// gap, which put the editor's bottom wall rail under the prompt.
303    ///
304    /// Lives here rather than in the camera system so a new screen has to
305    /// answer the question where it is declared.
306    fn chrome(self) -> Chrome {
307        match self {
308            // The menu is a full-bleed postcard laid out 1:1.
309            Screen::Menu => Chrome::of(0.0, 0.0, 0.0),
310            // Versus flanks the board with the two score panels.
311            Screen::Versus => Chrome::of(2.0 * side_panels::SIDEBAR_W + 60.0, ABOVE_BOARD, 104.0),
312            Screen::Puzzle
313            | Screen::Editor
314            | Screen::Lobby
315            | Screen::Settings
316            | Screen::Controls
317            | Screen::MatchSetup
318            | Screen::Achievements
319            | Screen::StageSelect
320            | Screen::Replays
321            | Screen::Interlude
322            | Screen::Language
323            | Screen::NewVersion => Chrome::of(40.0, ABOVE_BOARD, 104.0),
324        }
325    }
326}
327
328/// What the header keeps clear above the board: the header itself and
329/// the air the board's top edge wants under it. Derived, so a taller
330/// header pushes the board down rather than sitting on it.
331const ABOVE_BOARD: f32 = menu_ui::HEADER_H + 18.0;
332
333/// Puzzle-mode round phases (spec §5.1: place, run, win or lose, retry).
334#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
335pub enum Phase {
336    #[default]
337    Setup,
338    Running,
339    Won,
340    Lost,
341}
342
343/// Versus round flow: a count in, play until the tide, then results.
344///
345/// A round is entered in `Countdown`, which is where the phase rests
346/// between rounds, and `load_versus` moves it on to `Running` for a round
347/// that does not count in. Resting anywhere else let a round's first frame
348/// run before the count began: a phase asked for while the screen changes
349/// lands a frame later.
350#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
351pub enum VersusPhase {
352    /// The beach laid out and holding still while a count runs down, so
353    /// the table can read the map and find its castles (see `countdown`).
354    #[default]
355    Countdown,
356    Running,
357    Over,
358}
359
360/// Dev sandbox (`PINCH_SANDBOX=1`): skips the menu straight into a versus
361/// arena with preloaded castle tiers.
362#[derive(Resource)]
363pub struct Sandbox(pub bool);
364
365/// Rebuild the board from the campaign's current level. With `keep_posts`,
366/// signposts standing on the old board are re-placed: the fast retry loop.
367#[derive(Message)]
368pub struct LoadLevel {
369    pub keep_posts: bool,
370}
371
372/// A player's placement was rejected (occupied tile, rival post, or spent
373/// inventory): drives the denied sound and cursor flash.
374#[derive(Message)]
375pub struct PlacementDenied {
376    pub player: u8,
377    /// The inventory said no, not the tile. Worth a line of its own: every
378    /// other refusal is answered by aiming somewhere else, and this one by
379    /// picking a signpost back up, and with the same flash and knock "you
380    /// have none left" reads as "not there".
381    pub out_of_signposts: bool,
382}
383
384/// The editor wrote a level to disk. A message rather than a direct call so
385/// the editor need not know that anyone is keeping score.
386#[derive(Message)]
387pub struct LevelSaved;
388
389/// A level went out of the editor as a share code, and one came in as one.
390/// Their own messages rather than lines in the editor, for the reason
391/// [`LevelSaved`] is one: the editor stays ignorant of achievements.
392#[derive(Message)]
393pub struct CodeShared;
394
395#[derive(Message)]
396pub struct CodeTaken;
397
398/// How many players a versus round seats (2-4). Drives castles, cursors,
399/// and HUD chips.
400#[derive(Resource, Default)]
401pub struct Seats(pub u8);
402
403/// What each seat is called this round, resolved once at round load:
404/// online, the handshake's agreed table (never the local couch names,
405/// which would label rivals with leftovers); offline, the settings names.
406/// Empty entries fall back to the localized seat label.
407#[derive(Resource, Default)]
408pub struct SeatNames(pub [String; MAX_PLAYERS]);
409
410/// What held each seat this round: a person, the game's AI, or a bot. Read
411/// wherever a name is drawn, so a bot always wears the robot (see
412/// [`crate::app::art::Art::robot`]). Resolved with the names: a replay's
413/// own, the table's online, this machine's controllers otherwise.
414#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
415pub struct SeatKinds(pub [crate::sim::SeatKind; MAX_PLAYERS]);
416
417impl SeatKinds {
418    /// Whether a bot holds `seat`.
419    pub fn bot(&self, seat: u8) -> bool {
420        self.0.get(usize::from(seat)) == Some(&crate::sim::SeatKind::Bot)
421    }
422
423    /// The kinds a table of controllers makes.
424    pub fn of(controllers: &Controllers) -> SeatKinds {
425        SeatKinds(controllers.0.map(|c| match c {
426            SeatController::Local | SeatController::Remote => crate::sim::SeatKind::Human,
427            SeatController::Ai(_) => crate::sim::SeatKind::Ai,
428            SeatController::Bot => crate::sim::SeatKind::Bot,
429        }))
430    }
431}
432
433impl SeatNames {
434    /// The name to show for `seat`, or the localized "P{n}" fallback.
435    pub fn label(&self, tr: &i18n::Tr, seat: u8) -> String {
436        name_or_label(&self.0, tr, seat)
437    }
438}
439
440/// The localized "P{n}" label for a seat, off-by-one included: seats count
441/// from 0, players from 1. The screens that talk about a seat with no name
442/// to consult (bindings, match setup) say it this way too.
443pub fn seat_label(tr: &i18n::Tr, seat: u8) -> String {
444    fill(tr.player_label, &[("p", &(seat + 1).to_string())])
445}
446
447/// What to call a seat: its name, or the "P{n}" label when it has none.
448/// One rule for every table of names, the couch's, a round's and an
449/// online session's, so an unnamed seat reads the same on every screen.
450pub fn name_or_label(names: &[String], tr: &i18n::Tr, seat: u8) -> String {
451    match names.get(usize::from(seat)) {
452        Some(name) if !name.is_empty() => name.clone(),
453        _ => seat_label(tr, seat),
454    }
455}
456
457/// A round from a pasted code, waiting for [`Screen::Versus`] to seat it.
458/// Taken by `load_versus`, which is the one place that decides what board a
459/// round starts from.
460#[derive(Resource, Default)]
461pub struct Resuming(pub Option<suspend::Suspended>);
462
463/// What the menu has to say about the round you just copied or pasted, or
464/// failed to. Shown in the menu's status slot, so a code that will not read
465/// says so rather than doing nothing.
466#[derive(Resource, Default)]
467pub struct RoundNotice(pub String);
468
469#[cfg(test)]
470mod chrome_tests {
471    use super::*;
472
473    /// The header is drawn over the board's world, so every screen with a
474    /// board keeps at least the header's height clear above it, and the
475    /// menu, which has no board, reserves nothing.
476    #[test]
477    fn the_header_never_sits_on_the_board() {
478        assert_eq!(Screen::Menu.chrome().top, 0.0);
479        for screen in [
480            Screen::Versus,
481            Screen::Puzzle,
482            Screen::Editor,
483            Screen::Lobby,
484            Screen::Settings,
485            Screen::Controls,
486            Screen::MatchSetup,
487            Screen::Achievements,
488            Screen::StageSelect,
489            Screen::Replays,
490            Screen::Interlude,
491            Screen::Language,
492            Screen::NewVersion,
493        ] {
494            assert!(
495                screen.chrome().top >= menu_ui::HEADER_H,
496                "{screen:?} reserves {} under a header of {}",
497                screen.chrome().top,
498                menu_ui::HEADER_H
499            );
500        }
501    }
502}