1use core::f32::consts::TAU;
8use core::fmt::Display;
9use core::time::Duration;
10
11use mirage_engine::prelude::*;
12
13const MODEL: &str = "examples/assets/breakout.glb";
16
17const SOUNDS: [&str; 9] = [
20 "examples/assets/serve.ogg",
21 "examples/assets/lost.ogg",
22 "examples/assets/win.ogg",
23 "examples/assets/gameover.ogg",
24 "examples/assets/bounce.ogg",
25 "examples/assets/break.ogg",
26 "examples/assets/music.ogg",
27 "examples/assets/menu_music.ogg",
28 "examples/assets/click.ogg",
29];
30
31const PADDLE_MESH: &str = "paddle";
35
36const COURT_HALF_WIDTH: f32 = 6.0;
37const COURT_HALF_DEPTH: f32 = 8.0;
38const WALL_THICKNESS: f32 = 0.4;
39const WALL_HEIGHT: f32 = 1.0;
40
41const PADDLE_HALF_WIDTH: f32 = 1.0;
44const PADDLE_HALF_DEPTH: f32 = 0.25;
45const PADDLE_HALF_HEIGHT: f32 = 0.12;
46const PADDLE_Z: f32 = 6.6;
47const PADDLE_SPEED: f32 = 9.0;
48const PADDLE_LIMIT: f32 = COURT_HALF_WIDTH - WALL_THICKNESS - PADDLE_HALF_WIDTH;
49const PADDLE_FLASH: f32 = 0.2;
50
51const BALL_RADIUS: f32 = 0.22;
52const BALL_SPEED: f32 = 7.5;
53
54const TRAIL_LEN: usize = 5;
56const TRAIL_SCALE_MIN: f32 = 0.28;
59const TRAIL_SCALE_MAX: f32 = 0.82;
60const TRAIL_EMISSIVE_PEAK: f32 = 0.75;
64const TRAIL_ALPHA_FLOOR: f32 = 0.15;
66
67const BRICK_COLUMNS: usize = 8;
68const BRICK_ROWS: usize = 5;
69const BRICK_HALF_WIDTH: f32 = 0.55;
70const BRICK_HALF_HEIGHT: f32 = 0.3;
71const BRICK_HALF_DEPTH: f32 = 0.35;
72const BRICK_GAP: f32 = 0.18;
73const BRICK_ROW_GAP: f32 = 0.4;
76const BRICK_HITS: u8 = 2;
77const BRICK_ROW_COLORS: [Color; BRICK_ROWS] = [
78 Color::rgb(0.80, 0.20, 0.24),
79 Color::rgb(0.86, 0.47, 0.16),
80 Color::rgb(0.85, 0.74, 0.18),
81 Color::rgb(0.30, 0.62, 0.32),
82 Color::rgb(0.24, 0.45, 0.78),
83];
84
85const SPARK_BURST_COUNT: usize = 10;
87const SPARK_LIFETIME: f32 = 0.45;
89const SPARK_SPEED_MIN: f32 = 2.5;
91const SPARK_SPEED_MAX: f32 = 5.5;
92const SPARK_GRAVITY: f32 = 6.0;
94const SPARK_SIZE_START: f32 = 0.16;
96const SPARK_SIZE_END: f32 = 0.02;
97const SPARK_SPIN_SPEED: f32 = 10.0;
99const SPARK_EMISSIVE_PEAK: f32 = 2.0;
103
104const LIVES_START: u8 = 3;
105
106const LIFE_ROW_MARGIN: f32 = 0.25;
109const LIFE_ROW_X: f32 = -COURT_HALF_WIDTH + WALL_THICKNESS + BALL_RADIUS + LIFE_ROW_MARGIN;
111const LIFE_ROW_SPACING: f32 = BALL_RADIUS * 2.0 + 0.15;
113
114const BALL_GLOW: Color = Color::rgb(1.0, 0.86, 0.42);
115const BALL_LIGHT_RANGE: f32 = 9.0;
116const BALL_EMISSIVE: Color = Color::rgb(2.2, 1.85, 0.9);
118const WALL_COLOR: Color = Color::rgb(0.22, 0.24, 0.30);
119const FLOOR_COLOR: Color = Color::rgb(0.08, 0.09, 0.12);
120const PADDLE_BASE: Color = Color::rgb(0.75, 0.78, 0.85);
121const PADDLE_FLASH_EMISSIVE: Color = Color::rgb(2.6, 2.6, 3.0);
122const PADDLE_AMBIENT_EMISSIVE: Color = Color::rgb(0.05, 0.052, 0.06);
126
127const BLOOM_BASE: f32 = 0.08;
129const BLOOM_PULSE_PEAK: f32 = 0.3;
131const BRICK_FLASH: f32 = 0.25;
133const EXPOSURE_DIP_DEPTH: f32 = 0.3;
135const LIFE_LOST_FLASH: f32 = 0.5;
137
138const PADDLE_BOUNCE_PITCH: f32 = 1.15;
141const WALL_BOUNCE_PITCH: f32 = 0.85;
142const BRICK_BOUNCE_PITCH: f32 = 1.0;
143const BRICK_BREAK_REFERENCE: f32 = 10.0;
147
148const MUSIC_GAIN: f32 = 0.35;
150const MUSIC_CROSSFADE: Duration = Duration::from_secs(1);
153const MUSIC_LOOP_FROM: Duration = Duration::ZERO;
155const MENU_MUSIC_LOOP_FROM: Duration = Duration::ZERO;
156
157fn main() {
158 run(
159 Config::new("Mirage: breakout game")
160 .with_size(1280, 720)
161 .with_assets([MODEL].into_iter().chain(SOUNDS)),
162 Breakout::init,
163 );
164}
165
166#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
170struct Paddle;
171
172#[derive(Part, Clone, Debug, PartialEq, Eq, Hash)]
174enum PaddlePart {
175 #[part("Paddle Face")]
176 Face,
177}
178
179impl Mesh<PaddlePart> for Paddle {
180 fn build(&self, assets: &Assets) -> MeshData<PaddlePart> {
181 assets.mesh(PADDLE_MESH)
182 }
183}
184
185meshes! { enum Shape { Paddle, Sphere, Cube, Plane, Quad } }
188
189#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
191enum Sound {
192 Serve,
193 BallLost,
194 LevelClear,
195 GameOver,
196 Bounce,
198 BrickBreak,
199 Music,
200 MenuMusic,
201 Click,
202}
203
204impl Sounds for Sound {
205 fn build(&self, assets: &Assets) -> SoundData {
206 match self {
207 Sound::Serve => assets.sound("serve"),
208 Sound::BallLost => assets.sound("lost"),
209 Sound::LevelClear => assets.sound("win"),
210 Sound::GameOver => assets.sound("gameover"),
211 Sound::Bounce => assets.sound("bounce"),
212 Sound::BrickBreak => assets.sound("break"),
213 Sound::Music => assets.sound("music").streamed(),
214 Sound::MenuMusic => assets.sound("menu_music").streamed(),
215 Sound::Click => assets.sound("click"),
216 }
217 }
218}
219
220#[derive(InputAxisAction, Clone, Copy, PartialEq)]
223enum Move {
224 Paddle,
225}
226
227impl InputAxisAction for Move {
228 fn bindings(&self) -> Vec<AxisBinding> {
229 match self {
230 Move::Paddle => vec![
231 AxisBinding::from(ButtonAxis {
232 negative: Key::A,
233 positive: Key::D,
234 }),
235 AxisBinding::from(ButtonAxis {
236 negative: Key::Left,
237 positive: Key::Right,
238 }),
239 AxisBinding::from(ButtonAxis {
240 negative: Pad::DPadLeft,
241 positive: Pad::DPadRight,
242 }),
243 AxisBinding::pad(PadAxis::LeftX),
244 ],
245 }
246 }
247}
248
249#[derive(InputButtonAction, Clone, Copy, PartialEq)]
251enum Button {
252 Serve,
253 Pause,
254}
255
256impl InputButtonAction for Button {
257 fn bindings(&self) -> Vec<ButtonBinding> {
258 match self {
259 Button::Serve => vec![Key::Space.into(), Pad::South.into()],
260 Button::Pause => vec![Key::Escape.into(), Pad::Start.into()],
261 }
262 }
263}
264
265struct Controls;
266
267impl InputActions for Controls {
268 type Button = Button;
269 type Axis = Move;
270 type Axis2 = NoInputAxes2;
271}
272
273#[derive(Clone, Copy, PartialEq)]
275enum Listening {
276 Button(Button),
277 Move(Move),
278}
279
280#[derive(Clone, Copy)]
282struct Brick {
283 row: usize,
284 position: Vec3,
285 hits_remaining: u8,
286}
287
288#[derive(Clone, Copy)]
291struct Spark {
292 position: Vec3,
293 velocity: Vec3,
294 roll: f32,
295 age: f32,
296 color: Color,
297}
298
299#[derive(Clone, Copy, PartialEq, Eq)]
301enum Phase {
302 Serving,
304 Playing,
305 Won,
306 Lost,
307}
308
309struct Breakout {
310 paddle_x: f32,
311 paddle_prev_x: f32,
312 ball_pos: Vec3,
313 ball_prev: Vec3,
314 ball_vel: Vec3,
315 ball_trail: [Vec3; TRAIL_LEN + 1],
319 bricks: Vec<Brick>,
320 sparks: Vec<Spark>,
322 score: u32,
323 lives: u8,
324 phase: Phase,
325 paused: bool,
326 paddle_flash: f32,
327 brick_flash: f32,
329 life_lost_flash: f32,
332 master_volume: f32,
333 listening: Option<Listening>,
335}
336
337impl Breakout {
338 fn init(_ctx: &mut InitContext<'_, Breakout>) -> Result<Self, Error> {
341 Ok(Self::new())
342 }
343
344 fn new() -> Self {
345 let mut game = Self {
346 paddle_x: 0.0,
347 paddle_prev_x: 0.0,
348 ball_pos: Vec3::ZERO,
349 ball_prev: Vec3::ZERO,
350 ball_vel: Vec3::ZERO,
351 ball_trail: [Vec3::ZERO; TRAIL_LEN + 1],
352 bricks: spawn_bricks(),
353 sparks: Vec::new(),
354 score: 0,
355 lives: LIVES_START,
356 phase: Phase::Serving,
357 paused: false,
358 paddle_flash: 0.0,
359 brick_flash: 0.0,
360 life_lost_flash: 0.0,
361 master_volume: 1.0,
362 listening: None,
363 };
364 game.ready_serve();
365 game
366 }
367
368 fn restart(&mut self) {
370 let master_volume = self.master_volume;
371 *self = Self::new();
372 self.master_volume = master_volume;
373 }
374
375 fn ready_serve(&mut self) {
378 self.paddle_x = 0.0;
379 self.paddle_prev_x = 0.0;
380 self.ball_pos = Vec3::new(
381 0.0,
382 BALL_RADIUS,
383 PADDLE_Z - PADDLE_HALF_DEPTH - BALL_RADIUS - 1.2,
384 );
385 self.ball_prev = self.ball_pos;
386 self.ball_vel = Vec3::ZERO;
387 self.ball_trail = [self.ball_pos; TRAIL_LEN + 1];
388 self.phase = Phase::Serving;
389 }
390
391 fn launch(&mut self) {
393 self.ball_vel = Vec3::new(0.35, 0.0, -1.0).normalize() * BALL_SPEED;
394 self.phase = Phase::Playing;
395 }
396
397 fn hold_ball(&mut self, ctx: &mut TickContext<'_, Breakout>) {
400 self.ball_prev = self.ball_pos;
401 self.ball_pos.x = self.paddle_x;
402 self.ball_trail = [self.ball_pos; TRAIL_LEN + 1];
403
404 if !ctx.ui_wants_keyboard() && ctx.pressed(Button::Serve) {
405 self.launch();
406 ctx.play(Sound::Serve);
407 }
408 }
409
410 fn step_paddle(&mut self, axis: f32, dt: f32) {
411 self.paddle_prev_x = self.paddle_x;
412 self.paddle_x =
413 (self.paddle_x + axis * PADDLE_SPEED * dt).clamp(-PADDLE_LIMIT, PADDLE_LIMIT);
414 }
415
416 fn step_ball(&mut self, ctx: &mut TickContext<'_, Breakout>, dt: f32) {
417 self.ball_prev = self.ball_pos;
418 self.ball_pos += self.ball_vel * dt;
419
420 self.bounce_walls(ctx);
421 self.bounce_paddle(ctx);
422 self.bounce_bricks(ctx);
423 self.push_trail();
424
425 if self.ball_pos.z - BALL_RADIUS > COURT_HALF_DEPTH {
426 self.lose_life(ctx);
427 }
428 }
429
430 fn push_trail(&mut self) {
433 self.ball_trail.rotate_right(1);
434 self.ball_trail[0] = self.ball_pos;
435 }
436
437 fn bounce_walls(&mut self, ctx: &mut TickContext<'_, Breakout>) {
438 let left = -COURT_HALF_WIDTH + WALL_THICKNESS;
439 let right = COURT_HALF_WIDTH - WALL_THICKNESS;
440 let top = -COURT_HALF_DEPTH + WALL_THICKNESS;
441
442 let mut hit = false;
443 if self.ball_pos.x - BALL_RADIUS < left {
444 self.ball_pos.x = left + BALL_RADIUS;
445 self.ball_vel.x = self.ball_vel.x.abs();
446 hit = true;
447 } else if self.ball_pos.x + BALL_RADIUS > right {
448 self.ball_pos.x = right - BALL_RADIUS;
449 self.ball_vel.x = -self.ball_vel.x.abs();
450 hit = true;
451 }
452
453 if self.ball_pos.z - BALL_RADIUS < top {
454 self.ball_pos.z = top + BALL_RADIUS;
455 self.ball_vel.z = self.ball_vel.z.abs();
456 hit = true;
457 }
458
459 if hit {
460 ctx.play(Sound::Bounce.pitch(WALL_BOUNCE_PITCH));
461 }
462 }
463
464 fn bounce_paddle(&mut self, ctx: &mut TickContext<'_, Breakout>) {
466 if self.ball_vel.z <= 0.0 {
467 return;
468 }
469 let reach_x = PADDLE_HALF_WIDTH + BALL_RADIUS;
470 let reach_z = PADDLE_HALF_DEPTH + BALL_RADIUS;
471 let dx = self.ball_pos.x - self.paddle_x;
472 let dz = self.ball_pos.z - PADDLE_Z;
473 if dx.abs() > reach_x || dz.abs() > reach_z {
474 return;
475 }
476
477 let offset = (dx / PADDLE_HALF_WIDTH).clamp(-1.0, 1.0);
478 self.ball_vel = Vec3::new(offset, 0.0, -1.0).normalize() * BALL_SPEED;
479 self.ball_pos.z = PADDLE_Z - reach_z;
480 self.paddle_flash = PADDLE_FLASH;
481 ctx.play(Sound::Bounce.pitch(PADDLE_BOUNCE_PITCH));
482 }
483
484 fn bounce_bricks(&mut self, ctx: &mut TickContext<'_, Breakout>) {
486 let reach_x = BRICK_HALF_WIDTH + BALL_RADIUS;
487 let reach_z = BRICK_HALF_DEPTH + BALL_RADIUS;
488 let mut broken = None;
489
490 for brick in self
491 .bricks
492 .iter_mut()
493 .filter(|brick| brick.hits_remaining > 0)
494 {
495 let dx = self.ball_pos.x - brick.position.x;
496 let dz = self.ball_pos.z - brick.position.z;
497 if dx.abs() > reach_x || dz.abs() > reach_z {
498 continue;
499 }
500
501 if reach_x - dx.abs() < reach_z - dz.abs() {
502 self.ball_vel.x = if dx < 0.0 {
503 -self.ball_vel.x.abs()
504 } else {
505 self.ball_vel.x.abs()
506 };
507 } else {
508 self.ball_vel.z = if dz < 0.0 {
509 -self.ball_vel.z.abs()
510 } else {
511 self.ball_vel.z.abs()
512 };
513 }
514
515 brick.hits_remaining -= 1;
516 self.score += 10 * (BRICK_ROWS - brick.row) as u32;
517 ctx.play(Sound::Bounce.pitch(BRICK_BOUNCE_PITCH));
518 if brick.hits_remaining == 0 {
519 ctx.play(
520 Sound::BrickBreak
521 .at(brick.position)
522 .reference(BRICK_BREAK_REFERENCE),
523 );
524 self.brick_flash = BRICK_FLASH;
525 broken = Some((brick.position, BRICK_ROW_COLORS[brick.row]));
526 }
527 break;
528 }
529
530 if let Some((position, color)) = broken {
531 self.spawn_sparks(position, color);
532 }
533
534 if self.bricks.iter().all(|brick| brick.hits_remaining == 0) {
535 self.phase = Phase::Won;
536 ctx.play(Sound::LevelClear);
537 }
538 }
539
540 fn spawn_sparks(&mut self, position: Vec3, color: Color) {
543 for i in 0..SPARK_BURST_COUNT {
544 let t = i as f32 / SPARK_BURST_COUNT as f32;
545 let azimuth = t * TAU;
546 let rise = 0.6 + 0.4 * (t * 3.0).fract();
547 let speed = SPARK_SPEED_MIN.lerp(SPARK_SPEED_MAX, (t * 5.0).fract());
548 let direction = Vec3::new(azimuth.cos(), rise, azimuth.sin()).normalize();
549 self.sparks.push(Spark {
550 position,
551 velocity: direction * speed,
552 roll: azimuth,
553 age: 0.0,
554 color,
555 });
556 }
557 }
558
559 fn step_sparks(&mut self, dt: f32) {
562 for spark in &mut self.sparks {
563 spark.velocity.y -= SPARK_GRAVITY * dt;
564 spark.position += spark.velocity * dt;
565 spark.age += dt;
566 }
567 self.sparks.retain(|spark| spark.age < SPARK_LIFETIME);
568 }
569
570 fn lose_life(&mut self, ctx: &mut TickContext<'_, Breakout>) {
571 self.life_lost_flash = LIFE_LOST_FLASH;
572 self.lives = self.lives.saturating_sub(1);
573 if self.lives == 0 {
574 self.phase = Phase::Lost;
575 ctx.play(Sound::GameOver);
576 } else {
577 ctx.play(Sound::BallLost);
578 self.ready_serve();
579 }
580 }
581
582 fn camera() -> Camera {
583 Camera::new(
584 View::look_at(Vec3::new(0.0, 13.5, 12.5), Vec3::new(0.0, 0.0, 0.5)),
585 Projection::perspective(50.0),
586 )
587 }
588
589 fn paddle_face_material(&self) -> Material {
594 let t = (self.paddle_flash / PADDLE_FLASH).clamp(0.0, 1.0);
595 let flash = PADDLE_FLASH_EMISSIVE.dimmed(t);
596 let emissive = Color::rgb(
597 PADDLE_AMBIENT_EMISSIVE.red + flash.red,
598 PADDLE_AMBIENT_EMISSIVE.green + flash.green,
599 PADDLE_AMBIENT_EMISSIVE.blue + flash.blue,
600 );
601 Material::lit(PADDLE_BASE).emissive(emissive)
602 }
603
604 fn draw_court(&self, ctx: &mut FrameContext<'_, Breakout>) {
605 ctx.draw(
606 Plane
607 .at(Transform::from_scale(Vec3::new(
608 COURT_HALF_WIDTH * 2.0,
609 1.0,
610 COURT_HALF_DEPTH * 2.0,
611 )))
612 .material(Material::lit(FLOOR_COLOR)),
613 );
614
615 let side_half = Vec3::new(WALL_THICKNESS * 0.5, WALL_HEIGHT * 0.5, COURT_HALF_DEPTH);
616 for side in [-1.0, 1.0] {
617 let x = side * (COURT_HALF_WIDTH - WALL_THICKNESS * 0.5);
618 ctx.draw(
619 Cube.at(Transform::from_scale_rotation_translation(
620 side_half * 2.0,
621 Quat::IDENTITY,
622 Vec3::new(x, side_half.y, 0.0),
623 ))
624 .material(Material::lit(WALL_COLOR)),
625 );
626 }
627
628 let top_half = Vec3::new(COURT_HALF_WIDTH, WALL_HEIGHT * 0.5, WALL_THICKNESS * 0.5);
629 ctx.draw(
630 Cube.at(Transform::from_scale_rotation_translation(
631 top_half * 2.0,
632 Quat::IDENTITY,
633 Vec3::new(0.0, top_half.y, -COURT_HALF_DEPTH + WALL_THICKNESS * 0.5),
634 ))
635 .material(Material::lit(WALL_COLOR)),
636 );
637 }
638
639 fn draw_bricks(&self, ctx: &mut FrameContext<'_, Breakout>) {
640 let scale = Vec3::new(
641 BRICK_HALF_WIDTH * 2.0,
642 BRICK_HALF_HEIGHT * 2.0,
643 BRICK_HALF_DEPTH * 2.0,
644 );
645 for brick in self.bricks.iter().filter(|brick| brick.hits_remaining > 0) {
646 let health = f32::from(brick.hits_remaining) / f32::from(BRICK_HITS);
647 let color = BRICK_ROW_COLORS[brick.row].dimmed(0.4 + 0.6 * health);
648 ctx.draw(
649 Cube.at(Transform::from_scale_rotation_translation(
650 scale,
651 Quat::IDENTITY,
652 brick.position,
653 ))
654 .material(Material::shaded(color, health)),
655 );
656 }
657 }
658
659 fn draw_sparks(&self, ctx: &mut FrameContext<'_, Breakout>) {
662 for spark in &self.sparks {
663 let age = (spark.age / SPARK_LIFETIME).clamp(0.0, 1.0);
664 let fade = 1.0 - age;
665 let size = SPARK_SIZE_START.lerp(SPARK_SIZE_END, age);
666 ctx.draw(
667 Quad.at(Transform::from_scale_rotation_translation(
668 Vec3::splat(size),
669 Quat::IDENTITY,
670 spark.position,
671 ))
672 .billboard()
673 .roll(spark.roll + spark.age * SPARK_SPIN_SPEED)
674 .material(
675 Material::color(spark.color.with_alpha(fade))
676 .emissive(spark.color.dimmed(SPARK_EMISSIVE_PEAK))
677 .additive(),
678 ),
679 );
680 }
681 }
682
683 fn draw_trail(&self, ctx: &mut FrameContext<'_, Breakout>, alpha: f32) {
690 let head = self.ball_trail[1].lerp(self.ball_trail[0], alpha);
691 for i in 0..TRAIL_LEN {
692 let position = self.ball_trail[i + 1].lerp(self.ball_trail[i], alpha);
693 let age = (i + 1) as f32 / TRAIL_LEN as f32;
694 let fade = (1.0 - age).max(TRAIL_ALPHA_FLOOR);
695 let radius = (BALL_RADIUS * TRAIL_SCALE_MIN.lerp(TRAIL_SCALE_MAX, fade))
696 .min((BALL_RADIUS - head.distance(position)).max(0.0));
697 let scale = Vec3::splat(radius * 2.0);
698 ctx.draw(
699 Sphere { subdivisions: 2 }
700 .at(Transform::from_scale_rotation_translation(
701 scale,
702 Quat::IDENTITY,
703 position,
704 ))
705 .material(
706 Material::color(BALL_GLOW.with_alpha(fade))
707 .emissive(BALL_EMISSIVE.dimmed(TRAIL_EMISSIVE_PEAK)),
708 ),
709 );
710 }
711 }
712
713 fn draw_lives(&self, ctx: &mut FrameContext<'_, Breakout>) {
716 let held_lives = self.lives.saturating_sub(1);
717 for slot in 0..held_lives {
718 let z = PADDLE_Z + (slot + 1) as f32 * LIFE_ROW_SPACING;
719 ctx.draw(
720 Sphere { subdivisions: 2 }
721 .at(Transform::from_scale_rotation_translation(
722 Vec3::splat(BALL_RADIUS * 2.0),
723 Quat::IDENTITY,
724 Vec3::new(LIFE_ROW_X, BALL_RADIUS, z),
725 ))
726 .material(
727 Material::color(BALL_GLOW)
728 .emissive(BALL_EMISSIVE)
729 .additive(),
730 ),
731 );
732 }
733 }
734
735 fn overlay(&mut self, ctx: &mut FrameContext<'_, Breakout>) {
736 let bricks_left = self
737 .bricks
738 .iter()
739 .filter(|brick| brick.hits_remaining > 0)
740 .count();
741 let move_hint = bindings_text(ctx.bindings(Move::Paddle));
744 let pause_hint = bindings_text(ctx.bindings(Button::Pause));
745 let serve_hint = bindings_text(ctx.bindings(Button::Serve));
746 ctx.ui(|ui| {
747 ui.horizontal(|ui| {
748 ui.label(egui::RichText::new(format!("score {}", self.score)).size(32.0));
749 ui.label(format!("{bricks_left} bricks left"));
750 });
751 ui.label(format!("move: {move_hint} · {pause_hint} to pause"));
752 if self.phase == Phase::Serving {
753 ui.label(format!("{serve_hint} to serve"));
754 }
755 });
756
757 match self.phase {
758 Phase::Serving | Phase::Playing if self.paused => self.menu(ctx, "paused", false),
759 Phase::Won => self.menu(ctx, "you win", true),
760 Phase::Lost => self.menu(ctx, "game over", true),
761 _ => {}
762 }
763 }
764
765 fn menu(&mut self, ctx: &mut FrameContext<'_, Breakout>, title: &str, over: bool) {
766 let mut clicked = false;
767 let mut quit = false;
768
769 let buttons: Vec<(Button, String)> = Button::all()
772 .into_iter()
773 .map(|action| (action, bindings_text(ctx.bindings(action))))
774 .collect();
775 let axes: Vec<(Move, String)> = Move::all()
776 .into_iter()
777 .map(|action| (action, bindings_text(ctx.bindings(action))))
778 .collect();
779 let listening = self.listening;
780 let actuated_button = (!ctx.ui_wants_keyboard())
781 .then(|| ctx.actuated_button())
782 .flatten();
783 let actuated_axis = (!ctx.ui_wants_keyboard())
784 .then(|| ctx.actuated_axis())
785 .flatten();
786 let mut reset = None;
787
788 ctx.ui(|ui| {
789 egui::Window::new(title)
790 .collapsible(false)
791 .resizable(false)
792 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
793 .show(ui.ctx(), |ui| {
794 if over {
795 ui.label(format!("score {}", self.score));
796 }
797 if !over {
798 ui.add(
799 egui::Slider::new(&mut self.master_volume, 0.0..=1.0).text("volume"),
800 );
801 if ui.button("resume").clicked() {
802 self.paused = false;
803 clicked = true;
804 }
805 ui.separator();
806 ui.heading("controls");
807 for (action, text) in &buttons {
808 controls_row(
809 ui,
810 action.name(),
811 text,
812 listening == Some(Listening::Button(*action)),
813 &mut self.listening,
814 Listening::Button(*action),
815 &mut reset,
816 );
817 }
818 for (action, text) in &axes {
819 controls_row(
820 ui,
821 action.name(),
822 text,
823 listening == Some(Listening::Move(*action)),
824 &mut self.listening,
825 Listening::Move(*action),
826 &mut reset,
827 );
828 }
829 }
830 if ui.button("restart").clicked() {
831 self.restart();
832 clicked = true;
833 }
834 if ui.button("quit").clicked() {
835 quit = true;
836 }
837 });
838 });
839
840 match (self.listening, actuated_button, actuated_axis) {
841 (Some(Listening::Button(action)), Some(binding), _) => {
842 ctx.rebind(action, vec![binding]);
843 self.listening = None;
844 }
845 (Some(Listening::Move(action)), _, Some(binding)) => {
846 ctx.rebind(action, vec![binding]);
847 self.listening = None;
848 }
849 _ => {}
850 }
851 match reset {
852 Some(Listening::Button(action)) => ctx.rebind(action, action.bindings()),
853 Some(Listening::Move(action)) => ctx.rebind(action, action.bindings()),
854 None => {}
855 }
856
857 if clicked {
858 ctx.play(Sound::Click);
859 }
860 if quit {
861 ctx.close();
862 }
863 }
864
865 fn sustain_music(&self, ctx: &mut FrameContext<'_, Breakout>) {
873 let playing = !self.paused && matches!(self.phase, Phase::Serving | Phase::Playing);
874 let gain = |wanted: bool| match wanted {
875 true => MUSIC_GAIN,
876 false => 0.0,
877 };
878
879 ctx.sustain(
880 Sound::Music
881 .gain(gain(playing))
882 .fade(MUSIC_CROSSFADE)
883 .glide(MUSIC_CROSSFADE)
884 .loop_from(MUSIC_LOOP_FROM),
885 );
886 ctx.sustain(
887 Sound::MenuMusic
888 .gain(gain(!playing))
889 .fade(MUSIC_CROSSFADE)
890 .glide(MUSIC_CROSSFADE)
891 .loop_from(MENU_MUSIC_LOOP_FROM),
892 );
893 }
894}
895
896fn controls_row(
901 ui: &mut egui::Ui,
902 name: &str,
903 bindings: &str,
904 listening: bool,
905 target: &mut Option<Listening>,
906 action: Listening,
907 reset: &mut Option<Listening>,
908) {
909 ui.horizontal(|ui| {
910 ui.label(format!("{name}: {bindings}"));
911 if listening {
912 ui.label("listening");
913 if ui.button("cancel").clicked() {
914 *target = None;
915 }
916 } else if ui.button("rebind").clicked() {
917 *target = Some(action);
918 }
919 if ui.button("reset").clicked() {
920 *reset = Some(action);
921 }
922 });
923}
924
925fn bindings_text<B: Display>(bindings: Vec<B>) -> String {
928 bindings
929 .iter()
930 .map(ToString::to_string)
931 .collect::<Vec<_>>()
932 .join(", ")
933}
934
935fn spawn_bricks() -> Vec<Brick> {
936 let cell = BRICK_HALF_WIDTH * 2.0 + BRICK_GAP;
937 let row_span = BRICK_HALF_DEPTH * 2.0 + BRICK_ROW_GAP;
938 let grid_width = cell * BRICK_COLUMNS as f32 - BRICK_GAP;
939 let start_x = -grid_width * 0.5 + BRICK_HALF_WIDTH;
940 let start_z = -COURT_HALF_DEPTH + WALL_THICKNESS + BRICK_HALF_DEPTH + 0.6;
941
942 (0..BRICK_ROWS)
943 .flat_map(|row| {
944 (0..BRICK_COLUMNS).map(move |column| Brick {
945 row,
946 position: Vec3::new(
947 start_x + column as f32 * cell,
948 BRICK_HALF_HEIGHT,
949 start_z + row as f32 * row_span,
950 ),
951 hits_remaining: BRICK_HITS,
952 })
953 })
954 .collect()
955}
956
957impl Game for Breakout {
958 type Meshes = Shape;
959 type Sounds = Sound;
960 type InputActions = Controls;
961 type Skyboxes = NoSkyboxes;
962 type SurfaceStyles = NoSurfaceStyles;
963 type PostEffects = NoPostEffects;
964
965 fn tick(&mut self, ctx: &mut TickContext<'_, Breakout>) {
966 if self.paused {
967 return;
968 }
969
970 let dt = ctx.dt().as_secs_f32();
971 self.paddle_flash = (self.paddle_flash - dt).max(0.0);
972 self.brick_flash = (self.brick_flash - dt).max(0.0);
973 self.life_lost_flash = (self.life_lost_flash - dt).max(0.0);
974 self.step_sparks(dt);
975
976 if matches!(self.phase, Phase::Won | Phase::Lost) {
979 return;
980 }
981
982 let axis = if ctx.ui_wants_keyboard() {
983 0.0
984 } else {
985 ctx.axis(Move::Paddle)
986 };
987 self.step_paddle(axis, dt);
988
989 match self.phase {
990 Phase::Serving => self.hold_ball(ctx),
991 _ => self.step_ball(ctx, dt),
992 }
993 }
994
995 fn frame(&mut self, ctx: &mut FrameContext<'_, Breakout>) {
996 if matches!(self.phase, Phase::Serving | Phase::Playing) && ctx.pressed(Button::Pause) {
997 self.paused = !self.paused;
998 }
999
1000 ctx.set_volume(self.master_volume);
1001 self.sustain_music(ctx);
1002
1003 ctx.set_camera(Self::camera());
1004
1005 let brick_pulse = (self.brick_flash / BRICK_FLASH).clamp(0.0, 1.0);
1006 ctx.set_bloom((BLOOM_BASE + brick_pulse * BLOOM_PULSE_PEAK).clamp(0.0, 1.0));
1007
1008 let life_lost_t = (self.life_lost_flash / LIFE_LOST_FLASH).clamp(0.0, 1.0);
1009 ctx.set_exposure((1.0 - life_lost_t * EXPOSURE_DIP_DEPTH).clamp(0.0, 1.0));
1010
1011 let alpha = match self.phase {
1014 Phase::Serving | Phase::Playing if !self.paused => ctx.alpha(),
1015 _ => 1.0,
1016 };
1017 let paddle_x = self.paddle_prev_x.lerp(self.paddle_x, alpha);
1018 let ball_pos = self.ball_prev.lerp(self.ball_pos, alpha);
1019
1020 ctx.light(Light::point(ball_pos, BALL_GLOW, BALL_LIGHT_RANGE).shadow());
1021
1022 self.draw_court(ctx);
1023 self.draw_bricks(ctx);
1024 self.draw_sparks(ctx);
1025 self.draw_lives(ctx);
1026
1027 ctx.draw(
1028 Paddle
1029 .at(Transform::from_translation(Vec3::new(
1030 paddle_x,
1031 PADDLE_HALF_HEIGHT,
1032 PADDLE_Z,
1033 )))
1034 .material_of(PaddlePart::Face, self.paddle_face_material()),
1035 );
1036
1037 self.draw_trail(ctx, alpha);
1038 ctx.draw(
1039 Sphere { subdivisions: 2 }
1040 .at(Transform::from_scale_rotation_translation(
1041 Vec3::splat(BALL_RADIUS * 2.0),
1042 Quat::IDENTITY,
1043 ball_pos,
1044 ))
1045 .material(
1046 Material::color(BALL_GLOW)
1047 .emissive(BALL_EMISSIVE)
1048 .additive(),
1049 ),
1050 );
1051
1052 self.overlay(ctx);
1053 }
1054}