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}