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