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 binds;
12mod board_render;
13mod boot;
14pub mod campaign;
15mod clock;
16mod codes;
17/// Whether `screen` stands on the shared beach postcard. One list, asked
18/// by the run condition and by the backdrop tender, so a new screen
19/// decides once where it stands.
20pub(crate) fn postcard_screen(screen: Screen) -> bool {
21 match screen {
22 Screen::Menu
23 | Screen::StageSelect
24 | Screen::Settings
25 | Screen::Controls
26 | Screen::MatchSetup
27 | Screen::Achievements
28 | Screen::Replays
29 | Screen::Lobby
30 | Screen::Language
31 | Screen::Interlude
32 | Screen::NewVersion => true,
33 Screen::Puzzle | Screen::Versus | Screen::Editor => false,
34 }
35}
36
37mod conditions;
38mod controls;
39mod creatures;
40mod cursor;
41mod cycle;
42mod daily;
43mod dev;
44mod editor;
45mod effects;
46mod embedded;
47mod gamepad;
48mod hint;
49mod hud;
50pub(crate) mod i18n;
51mod keycaps;
52mod keymap;
53mod language;
54pub mod layout;
55mod lobby;
56mod play_input;
57// Public for the integration tests: a launched round's board is built
58// here, and the wire tests have to build it the way the game does.
59pub mod match_setup;
60mod menu_scene;
61mod menu_ui;
62pub mod net;
63mod open;
64pub mod palette;
65mod paths;
66mod pause;
67pub mod progress;
68pub mod replays;
69mod results;
70mod schedule;
71mod session;
72mod settings;
73mod side_panels;
74mod sim_events;
75mod stage_select;
76mod suspend;
77mod teams;
78mod tournament;
79mod typing;
80pub mod update;
81
82pub use campaign::{Campaign, CampaignKind};
83pub use daily::Daily;
84pub use schedule::run;
85
86use crate::app::i18n::fill;
87use crate::sim::{
88 Board, BotLevel, Level, MAX_PLAYERS, PlayerAction, PuzzleOutcome, Replay, bot_action,
89 castle_spots, classic_arena, classic_arena_seeded, generate_arena,
90};
91use bevy::prelude::*;
92use bevy::render::RenderPlugin;
93use bevy::render::settings::{InstanceFlags, RenderCreation, WgpuSettings};
94
95/// Where the last finished versus round's replay is written (spec §7.7).
96pub fn replay_path() -> std::path::PathBuf {
97 replays::library_dir().join("last.txt")
98}
99/// Where that round's highlight reel lands (see [`crate::highlight`]).
100pub fn highlight_path() -> std::path::PathBuf {
101 replays::library_dir().join("highlight.gif")
102}
103
104/// Where the finished round's highlight reel was written, so the results
105/// card can say so. Cleared when a round has no replay to build one from.
106#[derive(Resource, Default)]
107pub struct Highlight(pub Option<String>);
108
109/// The authoritative simulation, wrapped for Bevy. Systems read it freely;
110/// mutation happens in `advance_sim` (ticks) and, during puzzle setup only,
111/// in the placement input system.
112#[derive(Resource)]
113pub struct Sim(pub Board);
114
115/// Player actions accumulated from input since the last fixed tick, in the
116/// shape rollback netcode will feed. Taken (and reset) by `advance_sim`
117/// each tick.
118#[derive(Resource, Default)]
119pub struct PendingActions(pub [PlayerAction; MAX_PLAYERS]);
120
121#[derive(Resource, Default)]
122pub struct Paused(pub bool);
123
124/// Records the running versus round for the replay file (spec §7.7).
125#[derive(Resource, Default)]
126pub struct Recorder(pub Option<Replay>);
127
128/// Which seats are bot-driven this round, and at what difficulty.
129#[derive(Resource, Default)]
130pub struct Bots(pub [Option<BotLevel>; MAX_PLAYERS]);
131
132/// A loaded replay being watched, and the next input index to feed.
133#[derive(Resource, Default)]
134pub struct Playback(pub Option<(Replay, usize)>);
135
136/// Top-level mode select.
137#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
138pub enum Screen {
139 #[default]
140 Menu,
141 /// Tide Pool (spec §5.1).
142 Puzzle,
143 /// Turf War (spec §5.3).
144 Versus,
145 /// Driftwood (spec §5.4).
146 Editor,
147 /// LAN matchmaking for online Turf War.
148 Lobby,
149 Settings,
150 /// Per-key rebinding, reached from Settings.
151 Controls,
152 /// Local versus configuration (players, bots, map, ...).
153 MatchSetup,
154 /// Lifetime stats and trophies.
155 Achievements,
156 /// The stage list a puzzle campaign is entered through.
157 StageSelect,
158 /// The kept rounds, and which one to watch.
159 Replays,
160 /// Between-rounds breather in a series.
161 Interlude,
162 /// The very first screen of the very first run: which language the
163 /// game should speak. Never reached again once a settings file
164 /// exists, since the dial in Settings takes over from there.
165 Language,
166 /// A newer release is out: its notes and one question. Reached from
167 /// the menu when the start-up check comes back with one, and left
168 /// for the menu either way.
169 NewVersion,
170}
171
172/// Fixed interface a board must not slide under, in unscaled pixels.
173///
174/// Three floats in a row, two of them the same unit and one of them not,
175/// is a thing to get the wrong way round: the camera reads `top` and
176/// `bottom` to centre the board on the gap between the bars, and swapping
177/// them moves every board a few pixels the wrong way on every screen.
178#[derive(Clone, Copy)]
179pub struct Chrome {
180 /// The sidebars, both of them together.
181 pub width: f32,
182 /// The header bar.
183 pub top: f32,
184 /// The prompt line, which runs to two rows in the wordier languages,
185 /// and the crab legend above it.
186 pub bottom: f32,
187}
188
189impl Chrome {
190 const fn of(width: f32, top: f32, bottom: f32) -> Chrome {
191 Chrome { width, top, bottom }
192 }
193}
194
195impl Screen {
196 /// Every screen, so anything that must cover all of them can iterate
197 /// rather than be remembered.
198 pub const ALL: [Screen; 14] = [
199 Screen::Menu,
200 Screen::Puzzle,
201 Screen::Versus,
202 Screen::Editor,
203 Screen::Lobby,
204 Screen::Settings,
205 Screen::Controls,
206 Screen::MatchSetup,
207 Screen::Achievements,
208 Screen::StageSelect,
209 Screen::Replays,
210 Screen::Interlude,
211 Screen::Language,
212 Screen::NewVersion,
213 ];
214
215 /// The fixed interface this screen puts around a board.
216 ///
217 /// Top and bottom are separate because they are not equal: the header
218 /// is one line and the prompt runs to two in the wordier languages. A
219 /// single height would fit the board and then centre it on the window
220 /// rather than on the gap, which is how the editor's bottom wall rail
221 /// ended up under the prompt.
222 ///
223 /// Lives here rather than in the camera system because it is a fact
224 /// about the screen, and because a new screen should have to answer
225 /// this question at the point it is declared.
226 fn chrome(self) -> Chrome {
227 match self {
228 // The menu is a full-bleed postcard laid out 1:1.
229 Screen::Menu => Chrome::of(0.0, 0.0, 0.0),
230 // Versus flanks the board with the two score panels.
231 Screen::Versus => Chrome::of(2.0 * side_panels::SIDEBAR_W + 60.0, ABOVE_BOARD, 104.0),
232 Screen::Puzzle
233 | Screen::Editor
234 | Screen::Lobby
235 | Screen::Settings
236 | Screen::Controls
237 | Screen::MatchSetup
238 | Screen::Achievements
239 | Screen::StageSelect
240 | Screen::Replays
241 | Screen::Interlude
242 | Screen::Language
243 | Screen::NewVersion => Chrome::of(40.0, ABOVE_BOARD, 104.0),
244 }
245 }
246}
247
248/// What the header keeps clear above the board: the header itself and
249/// the air the board's top edge wants under it. Derived, so a taller
250/// header pushes the board down rather than sitting on it.
251const ABOVE_BOARD: f32 = menu_ui::HEADER_H + 18.0;
252
253/// Puzzle-mode round phases (spec §5.1: place, run, win or lose, retry).
254#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
255pub enum Phase {
256 #[default]
257 Setup,
258 Running,
259 Won,
260 Lost,
261}
262
263/// Versus round flow: play until the tide, then results.
264#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
265pub enum VersusPhase {
266 #[default]
267 Running,
268 Over,
269}
270
271/// Dev sandbox (`PINCH_SANDBOX=1`): skips the menu straight into a versus
272/// arena with preloaded castle tiers.
273#[derive(Resource)]
274pub struct Sandbox(pub bool);
275
276/// Rebuild the board from the campaign's current level. With `keep_posts`,
277/// signposts standing on the old board are re-placed: the fast retry loop.
278#[derive(Message)]
279pub struct LoadLevel {
280 pub keep_posts: bool,
281}
282
283/// A player's placement was rejected (occupied tile, rival post, or spent
284/// inventory): drives the denied sound and cursor flash.
285#[derive(Message)]
286pub struct PlacementDenied {
287 pub player: u8,
288 /// The inventory said no, not the tile. Worth a line of its own: every
289 /// other refusal is answered by aiming somewhere else, and this one is
290 /// answered by picking a signpost back up. They looked identical - the
291 /// same flash, the same knock - so "you have none left" read as "not
292 /// there".
293 pub out_of_signposts: bool,
294}
295
296/// The editor wrote a level to disk. A message rather than a direct call so
297/// the editor need not know that anyone is keeping score.
298#[derive(Message)]
299pub struct LevelSaved;
300
301/// A level went out of the editor as a share code, and one came in as one.
302/// Their own messages rather than lines in the editor, for the reason
303/// [`LevelSaved`] is one: the editor stays ignorant of achievements.
304#[derive(Message)]
305pub struct CodeShared;
306
307#[derive(Message)]
308pub struct CodeTaken;
309
310/// How many players a versus round seats (2-4). Drives castles, cursors,
311/// and HUD chips.
312#[derive(Resource, Default)]
313pub struct Seats(pub u8);
314
315/// What each seat is called this round, resolved once at round load:
316/// online, the handshake's agreed table (never the local couch names,
317/// which would label rivals with leftovers); offline, the settings names.
318/// Empty entries fall back to the localized seat label.
319#[derive(Resource, Default)]
320pub struct SeatNames(pub [String; MAX_PLAYERS]);
321
322impl SeatNames {
323 /// The name to show for `seat`, or the localized "P{n}" fallback.
324 pub fn label(&self, tr: &i18n::Tr, seat: u8) -> String {
325 match self.0.get(usize::from(seat)) {
326 Some(name) if !name.is_empty() => name.clone(),
327 _ => seat_label(tr, seat),
328 }
329 }
330}
331
332/// The localized "P{n}" label for a seat, off-by-one included: seats count
333/// from 0, players from 1. The screens that talk about a seat with no name
334/// to consult (bindings, match setup) say it this way too.
335pub fn seat_label(tr: &i18n::Tr, seat: u8) -> String {
336 fill(tr.player_label, &[("p", &(seat + 1).to_string())])
337}
338
339/// A round from a pasted code, waiting for [`Screen::Versus`] to seat it.
340/// Taken by `load_versus`, which is the one place that decides what board a
341/// round starts from.
342#[derive(Resource, Default)]
343pub struct Resuming(pub Option<suspend::Suspended>);
344
345/// What the menu has to say about the round you just copied or pasted, or
346/// failed to. Shown in the menu's status slot, so a code that will not read
347/// says so rather than doing nothing.
348#[derive(Resource, Default)]
349pub struct RoundNotice(pub String);
350
351#[cfg(test)]
352mod chrome_tests {
353 use super::*;
354
355 /// The header is drawn over the board's world, so every screen with a
356 /// board keeps at least the header's height clear above it, and the
357 /// menu, which has no board, reserves nothing.
358 #[test]
359 fn the_header_never_sits_on_the_board() {
360 assert_eq!(Screen::Menu.chrome().top, 0.0);
361 for screen in [
362 Screen::Versus,
363 Screen::Puzzle,
364 Screen::Editor,
365 Screen::Lobby,
366 Screen::Settings,
367 Screen::Controls,
368 Screen::MatchSetup,
369 Screen::Achievements,
370 Screen::StageSelect,
371 Screen::Replays,
372 Screen::Interlude,
373 Screen::Language,
374 Screen::NewVersion,
375 ] {
376 assert!(
377 screen.chrome().top >= menu_ui::HEADER_H,
378 "{screen:?} reserves {} under a header of {}",
379 screen.chrome().top,
380 menu_ui::HEADER_H
381 );
382 }
383 }
384}