Skip to main content

isometric_board/
isometric-board.rs

1//! A fixed diagonal view over a small board, with no foreshortening —
2//! `Projection::orthographic`, the only example that uses it. A unit
3//! takes each turn: a click on its `Ray::hit_aabb` box selects it, then a
4//! click on a board tile orders it there. Around the board, a rock is drawn
5//! for each `seed`, built by plain arithmetic, its own cached mesh.
6//! Movement is fixed-step in `tick`, at thirty steps a second — half the
7//! engine's default rate; drawing interpolates between the last two
8//! steps.
9
10use core::time::Duration;
11
12use mirage_engine::prelude::*;
13use mirage_engine::ray;
14
15/// Tiles on a side.
16const BOARD_TILES: i32 = 6;
17
18/// A tile's footprint, center to center, in meters.
19const TILE_SIZE: f32 = 1.0;
20
21/// The gap left between tiles' footprints, for a visible grid line.
22const TILE_GAP: f32 = 0.05;
23
24const TILE_THICKNESS: f32 = 0.16;
25
26/// Half the board's width and depth, in meters.
27const BOARD_HALF: f32 = BOARD_TILES as f32 * TILE_SIZE * 0.5;
28
29/// The unit's speed toward an ordered tile, in meters per second.
30const UNIT_SPEED: f32 = 2.5;
31
32/// The sprite unit's drawn size, in meters, matching its texture cell's
33/// `16x27` pixel aspect.
34const SPRITE_HEIGHT: f32 = 0.9;
35const SPRITE_WIDTH: f32 = SPRITE_HEIGHT * 16.0 / 27.0;
36
37const BLOCK_SIZE: f32 = 0.6;
38
39/// The half-extents `Ray::hit_aabb` reads the sprite unit's box by.
40const SPRITE_HALF_EXTENTS: Vec3 =
41    Vec3::new(SPRITE_WIDTH * 0.5, SPRITE_HEIGHT * 0.5, SPRITE_WIDTH * 0.5);
42
43/// The half-extents `Ray::hit_aabb` reads the block unit's box by.
44const BLOCK_HALF_EXTENTS: Vec3 = Vec3::splat(BLOCK_SIZE * 0.5);
45
46/// The name `assets.texture` pulls the sheet under, once loaded.
47const SPRITE_TEXTURE: &str = "walker";
48const SPRITE_SOURCE: &str = "examples/assets/walker.png";
49const CLICK_SOURCE: &str = "examples/assets/click.ogg";
50
51/// The texture's grid: rows top to bottom are toward, right, away, and
52/// left; four frames of a walk cycle across each row. The sprite unit
53/// draws the right row always, mirrored across its own width for a left
54/// order, in place of a left row of its own.
55const SPRITE_COLUMNS: u32 = 4;
56const SPRITE_ROWS: u32 = 4;
57const SPRITE_ROW: u32 = 1;
58const SPRITE_COLUMN: u32 = 0;
59
60/// Each rock around the board: position, a `seed` for its mesh, and a
61/// scale for the transform that places it.
62const ROCKS: [(f32, f32, u32, f32); 5] = [
63    (-BOARD_HALF - 1.2, -BOARD_HALF - 0.6, 11, 1.0),
64    (-BOARD_HALF - 0.8, BOARD_HALF + 1.0, 37, 0.8),
65    (BOARD_HALF + 1.4, -BOARD_HALF - 0.2, 58, 1.3),
66    (BOARD_HALF + 1.0, BOARD_HALF + 1.2, 71, 0.9),
67    (0.3, BOARD_HALF + 1.6, 94, 1.1),
68];
69
70/// How far a rock's corner is displaced from its position on a unit
71/// cube, on each axis.
72const ROCK_JITTER: f32 = 0.16;
73
74/// Half the ground's width and depth under the board and its rock ring,
75/// in meters.
76const GROUND_HALF: f32 = BOARD_HALF + 3.0;
77
78/// The ground's surface height, just under the board's own tiles, clear
79/// of a z-fighting seam with them.
80const GROUND_Y: f32 = -0.01;
81
82const LIGHT_TILE: Color = Color::rgb(0.80, 0.76, 0.64);
83const DARK_TILE: Color = Color::rgb(0.55, 0.50, 0.40);
84/// A reachable tile's own mark, smaller than the tile itself so the
85/// checker tone still shows around its edge.
86const REACHABLE_MARK: Color = Color::rgb(0.20, 0.85, 0.35);
87/// The fraction of a tile's own footprint the reachable mark draws at,
88/// small enough that the tile's own checker tone still shows around it.
89const REACHABLE_MARK_SCALE: f32 = 0.45;
90/// The reachable mark's own lift over the tile's surface, clear of
91/// z-fighting with it.
92const REACHABLE_MARK_LIFT: f32 = 0.01;
93/// The mark under the selected unit, its own color bright enough to read
94/// past the sprite's own tint at a distance.
95const CURRENT_MARK: Color = Color::rgb(1.0, 0.2, 0.75);
96const CURRENT_MARK_SCALE: f32 = 0.85;
97/// The mark under the unit whose turn it is while nothing is selected:
98/// smaller and dim next to [`CURRENT_MARK`], a hint rather than a claim.
99const TURN_MARK: Color = Color::rgb(0.85, 0.75, 0.15);
100const TURN_MARK_SCALE: f32 = 0.5;
101/// The tile a hover reads while a unit is selected: reachable, or blocked
102/// by the other unit standing there.
103const HOVER_REACHABLE_TILE: Color = Color::rgb(0.35, 0.75, 0.68);
104const HOVER_BLOCKED_TILE: Color = Color::rgb(0.62, 0.28, 0.26);
105const BLOCK_IDLE: Color = Color::rgb(0.32, 0.42, 0.58);
106/// `BLOCK_IDLE`, scaled toward white to mark the block unit's own turn.
107const BLOCK_TURN: Color = Color::rgb(0.42, 0.54, 0.72);
108const GROUND_COLOR: Color = Color::rgb(0.15, 0.16, 0.13);
109const ROCK_COLOR: Color = Color::rgb(0.42, 0.40, 0.38);
110const SUN_COLOR: Color = Color::rgb(0.95, 0.92, 0.85);
111
112/// The current unit's tint, close to white so the sprite's own texture
113/// still reads under it, and the light it adds on its own, low enough
114/// that the same texture still reads under its bloom too — distinct from
115/// `SELECTED_TINT`, so a hover and a selection never read the same.
116const HOVER_TINT: Color = Color::rgb(0.9, 1.15, 1.15);
117const HOVER_GLOW: Color = Color::rgb(0.02, 0.15, 0.2);
118
119/// The current unit's tint, close to white with more red where
120/// `HOVER_TINT` raises green and blue instead, so the sprite's own
121/// texture still reads under it, and the light it adds on its own,
122/// scaled down the same way `HOVER_GLOW` is — distinct from `HOVER_TINT`.
123const SELECTED_TINT: Color = Color::rgb(1.15, 0.95, 0.85);
124const SELECTED_GLOW: Color = Color::rgb(0.22, 0.11, 0.0);
125
126/// The unit whose turn it is shows this tint and glow before any hover or
127/// selection, so it reads as the one a click selects.
128const TURN_TINT: Color = Color::rgb(1.0, 1.0, 0.82);
129const TURN_GLOW: Color = Color::rgb(0.08, 0.08, 0.02);
130
131/// The fraction of the frame `set_bloom` spreads, so `HOVER_GLOW` and
132/// `SELECTED_GLOW` read as light around the current unit, not only a
133/// larger fill.
134const BLOOM: f32 = 0.35;
135
136/// The size a world-space prompt naming a click's effect reads at, in
137/// logical points.
138const PROMPT_SIZE: f32 = 15.0;
139/// Height a prompt is lifted over the tile it names, clear of the tile's
140/// own top corner under the diagonal view.
141const PROMPT_TILE_LIFT: f32 = 0.55;
142/// Height a prompt is lifted over the unit it names, past its own height.
143const PROMPT_UNIT_LIFT: f32 = 0.25;
144/// Margin a prompt's own backdrop keeps past its galley, in logical points.
145const PROMPT_PADDING: f32 = 4.0;
146/// How much dark a prompt's own backdrop puts behind its text.
147const PROMPT_BACKDROP: u8 = 190;
148const PROMPT_TEXT_COLOR: egui::Color32 = egui::Color32::from_gray(230);
149
150/// Thirty steps a second, half the engine's default rate; movement stays
151/// smooth through `alpha()` interpolation.
152const TICK_INTERVAL: Duration = Duration::from_nanos(33_333_333);
153
154/// Faces of a cube, each a normal with its right and up axes — the same
155/// layout `mesh::Cube` builds from, shared so a rock's corners hold the
156/// same eight positions between the faces that meet there.
157const ROCK_FACES: [(Vec3, Vec3, Vec3); 6] = [
158    (Vec3::X, Vec3::NEG_Z, Vec3::Y),
159    (Vec3::NEG_X, Vec3::Z, Vec3::Y),
160    (Vec3::Y, Vec3::X, Vec3::NEG_Z),
161    (Vec3::NEG_Y, Vec3::X, Vec3::Z),
162    (Vec3::Z, Vec3::X, Vec3::Y),
163    (Vec3::NEG_Z, Vec3::NEG_X, Vec3::Y),
164];
165
166const ROCK_TRIANGLES: [u32; 6] = [0, 1, 2, 0, 2, 3];
167
168fn main() {
169    run(
170        Config::new("Mirage: isometric board")
171            .with_size(1280, 720)
172            .with_assets([SPRITE_SOURCE, CLICK_SOURCE])
173            .with_tick_interval(TICK_INTERVAL),
174        Board::init,
175    );
176}
177
178/// A rock built for its own `seed`; each value is its own mesh.
179#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
180#[catalog(Self { seed: 0 })]
181struct Rock {
182    seed: u32,
183}
184
185impl Mesh for Rock {
186    fn build(&self, _: &Assets) -> MeshData {
187        build_rock(self.seed)
188    }
189}
190
191/// The sprite unit: a quad windowed to the walk sheet's row facing right.
192#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
193struct Sprite;
194
195impl Mesh for Sprite {
196    fn build(&self, assets: &Assets) -> MeshData {
197        Quad.build(assets)
198            .with_texture(assets.texture(SPRITE_TEXTURE).pixelated())
199    }
200}
201
202// Everything else this game draws: the ground and board tiles are the
203// engine's own Plane and Cube, given their color per draw; the block unit
204// draws as a plain Cube too.
205meshes! { enum Shape { Plane, Cube, Rock, Sprite } }
206
207/// The board's own sky: a dim gradient, so the sun stays the scene's
208/// brightest light.
209#[derive(Catalog, Clone, Copy, Debug, PartialEq, Eq, Hash)]
210enum Sky {
211    Day,
212}
213
214impl Skyboxes for Sky {
215    fn build(&self, _assets: &Assets) -> SkyboxData {
216        SkyboxData::gradient(
217            Color::rgb(0.55, 0.75, 0.95),
218            Color::rgb(0.85, 0.90, 0.95),
219            Color::rgb(0.35, 0.33, 0.30),
220        )
221        .lit_by(0.3)
222    }
223}
224
225/// The one sound this game plays.
226#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
227enum Sound {
228    Click,
229}
230
231impl Sounds for Sound {
232    fn build(&self, assets: &Assets) -> SoundData {
233        match self {
234            Sound::Click => assets.sound("click"),
235        }
236    }
237}
238
239/// The one control this game reads: a click, which selects the unit whose
240/// turn it is or orders it to a tile.
241#[derive(InputButtonAction, Clone, Copy)]
242enum Button {
243    Select,
244}
245
246impl InputButtonAction for Button {
247    fn bindings(&self) -> Vec<ButtonBinding> {
248        match self {
249            Button::Select => vec![MouseButton::Left.into()],
250        }
251    }
252}
253
254struct Controls;
255
256impl InputActions for Controls {
257    type Button = Button;
258    type Axis = NoInputAxes;
259    type Axis2 = NoInputAxes2;
260}
261
262/// Whose turn it is: `Sprite` draws the unit with a texture, `Block` the
263/// plain one.
264#[derive(Clone, Copy, PartialEq, Eq)]
265enum Turn {
266    Sprite,
267    Block,
268}
269
270impl Turn {
271    fn other(self) -> Self {
272        match self {
273            Turn::Sprite => Turn::Block,
274            Turn::Block => Turn::Sprite,
275        }
276    }
277}
278
279/// One unit's position on the board.
280struct Unit {
281    tile: (i32, i32),
282    position: Vec3,
283    previous: Vec3,
284    target: Option<Vec3>,
285    /// The order the unit last moved along `+X` under, kept while it
286    /// stays still.
287    facing_right: bool,
288}
289
290impl Unit {
291    fn resting(tile: (i32, i32), lift: f32) -> Self {
292        let position = tile_center(tile) + Vec3::Y * lift;
293        Self {
294            tile,
295            position,
296            previous: position,
297            target: None,
298            facing_right: true,
299        }
300    }
301
302    /// Steps the unit toward its ordered position by one tick's distance;
303    /// returns whether it landed on the position this tick.
304    fn advance(&mut self, dt: Duration) -> bool {
305        let Some(target) = self.target else {
306            return false;
307        };
308        let to_target = target - self.position;
309        let distance = to_target.length();
310        let step = UNIT_SPEED * dt.as_secs_f32();
311        if distance <= step {
312            self.position = target;
313            self.target = None;
314            true
315        } else {
316            self.position += to_target * (step / distance);
317            false
318        }
319    }
320}
321
322/// The vertical lift from a tile's surface to a unit's center, and the
323/// half-extents `Ray::hit_aabb` reads its box by.
324fn unit_geometry(turn: Turn) -> (f32, Vec3) {
325    match turn {
326        Turn::Sprite => (SPRITE_HEIGHT * 0.5, SPRITE_HALF_EXTENTS),
327        Turn::Block => (BLOCK_SIZE * 0.5, BLOCK_HALF_EXTENTS),
328    }
329}
330
331/// The world position a tile's center is at, on the board's surface
332/// plane.
333fn tile_center((col, row): (i32, i32)) -> Vec3 {
334    let half = (BOARD_TILES - 1) as f32 * 0.5;
335    Vec3::new(
336        (col as f32 - half) * TILE_SIZE,
337        0.0,
338        (row as f32 - half) * TILE_SIZE,
339    )
340}
341
342/// The tile `point` falls over, ignoring its height; `None` off the board.
343fn tile_at(point: Vec3) -> Option<(i32, i32)> {
344    let half = (BOARD_TILES - 1) as f32 * 0.5;
345    let col = (point.x / TILE_SIZE + half + 0.5).floor() as i32;
346    let row = (point.z / TILE_SIZE + half + 0.5).floor() as i32;
347    ((0..BOARD_TILES).contains(&col) && (0..BOARD_TILES).contains(&row)).then_some((col, row))
348}
349
350/// Cursor target this frame: nothing, the unit whose turn it is, or a
351/// board tile.
352#[derive(Clone, Copy, PartialEq)]
353enum Hover {
354    None,
355    CurrentUnit,
356    Tile((i32, i32)),
357}
358
359struct Board {
360    sprite: Unit,
361    block: Unit,
362    turn: Turn,
363    selected: bool,
364}
365
366impl Board {
367    fn init(ctx: &mut InitContext<'_, Board>) -> Result<Self, Error> {
368        for &(_, _, seed, _) in &ROCKS {
369            ctx.prepare(Rock { seed });
370        }
371
372        let (sprite_lift, _) = unit_geometry(Turn::Sprite);
373        let (block_lift, _) = unit_geometry(Turn::Block);
374        Ok(Self {
375            sprite: Unit::resting((1, 1), sprite_lift),
376            block: Unit::resting((BOARD_TILES - 2, BOARD_TILES - 2), block_lift),
377            turn: Turn::Sprite,
378            selected: false,
379        })
380    }
381
382    fn camera() -> Camera {
383        let eye = Vec3::new(9.0, 9.0, 9.0);
384        Camera::new(
385            View::look_at(eye, Vec3::ZERO),
386            Projection::orthographic(11.0),
387        )
388    }
389
390    fn current(&self) -> &Unit {
391        match self.turn {
392            Turn::Sprite => &self.sprite,
393            Turn::Block => &self.block,
394        }
395    }
396
397    fn current_mut(&mut self) -> &mut Unit {
398        match self.turn {
399            Turn::Sprite => &mut self.sprite,
400            Turn::Block => &mut self.block,
401        }
402    }
403
404    fn other(&self) -> &Unit {
405        match self.turn {
406            Turn::Sprite => &self.block,
407            Turn::Block => &self.sprite,
408        }
409    }
410
411    /// Resolves a left click: hitting the current unit's box toggles its
412    /// selection; while selected, a ground hit that lands on an open tile
413    /// orders a move there.
414    fn handle_click(&mut self, ctx: &mut TickContext<'_, Board>) {
415        if ctx.ui_wants_pointer() || !ctx.pressed(Button::Select) {
416            return;
417        }
418        let ray = ctx
419            .last_camera()
420            .ray_through(ctx.pointer(), ctx.window_size());
421        let (lift, half) = unit_geometry(self.turn);
422
423        if self.current().target.is_none() {
424            let center = self.current().position;
425            if ray.hit_aabb(center - half, center + half).is_some() {
426                self.selected = !self.selected;
427                return;
428            }
429        }
430        if !self.selected {
431            return;
432        }
433
434        let Some(distance) = ray.hit_plane(ray::Plane {
435            point: Vec3::ZERO,
436            normal: Vec3::Y,
437        }) else {
438            return;
439        };
440        let Some(tile) = tile_at(ray.at(distance)) else {
441            return;
442        };
443        if tile == self.current().tile || tile == self.other().tile {
444            return;
445        }
446
447        let destination = tile_center(tile) + Vec3::Y * lift;
448        let heading = destination.x - self.current().position.x;
449        let current = self.current_mut();
450        if heading.abs() > f32::EPSILON {
451            current.facing_right = heading > 0.0;
452        }
453        current.target = Some(destination);
454        self.selected = false;
455        ctx.play(Sound::Click);
456    }
457
458    /// Cursor target, `None` while the UI has the pointer.
459    fn hovered(&self, ctx: &FrameContext<'_, Board>) -> Hover {
460        if ctx.ui_wants_pointer() {
461            return Hover::None;
462        }
463        let ray = ctx
464            .last_camera()
465            .ray_through(ctx.pointer(), ctx.window_size());
466
467        if self.current().target.is_none() {
468            let (_, half) = unit_geometry(self.turn);
469            let center = self.current().position;
470            if ray.hit_aabb(center - half, center + half).is_some() {
471                return Hover::CurrentUnit;
472            }
473        }
474        let Some(distance) = ray.hit_plane(ray::Plane {
475            point: Vec3::ZERO,
476            normal: Vec3::Y,
477        }) else {
478            return Hover::None;
479        };
480        match tile_at(ray.at(distance)) {
481            Some(tile) => Hover::Tile(tile),
482            None => Hover::None,
483        }
484    }
485
486    /// Whether a selected unit could move to `tile`: on the board, and
487    /// standing under neither unit.
488    fn reachable(&self, tile: (i32, i32)) -> bool {
489        tile != self.current().tile && tile != self.other().tile
490    }
491
492    fn draw_board(&self, ctx: &mut FrameContext<'_, Board>, hover: Hover) {
493        let scale = Vec3::new(TILE_SIZE - TILE_GAP, TILE_THICKNESS, TILE_SIZE - TILE_GAP);
494        for col in 0..BOARD_TILES {
495            for row in 0..BOARD_TILES {
496                let tile = (col, row);
497                let center = tile_center(tile) - Vec3::Y * (TILE_THICKNESS * 0.5);
498                let reachable = self.selected && self.reachable(tile);
499                let hovered = self.selected && hover == Hover::Tile(tile);
500                let color = if hovered {
501                    if reachable {
502                        HOVER_REACHABLE_TILE
503                    } else {
504                        HOVER_BLOCKED_TILE
505                    }
506                } else if (col + row) % 2 == 0 {
507                    LIGHT_TILE
508                } else {
509                    DARK_TILE
510                };
511                ctx.draw(
512                    Cube.at(Transform::from_scale_rotation_translation(
513                        scale,
514                        Quat::IDENTITY,
515                        center,
516                    ))
517                    .material(Material::lit(color)),
518                );
519                if reachable && !hovered {
520                    self.draw_reachable_mark(ctx, tile);
521                }
522            }
523        }
524    }
525
526    /// A mark over a reachable tile, its own tone apart from the
527    /// checker's tone and the hover tone, so the checker still reads
528    /// under it.
529    fn draw_reachable_mark(&self, ctx: &mut FrameContext<'_, Board>, tile: (i32, i32)) {
530        let center = tile_center(tile) + Vec3::Y * REACHABLE_MARK_LIFT;
531        ctx.draw(
532            Plane
533                .at(Transform::from_scale_rotation_translation(
534                    Vec3::new(
535                        TILE_SIZE * REACHABLE_MARK_SCALE,
536                        1.0,
537                        TILE_SIZE * REACHABLE_MARK_SCALE,
538                    ),
539                    Quat::IDENTITY,
540                    center,
541                ))
542                .material(Material::color(REACHABLE_MARK)),
543        );
544    }
545
546    /// A mark bright enough to read past the sprite's own tint under the
547    /// selected unit, or a smaller, dim one under the unit whose turn it
548    /// is while nothing is selected — so the current unit reads from the
549    /// ground alone.
550    fn draw_current_mark(&self, ctx: &mut FrameContext<'_, Board>) {
551        let (color, scale) = if self.selected {
552            (CURRENT_MARK, CURRENT_MARK_SCALE)
553        } else {
554            (TURN_MARK, TURN_MARK_SCALE)
555        };
556        let center = tile_center(self.current().tile) + Vec3::Y * REACHABLE_MARK_LIFT;
557        ctx.draw(
558            Plane
559                .at(Transform::from_scale_rotation_translation(
560                    Vec3::new(TILE_SIZE * scale, 1.0, TILE_SIZE * scale),
561                    Quat::IDENTITY,
562                    center,
563                ))
564                .material(Material::color(color)),
565        );
566    }
567
568    fn draw_rocks(&self, ctx: &mut FrameContext<'_, Board>) {
569        for &(x, z, seed, scale) in &ROCKS {
570            let angle = hash_signed(seed, 99) * core::f32::consts::PI;
571            ctx.draw(
572                Rock { seed }
573                    .at(Transform::from_scale_rotation_translation(
574                        Vec3::splat(scale),
575                        Quat::from_rotation_y(angle),
576                        Vec3::new(x, 0.5 * scale, z),
577                    ))
578                    .material(Material::lit(ROCK_COLOR)),
579            );
580        }
581    }
582
583    fn draw_sprite(&self, ctx: &mut FrameContext<'_, Board>, hover: Hover) {
584        let position = self.sprite.previous.lerp(self.sprite.position, ctx.alpha());
585        let current = self.turn == Turn::Sprite;
586        let (tint, glow) = if current && self.selected {
587            (SELECTED_TINT, SELECTED_GLOW)
588        } else if current && hover == Hover::CurrentUnit {
589            (HOVER_TINT, HOVER_GLOW)
590        } else if current {
591            (TURN_TINT, TURN_GLOW)
592        } else {
593            (Color::WHITE, Color::BLACK)
594        };
595        ctx.draw(
596            Sprite
597                .at(Transform::from_scale_rotation_translation(
598                    Vec3::new(SPRITE_WIDTH, SPRITE_HEIGHT, 1.0),
599                    Quat::IDENTITY,
600                    position,
601                ))
602                .upright()
603                .frame(sprite_frame(self.sprite.facing_right))
604                .material(Material::lit(tint).cutout().emissive(glow)),
605        );
606    }
607
608    fn draw_block(&self, ctx: &mut FrameContext<'_, Board>, hover: Hover) {
609        let position = self.block.previous.lerp(self.block.position, ctx.alpha());
610        let current = self.turn == Turn::Block;
611        let (color, glow) = if current && self.selected {
612            (SELECTED_TINT, SELECTED_GLOW)
613        } else if current && hover == Hover::CurrentUnit {
614            (HOVER_TINT, HOVER_GLOW)
615        } else if current {
616            (BLOCK_TURN, TURN_GLOW)
617        } else {
618            (BLOCK_IDLE, Color::BLACK)
619        };
620        ctx.draw(
621            Cube.at(Transform::from_scale_rotation_translation(
622                Vec3::splat(BLOCK_SIZE),
623                Quat::IDENTITY,
624                position,
625            ))
626            .material(Material::lit(color).emissive(glow)),
627        );
628    }
629
630    fn draw_ground(&self, ctx: &mut FrameContext<'_, Board>) {
631        ctx.draw(
632            Plane
633                .at(Transform::from_scale_rotation_translation(
634                    Vec3::new(GROUND_HALF * 2.0, 1.0, GROUND_HALF * 2.0),
635                    Quat::IDENTITY,
636                    Vec3::new(0.0, GROUND_Y, 0.0),
637                ))
638                .material(Material::lit(GROUND_COLOR)),
639        );
640    }
641
642    /// Whose turn it is, and what a click does next.
643    fn overlay(&self, ctx: &mut FrameContext<'_, Board>) {
644        ctx.ui(|ui| {
645            ui.label(match self.turn {
646                Turn::Sprite => "the sprite unit's turn",
647                Turn::Block => "the block unit's turn",
648            });
649            ui.label(if self.selected {
650                "click a marked tile to order the move"
651            } else {
652                "click the glowing unit to select it"
653            });
654        });
655    }
656
657    /// What a click at `hover` does, named for the player; `None` where a
658    /// click has no effect.
659    fn click_effect(&self, hover: Hover) -> Option<&'static str> {
660        match hover {
661            Hover::CurrentUnit if self.selected => Some("deselect"),
662            Hover::CurrentUnit => Some("select"),
663            Hover::Tile(tile) if self.selected && self.reachable(tile) => Some("move here"),
664            Hover::Tile(_) if self.selected => Some("occupied"),
665            _ => None,
666        }
667    }
668
669    /// A prompt beside the pointer's target, naming what its click does;
670    /// absent where [`Self::click_effect`] reads no effect.
671    fn draw_prompt(&self, ctx: &mut FrameContext<'_, Board>, camera: Camera, hover: Hover) {
672        let Some(text) = self.click_effect(hover) else {
673            return;
674        };
675        let point = match hover {
676            Hover::CurrentUnit => {
677                let (lift, _) = unit_geometry(self.turn);
678                self.current().position + Vec3::Y * (lift * 2.0 + PROMPT_UNIT_LIFT)
679            }
680            Hover::Tile(tile) => tile_center(tile) + Vec3::Y * PROMPT_TILE_LIFT,
681            Hover::None => return,
682        };
683        let galley = ctx.text_layout(text, egui::FontId::proportional(PROMPT_SIZE));
684        let window_size = ctx.window_size();
685        let pixels_per_point = ctx.pixels_per_point();
686        let Some(pixel) = camera.pixel_of(point, window_size) else {
687            return;
688        };
689        let at = logical(pixel, pixels_per_point);
690        ctx.ui(|ui| {
691            let painter = ui.painter();
692            let ink = galley.mesh_bounds;
693            let pos = egui::pos2(at.x - ink.center().x, at.y - ink.center().y);
694            let backdrop = egui::Rect::from_center_size(
695                at,
696                ink.size() + egui::Vec2::splat(PROMPT_PADDING * 2.0),
697            );
698            painter.rect_filled(
699                backdrop,
700                PROMPT_PADDING,
701                egui::Color32::from_black_alpha(PROMPT_BACKDROP),
702            );
703            painter.galley(pos, galley, PROMPT_TEXT_COLOR);
704        });
705    }
706}
707
708/// The logical point egui paints the physical pixel `pixel` at.
709fn logical(pixel: Vec2, pixels_per_point: f32) -> egui::Pos2 {
710    let point = pixel / pixels_per_point;
711    egui::pos2(point.x, point.y)
712}
713
714/// The texture window for the sprite unit's draw: the right row's cell,
715/// mirrored for a left order in place of a left row of its own.
716fn sprite_frame(facing_right: bool) -> Frame {
717    let cell = Sheet::new(UVec2::new(SPRITE_COLUMNS, SPRITE_ROWS))
718        .cell_at(UVec2::new(SPRITE_COLUMN, SPRITE_ROW));
719    if facing_right { cell } else { cell.mirrored() }
720}
721
722/// A rock built from `seed`: a unit cube whose eight corners are each
723/// displaced by an integer-hash of `seed` and the corner's index.
724fn build_rock(seed: u32) -> MeshData {
725    let corners: [Vec3; 8] = core::array::from_fn(|index| {
726        let sign = Vec3::new(
727            if index & 1 == 0 { -0.5 } else { 0.5 },
728            if index & 2 == 0 { -0.5 } else { 0.5 },
729            if index & 4 == 0 { -0.5 } else { 0.5 },
730        );
731        sign + corner_offset(seed, index as u32)
732    });
733    let corner_at = |sign: Vec3| corners[corner_index(sign)];
734
735    let mut vertices = Vec::with_capacity(ROCK_FACES.len() * 4);
736    let mut indices = Vec::with_capacity(ROCK_FACES.len() * 6);
737    for (face, &(normal, right, up)) in ROCK_FACES.iter().enumerate() {
738        let quad = [
739            corner_at(normal - right - up),
740            corner_at(normal + right - up),
741            corner_at(normal + right + up),
742            corner_at(normal - right + up),
743        ];
744        let normal = (quad[1] - quad[0]).cross(quad[3] - quad[0]).normalize();
745        let uvs = [
746            Vec2::new(0.0, 1.0),
747            Vec2::new(1.0, 1.0),
748            Vec2::new(1.0, 0.0),
749            Vec2::new(0.0, 0.0),
750        ];
751        vertices.extend(
752            quad.into_iter()
753                .zip(uvs)
754                .map(|(corner, uv)| Vertex::new(corner, normal, uv)),
755        );
756        let base = face as u32 * 4;
757        indices.extend(ROCK_TRIANGLES.map(|index| base + index));
758    }
759    MeshData::new(vertices, indices)
760}
761
762/// The index `0..8` the corner `sign` (`x`, `y`, and `z` each `-0.5` or
763/// `0.5`) is kept at.
764fn corner_index(sign: Vec3) -> usize {
765    let bit = |component: f32| usize::from(component > 0.0);
766    bit(sign.x) | bit(sign.y) << 1 | bit(sign.z) << 2
767}
768
769/// How far the corner `index` is displaced from its position on a unit
770/// cube, an integer-hash of `seed` and `index` on each axis, scaled to
771/// `ROCK_JITTER`.
772fn corner_offset(seed: u32, index: u32) -> Vec3 {
773    Vec3::new(
774        hash_signed(seed, index * 3),
775        hash_signed(seed, index * 3 + 1),
776        hash_signed(seed, index * 3 + 2),
777    ) * ROCK_JITTER
778}
779
780/// An integer-hash of `seed` and `salt`.
781fn hash(seed: u32, salt: u32) -> u32 {
782    let mut x = seed ^ salt.wrapping_mul(0x9E37_79B9);
783    x ^= x >> 16;
784    x = x.wrapping_mul(0x7FEB_352D);
785    x ^= x >> 15;
786    x = x.wrapping_mul(0x846C_A68B);
787    x ^= x >> 16;
788    x
789}
790
791/// `hash`, scaled to `-1.0..1.0`.
792fn hash_signed(seed: u32, salt: u32) -> f32 {
793    hash(seed, salt) as f32 / u32::MAX as f32 * 2.0 - 1.0
794}
795
796impl Game for Board {
797    type Meshes = Shape;
798    type Sounds = Sound;
799    type InputActions = Controls;
800    type Skyboxes = Sky;
801    type SurfaceStyles = ();
802    type PostEffects = ();
803
804    fn tick(&mut self, ctx: &mut TickContext<'_, Board>) {
805        self.sprite.previous = self.sprite.position;
806        self.block.previous = self.block.position;
807
808        self.handle_click(ctx);
809
810        if self.current_mut().advance(ctx.dt()) {
811            if let Some(tile) = tile_at(self.current().position) {
812                self.current_mut().tile = tile;
813            }
814            self.turn = self.turn.other();
815            self.selected = false;
816        }
817    }
818
819    fn frame(&mut self, ctx: &mut FrameContext<'_, Board>) {
820        let camera = Self::camera();
821        ctx.set_camera(camera);
822        ctx.set_skybox(Sky::Day);
823        ctx.light(Light::directional(Vec3::new(-0.35, -1.0, -0.5), SUN_COLOR).shadow());
824        ctx.set_bloom(BLOOM);
825
826        let hover = self.hovered(ctx);
827        self.draw_ground(ctx);
828        self.draw_board(ctx, hover);
829        self.draw_current_mark(ctx);
830        self.draw_rocks(ctx);
831        self.draw_sprite(ctx, hover);
832        self.draw_block(ctx, hover);
833        self.draw_prompt(ctx, camera, hover);
834
835        self.overlay(ctx);
836    }
837}