1use 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 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#[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
319fn 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 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 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}