Skip to main content

sprite_adventure/
sprite-adventure.rs

1//! A small top-down adventure across two areas: a grass overworld around a
2//! pond drawn in a custom water style, and a cave of its own tileset. Either
3//! area is left through the opening between a mouth's
4//! two pillars. A wall across the cave holds a torch-lit door the player
5//! interacts beside, with a gem drawn behind it. The current area, whether
6//! the gem is taken, and the
7//! position where the player last moved between the two or took the gem are
8//! saved across runs. Movement is fixed-step in `tick`, with the upright
9//! player shared with `examples/isometric-board.rs`, pushed out of
10//! whatever it walks into; drawing interpolates between the last two steps.
11
12use core::ops::Range;
13use core::time::Duration;
14
15use mirage_engine::prelude::*;
16
17// ---------------------------------------------------------------------
18// Assets
19// ---------------------------------------------------------------------
20
21/// The player's sprite sheet, shared with `examples/isometric-board.rs`.
22/// Cropped of its 5 empty bottom texel rows per cell and then moved cell by cell onto
23/// its own drawn bottom, so every frame's feet land on the sheet's own bottom edge
24/// and none of them hovers a texel over its shadow:
25/// `trim examples/assets/walker.png --cells 4x4 --baseline`.
26const WALKER_SOURCE: &str = "examples/assets/walker.png";
27const WALKER_SHEET: &str = "walker";
28/// The player's relief, generated from its own sheet at `--detail 0`,
29/// which keeps its lit color round instead of a bright band across it.
30/// `--strength 16` shades more rows than the default `4` does in the
31/// engine's own draw, measured off the sheet's own mirrored center
32/// column, where every strength shades the same single color:
33/// `spritemaps examples/assets/walker.png --cells 4x4 --dome 0.8 --detail 0.0 --strength 16.0 --out examples/assets/walker-relief.png`.
34const WALKER_RELIEF_SOURCE: &str = "examples/assets/walker-relief.png";
35const WALKER_RELIEF: &str = "walker-relief";
36const WALKER_COLUMNS: u32 = 4;
37const WALKER_ROWS: u32 = 4;
38const WALKER_WIDTH: f32 = 1.0;
39/// The cropped sheet's own `16x27` cell aspect at the tileset's sixteen
40/// texels to the meter: `2.0 * 27 / 32` keeps its visible size at what it
41/// was before.
42const WALKER_HEIGHT: f32 = 1.6875;
43const WALK_SPEED: f32 = 4.0;
44const TICKS_PER_WALK_FRAME: u32 = 8;
45
46/// The overworld's ground tiles, cropped from the owner's tileset: row `0`
47/// is four grass variants, row `1` the path — its west verge, two dirt
48/// variants, then its east verge, in that column order.
49const GROUND_SOURCE: &str = "examples/assets/keep-ground.png";
50const GROUND_SHEET: &str = "keep-ground";
51const GROUND_COLUMNS: u32 = 4;
52const GROUND_ROWS: u32 = 2;
53const GRASS_ROW: u32 = 0;
54const PATH_ROW: u32 = 1;
55const PATH_WEST_VERGE: u32 = 0;
56/// The first of the path row's two dirt columns; the second follows it.
57const PATH_DIRT: u32 = 1;
58const PATH_EAST_VERGE: u32 = 3;
59
60/// Cropped of its 1 empty bottom texel row:
61/// `trim examples/assets/keep-bush.png --cells 1x1`.
62const BUSH_SOURCE: &str = "examples/assets/keep-bush.png";
63const BUSH_SPRITE: &str = "keep-bush";
64/// The bush's relief, generated from its own sprite:
65/// `spritemaps examples/assets/keep-bush.png --cells 1x1 --out examples/assets/keep-bush-relief.png`.
66const BUSH_RELIEF_SOURCE: &str = "examples/assets/keep-bush-relief.png";
67const BUSH_RELIEF: &str = "keep-bush-relief";
68/// Cropped of its 2 empty bottom texel rows:
69/// `trim examples/assets/keep-rock.png --cells 1x1`.
70const ROCK_SOURCE: &str = "examples/assets/keep-rock.png";
71const ROCK_SPRITE: &str = "keep-rock";
72/// The rock's relief, generated from its own sprite:
73/// `spritemaps examples/assets/keep-rock.png --cells 1x1 --out examples/assets/keep-rock-relief.png`.
74const ROCK_RELIEF_SOURCE: &str = "examples/assets/keep-rock-relief.png";
75const ROCK_RELIEF: &str = "keep-rock-relief";
76
77const CRATE_SOURCE: &str = "examples/assets/keep-crate.png";
78const CRATE_TEXTURE: &str = "keep-crate";
79
80/// The well's two cells, side by side: grey masonry for the rim, then the
81/// mouth — a stone ring around a dark hole.
82const WELL_SOURCE: &str = "examples/assets/keep-well.png";
83const WELL_SHEET: &str = "keep-well";
84const WELL_CELLS: u32 = 2;
85const WELL_RIM_CELL: u32 = 0;
86const WELL_MOUTH_CELL: u32 = 1;
87
88/// The mouth's stone, one column of cells: a capital over the plain coursed
89/// masonry under it.
90const STONE_SOURCE: &str = "examples/assets/keep-stone.png";
91const STONE_SHEET: &str = "keep-stone";
92const STONE_ROWS: u32 = 2;
93
94/// The pond's two cells: the whole sprite, then the same shoreline with its
95/// water cleared away, which is the one drawn over the styled water.
96const POND_SOURCE: &str = "examples/assets/keep-pond.png";
97const POND_SHEET: &str = "keep-pond";
98const POND_CELLS: u32 = 2;
99const POND_SHORE_CELL: u32 = 1;
100
101/// A torch's post, drawn as a cutout sprite like the flora: its four lit
102/// texels of binding are what the flame is drawn over.
103/// `trim examples/assets/keep-torch.png --cells 1x1` measures no empty
104/// bottom row, so it stays as it was.
105const TORCH_SOURCE: &str = "examples/assets/keep-torch.png";
106const TORCH_SPRITE: &str = "keep-torch";
107/// The post's relief, generated from its own sprite:
108/// `spritemaps examples/assets/keep-torch.png --cells 1x1 --out examples/assets/keep-torch-relief.png`.
109const TORCH_RELIEF_SOURCE: &str = "examples/assets/keep-torch-relief.png";
110const TORCH_RELIEF: &str = "keep-torch-relief";
111/// The flame's loop, one row of cells. It has no relief: the flame is
112/// drawn unlit and added, so a normal changes nothing it draws.
113const FLAME_SOURCE: &str = "examples/assets/keep-flame.png";
114const FLAME_SHEET: &str = "keep-flame";
115const FLAME_CELLS: u32 = 7;
116
117/// The cave's tileset. Columns are a variant; row `0` is the floor and the
118/// three under it are one wall face, drawn as tall as it is in the tileset.
119const CAVE_SOURCE: &str = "examples/assets/keep-cave.png";
120const CAVE_SHEET: &str = "keep-cave";
121const CAVE_COLUMNS: u32 = 4;
122const CAVE_ROWS: u32 = 4;
123const CAVE_FLOOR_ROW: u32 = 0;
124
125/// The door and gem, built by `tools/keep_fixture.py`.
126const MODEL: &str = "examples/assets/keep.glb";
127const DOOR_MESH: &str = "door";
128const GEM_MESH: &str = "gem";
129
130const INTERACT_SOUND: &str = "examples/assets/click.ogg";
131const GEM_SOUND: &str = "examples/assets/win.ogg";
132
133// ---------------------------------------------------------------------
134// Overworld layout
135// ---------------------------------------------------------------------
136
137const TILE_SIZE: f32 = 1.0;
138/// Half the ground tiling's width and depth, in tiles: past the farthest
139/// ground the camera frames from anywhere in the clearing, which is just
140/// over thirty meters out at the frame's top corners.
141const GROUND_DRAW_HALF: i32 = 31;
142/// The two sides a pair is placed on: `-1.0` or `1.0` along the axis
143/// they straddle.
144const SIDES: [f32; 2] = [-1.0, 1.0];
145/// Player width for collision: a circle this radius at their feet, narrower
146/// than the sprite so a one-tile doorway stays walkable.
147const PLAYER_RADIUS: f32 = 0.3;
148/// The tile column the dirt path runs along; its verges edge the two
149/// beside it.
150const PATH_COLUMN: i32 = 0;
151
152const PLAYER_SPAWN: Vec3 = Vec3::new(0.0, 0.0, 5.0);
153const CAVE_MOUTH: Vec3 = Vec3::new(0.0, 0.0, -6.0);
154/// Landing position for a return from the cave: past the [`ENTRANCE`] band,
155/// so the return does not count as another step through the mouth.
156const RETURN_SPAWN: Vec3 = Vec3::new(
157    CAVE_MOUTH.x,
158    0.0,
159    CAVE_MOUTH.z + MOUTH_CROSSING_INSET + PORTAL_CLEARANCE,
160);
161
162/// The pond's position and half the side of it: the shoreline sprite is
163/// three meters square, the sixteen texels to the meter the game keeps. The
164/// styled water is drawn barely off the ground, clear of z-fighting with it; the
165/// shore draws at the ground's own `y` instead, submitted after it, so
166/// submission order alone separates the two and the shore never shadows the
167/// ground it lies on.
168const POND_CENTER: Vec3 = Vec3::new(4.2, 0.01, -1.5);
169const POND_HALF: f32 = 1.5;
170/// Half the open water that sprite's shoreline rings — the middle three
171/// fifths of its side, both drawn styled and blocking the player: the square
172/// stops well inside the shore, so its own edges never show.
173const POND_WATER_HALF: f32 = POND_HALF * 0.6;
174const WATER_COLOR: Color = Color::rgba(0.06, 0.20, 0.27, 0.92);
175const WATER_LITNESS: f32 = 0.45;
176
177/// Footprint and turn of the two crates beside the spawn: far enough apart
178/// that neither turned box overlaps the other, and clear of the path's verge.
179const CRATE_POSITIONS: [(f32, f32, f32); 2] = [(-2.7, 3.5, 0.5), (-1.2, 2.6, -0.3)];
180const CRATE_SIZE: f32 = 1.0;
181const WELL_POSITION: Vec3 = Vec3::new(2.6, 0.0, 3.2);
182/// The well's rim: width, height, depth in meters, as wide as the two tiles
183/// its mouth is drawn from.
184const WELL_SIZE: Vec3 = Vec3::new(2.0, 0.6, 2.0);
185/// Mouth lift over the rim's top face, clear of z-fighting.
186const WELL_MOUTH_LIFT: f32 = 0.01;
187
188/// Flora position: `x, z`, and whether it is a rock rather than a bush.
189const FLORA: [(f32, f32, bool); 6] = [
190    (-4.2, 1.4, false),
191    (-4.8, -1.8, true),
192    (5.8, 2.6, false),
193    (6.6, -1.0, false),
194    (3.2, -4.8, true),
195    (-2.2, -5.0, true),
196];
197/// Both sprites are two tiles wide — the tileset's own sixteen texels to
198/// the meter, as the ground and player. Height matches each one's own
199/// cropped sheet, so its visible size stays the same.
200const BUSH_WIDTH: f32 = 2.0;
201const BUSH_HEIGHT: f32 = 1.9375;
202const ROCK_WIDTH: f32 = 2.0;
203const ROCK_HEIGHT: f32 = 1.875;
204/// Each one's block: the base under the sprite, well inside the two meters
205/// it is drawn across — a bush's stems, a rock's foot.
206const BUSH_FOOTPRINT: f32 = 0.6;
207const ROCK_FOOTPRINT: f32 = 0.8;
208
209/// The bush rows that ring the clearing, hiding where the ground tiles stop:
210/// an inner and an outer half-extent in meters, the step along each side,
211/// and the gap left either side of the path.
212const HEDGE_INNER_HALF: f32 = 10.4;
213const HEDGE_OUTER_HALF: f32 = 11.8;
214const HEDGE_STEP: f32 = 1.4;
215const HEDGE_GATE_HALF: f32 = 2.2;
216
217/// Walkable extent of the clearing: to the inner hedge row's near face, so
218/// the bushes the player can see are the wall.
219const CLEARING_HALF: f32 = HEDGE_INNER_HALF - BUSH_WIDTH * 0.5;
220
221/// The mouth's flanking pillars, half this far either side of its center,
222/// and the dark opening drawn between them.
223const MOUTH_PILLAR_OFFSET: f32 = 1.1;
224/// Width, height and depth of each of those pillars: the whole stone sheet
225/// at its own texels to the meter, so the capital lands where a capital
226/// goes.
227const MOUTH_PILLAR_SIZE: Vec3 = Vec3::new(1.0, STONE_ROWS as f32 * TILE_SIZE, 1.0);
228/// Whole tiles the stone across the pillars runs: enough to end
229/// past both of them, so no face of it lands in either pillar, and a
230/// whole number so its masonry repeats without a part course.
231const MOUTH_LINTEL_TILES: f32 = 4.0;
232/// That stone's own size: those tiles across, one course tall, and deeper
233/// than the pillars.
234const MOUTH_LINTEL_SIZE: Vec3 = Vec3::new(
235    MOUTH_LINTEL_TILES * TILE_SIZE,
236    TILE_SIZE,
237    MOUTH_PILLAR_SIZE.z + 0.1,
238);
239/// Height of the dark between the pillars, drawn up into the lintel, so a
240/// camera looking down finds stone over it rather than the ground behind.
241const MOUTH_DARK_HEIGHT: f32 = MOUTH_PILLAR_SIZE.y + MOUTH_LINTEL_SIZE.y * 0.5;
242/// Half the clear opening between the pillars' inner faces.
243const MOUTH_OPENING_HALF: f32 = MOUTH_PILLAR_OFFSET - MOUTH_PILLAR_SIZE.x * 0.5;
244/// Distance in front of a mouth for the plane that stepping through its
245/// opening crosses.
246const MOUTH_CROSSING_INSET: f32 = 0.1;
247/// Distance past that plane a portal lands the player.
248const PORTAL_CLEARANCE: f32 = 0.8;
249
250/// The overworld's mouth into the cave, and the one at the cave's near end
251/// that leads back out.
252const ENTRANCE: Mouth = Mouth {
253    at: CAVE_MOUTH,
254    room_side: 1.0,
255};
256const EXIT: Mouth = Mouth {
257    at: CAVE_EXIT,
258    room_side: -1.0,
259};
260
261const SUN_COLOR: Color = Color::rgb(0.92, 0.87, 0.72);
262/// The sun's direction: low enough that props cast shadows about their own
263/// length, and from over the player's left shoulder, which is where the
264/// tileset's own sprites are shaded from.
265const SUN_DIRECTION: Vec3 = Vec3::new(0.6, -0.75, 0.4);
266
267// ---------------------------------------------------------------------
268// Cave layout
269// ---------------------------------------------------------------------
270
271const CAVE_HALF_WIDTH: f32 = 4.0;
272/// Room tiling and walls toward the camera, past the wall closing it: under
273/// the bottom corners of the frame, wherever the player walks.
274const CAVE_NEAR_Z: f32 = 10.0;
275const CAVE_FAR_Z: f32 = -6.0;
276/// Walkable extent of the room at its far end: the back wall's inner face,
277/// less the player's own half-width, so the sprite stops flush against
278/// stone.
279const CAVE_WALK_FAR_Z: f32 = CAVE_FAR_Z + TILE_SIZE * 0.5 + WALKER_WIDTH * 0.5;
280/// Walkable extent at the room's near end: past the plane stepping out
281/// crosses, so the last step through the mouth is never clamped away. The
282/// near wall itself is what keeps the player from the ledge beyond.
283const CAVE_WALK_NEAR_Z: f32 = CAVE_EXIT.z + PLAYER_RADIUS;
284/// As many meters as the wall face is tiles tall, so it samples the sheet
285/// at the floor's own texels to the meter.
286const WALL_HEIGHT: f32 = 3.0;
287/// Wall height: over the camera, so it neither looks over them nor down onto
288/// their tops.
289const WALL_TOP: f32 = CAVE_CAMERA_OFFSET.y + TILE_SIZE * 0.5;
290/// Courses that takes; the last one is cut to what is left of it.
291const WALL_COURSES: i32 = (WALL_TOP / WALL_HEIGHT) as i32 + 1;
292/// The tile row the room's near end is closed along, from each side wall to
293/// the mouth in it.
294const CAVE_LIP_Z: f32 = 5.0;
295/// Height of that wall: one tile, so its top face is at the floor's own
296/// texels to the meter, and low enough that it never hides the player.
297const CAVE_LIP_HEIGHT: f32 = TILE_SIZE;
298/// The cave's own mouth position: in the near wall, its pillars half their
299/// depth past it.
300const CAVE_EXIT: Vec3 = Vec3::new(0.0, 0.0, CAVE_LIP_Z + MOUTH_PILLAR_SIZE.z * 0.5);
301/// Landing position for an entry into the cave: past the [`EXIT`] band, so
302/// the entry does not count as another step through the mouth.
303const CAVE_SPAWN: Vec3 = Vec3::new(
304    CAVE_EXIT.x,
305    0.0,
306    CAVE_EXIT.z - MOUTH_CROSSING_INSET - PORTAL_CLEARANCE,
307);
308
309const DOOR_Z: f32 = 0.0;
310/// The near face of the wall the door hangs in: past it the wall is
311/// between the camera and the player, and is drawn through.
312const DOOR_WALL_NEAR_Z: f32 = DOOR_Z + TILE_SIZE * 0.5;
313/// Alpha for that wall and its door once the player has been behind them
314/// for [`GHOST_RAMP_TICKS`]: opaque enough for stone in a dark room,
315/// translucent enough for the chamber and the player to show through.
316const GHOST_ALPHA: f32 = 0.6;
317/// The fade of a draw nothing is seen through.
318const SOLID: f32 = 1.0;
319/// Ticks the wall and door take to fade between [`SOLID`] and
320/// [`GHOST_ALPHA`].
321const GHOST_RAMP_TICKS: u32 = 6;
322/// Span either side of the player the wall is drawn through: wide enough for
323/// the sight line to the player, no wider, so no more of the light the
324/// stone holds off the chamber passes through it than that line needs.
325const GHOST_CORRIDOR_HALF: f32 = 1.5;
326/// The door's hinge: `tools/keep_fixture.py`'s box is hinged at its own
327/// local origin and spans [`DOOR_WIDTH`] along local `+X`.
328const DOOR_HINGE: Vec3 = Vec3::new(-DOOR_WIDTH * 0.5, 0.0, DOOR_Z);
329/// Width of that box, as the fixture builds it.
330const DOOR_WIDTH: f32 = 1.0;
331/// Thickness of it, as the fixture builds it.
332const DOOR_THICKNESS: f32 = 0.12;
333/// Height of it, as the fixture builds it: the stone over the doorway starts
334/// here.
335const DOOR_HEIGHT: f32 = 1.9;
336/// Half the doorway the door hangs in: one tile wide, on the room's axis.
337const DOORWAY_HALF: f32 = TILE_SIZE * 0.5;
338/// Span from that axis of the wall the door hangs in: to the side walls'
339/// inner faces.
340const DOOR_WALL_END: f32 = CAVE_HALF_WIDTH + TILE_SIZE * 0.5;
341const INTERACT_POINT: Vec3 = Vec3::new(0.0, 0.0, DOOR_Z);
342const INTERACT_RADIUS: f32 = 1.8;
343
344/// The door slab's own color, a deep red the cave's grey stone never is, so
345/// the slab reads as a door rather than more wall.
346const DOOR_COLOR: Color = Color::rgb(0.58, 0.16, 0.09);
347const DOOR_LITNESS: f32 = 0.85;
348/// Thickness of the posts and lintel framing the doorway, and how far in
349/// front of the wall face they stand, clear of z-fighting with it.
350const DOOR_FRAME_THICKNESS: f32 = 0.14;
351const DOOR_FRAME_STANDOFF: f32 = 0.03;
352/// The frame's own color, visible from the cave's own spawn well before a
353/// torch reaches the doorway.
354const DOOR_FRAME_COLOR: Color = Color::rgb(0.85, 0.55, 0.2);
355const DOOR_FRAME_LITNESS: f32 = 0.9;
356/// The frame's own light while the door is closed and within
357/// [`INTERACT_RADIUS`]: the cue that it can be opened.
358const DOOR_FRAME_GLOW: Color = Color::rgb(1.6, 0.9, 0.35);
359
360const GEM_POSITION: Vec3 = Vec3::new(0.0, 0.5, -4.6);
361const GEM_BOB_HEIGHT: f32 = 0.12;
362const GEM_SPIN_SPEED: f32 = 1.4;
363const PICKUP_RADIUS: f32 = 1.0;
364const GEM_COLOR: Color = Color::rgb(0.35, 0.95, 0.85);
365/// The gem's own light: what lights the chamber until the door opens on the
366/// torches, and gone with the gem once it is taken.
367const GEM_LIGHT_COLOR: Color = Color::rgb(0.3, 0.85, 0.78);
368/// Reach of it: short of the door wall, so what the wall casts never depends
369/// on the gem.
370const GEM_LIGHT_RANGE: f32 = 4.0;
371/// Lift over the gem, clear of the gem's own facets, which would otherwise
372/// shadow the chamber from inside it.
373const GEM_LIGHT_LIFT: f32 = 0.7;
374
375/// The two torches' position, `x, z`: flanking the doorway on the near side
376/// of the wall, the side the player arrives on.
377const TORCH_POSITIONS: [(f32, f32); 2] = [(-3.0, 0.6), (3.0, 0.6)];
378const TORCH_STAND_HEIGHT: f32 = 2.0;
379/// Thickness of a torch's post: the four texels the sprite draws it as,
380/// which is also what it blocks the player as.
381const TORCH_STAND_WIDTH: f32 = 0.25;
382/// Width of the sprite around that post: its canvas is twice the post,
383/// transparent either side.
384const TORCH_SPRITE_WIDTH: f32 = TORCH_STAND_WIDTH * 2.0;
385const TORCH_LIGHT_RANGE: f32 = 10.0;
386/// Height above its flame a torch's light is placed, and how far it is
387/// offset from the post toward the room: straight over the post, its own face
388/// turns edge-on to the light and goes dark.
389const TORCH_LIGHT_LIFT: f32 = 0.7;
390const TORCH_LIGHT_STANDOFF: f32 = 0.8;
391const TORCH_LIGHT_COLOR: Color = Color::rgb(1.0, 0.6, 0.28);
392/// Size of one cell of the flame's loop: a tile, as everything else the
393/// tilesets draw.
394const FLAME_SIZE: f32 = TILE_SIZE;
395/// Lift of the flame's center over the post's top: its own half-height, less
396/// the overlap that keeps the two from parting.
397const FLAME_LIFT: f32 = FLAME_SIZE * 0.5 - 0.1;
398/// The flame's tint, past `1.0`: an additive draw's tint scales its sampled
399/// texel, so this lifts the flame's own lit texels out of the cave's dark
400/// without a flat color added over its dark, unlit base.
401const FLAME_TINT: Color = Color::rgb(2.2, 1.5, 0.7);
402/// Speed the loop runs, in cells a second.
403const FLAME_RATE: f32 = 12.0;
404/// Speed the flicker runs, and how far it lifts and rolls the flame.
405const FLAME_FLICKER_SPEED: f32 = 9.0;
406const FLAME_BOB: f32 = 0.03;
407const FLAME_ROLL: f32 = 0.12;
408
409/// How much dark the overlay puts behind its lines, and how far that dark
410/// extends past them — the ground under it is bright enough to hide bare text
411/// without it.
412const HUD_BACKDROP: u8 = 200;
413const HUD_PADDING: i8 = 8;
414
415/// Ticks the door's own world prompt still reads as it swings once opened,
416/// after which it is taken as open and the prompt drops.
417const DOOR_SWING_TICKS: u32 = 24;
418/// Size the door's world prompt reads at, in logical points, and how far
419/// over the doorway it is lifted.
420const DOOR_PROMPT_SIZE: f32 = 15.0;
421const DOOR_PROMPT_LIFT: f32 = 0.3;
422/// Margin the prompt's own backdrop keeps past its galley, in logical
423/// points, and how much dark that backdrop puts behind the text.
424const DOOR_PROMPT_PADDING: f32 = 4.0;
425const DOOR_PROMPT_BACKDROP: u8 = 190;
426const DOOR_PROMPT_COLOR: egui::Color32 = egui::Color32::from_gray(230);
427
428const CAMERA_FOV: f32 = 45.0;
429/// Both cameras look down about forty degrees: shallow enough that the
430/// upright sprites keep close to their full height on screen.
431const OVERWORLD_CAMERA_OFFSET: Vec3 = Vec3::new(0.0, 9.0, 11.0);
432const CAVE_CAMERA_OFFSET: Vec3 = Vec3::new(0.0, 6.5, 7.5);
433
434// ---------------------------------------------------------------------
435// Collision footprints
436// ---------------------------------------------------------------------
437
438/// Excess reach of a square box turned `turn` about `+Y`, along either
439/// ground axis, as a factor of its side.
440fn turned_span(turn: f32) -> f32 {
441    turn.cos().abs() + turn.sin().abs()
442}
443
444/// One drawn prop's block: its footprint in the `x, z` the player walks.
445///
446/// Everything drawn on the ground is one — a box at its own extents,
447/// an upright sprite at the base under it. Pickups, flames, and whatever a
448/// clamp already holds — the hedgerow, the cave's own walls — are not.
449#[derive(Clone, Copy)]
450struct Obstacle {
451    center: Vec2,
452    half: Vec2,
453}
454
455impl Obstacle {
456    /// The footprint of a box `size` across centered on `at`, both
457    /// in the `x, z` the player walks.
458    fn footprint(at: Vec2, size: Vec2) -> Self {
459        Self {
460            center: at,
461            half: size * 0.5,
462        }
463    }
464
465    /// The footprint that just covers `corners` — what a prop the game turns
466    /// blocks the player as.
467    fn over(corners: [Vec2; 4]) -> Self {
468        let [first, rest @ ..] = corners;
469        let low = rest.iter().fold(first, |low, &corner| low.min(corner));
470        let high = rest.iter().fold(first, |high, &corner| high.max(corner));
471
472        Self {
473            center: (low + high) * 0.5,
474            half: (high - low) * 0.5,
475        }
476    }
477
478    /// Result of pushing a circle of `radius` at `point` out of this obstacle
479    /// the shortest way; `point` itself when it is already clear.
480    fn push_out(self, point: Vec2, radius: f32) -> Vec2 {
481        let delta = point - self.center;
482        let escape = self.half + Vec2::splat(radius) - delta.abs();
483
484        if escape.min_element() <= 0.0 {
485            point
486        } else if escape.x < escape.y {
487            let x = self.center.x + delta.x.signum() * (self.half.x + radius);
488            Vec2::new(x, point.y)
489        } else {
490            let z = self.center.y + delta.y.signum() * (self.half.y + radius);
491            Vec2::new(point.x, z)
492        }
493    }
494}
495
496/// A cave mouth's opening: where it is, and which way along `Z` the room
497/// it leads out of lies.
498#[derive(Clone, Copy)]
499struct Mouth {
500    at: Vec3,
501    room_side: f32,
502}
503
504impl Mouth {
505    /// True where `position` lies between the pillars and past the plane
506    /// in front of the opening — where the only two outcomes are stepping
507    /// through and being held.
508    fn holds(&self, position: Vec3) -> bool {
509        let plane = self.at.z + self.room_side * MOUTH_CROSSING_INSET;
510
511        (position.x - self.at.x).abs() < MOUTH_OPENING_HALF
512            && (plane - position.z) * self.room_side > 0.0
513    }
514
515    /// Position of each of the two pillars flanking the opening.
516    fn pillars(&self) -> impl Iterator<Item = Vec3> + Clone {
517        let at = self.at;
518
519        SIDES
520            .into_iter()
521            .map(move |side| at + Vec3::X * (side * MOUTH_PILLAR_OFFSET))
522    }
523
524    /// True when the camera looks into this mouth: it always lies on `+Z`
525    /// of the player, so the mouth whose room lies that way is the one seen
526    /// from the room's side, and the one to fill with a lintel and the dark
527    /// under it. The other is looked through from behind, and leaves its
528    /// opening clear for the room to show through.
529    fn looked_into(&self) -> bool {
530        self.room_side > 0.0
531    }
532}
533
534// ---------------------------------------------------------------------
535// The water style
536// ---------------------------------------------------------------------
537
538/// The pond's whole look, over the one value it reads: how far its ripple
539/// has traveled.
540#[derive(Default, ShaderValues)]
541struct Water {
542    time: f32,
543}
544
545impl SurfaceStyle for Water {
546    const PASS: DrawPass = DrawPass::Translucent;
547    const SURFACE: Option<&'static str> = Some(include_str!("sprite_adventure_water.wgsl"));
548}
549
550surface_styles! { enum Looks { Water } }
551
552// ---------------------------------------------------------------------
553// Meshes
554// ---------------------------------------------------------------------
555
556/// The player's current area; never both drawn in one frame.
557#[derive(Clone, Copy, PartialEq, Eq)]
558enum Area {
559    Overworld,
560    Cave,
561}
562
563/// The overworld's ground tile, its texture the only thing that separates a
564/// draw of it from another.
565#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
566struct Ground;
567
568impl Mesh for Ground {
569    fn build(&self, assets: &Assets) -> MeshData {
570        Plane
571            .build(assets)
572            .with_texture(assets.texture(GROUND_SHEET).pixelated())
573    }
574}
575
576/// The shoreline sprite laid over the pond's styled water, cutout so the
577/// water shows through its cleared middle.
578#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
579struct Shore;
580
581impl Mesh for Shore {
582    fn build(&self, assets: &Assets) -> MeshData {
583        Plane
584            .build(assets)
585            .with_texture(assets.texture(POND_SHEET).pixelated())
586            .with_material(Material::lit(Color::WHITE).cutout())
587    }
588}
589
590/// A crate prop, its texture drawn over a cube.
591#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
592struct Crate;
593
594impl Mesh for Crate {
595    fn build(&self, assets: &Assets) -> MeshData {
596        Cube.build(assets)
597            .with_texture(assets.texture(CRATE_TEXTURE).pixelated())
598    }
599}
600
601/// The well's rim.
602#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
603struct Well;
604
605impl Mesh for Well {
606    fn build(&self, assets: &Assets) -> MeshData {
607        Cube.build(assets)
608            .with_texture(assets.texture(WELL_SHEET).pixelated())
609    }
610}
611
612/// The well's mouth, laid flat over the rim's top face.
613#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
614struct WellMouth;
615
616impl Mesh for WellMouth {
617    fn build(&self, assets: &Assets) -> MeshData {
618        Plane
619            .build(assets)
620            .with_texture(assets.texture(WELL_SHEET).pixelated())
621    }
622}
623
624/// A stone box: the mouth's pillars and lintel.
625#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
626struct Stone;
627
628impl Mesh for Stone {
629    fn build(&self, assets: &Assets) -> MeshData {
630        Cube.build(assets)
631            .with_texture(assets.texture(STONE_SHEET).pixelated())
632    }
633}
634
635/// A bush sprite, cutout with its own relief.
636#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
637struct Bush;
638
639impl Mesh for Bush {
640    fn build(&self, assets: &Assets) -> MeshData {
641        Quad.build(assets)
642            .with_texture(assets.texture(BUSH_SPRITE).pixelated())
643            .with_relief(assets.relief(BUSH_RELIEF))
644            .with_material(Material::lit(Color::WHITE).cutout())
645    }
646}
647
648/// A rock sprite, cutout with its own relief.
649#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
650struct Rock;
651
652impl Mesh for Rock {
653    fn build(&self, assets: &Assets) -> MeshData {
654        Quad.build(assets)
655            .with_texture(assets.texture(ROCK_SPRITE).pixelated())
656            .with_relief(assets.relief(ROCK_RELIEF))
657            .with_material(Material::lit(Color::WHITE).cutout())
658    }
659}
660
661/// A torch's post sprite, cutout with its own relief.
662#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
663struct Torch;
664
665impl Mesh for Torch {
666    fn build(&self, assets: &Assets) -> MeshData {
667        Quad.build(assets)
668            .with_texture(assets.texture(TORCH_SPRITE).pixelated())
669            .with_relief(assets.relief(TORCH_RELIEF))
670            .with_material(Material::lit(Color::WHITE).cutout())
671    }
672}
673
674/// A torch's flame sprite, added over the dark rather than lit.
675#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
676struct Flame;
677
678impl Mesh for Flame {
679    fn build(&self, assets: &Assets) -> MeshData {
680        Quad.build(assets)
681            .with_texture(assets.texture(FLAME_SHEET).pixelated())
682            .with_material(Material::color(FLAME_TINT).additive())
683    }
684}
685
686/// The player's sprite, cutout with its own relief, its sheet shared
687/// with `examples/isometric-board.rs`.
688#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
689struct Walker;
690
691impl Mesh for Walker {
692    fn build(&self, assets: &Assets) -> MeshData {
693        Quad.build(assets)
694            .with_texture(assets.texture(WALKER_SHEET).pixelated())
695            .with_relief(assets.relief(WALKER_RELIEF))
696            .with_material(Material::lit(Color::WHITE).cutout())
697    }
698}
699
700/// The cave floor tile.
701#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
702struct CaveFloor;
703
704impl Mesh for CaveFloor {
705    fn build(&self, assets: &Assets) -> MeshData {
706        Plane
707            .build(assets)
708            .with_texture(assets.texture(CAVE_SHEET).pixelated())
709    }
710}
711
712/// The cave wall face.
713#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
714struct CaveWall;
715
716impl Mesh for CaveWall {
717    fn build(&self, assets: &Assets) -> MeshData {
718        Cube.build(assets)
719            .with_texture(assets.texture(CAVE_SHEET).pixelated())
720    }
721}
722
723/// The loaded door, drawn as its source authored it.
724#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
725struct Door;
726
727impl Mesh for Door {
728    fn build(&self, assets: &Assets) -> MeshData {
729        assets.mesh(DOOR_MESH)
730    }
731}
732
733/// The loaded gem, repainted whole per draw so its glow color shifts.
734#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
735struct Gem;
736
737impl Mesh for Gem {
738    fn build(&self, assets: &Assets) -> MeshData {
739        assets.mesh(GEM_MESH)
740    }
741}
742
743// Everything this game can draw: the meshes above, plus the styled water,
744// the dark filling a looked-into mouth's opening, and the door's own frame,
745// which draw the bare engine primitives Plane, Quad and Cube.
746meshes! {
747    enum Shape {
748        Ground, Shore, Crate, Well, WellMouth, Stone, Bush, Rock, Torch,
749        Flame, Walker, CaveFloor, CaveWall, Door, Gem, Plane, Quad, Cube,
750    }
751}
752
753/// The interact click and the gem's chime, shared with the other examples.
754#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
755enum Sound {
756    Interact,
757    Gem,
758}
759
760impl Sounds for Sound {
761    fn build(&self, assets: &Assets) -> SoundData {
762        match self {
763            Sound::Interact => assets.sound("click"),
764            Sound::Gem => assets.sound("win"),
765        }
766    }
767}
768
769// ---------------------------------------------------------------------
770// Input
771// ---------------------------------------------------------------------
772
773/// Player movement: `WASD`, arrows, or a stick — the strongest reading is
774/// kept.
775#[derive(InputAxis2Action, Clone, Copy, PartialEq)]
776enum Move {
777    Walk,
778}
779
780impl InputAxis2Action for Move {
781    fn bindings(&self) -> Vec<Axis2Binding> {
782        match self {
783            Move::Walk => vec![
784                Axis2Binding::from(ButtonAxis2 {
785                    left: Key::A,
786                    right: Key::D,
787                    down: Key::S,
788                    up: Key::W,
789                }),
790                Axis2Binding::from(ButtonAxis2 {
791                    left: Key::Left,
792                    right: Key::Right,
793                    down: Key::Down,
794                    up: Key::Up,
795                }),
796                Axis2Binding::stick(Stick::Left),
797            ],
798        }
799    }
800}
801
802/// The two verbs this game reads as an edge: interacting with the door, and
803/// a reset of the world to every saved key's fallback.
804#[derive(InputButtonAction, Clone, Copy, PartialEq)]
805enum Button {
806    Interact,
807    Reset,
808}
809
810impl InputButtonAction for Button {
811    fn bindings(&self) -> Vec<ButtonBinding> {
812        match self {
813            Button::Interact => vec![Key::E.into(), Pad::West.into()],
814            Button::Reset => vec![Key::R.into()],
815        }
816    }
817}
818
819struct Controls;
820
821impl InputActions for Controls {
822    type Button = Button;
823    type Axis = NoInputAxes;
824    type Axis2 = Move;
825}
826
827// ---------------------------------------------------------------------
828// Save data
829// ---------------------------------------------------------------------
830
831/// The player's last position, read at startup and saved on area
832/// transition and gem pickup.
833#[derive(Saves, Clone, Copy)]
834enum Position {
835    X,
836    Z,
837}
838
839impl SaveKey for Position {
840    type Value = f64;
841
842    fn fallback(&self) -> f64 {
843        match self {
844            Position::X => PLAYER_SPAWN.x as f64,
845            Position::Z => PLAYER_SPAWN.z as f64,
846        }
847    }
848}
849
850/// The area the player is in, and whether the gem is taken.
851#[derive(Saves, Clone, Copy)]
852enum Flag {
853    InCave,
854    GemTaken,
855}
856
857impl SaveKey for Flag {
858    type Value = bool;
859
860    fn fallback(&self) -> bool {
861        false
862    }
863}
864
865// ---------------------------------------------------------------------
866// The player's facing
867// ---------------------------------------------------------------------
868
869/// The player's last facing: also its row in the sheet, top to bottom.
870#[derive(Clone, Copy, PartialEq)]
871enum Facing {
872    Toward = 0,
873    Right = 1,
874    Away = 2,
875    Left = 3,
876}
877
878impl Facing {
879    /// The facing `heading` points in, favoring its larger axis; `None` at
880    /// rest, so the caller can keep the last facing.
881    fn from_heading(heading: Vec2) -> Option<Self> {
882        if heading == Vec2::ZERO {
883            return None;
884        }
885        Some(if heading.x.abs() > heading.y.abs() {
886            if heading.x > 0.0 {
887                Self::Right
888            } else {
889                Self::Left
890            }
891        } else if heading.y > 0.0 {
892            Self::Away
893        } else {
894            Self::Toward
895        })
896    }
897}
898
899// ---------------------------------------------------------------------
900// The game
901// ---------------------------------------------------------------------
902
903/// The ground tile at `col, row`: the path's dirt along [`PATH_COLUMN`],
904/// the verges that edge it, and a hashed grass variant everywhere else.
905fn ground_cell(col: i32, row: i32) -> Frame {
906    let (column, sheet_row) = match col - PATH_COLUMN {
907        0 => (PATH_DIRT + row.rem_euclid(2) as u32, PATH_ROW),
908        -1 => (PATH_WEST_VERGE, PATH_ROW),
909        1 => (PATH_EAST_VERGE, PATH_ROW),
910        _ => (
911            (col * 31 + row * 17).rem_euclid(GROUND_COLUMNS as i32) as u32,
912            GRASS_ROW,
913        ),
914    };
915
916    Sheet::new(UVec2::new(GROUND_COLUMNS, GROUND_ROWS)).cell_at(UVec2::new(column, sheet_row))
917}
918
919/// The stone sheet's plain masonry, laid `tiles` times across: the sampler
920/// wraps, so a window wider than the sheet repeats the course.
921fn masonry(tiles: f32) -> Frame {
922    let course = 1.0 / STONE_ROWS as f32;
923
924    Frame::rect(Vec2::new(0.0, 1.0 - course), Vec2::new(tiles, 1.0))
925}
926
927/// The wall or door's alpha `fraction` of the way from [`SOLID`] to
928/// [`GHOST_ALPHA`].
929fn ghost_alpha(fraction: f32) -> f32 {
930    SOLID + (GHOST_ALPHA - SOLID) * fraction
931}
932
933/// The wall face in column `variant`, windowed to the meters `standing` of
934/// one course, measured up from that course's own base: every row of the
935/// cave sheet below the floor's covers [`WALL_HEIGHT`], so a course keeps
936/// the floor's texels to the meter however it is cut.
937fn cave_wall_face(variant: u32, standing: Range<f32>) -> Frame {
938    let cell = Vec2::new(1.0 / CAVE_COLUMNS as f32, 1.0 / CAVE_ROWS as f32);
939    let left = (variant % CAVE_COLUMNS) as f32 * cell.x;
940    let face = (CAVE_FLOOR_ROW + 1) as f32 * cell.y;
941    let up_from_base = |height: f32| 1.0 - (1.0 - face) * (height / WALL_HEIGHT);
942
943    Frame::rect(
944        Vec2::new(left, up_from_base(standing.end)),
945        Vec2::new(left + cell.x, up_from_base(standing.start)),
946    )
947}
948
949/// The logical point egui paints the physical pixel `pixel` at.
950fn logical(pixel: Vec2, pixels_per_point: f32) -> egui::Pos2 {
951    let point = pixel / pixels_per_point;
952    egui::pos2(point.x, point.y)
953}
954
955fn main() {
956    run(
957        Config::new("Mirage: sprite adventure")
958            .with_size(1280, 720)
959            .with_assets([
960                MODEL,
961                WALKER_SOURCE,
962                WALKER_RELIEF_SOURCE,
963                GROUND_SOURCE,
964                BUSH_SOURCE,
965                BUSH_RELIEF_SOURCE,
966                ROCK_SOURCE,
967                ROCK_RELIEF_SOURCE,
968                TORCH_RELIEF_SOURCE,
969                CRATE_SOURCE,
970                WELL_SOURCE,
971                STONE_SOURCE,
972                CAVE_SOURCE,
973                POND_SOURCE,
974                TORCH_SOURCE,
975                FLAME_SOURCE,
976                INTERACT_SOUND,
977                GEM_SOUND,
978            ]),
979        Keep::init,
980    );
981}
982
983struct Keep {
984    area: Area,
985    position: Vec3,
986    previous: Vec3,
987    facing: Facing,
988    walk_ticks: u32,
989    simulated: Duration,
990    door_opening: bool,
991    /// Ticks the door has been opening for, at a cap of
992    /// [`DOOR_SWING_TICKS`]: how long its world prompt reads "opening" once
993    /// it starts.
994    swing_ticks: u32,
995    gem_taken: bool,
996    /// How far the door wall's fade from [`SOLID`] to [`GHOST_ALPHA`] has
997    /// run as of the last tick: `0.0` to `1.0`.
998    ghost: f32,
999    /// Set by the panel's reset button, since its click lands in a frame
1000    /// rather than a tick; read and cleared on the next tick.
1001    reset_requested: bool,
1002}
1003
1004impl Keep {
1005    /// Prepares every startup-cataloged mesh and resumes wherever the last
1006    /// run left the player.
1007    fn init(ctx: &mut InitContext<'_, Keep>) -> Result<Self, Error> {
1008        let startup = ctx.startup();
1009        let gem_taken = startup.saved(Flag::GemTaken);
1010        let area = if startup.saved(Flag::InCave) {
1011            Area::Cave
1012        } else {
1013            Area::Overworld
1014        };
1015        let position = Vec3::new(
1016            startup.saved(Position::X) as f32,
1017            0.0,
1018            startup.saved(Position::Z) as f32,
1019        );
1020
1021        Ok(Self {
1022            area,
1023            position,
1024            previous: position,
1025            facing: Facing::Toward,
1026            walk_ticks: 0,
1027            simulated: Duration::ZERO,
1028            door_opening: gem_taken,
1029            swing_ticks: if gem_taken { DOOR_SWING_TICKS } else { 0 },
1030            gem_taken,
1031            ghost: 0.0,
1032            reset_requested: false,
1033        })
1034    }
1035
1036    fn camera(position: Vec3, offset: Vec3) -> Camera {
1037        Camera::new(
1038            View::look_at(position + offset, position),
1039            Projection::perspective(CAMERA_FOV),
1040        )
1041    }
1042
1043    /// Obstacles from the overworld's props: the crates, turned as they are
1044    /// drawn, the well's rim, the open water the shoreline rings, the
1045    /// mouth's pillars, and each flora's base.
1046    fn overworld_obstacles() -> impl Iterator<Item = Obstacle> + Clone {
1047        CRATE_POSITIONS
1048            .into_iter()
1049            .map(|(x, z, turn)| {
1050                Obstacle::footprint(Vec2::new(x, z), Vec2::splat(CRATE_SIZE * turned_span(turn)))
1051            })
1052            .chain([
1053                Obstacle::footprint(WELL_POSITION.xz(), WELL_SIZE.xz()),
1054                Obstacle::footprint(POND_CENTER.xz(), Vec2::splat(POND_WATER_HALF * 2.0)),
1055            ])
1056            .chain(
1057                ENTRANCE
1058                    .pillars()
1059                    .map(|at| Obstacle::footprint(at.xz(), MOUTH_PILLAR_SIZE.xz())),
1060            )
1061            .chain(FLORA.into_iter().map(|(x, z, rock)| {
1062                let base = if rock { ROCK_FOOTPRINT } else { BUSH_FOOTPRINT };
1063                Obstacle::footprint(Vec2::new(x, z), Vec2::splat(base))
1064            }))
1065    }
1066
1067    /// Obstacles from the cave: the torch posts, its own mouth's pillars,
1068    /// the runs of wall either side of the doorway and of the mouth, and the
1069    /// `door` leaf.
1070    fn cave_obstacles(door: Obstacle) -> impl Iterator<Item = Obstacle> + Clone {
1071        TORCH_POSITIONS
1072            .into_iter()
1073            .map(|(x, z)| Obstacle::footprint(Vec2::new(x, z), Vec2::splat(TORCH_STAND_WIDTH)))
1074            .chain(
1075                EXIT.pillars()
1076                    .map(|at| Obstacle::footprint(at.xz(), MOUTH_PILLAR_SIZE.xz())),
1077            )
1078            .chain(SIDES.into_iter().flat_map(|side| {
1079                [
1080                    Self::wall_run(side, DOOR_Z),
1081                    Self::wall_run(side, CAVE_LIP_Z),
1082                ]
1083            }))
1084            .chain([door])
1085    }
1086
1087    /// One of the two runs of wall either side of a one-tile opening on the
1088    /// room's axis, at `z`.
1089    fn wall_run(side: f32, z: f32) -> Obstacle {
1090        Obstacle::footprint(
1091            Vec2::new(side * (DOORWAY_HALF + DOOR_WALL_END) * 0.5, z),
1092            Vec2::new(DOOR_WALL_END - DOORWAY_HALF, TILE_SIZE),
1093        )
1094    }
1095
1096    /// Obstacle from the door leaf's own footprint: the box over its four
1097    /// corners, swung back against the wall once the door is opened.
1098    fn door_obstacle(&self) -> Obstacle {
1099        let hinge = DOOR_HINGE.xz();
1100        let across = DOOR_THICKNESS * 0.5;
1101        let corner = |along: f32, aside: f32| {
1102            let (x, z) = if self.door_opening {
1103                (aside, -along)
1104            } else {
1105                (along, aside)
1106            };
1107            hinge + Vec3::new(x, 0.0, z).xz()
1108        };
1109
1110        Obstacle::over([
1111            corner(0.0, -across),
1112            corner(0.0, across),
1113            corner(DOOR_WIDTH, -across),
1114            corner(DOOR_WIDTH, across),
1115        ])
1116    }
1117
1118    /// Pushes the player out of every obstacle their circle has walked into,
1119    /// over as many passes as it takes for one to leave them where the last
1120    /// one did — overlapping obstacles need more than one.
1121    fn push_out_of(&mut self, obstacles: impl Iterator<Item = Obstacle> + Clone) {
1122        /// Passes an overlap is given to settle before the frame takes what
1123        /// it has; ones this game builds settle in two.
1124        const PASSES: u32 = 4;
1125
1126        let mut standing = self.position.xz();
1127        for _ in 0..PASSES {
1128            let settled = obstacles.clone().fold(standing, |point, obstacle| {
1129                obstacle.push_out(point, PLAYER_RADIUS)
1130            });
1131            if settled == standing {
1132                break;
1133            }
1134            standing = settled;
1135        }
1136
1137        self.position.x = standing.x;
1138        self.position.z = standing.y;
1139    }
1140
1141    fn tick_overworld(&mut self, ctx: &mut TickContext<'_, Keep>) {
1142        self.push_out_of(Self::overworld_obstacles());
1143        self.position.x = self.position.x.clamp(-CLEARING_HALF, CLEARING_HALF);
1144        self.position.z = self.position.z.clamp(-CLEARING_HALF, CLEARING_HALF);
1145
1146        if ENTRANCE.holds(self.position) {
1147            if ENTRANCE.holds(self.previous) {
1148                self.position.z = self.previous.z;
1149            } else {
1150                self.enter_cave(ctx);
1151            }
1152        }
1153    }
1154
1155    fn tick_cave(&mut self, ctx: &mut TickContext<'_, Keep>) {
1156        self.push_out_of(Self::cave_obstacles(self.door_obstacle()));
1157        self.position.x = self.position.x.clamp(-CAVE_HALF_WIDTH, CAVE_HALF_WIDTH);
1158        self.position.z = self.position.z.clamp(CAVE_WALK_FAR_Z, CAVE_WALK_NEAR_Z);
1159
1160        let target = if self.position.z < DOOR_WALL_NEAR_Z {
1161            1.0
1162        } else {
1163            0.0
1164        };
1165        let step = 1.0 / GHOST_RAMP_TICKS as f32;
1166        self.ghost += (target - self.ghost).clamp(-step, step);
1167
1168        if !self.door_opening
1169            && ctx.pressed(Button::Interact)
1170            && self.position.distance(INTERACT_POINT) < INTERACT_RADIUS
1171        {
1172            self.door_opening = true;
1173            self.swing_ticks = 0;
1174            ctx.play(Sound::Interact);
1175        }
1176        if self.door_opening && self.swing_ticks < DOOR_SWING_TICKS {
1177            self.swing_ticks += 1;
1178        }
1179
1180        if !self.gem_taken && self.position.distance(GEM_POSITION) < PICKUP_RADIUS {
1181            self.gem_taken = true;
1182            ctx.play(Sound::Gem);
1183            ctx.save(Flag::GemTaken, true);
1184            ctx.save(Position::X, self.position.x as f64);
1185            ctx.save(Position::Z, self.position.z as f64);
1186        }
1187
1188        if EXIT.holds(self.position) {
1189            if EXIT.holds(self.previous) {
1190                self.position.z = self.previous.z;
1191            } else {
1192                self.exit_cave(ctx);
1193            }
1194        }
1195    }
1196
1197    /// Puts the player back at [`PLAYER_SPAWN`] with the cave and the gem
1198    /// returned to their saved fallbacks, all in this tick: a reset saves
1199    /// every key's own fallback, since there is nothing to clear it to.
1200    fn reset(&mut self, ctx: &mut TickContext<'_, Keep>) {
1201        ctx.save(Position::X, Position::X.fallback());
1202        ctx.save(Position::Z, Position::Z.fallback());
1203        ctx.save(Flag::InCave, Flag::InCave.fallback());
1204        ctx.save(Flag::GemTaken, Flag::GemTaken.fallback());
1205
1206        self.area = Area::Overworld;
1207        self.position = PLAYER_SPAWN;
1208        self.previous = PLAYER_SPAWN;
1209        self.gem_taken = false;
1210        self.door_opening = false;
1211        self.swing_ticks = 0;
1212        self.ghost = 0.0;
1213    }
1214
1215    /// Steps into the cave at [`CAVE_SPAWN`], saving the transition.
1216    fn enter_cave(&mut self, ctx: &mut TickContext<'_, Keep>) {
1217        self.area = Area::Cave;
1218        self.position = CAVE_SPAWN;
1219        self.previous = CAVE_SPAWN;
1220        ctx.save(Flag::InCave, true);
1221        ctx.save(Position::X, CAVE_SPAWN.x as f64);
1222        ctx.save(Position::Z, CAVE_SPAWN.z as f64);
1223    }
1224
1225    /// Steps back out to the mouth at [`RETURN_SPAWN`], saving the
1226    /// transition.
1227    fn exit_cave(&mut self, ctx: &mut TickContext<'_, Keep>) {
1228        self.area = Area::Overworld;
1229        self.position = RETURN_SPAWN;
1230        self.previous = RETURN_SPAWN;
1231        ctx.save(Flag::InCave, false);
1232        ctx.save(Position::X, RETURN_SPAWN.x as f64);
1233        ctx.save(Position::Z, RETURN_SPAWN.z as f64);
1234    }
1235
1236    fn draw_ground(&self, ctx: &mut FrameContext<'_, Keep>) {
1237        for col in -GROUND_DRAW_HALF..=GROUND_DRAW_HALF {
1238            for row in -GROUND_DRAW_HALF..=GROUND_DRAW_HALF {
1239                ctx.draw(
1240                    Ground
1241                        .at(Vec3::new(
1242                            col as f32 * TILE_SIZE,
1243                            0.0,
1244                            row as f32 * TILE_SIZE,
1245                        ))
1246                        .frame(ground_cell(col, row)),
1247                );
1248            }
1249        }
1250    }
1251
1252    /// Two staggered rows of bushes around the clearing, open where the path
1253    /// leaves it, drawn between the camera and the ground's edge. The rows
1254    /// running along `Z` skip their two ends, which the rows running along
1255    /// `X` already cover.
1256    fn draw_hedgerow(&self, ctx: &mut FrameContext<'_, Keep>) {
1257        for (row, half) in [HEDGE_INNER_HALF, HEDGE_OUTER_HALF].into_iter().enumerate() {
1258            let row = row as i32;
1259            // The inner row covers both corners; the outer one is half a
1260            // span in from each, backing the gaps the inner row leaves.
1261            let spans = ((2.0 * half / HEDGE_STEP).round() as i32).max(1);
1262            let span = 2.0 * half / spans as f32;
1263            let steps = spans - row;
1264            for step in 0..=steps {
1265                let along = -half + (step as f32 + 0.5 * row as f32) * span;
1266                let scale = if (step + row) % 2 == 0 { 1.0 } else { 0.8 };
1267                let (width, height) = (BUSH_WIDTH * scale, BUSH_HEIGHT * scale);
1268                // The path leaves through the rows running along `X`, so only
1269                // those two open around it.
1270                let gated = along.abs() < HEDGE_GATE_HALF;
1271                let corner = step == 0 || step == steps;
1272                let places = [
1273                    (along, -half, gated),
1274                    (along, half, gated),
1275                    (-half, along, corner),
1276                    (half, along, corner),
1277                ];
1278                for (x, z, skip) in places {
1279                    if skip {
1280                        continue;
1281                    }
1282                    ctx.draw(
1283                        Bush.at(Transform::from_scale_rotation_translation(
1284                            Vec3::new(width, height, width),
1285                            Quat::IDENTITY,
1286                            Vec3::new(x, height * 0.5, z),
1287                        ))
1288                        .upright(),
1289                    );
1290                }
1291            }
1292        }
1293    }
1294
1295    /// The pond: a square of styled water, and the shoreline sprite laid over
1296    /// it, which rings the open middle and hides the water's own edges.
1297    fn draw_pond(&self, ctx: &mut FrameContext<'_, Keep>) {
1298        ctx.draw(
1299            Plane
1300                .at(Transform::from_scale_rotation_translation(
1301                    Vec3::splat(POND_WATER_HALF * 2.0),
1302                    Quat::IDENTITY,
1303                    POND_CENTER,
1304                ))
1305                .material(Material::shaded(WATER_COLOR, WATER_LITNESS))
1306                .surface_style::<Water>(),
1307        );
1308        ctx.draw(
1309            Shore
1310                .at(Transform::from_scale_rotation_translation(
1311                    Vec3::splat(POND_HALF * 2.0),
1312                    Quat::IDENTITY,
1313                    Vec3::new(POND_CENTER.x, 0.0, POND_CENTER.z),
1314                ))
1315                .frame(Sheet::new(UVec2::new(POND_CELLS, 1)).cell(POND_SHORE_CELL)),
1316        );
1317    }
1318
1319    fn draw_crates(&self, ctx: &mut FrameContext<'_, Keep>) {
1320        for &(x, z, turn) in &CRATE_POSITIONS {
1321            ctx.draw(Crate.at(Transform::from_scale_rotation_translation(
1322                Vec3::splat(CRATE_SIZE),
1323                Quat::from_rotation_y(turn),
1324                Vec3::new(x, CRATE_SIZE * 0.5, z),
1325            )));
1326        }
1327    }
1328
1329    /// The well: its rim in grey masonry, and the mouth cell laid over the
1330    /// rim's top face.
1331    fn draw_well(&self, ctx: &mut FrameContext<'_, Keep>) {
1332        let cells = Sheet::new(UVec2::new(WELL_CELLS, 1));
1333        ctx.draw(
1334            Well.at(Transform::from_scale_rotation_translation(
1335                WELL_SIZE,
1336                Quat::IDENTITY,
1337                WELL_POSITION + Vec3::Y * (WELL_SIZE.y * 0.5),
1338            ))
1339            .frame(cells.cell(WELL_RIM_CELL)),
1340        );
1341        ctx.draw(
1342            WellMouth
1343                .at(Transform::from_scale_rotation_translation(
1344                    Vec3::new(WELL_SIZE.x, 1.0, WELL_SIZE.z),
1345                    Quat::IDENTITY,
1346                    WELL_POSITION + Vec3::Y * (WELL_SIZE.y + WELL_MOUTH_LIFT),
1347                ))
1348                .frame(cells.cell(WELL_MOUTH_CELL)),
1349        );
1350    }
1351
1352    fn draw_flora(&self, ctx: &mut FrameContext<'_, Keep>) {
1353        for &(x, z, rock) in &FLORA {
1354            let (width, height) = if rock {
1355                (ROCK_WIDTH, ROCK_HEIGHT)
1356            } else {
1357                (BUSH_WIDTH, BUSH_HEIGHT)
1358            };
1359            let standing = Transform::from_scale_rotation_translation(
1360                Vec3::new(width, height, width),
1361                Quat::IDENTITY,
1362                Vec3::new(x, height * 0.5, z),
1363            );
1364            let flora: Instance<Shape, _> = if rock {
1365                Rock.at(standing).into_set()
1366            } else {
1367                Bush.at(standing).into_set()
1368            };
1369            ctx.draw(flora.upright());
1370        }
1371    }
1372
1373    /// One stone box drawn on the ground at `at`, `size` across, sampling
1374    /// the part of the sheet `frame` covers.
1375    fn draw_stone(ctx: &mut FrameContext<'_, Keep>, at: Vec3, size: Vec3, frame: Frame) {
1376        ctx.draw(
1377            Stone
1378                .at(Transform::from_scale_rotation_translation(
1379                    size,
1380                    Quat::IDENTITY,
1381                    at + Vec3::Y * (size.y * 0.5),
1382                ))
1383                .frame(frame),
1384        );
1385    }
1386
1387    /// Two stone pillars drawn where `mouth` blocks the player, each a
1388    /// capital over its own course of masonry, and, on the one the camera
1389    /// looks into, the lintel across their tops and the dark filling
1390    /// the opening under it.
1391    fn draw_mouth(ctx: &mut FrameContext<'_, Keep>, mouth: Mouth) {
1392        for at in mouth.pillars() {
1393            Self::draw_stone(ctx, at, MOUTH_PILLAR_SIZE, Frame::default());
1394        }
1395        if !mouth.looked_into() {
1396            return;
1397        }
1398
1399        Self::draw_stone(
1400            ctx,
1401            mouth.at + Vec3::Y * MOUTH_PILLAR_SIZE.y,
1402            MOUTH_LINTEL_SIZE,
1403            masonry(MOUTH_LINTEL_TILES),
1404        );
1405        ctx.draw(
1406            Quad.at(Transform::from_scale_rotation_translation(
1407                Vec3::new(MOUTH_PILLAR_OFFSET * 2.0, MOUTH_DARK_HEIGHT, 1.0),
1408                Quat::IDENTITY,
1409                mouth.at + Vec3::Y * (MOUTH_DARK_HEIGHT * 0.5),
1410            ))
1411            .material(Material::color(Color::BLACK)),
1412        );
1413    }
1414
1415    fn draw_cave_floor(&self, ctx: &mut FrameContext<'_, Keep>) {
1416        let half = CAVE_HALF_WIDTH as i32;
1417        let near = CAVE_NEAR_Z as i32;
1418        let far = CAVE_FAR_Z as i32;
1419        for col in -half..=half {
1420            for row in far..=near {
1421                let variant = (col * 13 + row * 7).rem_euclid(CAVE_COLUMNS as i32) as u32;
1422                ctx.draw(
1423                    CaveFloor
1424                        .at(Vec3::new(
1425                            col as f32 * TILE_SIZE,
1426                            0.0,
1427                            row as f32 * TILE_SIZE,
1428                        ))
1429                        .frame(
1430                            Sheet::new(UVec2::new(CAVE_COLUMNS, CAVE_ROWS))
1431                                .cell_at(UVec2::new(variant, CAVE_FLOOR_ROW)),
1432                        ),
1433                );
1434            }
1435        }
1436    }
1437
1438    /// The wall drawn at `at` over the meters `standing`, in courses
1439    /// [`WALL_HEIGHT`] tall from the floor up, each cut to the part of it the
1440    /// span leaves; its faces are picked by `seed` and its stone faded to
1441    /// `fade`, which is `1.0` wherever it is solid.
1442    fn draw_wall(
1443        ctx: &mut FrameContext<'_, Keep>,
1444        at: Vec2,
1445        standing: Range<f32>,
1446        seed: i32,
1447        fade: f32,
1448    ) {
1449        for course in 0..WALL_COURSES {
1450            let base = course as f32 * WALL_HEIGHT;
1451            let low = (standing.start - base).max(0.0);
1452            let high = (standing.end - base).min(WALL_HEIGHT);
1453            if high <= low {
1454                continue;
1455            }
1456
1457            let variant = (seed + course).rem_euclid(CAVE_COLUMNS as i32) as u32;
1458            ctx.draw(
1459                CaveWall
1460                    .at(Transform::from_scale_rotation_translation(
1461                        Vec3::new(TILE_SIZE, high - low, TILE_SIZE),
1462                        Quat::IDENTITY,
1463                        Vec3::new(at.x, base + (low + high) * 0.5, at.y),
1464                    ))
1465                    .frame(cave_wall_face(variant, low..high))
1466                    .faded(fade),
1467            );
1468        }
1469    }
1470
1471    /// The room's two side walls and its back wall, full height, and the low
1472    /// wall closing its near end between the side walls and the mouth. The
1473    /// back wall stops short of the corners the side walls already fill, and
1474    /// the near one leaves the mouth's own tile open.
1475    fn draw_cave_walls(&self, ctx: &mut FrameContext<'_, Keep>) {
1476        let half = CAVE_HALF_WIDTH as i32 + 1;
1477        let near = CAVE_NEAR_Z as i32;
1478        let far = CAVE_FAR_Z as i32;
1479
1480        for row in far..=near {
1481            let z = row as f32 * TILE_SIZE;
1482            let west = Vec2::new(-half as f32 * TILE_SIZE, z);
1483            let east = Vec2::new(half as f32 * TILE_SIZE, z);
1484            Self::draw_wall(ctx, west, 0.0..WALL_TOP, row * 5, SOLID);
1485            Self::draw_wall(ctx, east, 0.0..WALL_TOP, row * 5 + 1, SOLID);
1486        }
1487        for col in (-half + 1)..half {
1488            let x = col as f32 * TILE_SIZE;
1489            let back = Vec2::new(x, far as f32 * TILE_SIZE);
1490            Self::draw_wall(ctx, back, 0.0..WALL_TOP, col * 5 + 2, SOLID);
1491            if col != 0 {
1492                let lip = Vec2::new(x, CAVE_LIP_Z);
1493                Self::draw_wall(ctx, lip, 0.0..CAVE_LIP_HEIGHT, col * 5 + 4, SOLID);
1494            }
1495        }
1496    }
1497
1498    /// The wall the door hangs in, run across the room between the side walls
1499    /// with one tile left open on the room's axis for the doorway and stone
1500    /// filling the column over the door. A player behind the wall is drawn
1501    /// through the stacks between them and the camera, at `seen_through`,
1502    /// faded by `ghost`; the rest of it stays solid, and keeps casting.
1503    fn draw_door_wall(ctx: &mut FrameContext<'_, Keep>, seen_through: Option<f32>, ghost: f32) {
1504        let stone = |x: f32| match seen_through {
1505            Some(at) if (x - at).abs() < GHOST_CORRIDOR_HALF => ghost_alpha(ghost),
1506            _ => SOLID,
1507        };
1508        let half = CAVE_HALF_WIDTH as i32;
1509
1510        for col in (-half..=half).filter(|&col| col != 0) {
1511            let x = col as f32 * TILE_SIZE;
1512            Self::draw_wall(
1513                ctx,
1514                Vec2::new(x, DOOR_Z),
1515                0.0..WALL_TOP,
1516                col * 5 + 3,
1517                stone(x),
1518            );
1519        }
1520        Self::draw_wall(
1521            ctx,
1522            Vec2::new(0.0, DOOR_Z),
1523            DOOR_HEIGHT..WALL_TOP,
1524            3,
1525            stone(0.0),
1526        );
1527    }
1528
1529    /// The two torches: an upright cutout post apiece, the flame's loop
1530    /// burning over its binding, and the light that flame casts.
1531    fn draw_torches(&self, ctx: &mut FrameContext<'_, Keep>) {
1532        let elapsed = self.simulated.as_secs_f32();
1533        let loop_cells = Sheet::new(UVec2::new(FLAME_CELLS, 1));
1534
1535        for (index, &(x, z)) in TORCH_POSITIONS.iter().enumerate() {
1536            let base = Vec3::new(x, 0.0, z);
1537            ctx.draw(
1538                Torch
1539                    .at(Transform::from_scale_rotation_translation(
1540                        Vec3::new(TORCH_SPRITE_WIDTH, TORCH_STAND_HEIGHT, 1.0),
1541                        Quat::IDENTITY,
1542                        base + Vec3::Y * (TORCH_STAND_HEIGHT * 0.5),
1543                    ))
1544                    .upright(),
1545            );
1546
1547            let phase = index as f32 * 2.1;
1548            let flicker = (elapsed * FLAME_FLICKER_SPEED + phase).sin();
1549            let flame_pos =
1550                base + Vec3::Y * (TORCH_STAND_HEIGHT + FLAME_LIFT + flicker * FLAME_BOB);
1551
1552            let light_pos = flame_pos + Vec3::new(0.0, TORCH_LIGHT_LIFT, TORCH_LIGHT_STANDOFF);
1553            ctx.light(Light::point(light_pos, TORCH_LIGHT_COLOR, TORCH_LIGHT_RANGE).shadow());
1554            // The pair burn an even share of the loop apart.
1555            let offset = index as u32 * FLAME_CELLS / TORCH_POSITIONS.len() as u32;
1556            ctx.draw(
1557                Flame
1558                    .at(Transform::from_scale_rotation_translation(
1559                        Vec3::splat(FLAME_SIZE),
1560                        Quat::IDENTITY,
1561                        flame_pos,
1562                    ))
1563                    .billboard()
1564                    .roll(flicker * FLAME_ROLL)
1565                    .frame(loop_cells.cell((elapsed * FLAME_RATE) as u32 + offset)),
1566            );
1567        }
1568    }
1569
1570    /// The door at its hinge — swung back against the wall once opened —
1571    /// drawn through alongside its wall, faded by `ghost`.
1572    fn draw_door(&self, ctx: &mut FrameContext<'_, Keep>, ghost: f32) {
1573        let fade = ghost_alpha(ghost);
1574        let swung = if self.door_opening {
1575            Quat::from_rotation_y(core::f32::consts::FRAC_PI_2)
1576        } else {
1577            Quat::IDENTITY
1578        };
1579
1580        ctx.draw(
1581            Door.at(Transform::from_rotation_translation(swung, DOOR_HINGE))
1582                .material(Material::shaded(DOOR_COLOR, DOOR_LITNESS))
1583                .faded(fade),
1584        );
1585    }
1586
1587    /// The posts and lintel framing the doorway, in a color the stone never
1588    /// is, standing clear of the wall so the opening reads as a door from
1589    /// across the chamber. Glowing of their own while the door is closed and
1590    /// within [`INTERACT_RADIUS`], the cue that it opens.
1591    fn draw_door_frame(&self, ctx: &mut FrameContext<'_, Keep>, ghost: f32) {
1592        let reachable =
1593            !self.door_opening && self.position.distance(INTERACT_POINT) < INTERACT_RADIUS;
1594        let material =
1595            Material::shaded(DOOR_FRAME_COLOR, DOOR_FRAME_LITNESS).emissive(if reachable {
1596                DOOR_FRAME_GLOW
1597            } else {
1598                Color::BLACK
1599            });
1600        let fade = ghost_alpha(ghost);
1601        let z = DOOR_WALL_NEAR_Z + DOOR_FRAME_STANDOFF;
1602        let jamb_height = DOOR_HEIGHT + DOOR_FRAME_THICKNESS;
1603
1604        for side in SIDES {
1605            ctx.draw(
1606                Cube.at(Transform::from_scale_rotation_translation(
1607                    Vec3::new(DOOR_FRAME_THICKNESS, jamb_height, DOOR_FRAME_THICKNESS),
1608                    Quat::IDENTITY,
1609                    Vec3::new(
1610                        side * (DOORWAY_HALF + DOOR_FRAME_THICKNESS * 0.5),
1611                        jamb_height * 0.5,
1612                        z,
1613                    ),
1614                ))
1615                .material(material)
1616                .faded(fade),
1617            );
1618        }
1619        ctx.draw(
1620            Cube.at(Transform::from_scale_rotation_translation(
1621                Vec3::new(
1622                    DOOR_WIDTH + DOOR_FRAME_THICKNESS * 2.0,
1623                    DOOR_FRAME_THICKNESS,
1624                    DOOR_FRAME_THICKNESS,
1625                ),
1626                Quat::IDENTITY,
1627                Vec3::new(0.0, DOOR_HEIGHT + DOOR_FRAME_THICKNESS * 0.5, z),
1628            ))
1629            .material(material)
1630            .faded(fade),
1631        );
1632    }
1633
1634    /// A world prompt over the door: what opens it while the player is
1635    /// within [`INTERACT_RADIUS`] and it is closed, and that it swings while
1636    /// it does; gone once it has swung [`DOOR_SWING_TICKS`]. Laid out and
1637    /// placed like `examples/animation.rs`'s own prompt.
1638    fn draw_door_prompt(&self, ctx: &mut FrameContext<'_, Keep>, camera: Camera) {
1639        let near = self.position.distance(INTERACT_POINT) < INTERACT_RADIUS;
1640        let swinging = self.door_opening && self.swing_ticks < DOOR_SWING_TICKS;
1641        let text = if swinging {
1642            "opening"
1643        } else if near && !self.door_opening {
1644            "e opens the door"
1645        } else {
1646            return;
1647        };
1648
1649        let galley = ctx.text_layout(text, egui::FontId::proportional(DOOR_PROMPT_SIZE));
1650        let point = INTERACT_POINT + Vec3::Y * (DOOR_HEIGHT + DOOR_PROMPT_LIFT);
1651        let window_size = ctx.window_size();
1652        let pixels_per_point = ctx.pixels_per_point();
1653        let Some(pixel) = camera.pixel_of(point, window_size) else {
1654            return;
1655        };
1656
1657        ctx.ui(|ui| {
1658            let painter = ui.painter();
1659            let at = logical(pixel, pixels_per_point);
1660            let ink = galley.mesh_bounds;
1661            let pos = egui::pos2(at.x - ink.center().x, at.y - ink.center().y);
1662            let backdrop = egui::Rect::from_center_size(
1663                at,
1664                ink.size() + egui::Vec2::splat(DOOR_PROMPT_PADDING * 2.0),
1665            );
1666            painter.rect_filled(
1667                backdrop,
1668                DOOR_PROMPT_PADDING,
1669                egui::Color32::from_black_alpha(DOOR_PROMPT_BACKDROP),
1670            );
1671            painter.galley(pos, galley, DOOR_PROMPT_COLOR);
1672        });
1673    }
1674
1675    /// The gem, spinning and bobbing over the chamber's floor, and the light
1676    /// it casts over it.
1677    fn draw_gem(&self, ctx: &mut FrameContext<'_, Keep>) {
1678        let t = self.simulated.as_secs_f32();
1679        let bob = (t * 2.0).sin() * GEM_BOB_HEIGHT;
1680        ctx.light(
1681            Light::point(
1682                GEM_POSITION + Vec3::Y * (bob + GEM_LIGHT_LIFT),
1683                GEM_LIGHT_COLOR,
1684                GEM_LIGHT_RANGE,
1685            )
1686            .shadow(),
1687        );
1688        ctx.draw(
1689            Gem.at(Transform::from_scale_rotation_translation(
1690                Vec3::ONE,
1691                Quat::from_rotation_y(t * GEM_SPIN_SPEED),
1692                GEM_POSITION + Vec3::Y * bob,
1693            ))
1694            .material(Material::shaded(GEM_COLOR, 0.7).emissive(GEM_COLOR.dimmed(1.6))),
1695        );
1696    }
1697
1698    /// The player: upright so it always faces the camera about `+Y`,
1699    /// windowed to its facing's row and the walk cycle's current frame.
1700    fn draw_walker(&self, ctx: &mut FrameContext<'_, Keep>, ground: Vec3) {
1701        let step = if self.walk_ticks > 0 {
1702            (self.walk_ticks / TICKS_PER_WALK_FRAME) % WALKER_COLUMNS
1703        } else {
1704            0
1705        };
1706        let cell = Sheet::new(UVec2::new(WALKER_COLUMNS, WALKER_ROWS))
1707            .cell_at(UVec2::new(step, self.facing as u32));
1708        let size = Vec2::new(WALKER_WIDTH, WALKER_HEIGHT);
1709
1710        ctx.draw(
1711            Walker
1712                .at(Transform::from_scale_rotation_translation(
1713                    size.extend(1.0),
1714                    Quat::IDENTITY,
1715                    ground + Vec3::Y * (WALKER_HEIGHT * 0.5),
1716                ))
1717                .upright()
1718                .frame(cell),
1719        );
1720    }
1721
1722    fn frame_overworld(&mut self, ctx: &mut FrameContext<'_, Keep>) {
1723        let drawn_at = self.previous.lerp(self.position, ctx.alpha());
1724        ctx.set_camera(Self::camera(drawn_at, OVERWORLD_CAMERA_OFFSET));
1725        ctx.light(Light::directional(SUN_DIRECTION, SUN_COLOR).shadow());
1726
1727        self.draw_ground(ctx);
1728        self.draw_hedgerow(ctx);
1729        self.draw_pond(ctx);
1730        self.draw_crates(ctx);
1731        self.draw_well(ctx);
1732        self.draw_flora(ctx);
1733        Self::draw_mouth(ctx, ENTRANCE);
1734        self.draw_walker(ctx, drawn_at);
1735    }
1736
1737    fn frame_cave(&mut self, ctx: &mut FrameContext<'_, Keep>) {
1738        let drawn_at = self.previous.lerp(self.position, ctx.alpha());
1739        let camera = Self::camera(drawn_at, CAVE_CAMERA_OFFSET);
1740        ctx.set_camera(camera);
1741
1742        self.draw_cave_floor(ctx);
1743        self.draw_cave_walls(ctx);
1744        Self::draw_door_wall(ctx, (self.ghost > 0.0).then_some(drawn_at.x), self.ghost);
1745        Self::draw_mouth(ctx, EXIT);
1746        self.draw_torches(ctx);
1747        self.draw_door(ctx, self.ghost);
1748        self.draw_door_frame(ctx, self.ghost);
1749        if !self.gem_taken {
1750            self.draw_gem(ctx);
1751        }
1752        self.draw_walker(ctx, drawn_at);
1753        self.draw_door_prompt(ctx, camera);
1754    }
1755
1756    /// Instructions and the door's interact hint — gathered before `ctx.ui`,
1757    /// which cannot read `ctx`.
1758    fn overlay(&mut self, ctx: &mut FrameContext<'_, Keep>) {
1759        let near_door = self.area == Area::Cave
1760            && !self.door_opening
1761            && self.position.distance(INTERACT_POINT) < INTERACT_RADIUS;
1762        let gem_taken = self.area == Area::Cave && self.gem_taken;
1763        let mut reset_clicked = false;
1764
1765        ctx.ui(|ui| {
1766            egui::Frame::new()
1767                .fill(egui::Color32::from_black_alpha(HUD_BACKDROP))
1768                .inner_margin(HUD_PADDING)
1769                .corner_radius(f32::from(HUD_PADDING))
1770                .show(ui, |ui| {
1771                    ui.visuals_mut().override_text_color = Some(egui::Color32::WHITE);
1772                    ui.label("wasd / arrows / stick to walk");
1773                    if near_door {
1774                        ui.label("e / west button to open the door");
1775                    }
1776                    if gem_taken {
1777                        ui.label("gem recovered");
1778                    }
1779                    ui.label("kept between runs: position, gem, cave");
1780                    ui.label("r to reset world");
1781                    if ui.button("reset world").clicked() {
1782                        reset_clicked = true;
1783                    }
1784                });
1785        });
1786
1787        if reset_clicked {
1788            self.reset_requested = true;
1789        }
1790    }
1791}
1792
1793impl Game for Keep {
1794    type Meshes = Shape;
1795    type Sounds = Sound;
1796    type InputActions = Controls;
1797    type Skyboxes = NoSkyboxes;
1798    type SurfaceStyles = Looks;
1799    type PostEffects = NoPostEffects;
1800
1801    fn tick(&mut self, ctx: &mut TickContext<'_, Keep>) {
1802        self.previous = self.position;
1803
1804        if self.reset_requested || ctx.pressed(Button::Reset) {
1805            self.reset_requested = false;
1806            self.reset(ctx);
1807            return;
1808        }
1809
1810        let heading = if ctx.ui_wants_keyboard() {
1811            Vec2::ZERO
1812        } else {
1813            ctx.axis2(Move::Walk)
1814        };
1815        match Facing::from_heading(heading) {
1816            Some(facing) => {
1817                self.facing = facing;
1818                self.walk_ticks += 1;
1819            }
1820            None => self.walk_ticks = 0,
1821        }
1822        let stride = Vec3::new(heading.x, 0.0, -heading.y) * WALK_SPEED * ctx.dt().as_secs_f32();
1823        self.position += stride;
1824        self.simulated += ctx.dt();
1825
1826        match self.area {
1827            Area::Overworld => self.tick_overworld(ctx),
1828            Area::Cave => self.tick_cave(ctx),
1829        }
1830    }
1831
1832    fn frame(&mut self, ctx: &mut FrameContext<'_, Keep>) {
1833        ctx.set_surface_style(Water {
1834            time: self.simulated.as_secs_f32(),
1835        });
1836
1837        match self.area {
1838            Area::Overworld => self.frame_overworld(ctx),
1839            Area::Cave => self.frame_cave(ctx),
1840        }
1841
1842        self.overlay(ctx);
1843    }
1844}