Skip to main content

breakout_game/
breakout-game.rs

1//! 3D breakout: a paddle patrols the near wall of a walled court, batting a
2//! ball at a grid of bricks. The ball is the only light in the scene, so it
3//! draws its own glow across a dim court. Movement and collision are
4//! fixed-step physics in `tick`; drawing interpolates between the last two
5//! steps. Escape pauses; every menu offers a restart and a close.
6
7use core::f32::consts::TAU;
8use core::fmt::Display;
9use core::time::Duration;
10
11use mirage_engine::prelude::*;
12
13/// The mesh source, next to `index.html` on the web and under the working
14/// directory on the desktop — one path that resolves on both.
15const MODEL: &str = "examples/assets/breakout.glb";
16
17/// The game's sounds, alongside the mesh source — one file per [`Sound`]
18/// variant, named by its stem inside `build`.
19const 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
31/// The paddle's playing surface, addressed and repainted every frame; its
32/// underside and end caps go unnamed in the source, so they draw exactly as
33/// it authored them.
34const 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
41/// Half the paddle's width and depth, matching the half-extents
42/// `tools/breakout_fixture.py` authored the mesh at.
43const 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
54/// Ghost positions the ball's trail holds.
55const TRAIL_LEN: usize = 5;
56/// Smallest and largest a trail ghost's diameter shrinks to, as a fraction
57/// of the ball's.
58const TRAIL_SCALE_MIN: f32 = 0.28;
59const TRAIL_SCALE_MAX: f32 = 0.82;
60/// A trail ghost's emissive share of the ball's own; the tint alpha below
61/// is what fades the glow back along the trail, so this stays constant per
62/// ghost rather than fading a second time.
63const TRAIL_EMISSIVE_PEAK: f32 = 0.75;
64/// The tint alpha the oldest ghost fades to, never all the way to nothing.
65const 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;
73/// The gap between rows, wider than [`BRICK_GAP`] between columns so each
74/// row reads as its own band and a broken brick's gap shows against it.
75const 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
85/// Sparks a broken brick sends outward.
86const SPARK_BURST_COUNT: usize = 10;
87/// Spark lifetime, in seconds.
88const SPARK_LIFETIME: f32 = 0.45;
89/// A spark's outward speed range, in meters per second.
90const SPARK_SPEED_MIN: f32 = 2.5;
91const SPARK_SPEED_MAX: f32 = 5.5;
92/// Gravity that curves a spark's path back down.
93const SPARK_GRAVITY: f32 = 6.0;
94/// A spark's edge length at birth and where it shrinks to, in meters.
95const SPARK_SIZE_START: f32 = 0.16;
96const SPARK_SIZE_END: f32 = 0.02;
97/// A spark's roll speed in the view plane, in radians per second.
98const SPARK_SPIN_SPEED: f32 = 10.0;
99/// A spark's emissive share of its row color; past `1.0` so it blooms. The
100/// tint alpha below is what fades it out, so this stays constant per spark
101/// rather than fading a second time.
102const SPARK_EMISSIVE_PEAK: f32 = 2.0;
103
104const LIVES_START: u8 = 3;
105
106/// Distance the row of held ball meshes sits in from the near wall, clear
107/// of the paddle's own path and the brick grid.
108const LIFE_ROW_MARGIN: f32 = 0.25;
109/// Fixed `x` the row sits at, alongside the paddle's own path.
110const LIFE_ROW_X: f32 = -COURT_HALF_WIDTH + WALL_THICKNESS + BALL_RADIUS + LIFE_ROW_MARGIN;
111/// Gap between two held ball meshes in the row, center to center.
112const 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;
116/// Past `1.0`, so the ball blooms rather than only glowing.
117const 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);
122/// A small emissive kept under [`PADDLE_FLASH_EMISSIVE`], so the paddle
123/// stays visible where the ball's own light does not reach, rather than
124/// drawing unlit.
125const PADDLE_AMBIENT_EMISSIVE: Color = Color::rgb(0.05, 0.052, 0.06);
126
127/// Baseline bloom spread during play; a brick break pulses on top of it.
128const BLOOM_BASE: f32 = 0.08;
129/// Bloom a brick break's pulse adds at its peak, above the baseline.
130const BLOOM_PULSE_PEAK: f32 = 0.3;
131/// Decay time for a brick break's bloom pulse back to the baseline.
132const BRICK_FLASH: f32 = 0.25;
133/// Exposure dip below `1.0` at the instant a ball is lost.
134const EXPOSURE_DIP_DEPTH: f32 = 0.3;
135/// Time for the exposure dip to recover after a ball is lost.
136const LIFE_LOST_FLASH: f32 = 0.5;
137
138/// Pitch the shared bounce clip plays at off the paddle; walls and bricks
139/// play it at their own pitches instead, so one clip covers three surfaces.
140const PADDLE_BOUNCE_PITCH: f32 = 1.15;
141const WALL_BOUNCE_PITCH: f32 = 0.85;
142const BRICK_BOUNCE_PITCH: f32 = 1.0;
143/// Distance a break holds its full level within: the court lies about 20
144/// meters from the camera, which is the listener, so a break at any brick is
145/// heard.
146const BRICK_BREAK_REFERENCE: f32 = 10.0;
147
148/// Gameplay and menu music volume under their default gain.
149const MUSIC_GAIN: f32 = 0.35;
150/// Span a track fades in over, and the span its gain slides over when a menu
151/// opens or closes, which is what makes the crossfade.
152const MUSIC_CROSSFADE: Duration = Duration::from_secs(1);
153/// Gameplay and menu music loop point; `ZERO` until each is set by ear.
154const 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/// The loaded paddle: its playing surface, addressed and repainted every
167/// frame; its underside and end caps go unnamed in the source, so they draw
168/// exactly as it authored them.
169#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
170struct Paddle;
171
172/// The part a draw of [`Paddle`] can repaint: its playing surface.
173#[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
185// Everything else this game draws is an engine primitive, scaled, placed
186// and given its color per draw.
187meshes! { enum Shape { Paddle, Sphere, Cube, Plane, Quad } }
188
189/// Every clip this game plays, named by its stem.
190#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
191enum Sound {
192    Serve,
193    BallLost,
194    LevelClear,
195    GameOver,
196    /// Paddle and wall hits share this clip, differing only in pitch.
197    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/// Distance the paddle is pushed; the bindings are alternatives, and the one
221/// pushed furthest is kept.
222#[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/// Every verb this game reads as held or not.
250#[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/// The action a controls-menu rebind is waiting on, if any.
274#[derive(Clone, Copy, PartialEq)]
275enum Listening {
276    Button(Button),
277    Move(Move),
278}
279
280/// One surviving or destroyed brick in the grid.
281#[derive(Clone, Copy)]
282struct Brick {
283    row: usize,
284    position: Vec3,
285    hits_remaining: u8,
286}
287
288/// One spark from a broken brick's burst: its own position and velocity,
289/// a roll that keeps advancing as it tumbles, and its age since spawn.
290#[derive(Clone, Copy)]
291struct Spark {
292    position: Vec3,
293    velocity: Vec3,
294    roll: f32,
295    age: f32,
296    color: Color,
297}
298
299/// The round's current state.
300#[derive(Clone, Copy, PartialEq, Eq)]
301enum Phase {
302    /// The ball is held above the paddle, waiting for `Button::Serve`.
303    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    /// The ball's last few resolved positions, the newest first, plus one
316    /// further so each drawn ghost has a previous tick to interpolate from;
317    /// drawn as a fading trail.
318    ball_trail: [Vec3; TRAIL_LEN + 1],
319    bricks: Vec<Brick>,
320    /// Live sparks from broken bricks, integrated and aged in `tick`.
321    sparks: Vec<Spark>,
322    score: u32,
323    lives: u8,
324    phase: Phase,
325    paused: bool,
326    paddle_flash: f32,
327    /// Counts down from `BRICK_FLASH` after a brick breaks, pulsing bloom.
328    brick_flash: f32,
329    /// Counts down from `LIFE_LOST_FLASH` after a ball is lost, dipping
330    /// exposure.
331    life_lost_flash: f32,
332    master_volume: f32,
333    /// The action a controls-menu rebind is waiting on, if any.
334    listening: Option<Listening>,
335}
336
337impl Breakout {
338    /// Prepares every mesh kind so no draw hitches on its first frame, then
339    /// starts the first round.
340    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    /// Starts a new round, keeping the player's volume setting.
369    fn restart(&mut self) {
370        let master_volume = self.master_volume;
371        *self = Self::new();
372        self.master_volume = master_volume;
373    }
374
375    /// Centers the paddle and places the ball above it, waiting for
376    /// [`Button::Serve`].
377    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    /// Sends the held ball back up-court, into play.
392    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    /// Holds the ball above the paddle while it waits to be served, tracking
398    /// the paddle's own steering, and launches it once the player serves.
399    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    /// Shifts the ghost trail back one slot and records the ball's newly
431    /// resolved position at the front.
432    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    /// Bounces the ball off the paddle, steering it by where it landed.
465    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    /// Bounces the ball off the nearest overlapping brick, damaging it.
485    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    /// Sends [`SPARK_BURST_COUNT`] sparks outward and upward from a broken
541    /// brick's position, spread by index so no randomness is needed.
542    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    /// Integrates and ages the live sparks, dropping any past their
560    /// lifetime.
561    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    /// The paddle's face material: fully lit by the court's own lights, its
590    /// base tone flashed with emissive light past `1.0` just after it last
591    /// hit the ball, and a small emissive kept under that so it stays
592    /// visible where the ball's own light does not reach.
593    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    /// Draws the live spark burst: additive, tumbling by roll as they age,
660    /// shrinking and fading out over their lifetime.
661    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    /// Draws the ball's ghost trail, each ghost smaller and more transparent
684    /// than the one ahead of it; each ghost's position interpolates between
685    /// its own last two resolved ticks by the same `alpha` the ball itself
686    /// draws at, and its radius clamps to what the ball's own radius has
687    /// left over its distance from the head, so a ghost still close to the
688    /// ball never draws past its edge.
689    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    /// Draws one held ball for every life past the one in play, set in a
714    /// row alongside the paddle's own path.
715    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        // Read before `ctx.ui` so a rebind changes what the hint reads this
742        // frame too.
743        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        // `ctx.ui` cannot borrow `ctx`, so anything the controls list needs is
770        // read first and applied after.
771        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    /// Sustains both tracks every frame, and the gain goes to whichever the
866    /// game calls for: gameplay music while a round is live, serving
867    /// included, and menu music whenever a menu covers it.
868    ///
869    /// Each fades in over [`MUSIC_CROSSFADE`] and slides every later gain
870    /// over it, which is the crossfade itself; the one at no gain costs no
871    /// voice while its playback goes on under the other.
872    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
896/// One action's name, its live bindings, a rebind control that starts
897/// listening for a new one, and a reset to its defaults; cancel is a
898/// button rather than Escape, since Escape is itself a binding a listen
899/// could capture.
900fn 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
925/// The controls-menu text for a live binding list: each alternative,
926/// separated, in the order the player can use them.
927fn 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 = ();
963    type PostEffects = ();
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        // Decay runs before the end-screen return below, so the last pulse and
977        // burst do not stay on screen.
978        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        // The tick moves nothing behind a menu, so a frame there draws the last
1012        // step whole rather than interpolating from the one before.
1013        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}