crystal_cavern/
lib.rs

1//! A small cave expedition: recover three crystals, wake two altars, find daylight.
2use ferravex_core::FixedClock;
3type Aabb2 = ferravex_core::collision::Aabb2<Vec2>;
4use ferravex_render::{
5    demo::Demo,
6    material::{MaterialOptions, ShaderMaterial, ShaderSource},
7    mesh::Mesh3d,
8    prelude::*,
9    shaders::{LitMaterial, LitSettings},
10};
11use std::collections::VecDeque;
12
13const TILE: f32 = 2.7;
14const RADIUS: f32 = 0.29;
15const SPEED: f32 = 4.3;
16const MAP: [&str; 15] = [
17    "#############",
18    "#####...#####",
19    "#####.X.#####",
20    "######G######",
21    "###.......###",
22    "###A...B..###",
23    "###.......###",
24    "######.######",
25    "#....#.#....#",
26    "#.C.......C.#",
27    "#....#.#....#",
28    "###....C..###",
29    "#####...#####",
30    "#####.S.#####",
31    "#############",
32];
33const MINT: Color = Color::from_rgba(108, 239, 209, 255);
34const INK: Color = Color::from_rgba(20, 26, 29, 255);
35const WARM: Color = Color::from_rgba(202, 165, 107, 255);
36
37fn center(x: usize, z: usize) -> Vec2 {
38    vec2(x as f32 * TILE, z as f32 * TILE)
39}
40fn at(marker: u8) -> Vec2 {
41    for (z, row) in MAP.iter().enumerate() {
42        if let Some(x) = row.bytes().position(|c| c == marker) {
43            return center(x, z);
44        }
45    }
46    panic!("map is missing marker");
47}
48fn cell(p: Vec2) -> (usize, usize) {
49    ((p.x / TILE).round() as usize, (p.y / TILE).round() as usize)
50}
51fn forward(yaw: f32) -> Vec2 {
52    vec2(yaw.sin(), -yaw.cos())
53}
54fn world(p: Vec2, height: f32) -> Vec3 {
55    vec3(p.x, height, p.y)
56}
57
58#[derive(Clone, Copy, Default)]
59struct Input {
60    movement: Vec2,
61    turn: f32,
62    interact: bool,
63}
64
65struct Game {
66    position: Vec2,
67    yaw: f32,
68    walls: Vec<Aabb2>,
69    crystals: Vec<Vec2>,
70    collected: [bool; 3],
71    switches: u8,
72    gate: Aabb2,
73    won: bool,
74    elapsed: f32,
75    notice: &'static str,
76    notice_time: f32,
77}
78impl Game {
79    fn new() -> Self {
80        let mut walls = Vec::new();
81        let mut crystals = Vec::new();
82        for (z, row) in MAP.iter().enumerate() {
83            for (x, tile) in row.bytes().enumerate() {
84                if tile == b'#' {
85                    walls.push(Aabb2::from_min_size(
86                        center(x, z) - Vec2::splat(TILE * 0.5),
87                        Vec2::splat(TILE),
88                    ));
89                } else if tile == b'C' {
90                    crystals.push(center(x, z));
91                }
92            }
93        }
94        Self {
95            position: at(b'S'),
96            yaw: 0.0,
97            walls,
98            crystals,
99            collected: [false; 3],
100            switches: 0,
101            gate: Aabb2::from_min_size(at(b'G') - vec2(TILE * 0.5, 0.175), vec2(TILE, 0.35)),
102            won: false,
103            elapsed: 0.0,
104            notice: "Recover three crystals. Wake the numbered altars.",
105            notice_time: 8.0,
106        }
107    }
108    fn count(&self) -> usize {
109        self.collected.iter().filter(|&&c| c).count()
110    }
111    fn open(&self) -> bool {
112        self.switches == 2 && self.count() == 3
113    }
114    fn obstacles(&self) -> impl Iterator<Item = &Aabb2> {
115        self.walls
116            .iter()
117            .chain((!self.open()).then_some(&self.gate))
118    }
119    fn visible(&self, p: Vec2) -> bool {
120        !self
121            .obstacles()
122            .any(|wall| wall.cast_segment(self.position, p).is_some())
123    }
124    fn near(&self, p: Vec2) -> bool {
125        self.position.distance_squared(p) < 1.7 * 1.7 && self.visible(p)
126    }
127    fn message(&mut self, text: &'static str) {
128        self.notice = text;
129        self.notice_time = 3.5;
130    }
131    fn interact(&mut self) {
132        for i in 0..self.crystals.len() {
133            if !self.collected[i] && self.near(self.crystals[i]) {
134                self.collected[i] = true;
135                self.message(if self.count() == 3 {
136                    "All three recovered. Find altar I, then altar II."
137                } else {
138                    "Crystal recovered."
139                });
140                return;
141            }
142        }
143        if self.near(at(b'A')) {
144            if self.count() < 3 {
145                self.message("Altar I needs all three crystals.");
146            } else if self.switches == 0 {
147                self.switches = 1;
148                self.message("Altar I awakened. The second altar is ready.");
149            }
150        } else if self.near(at(b'B')) {
151            if self.switches == 0 {
152                self.message("Wake altar I first.");
153            } else if self.switches == 1 {
154                self.switches = 2;
155                self.message("The seal is open. Follow the daylight.");
156            }
157        }
158    }
159    fn prompt(&self) -> Option<&'static str> {
160        if self.won {
161            return None;
162        }
163        if self
164            .crystals
165            .iter()
166            .enumerate()
167            .any(|(i, &p)| !self.collected[i] && self.near(p))
168        {
169            Some("E  /  Recover crystal")
170        } else if self.near(at(b'A')) && self.switches == 0 {
171            Some(if self.count() == 3 {
172                "E  /  Awaken altar I"
173            } else {
174                "Altar I  /  Three crystals required"
175            })
176        } else if self.near(at(b'B')) && self.switches < 2 {
177            Some(if self.switches == 1 {
178                "E  /  Awaken altar II"
179            } else {
180                "Altar II  /  Wake altar I first"
181            })
182        } else {
183            None
184        }
185    }
186    fn step(&mut self, input: Input, dt: f32) {
187        self.notice_time = (self.notice_time - dt).max(0.0);
188        if self.won {
189            return;
190        }
191        self.elapsed += dt;
192        self.yaw += input.turn * dt;
193        let front = forward(self.yaw);
194        let right = vec2(-front.y, front.x);
195        let movement = input.movement.clamp_length_max(1.0);
196        let delta = (front * movement.y + right * movement.x) * SPEED * dt;
197        // Two swept point-vs-expanded-box passes keep motion sliding along walls.
198        // Unlike destination-only tests, this also blocks unusually long steps.
199        for axis in [vec2(delta.x, 0.0), vec2(0.0, delta.y)] {
200            if axis.length_squared() < 1e-12 {
201                continue;
202            }
203            let mut fraction: f32 = 1.0;
204            for wall in self.obstacles() {
205                let expanded = Aabb2 {
206                    min: wall.min - Vec2::splat(RADIUS),
207                    max: wall.max + Vec2::splat(RADIUS),
208                };
209                if let Some(hit) = expanded.cast_segment(self.position, self.position + axis) {
210                    fraction = fraction.min((hit - 0.001 / axis.length()).max(0.0));
211                }
212            }
213            self.position += axis * fraction;
214        }
215        if input.interact {
216            self.interact();
217        }
218        if self.open() && self.position.distance(at(b'X')) < 1.2 {
219            self.won = true;
220            self.message("Expedition complete.");
221        }
222    }
223}
224
225/// The smoke driver plans a route, then emits the same movement/turn/E inputs as a player.
226#[derive(Default)]
227struct Pilot {
228    goal: Option<Vec2>,
229    path: VecDeque<Vec2>,
230}
231impl Pilot {
232    fn input(&mut self, game: &Game) -> Input {
233        if game.won {
234            return Input::default();
235        }
236        let goal = game
237            .crystals
238            .iter()
239            .enumerate()
240            .find(|(i, _)| !game.collected[*i])
241            .map(|(_, &p)| p)
242            .unwrap_or_else(|| {
243                at(if game.switches == 0 {
244                    b'A'
245                } else if game.switches == 1 {
246                    b'B'
247                } else {
248                    b'X'
249                })
250            });
251        if self.goal != Some(goal) {
252            self.goal = Some(goal);
253            self.path = route(game, goal);
254        }
255        while self
256            .path
257            .front()
258            .is_some_and(|p| game.position.distance(*p) < 0.18)
259        {
260            self.path.pop_front();
261        }
262        let target = self.path.front().copied().unwrap_or(goal);
263        let delta = target - game.position;
264        let angle = delta.x.atan2(-delta.y);
265        let turn = (angle - game.yaw + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU)
266            - std::f32::consts::PI;
267        Input {
268            movement: vec2(
269                0.0,
270                if turn.abs() < 0.15 && delta.length() > 0.06 {
271                    1.0
272                } else {
273                    0.0
274                },
275            ),
276            turn: (turn * 10.0).clamp(-3.5, 3.5),
277            interact: game.near(goal),
278        }
279    }
280}
281fn route(game: &Game, goal: Vec2) -> VecDeque<Vec2> {
282    let start = cell(game.position);
283    let finish = cell(goal);
284    let mut previous = [[None; 13]; 15];
285    let mut queue = VecDeque::from([start]);
286    previous[start.1][start.0] = Some(start);
287    while let Some((x, z)) = queue.pop_front() {
288        if (x, z) == finish {
289            break;
290        }
291        for (dx, dz) in [(1_i32, 0_i32), (-1, 0), (0, 1), (0, -1)] {
292            let nx = x as i32 + dx;
293            let nz = z as i32 + dz;
294            if !(0..13).contains(&nx) || !(0..15).contains(&nz) {
295                continue;
296            }
297            let next = (nx as usize, nz as usize);
298            let tile = MAP[next.1].as_bytes()[next.0];
299            if tile == b'#' || (tile == b'G' && !game.open()) || previous[next.1][next.0].is_some()
300            {
301                continue;
302            }
303            previous[next.1][next.0] = Some((x, z));
304            queue.push_back(next);
305        }
306    }
307    let mut path = VecDeque::new();
308    let mut node = finish;
309    while node != start {
310        path.push_front(center(node.0, node.1));
311        let Some(parent) = previous[node.1][node.0] else {
312            return VecDeque::new();
313        };
314        node = parent;
315    }
316    path
317}
318
319/// Small triangular rock faces, bounded to 14 cm beyond the collision slab.
320/// Flat normals and vertex colors provide material detail without texture assets.
321fn faceted_wall(color: Color, seed: usize) -> Mesh3d {
322    let mut vertices = Vec::new();
323    for (side, (normal, tangent)) in [
324        (Vec3::X, Vec3::Z),
325        (-Vec3::X, -Vec3::Z),
326        (Vec3::Z, -Vec3::X),
327        (-Vec3::Z, Vec3::X),
328    ]
329    .iter()
330    .enumerate()
331    {
332        for band in 0..3 {
333            let bottom = -2.1 + band as f32 * 1.4;
334            let top = bottom + 1.4;
335            let origin = *normal * TILE * 0.5;
336            let corners = [
337                origin - *tangent * TILE * 0.5 + Vec3::Y * bottom,
338                origin + *tangent * TILE * 0.5 + Vec3::Y * bottom,
339                origin + *tangent * TILE * 0.5 + Vec3::Y * top,
340                origin - *tangent * TILE * 0.5 + Vec3::Y * top,
341            ];
342            let middle = origin
343                + *normal * 0.14
344                + *tangent * (((seed + band + side) % 3) as f32 - 1.0) * 0.35
345                + Vec3::Y * (bottom + 0.63);
346            for face in 0..4 {
347                let points = [corners[face], corners[(face + 1) % 4], middle];
348                let mut face_normal = (points[1] - points[0])
349                    .cross(points[2] - points[0])
350                    .normalize();
351                if face_normal.dot(*normal) < 0.0 {
352                    face_normal = -face_normal;
353                }
354                let shade = 0.83 + ((seed + side * 3 + face + band * 2) % 5) as f32 * 0.045;
355                for point in points {
356                    let mut vertex = Vertex::new2(
357                        point,
358                        Vec2::ZERO,
359                        Color::new(color.r * shade, color.g * shade, color.b * shade, 1.0),
360                    );
361                    vertex.normal = face_normal.extend(0.0);
362                    vertices.push(vertex);
363                }
364            }
365        }
366    }
367    Mesh3d::from_mesh(Mesh {
368        indices: (0..vertices.len() as u16).collect(),
369        vertices,
370        texture: None,
371    })
372}
373
374struct Piece {
375    mesh: Mesh3d,
376    position: Vec3,
377    rotation: Quat,
378}
379struct Scene {
380    pulse: ShaderMaterial,
381    #[cfg(not(target_arch = "wasm32"))]
382    watcher: std::cell::RefCell<Option<ferravex_render::material::MaterialFileWatcher>>,
383    stone: Vec<Piece>,
384    crystal: Mesh3d,
385    tip: Mesh3d,
386    pedestal: Mesh3d,
387    rune: Mesh3d,
388    gate: Mesh3d,
389    daylight: Mesh3d,
390}
391impl Scene {
392    fn new() -> Self {
393        let mut stone = Vec::new();
394        for (z, row) in MAP.iter().enumerate() {
395            for (x, tile) in row.bytes().enumerate() {
396                let p = center(x, z);
397                let hash = (x * 17 + z * 31) % 7;
398                if tile == b'#' {
399                    let adjacent = [
400                        (x as i32 - 1, z as i32),
401                        (x as i32 + 1, z as i32),
402                        (x as i32, z as i32 - 1),
403                        (x as i32, z as i32 + 1),
404                    ]
405                    .iter()
406                    .any(|&(a, b)| {
407                        (0..13).contains(&a)
408                            && (0..15).contains(&b)
409                            && MAP[b as usize].as_bytes()[a as usize] != b'#'
410                    });
411                    if !adjacent {
412                        continue;
413                    }
414                    let c = Color::from_rgba(
415                        91 + hash as u8 * 5,
416                        69 + hash as u8 * 3,
417                        55 + hash as u8 * 2,
418                        255,
419                    );
420                    stone.push(Piece {
421                        mesh: faceted_wall(c, hash),
422                        position: world(p, 2.1),
423                        rotation: Quat::IDENTITY,
424                    });
425                } else {
426                    let shade = 45 + hash as u8 * 2;
427                    stone.push(Piece {
428                        mesh: Mesh3d::cuboid(
429                            vec3(TILE, 0.22, TILE),
430                            Color::from_rgba(shade, shade - 5, shade - 8, 255),
431                        ),
432                        position: world(p, -0.12),
433                        rotation: Quat::IDENTITY,
434                    });
435                    stone.push(Piece {
436                        mesh: Mesh3d::cuboid(
437                            vec3(TILE, 0.4, TILE),
438                            Color::from_rgba(61, 48, 41, 255),
439                        ),
440                        position: world(p, 4.3),
441                        rotation: Quat::IDENTITY,
442                    });
443                    // Ceiling formations frame the view without blocking the walking plane.
444                    if hash < 3 && tile == b'.' {
445                        stone.push(Piece {
446                            mesh: Mesh3d::cone(
447                                0.55,
448                                0.8 + hash as f32 * 0.25,
449                                Color::from_rgba(84, 64, 48, 255),
450                                5,
451                            ),
452                            position: world(p + vec2(0.6, 0.7), 3.5),
453                            rotation: Quat::from_rotation_x(std::f32::consts::PI),
454                        });
455                    }
456                }
457            }
458        }
459        let occlusion = ferravex_render::ao::AoScene::new(stone.iter().map(|p| {
460            (
461                &p.mesh.mesh,
462                Mat4::from_rotation_translation(p.rotation, p.position),
463            )
464        }))
465        .expect("cave AO scene");
466        for p in &mut stone {
467            occlusion
468                .bake(
469                    &mut p.mesh.mesh,
470                    Mat4::from_rotation_translation(p.rotation, p.position),
471                    ferravex_render::ao::AoSettings {
472                        radius: 1.5,
473                        strength: 0.3,
474                        ..Default::default()
475                    },
476                )
477                .expect("cave vertex AO");
478            p.mesh.invalidate_gpu();
479        }
480        let pulse = ShaderMaterial::new(
481            ShaderSource::fragment(include_str!("../shaders/crystal.frag")),
482            MaterialOptions {
483                blend: ferravex_render::material::BlendMode::Opaque,
484                ..Default::default()
485            },
486        )
487        .expect("crystal material");
488        #[cfg(not(target_arch = "wasm32"))]
489        let watcher = std::env::var_os("FERRAVEX_CRYSTAL_SHADER").map(|path| {
490            ferravex_render::material::MaterialFileWatcher::new(pulse.clone(), None, path.into())
491        });
492        Self {
493            pulse,
494            #[cfg(not(target_arch = "wasm32"))]
495            watcher: std::cell::RefCell::new(watcher),
496            stone,
497            crystal: Mesh3d::frustum(0.32, 0.24, 0.8, MINT, 6),
498            tip: Mesh3d::cone(0.25, 0.55, Color::from_rgba(160, 250, 235, 255), 6),
499            pedestal: Mesh3d::cylinder(0.7, 0.65, Color::from_rgba(112, 99, 79, 255), 8),
500            rune: Mesh3d::cuboid(vec3(0.075, 0.025, 0.4), MINT),
501            gate: Mesh3d::cuboid(vec3(TILE, 3.9, 0.35), Color::from_rgba(56, 102, 101, 255)),
502            daylight: Mesh3d::cuboid(vec3(2.4, 3.5, 0.08), Color::from_rgba(225, 235, 182, 255)),
503        }
504    }
505    fn draw(&self, game: &Game, material: &LitMaterial, eye: Vec3, time: f32) {
506        #[cfg(not(target_arch = "wasm32"))]
507        if let Some(watcher) = self.watcher.borrow_mut().as_mut()
508            && let Err(error) = watcher.poll()
509        {
510            eprintln!("Crystal shader reload: {error}");
511        }
512        let mut lights = [ferravex_render::lighting::PointLight {
513            position: Vec3::ZERO,
514            color: MINT,
515            radius: 4.8,
516            intensity: 1.8,
517        }; 3];
518        let mut count = 0;
519        for (i, &p) in game.crystals.iter().enumerate() {
520            if !game.collected[i] {
521                lights[count].position = world(p, 1.35);
522                count += 1;
523            }
524        }
525        ferravex_render::set_point_lights(&lights[..count]).unwrap();
526        let settings = LitSettings {
527            camera_position: eye,
528            light_direction: vec3(-0.3, 0.8, 0.4),
529            fog_color: INK,
530            fog_start: 7.0,
531            fog_end: 27.0,
532            ambient: 0.58,
533        };
534        material.begin(settings);
535        for piece in &self.stone {
536            piece.mesh.draw(piece.position, piece.rotation);
537        }
538        for marker in *b"AB" {
539            self.pedestal.draw(world(at(marker), 0.325), Quat::IDENTITY);
540        }
541        if !game.open() {
542            self.gate.draw(world(at(b'G'), 1.95), Quat::IDENTITY);
543        }
544        // Full ambient keeps crystal facets luminous without adding a texture or post-process.
545        material.begin(LitSettings {
546            ambient: 1.0,
547            ..settings
548        });
549        self.pulse
550            .begin([[time, 0., 0., 0.], [0.; 4], [0.; 4], [0.; 4]])
551            .unwrap();
552        for (i, &p) in game.crystals.iter().enumerate() {
553            if game.collected[i] {
554                continue;
555            }
556            let height = 0.95 + (time * 1.6 + i as f32).sin() * 0.09;
557            let rotation = Quat::from_rotation_y(time * 0.22 + i as f32);
558            self.crystal.draw(world(p, height), rotation);
559            self.tip.draw(world(p, height + 0.65), rotation);
560            self.tip.draw(
561                world(p, height - 0.65),
562                rotation * Quat::from_rotation_x(std::f32::consts::PI),
563            );
564        }
565        self.pulse.end();
566        for (index, marker) in b"AB".iter().enumerate() {
567            let p = at(*marker);
568            for bar in 0..=index {
569                self.rune.draw(
570                    world(
571                        p + vec2((bar as f32 - index as f32 * 0.5) * 0.19, 0.0),
572                        0.665,
573                    ),
574                    Quat::IDENTITY,
575                );
576            }
577            if game.switches as usize > index {
578                self.tip
579                    .draw(world(p, 1.2), Quat::from_rotation_y(time * 0.3));
580            }
581        }
582        if !game.open() {
583            for i in 0..3 {
584                self.rune.draw(
585                    world(at(b'G') + vec2((i as f32 - 1.0) * 0.35, 0.22), 1.65),
586                    Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
587                );
588            }
589        }
590        self.daylight
591            .draw(world(at(b'X') + vec2(0.0, -0.7), 1.75), Quat::IDENTITY);
592        material.end();
593    }
594}
595
596fn text_center(text: &str, y: f32, size: u16, color: Color) {
597    let x = (screen_width() - measure_text(text, None, size, 1.0).width) * 0.5;
598    draw_text(text, x + 1.0, y + 1.0, size as f32, BLACK);
599    draw_text(text, x, y, size as f32, color);
600}
601fn window_conf() -> Conf {
602    ferravex_render::window_conf("Ferravex / Crystal Cavern")
603}
604ferravex_render::game!(window_conf, game);
605async fn game() {
606    let font = get_default_font();
607    let characters: Vec<char> = (' '..='~').collect();
608    for size in [17, 20, 22, 23, 24, 38] {
609        font.populate_font_cache(&characters, size);
610    }
611    let mut demo = Demo::new();
612    ferravex_render::set_quality(ferravex_render::lighting::Quality::Medium);
613    let mut game = Game::new();
614    let scene = Scene::new();
615    let material = LitMaterial::new().expect("Ferravex cave shader");
616    let mut clock = FixedClock::new(60.0, 8);
617    let mut pilot = Pilot::default();
618    let mut pitch = -0.03_f32;
619    let mut previous_mouse = Vec2::from(mouse_position());
620    let mut pending_interact = false;
621    let mut help = true;
622    loop {
623        ferravex_render::ui::warm_up_font_sizes(&[17, 20, 22, 23, 24, 38]);
624        let dt = demo.dt();
625        let pointer = Vec2::from(mouse_position());
626        if is_key_pressed(KeyCode::R) {
627            game = Game::new();
628            clock.reset();
629            pilot = Pilot::default();
630            pitch = -0.03;
631            pending_interact = false;
632        }
633        if is_key_pressed(KeyCode::H) {
634            help = !help;
635        }
636        let mut input = Input::default();
637        input.movement.x = f32::from(is_key_down(KeyCode::D)) - f32::from(is_key_down(KeyCode::A));
638        input.movement.y = f32::from(is_key_down(KeyCode::W)) - f32::from(is_key_down(KeyCode::S));
639        input.turn =
640            (f32::from(is_key_down(KeyCode::Right)) - f32::from(is_key_down(KeyCode::Left))) * 1.8;
641        pitch = (pitch
642            + (f32::from(is_key_down(KeyCode::Up)) - f32::from(is_key_down(KeyCode::Down))) * dt)
643            .clamp(-0.8, 0.8);
644        if is_mouse_button_down(MouseButton::Left)
645            && !is_mouse_button_pressed(MouseButton::Left)
646            && !demo.autoplay
647        {
648            let delta = pointer - previous_mouse;
649            game.yaw += delta.x * 0.004;
650            pitch = (pitch - delta.y * 0.004).clamp(-0.8, 0.8);
651        }
652        previous_mouse = pointer;
653        pending_interact |= is_key_pressed(KeyCode::E);
654        let frame = clock.advance(dt as f64);
655        for _ in 0..frame.steps {
656            if demo.autoplay {
657                input = pilot.input(&game);
658            } else {
659                input.interact = pending_interact;
660                pending_interact = false;
661            }
662            game.step(input, frame.dt);
663        }
664        clear_background(INK);
665        let eye = world(game.position, 1.55);
666        let direction = forward(game.yaw);
667        set_camera(&Camera3D {
668            position: eye,
669            target: eye
670                + vec3(
671                    direction.x * pitch.cos(),
672                    pitch.sin(),
673                    direction.y * pitch.cos(),
674                ),
675            up: Vec3::Y,
676            fovy: 67_f32.to_radians(),
677            z_near: 0.06,
678            z_far: 80.0,
679            ..Default::default()
680        });
681        scene.draw(&game, &material, eye, demo.time());
682        set_default_camera();
683        draw_text("CRYSTAL CAVERN", 26.0, 36.0, 22.0, WARM);
684        draw_text(&format!("{} / 3", game.count()), 26.0, 63.0, 24.0, MINT);
685        if !game.won {
686            draw_circle(
687                screen_width() * 0.5,
688                screen_height() * 0.5,
689                1.7,
690                Color::new(0.8, 0.9, 0.85, 0.6),
691            );
692            if let Some(prompt) = game.prompt() {
693                text_center(prompt, screen_height() * 0.65, 23, MINT);
694            }
695            if game.notice_time > 0.0 {
696                text_center(game.notice, screen_height() - 70.0, 20, WARM);
697            }
698            if help {
699                text_center(
700                    "WASD  Move    Arrows / drag  Look    E  Interact    R  Restart    H  Hide help",
701                    screen_height() - 28.0,
702                    17,
703                    Color::new(0.78, 0.79, 0.74, 1.0),
704                );
705            }
706        } else {
707            draw_rectangle(
708                0.0,
709                screen_height() * 0.34,
710                screen_width(),
711                150.0,
712                Color::new(0.03, 0.08, 0.08, 0.86),
713            );
714            text_center("DAYLIGHT", screen_height() * 0.34 + 52.0, 38, MINT);
715            text_center(
716                &format!("Three crystals recovered  /  {:.0}s", game.elapsed),
717                screen_height() * 0.34 + 86.0,
718                22,
719                WARM,
720            );
721            text_center(
722                "R  /  Explore again",
723                screen_height() * 0.34 + 120.0,
724                20,
725                WHITE,
726            );
727        }
728        if demo.finish_frame() {
729            break;
730        }
731        next_frame().await;
732    }
733    if demo.autoplay {
734        println!(
735            "Crystal Cavern: crystals={}, altars={}, won={}, elapsed={:.2}",
736            game.count(),
737            game.switches,
738            game.won,
739            game.elapsed
740        );
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    #[test]
748    fn walls_block_even_large_steps() {
749        let mut game = Game::new();
750        game.step(
751            Input {
752                movement: vec2(1.0, 0.0),
753                ..Input::default()
754            },
755            10.0,
756        );
757        assert!(game.position.x < center(8, 13).x - TILE * 0.5 - RADIUS);
758        assert!(game.position.x > at(b'S').x);
759    }
760    #[test]
761    fn gate_requires_crystals_and_ordered_altars() {
762        let mut game = Game::new();
763        game.position = at(b'B');
764        game.interact();
765        assert_eq!(game.switches, 0);
766        game.position = at(b'A');
767        game.interact();
768        assert_eq!(game.switches, 0);
769        for p in game.crystals.clone() {
770            game.position = p;
771            game.interact();
772        }
773        assert_eq!(game.count(), 3);
774        game.position = at(b'B');
775        game.interact();
776        assert!(!game.open());
777        game.position = at(b'A');
778        game.interact();
779        assert_eq!(game.switches, 1);
780        game.position = at(b'B');
781        game.interact();
782        assert!(game.open());
783    }
784    #[test]
785    fn sealed_gate_blocks_exit_and_interaction_through_walls() {
786        let mut game = Game::new();
787        game.position = center(6, 4);
788        game.step(
789            Input {
790                movement: Vec2::Y,
791                ..Input::default()
792            },
793            2.0,
794        );
795        assert!(game.position.y > game.gate.max.y + RADIUS);
796        assert!(!game.won);
797        game.position = center(2, 7);
798        assert!(!game.visible(game.crystals[0]));
799    }
800    #[test]
801    fn autoplay_solves_with_player_inputs_and_restart_clears_state() {
802        let mut game = Game::new();
803        let mut pilot = Pilot::default();
804        for _ in 0..12_000 {
805            game.step(pilot.input(&game), 1.0 / 60.0);
806            if game.won {
807                break;
808            }
809        }
810        assert!(
811            game.won,
812            "position={:?}, crystals={}, switches={}",
813            game.position,
814            game.count(),
815            game.switches
816        );
817        assert!(game.elapsed < 120.0);
818        game = Game::new();
819        assert_eq!(game.count(), 0);
820        assert_eq!(game.switches, 0);
821        assert!(!game.won);
822        assert_eq!(game.position, at(b'S'));
823    }
824}