pinch-points 0.9.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! The board's two shaders: the water (assets/shaders/water.wgsl), one
//! material for every body of water in a round so the rock pools and the
//! sea catch the same moving light, and the dry sand round the board
//! (assets/shaders/sand.wgsl).
//!
//! And [`FadeMaterial`] (assets/shaders/fade.wesl): a flat colour each quad
//! draws at its own strength, for the footprints.
//!
//! A [`WaterMaterial`] is drawn on a quad. Which water it is comes from
//! [`WaterMode`]; every board shares the few materials held in [`Shaders`].

use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, ShaderType};
use bevy::shader::ShaderRef;
use bevy::sprite_render::{AlphaMode2d, Material2d, Material2dPlugin};

/// Which water a quad is. Its number is the shader's `mode`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WaterMode {
    /// The sea along the bottom of the beach, the sand its waves run up
    /// along the quad's top, deepening away from shore.
    Sea = 0,
    /// One layer of a rock pool, cut to the puddle's shape.
    Pool = 1,
    /// The sun caught on a pool's surface.
    Glint = 2,
    /// The menu postcard's open sea, from the waterline to the horizon.
    Ocean = 3,
}

/// The shader's uniform. Field order and types are the WGSL `Water`
/// struct's; change both together.
#[derive(ShaderType, Clone, Copy, Debug)]
pub struct WaterUniform {
    pub shallow: LinearRgba,
    pub deep: LinearRgba,
    /// A mania's colour on the sea, and in its alpha how far the water
    /// takes it on; none in calm water.
    pub tint: LinearRgba,
    pub mode: u32,
    /// How strongly the moving light shows, 0 for none.
    pub caustics: f32,
    /// How tall the band of wet sand along the sea's top is.
    pub wet: f32,
    /// Over what depth of water the sea goes from shallow to deep, world
    /// units.
    pub depth_span: f32,
    /// 1, or 0 under reduced motion, which stops the water's clock: the
    /// light, the waves and the glint hold still.
    pub motion: f32,
}

impl WaterUniform {
    pub fn new(mode: WaterMode, shallow: Color, deep: Color) -> Self {
        WaterUniform {
            shallow: shallow.into(),
            deep: deep.into(),
            tint: LinearRgba::NONE,
            mode: mode as u32,
            caustics: CAUSTICS,
            wet: 0.0,
            depth_span: 1.0,
            motion: 1.0,
        }
    }
}

/// How strongly the light plays on the water. Kept low: the pools sit
/// under the crabs, and moving light pulls the eye far harder than a
/// still picture of it suggests.
const CAUSTICS: f32 = 0.16;

#[derive(Asset, TypePath, AsBindGroup, Clone, Debug)]
pub struct WaterMaterial {
    #[uniform(0)]
    pub water: WaterUniform,
    /// The shape a pool layer is cut to; the other modes ignore it.
    #[texture(1)]
    #[sampler(2)]
    pub mask: Handle<Image>,
}

impl Material2d for WaterMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/water.wgsl".into()
    }

    fn alpha_mode(&self) -> AlphaMode2d {
        AlphaMode2d::Blend
    }
}

/// The colours of a rock pool's water, shallow rim then deep middle: the
/// flat layers' own, which tools/gen_sprites.py also bakes into the moat
/// round a big castle and the editor's pool brush: change all three
/// together, or a pool beside a moat no longer matches it.
pub const POOL_SHALLOW: Color = Color::srgb(0.44, 0.69, 0.80);
pub const POOL_DEEP: Color = Color::srgb(0.33, 0.57, 0.72);

/// The open sea on the menu's postcard: the round's sea, its waterline
/// and shallows and wet sand, deepening to the postcard's old horizon
/// blue. Built on the round's, so retuning one retunes both.
fn ocean_uniform() -> WaterUniform {
    let mut water = crate::app::board_render::sea_uniform();
    water.mode = WaterMode::Ocean as u32;
    water.deep = Color::srgb(0.16, 0.36, 0.52).into();
    water.caustics = CAUSTICS * 0.8;
    water
}

/// The beach's own colour, under everything: the sand shader's shade in
/// a dune's lee, which is also what shows for the frames the shaders take
/// to compile on the first board after launch, rather than the engine's
/// grey with the margin's scenery floating on it.
pub const SAND: Color = Color::srgb(0.78, 0.65, 0.46);

/// The quad and the shared materials every board's water and sand are
/// drawn with.
#[derive(Clone, Default)]
pub struct Shaders {
    /// A 1x1 quad, sized by its transform's scale.
    pub quad: Handle<Mesh>,
    pub pool_shallow: Handle<WaterMaterial>,
    pub pool_deep: Handle<WaterMaterial>,
    pub glint: Handle<WaterMaterial>,
    /// The sea's, its mania colour rewritten as manias come and go.
    pub sea: Handle<WaterMaterial>,
    /// The postcard's open sea.
    pub ocean: Handle<WaterMaterial>,
    /// The dry beach under everything.
    pub sand: Handle<SandMaterial>,
    /// The board's own sand, one quad over the whole board.
    pub board_sand: Handle<SandMaterial>,
    /// The crabs' footprints, each quad at its own strength.
    pub prints: Handle<FadeMaterial>,
}

impl Shaders {
    /// The 1x1 quad drawn in `material`, placed by `place` and stretched
    /// to `size`: every water and sand surface is one.
    pub fn quad<M: Material2d>(
        &self,
        material: &Handle<M>,
        place: Transform,
        size: Vec2,
    ) -> (Mesh2d, MeshMaterial2d<M>, Transform) {
        (
            Mesh2d(self.quad.clone()),
            MeshMaterial2d(material.clone()),
            place.with_scale(size.extend(1.0)),
        )
    }

    /// Built with the rest of [`crate::app::art::Art`], cutting the pools
    /// to the puddle sprite's shape.
    pub fn new(world: &mut World, puddle: &Handle<Image>) -> Self {
        let quad = world
            .resource_mut::<Assets<Mesh>>()
            .add(Rectangle::new(1.0, 1.0));
        let noise = world.resource_mut::<Assets<Image>>().add(sand_noise());
        let mut materials = world.resource_mut::<Assets<WaterMaterial>>();
        let mut layer = |mode, colour| {
            materials.add(WaterMaterial {
                water: WaterUniform::new(mode, colour, colour),
                mask: puddle.clone(),
            })
        };
        Shaders {
            quad,
            pool_shallow: layer(WaterMode::Pool, POOL_SHALLOW),
            pool_deep: layer(WaterMode::Pool, POOL_DEEP),
            glint: layer(WaterMode::Glint, Color::WHITE),
            sea: materials.add(WaterMaterial {
                water: crate::app::board_render::sea_uniform(),
                mask: Handle::default(),
            }),
            ocean: materials.add(WaterMaterial {
                water: ocean_uniform(),
                mask: Handle::default(),
            }),
            sand: world
                .resource_mut::<Assets<SandMaterial>>()
                .add(SandMaterial {
                    sand: SandUniform::beach(),
                    noise: noise.clone(),
                }),
            board_sand: world
                .resource_mut::<Assets<SandMaterial>>()
                .add(SandMaterial {
                    sand: SandUniform::board(),
                    noise,
                }),
            prints: world
                .resource_mut::<Assets<FadeMaterial>>()
                .add(FadeMaterial::new(PRINT)),
        }
    }
}

/// Stop the water under reduced motion and start it again when the
/// setting is turned off, in every shared material. Shaders read the
/// engine's clock directly, so without this the water moved for players
/// who asked nothing to.
pub(super) fn still_water(
    settings: Res<crate::app::settings::GameSettings>,
    art: Res<crate::app::art::Art>,
    mut materials: ResMut<Assets<WaterMaterial>>,
) {
    if !settings.is_changed() {
        return;
    }
    let motion = if settings.reduced_motion { 0.0 } else { 1.0 };
    let Shaders {
        quad: _,
        pool_shallow,
        pool_deep,
        glint,
        sea,
        ocean,
        sand: _,
        board_sand: _,
        prints: _,
    } = &art.shaded;
    for handle in [pool_shallow, pool_deep, glint, sea, ocean] {
        if materials
            .get(handle)
            .is_some_and(|m| m.water.motion != motion)
            && let Some(mut material) = materials.get_mut(handle)
        {
            material.water.motion = motion;
        }
    }
}

/// Register the material. Before `Art` is built, which makes its
/// [`Shaders`].
pub(super) fn install(app: &mut App) {
    app.add_plugins((
        Material2dPlugin::<WaterMaterial>::default(),
        Material2dPlugin::<SandMaterial>::default(),
        Material2dPlugin::<FadeMaterial>::default(),
    ))
    .insert_resource(ClearColor(SAND))
    .add_systems(Update, still_water);
    // The shaders run off the view's clock, which Bevy wraps every hour:
    // a long LAN evening saw every wave and glint jump at once on the
    // hour. Nothing else reads the wrapped clock. A day keeps it to the
    // nearest hundredth of a second, finer than a frame.
    app.world_mut()
        .resource_mut::<Time<Virtual>>()
        .set_wrap_period(std::time::Duration::from_secs(24 * 60 * 60));
}

/// A crab's footprint pressed into dry sand, at its strongest. Near-opaque:
/// the prints are tuned to read at this strength against the beach.
pub const PRINT: Color = Color::srgba(0.45, 0.36, 0.24, 0.9);

/// A flat colour that each quad drawn with it shows at a strength of its
/// own, carried in the quad's `MeshTag` as an f32's bits ([`fade_tag`]).
///
/// For the many small things that only fade, the footprints above all: a
/// sprite fading changes its colour, and Bevy draws each colour a sprite
/// takes with a material of its own, so every print on the beach made a new
/// material, and the renderer set it up, every frame of its life. Drawn
/// with this, all of them share one, and a frame's fade is a number in the
/// instance data. What reaches the screen is the sprite's own sum: the
/// colour in linear light at that alpha, blended the same way.
#[derive(Asset, TypePath, AsBindGroup, Clone, Debug)]
pub struct FadeMaterial {
    /// Linear; the alpha is not read, the tag is.
    #[uniform(0)]
    pub color: LinearRgba,
}

impl FadeMaterial {
    pub fn new(color: Color) -> Self {
        FadeMaterial {
            color: color.into(),
        }
    }
}

impl Material2d for FadeMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/fade.wesl".into()
    }

    fn alpha_mode(&self) -> AlphaMode2d {
        AlphaMode2d::Blend
    }

    /// The shader reads the mesh pipeline's per-instance data itself, so it
    /// declares it the way that pipeline was built: a uniform array in
    /// batches on a device without storage buffers (WebGL2, some older
    /// mobile GPUs), the batch size passed as the shader def Bevy's own
    /// mesh2d bindings are built with, else a storage array. Read off the
    /// pipeline, so it is the answer the mesh pipeline itself was given.
    /// Declared a storage array whatever the device, the first footprint
    /// failed validation there and closed the game.
    fn specialize(
        pipeline: &bevy::sprite_render::Material2dPipeline,
        descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor,
        _layout: &bevy::mesh::MeshVertexBufferLayoutRef,
        _key: bevy::sprite_render::Material2dKey<Self>,
    ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
        if let Some(batch) = pipeline.mesh2d_pipeline.per_object_buffer_batch_size
            && let Some(fragment) = descriptor.fragment.as_mut()
        {
            fragment.shader_defs.push(bevy::shader::ShaderDefVal::UInt(
                "PER_OBJECT_BUFFER_BATCH_SIZE".into(),
                batch,
            ));
        }
        Ok(())
    }
}

/// The tag a [`FadeMaterial`] quad is drawn at `alpha` with.
pub fn fade_tag(alpha: f32) -> bevy::mesh::MeshTag {
    bevy::mesh::MeshTag::new(alpha.to_bits())
}

/// The sand shader's uniform; the WGSL `Sand` struct's fields, in order.
#[derive(ShaderType, Clone, Copy, Debug)]
pub struct SandUniform {
    /// The sand in a dune's lee, and on its sunlit side.
    pub low: LinearRgba,
    pub high: LinearRgba,
    /// The specks of grain, each with how strongly it shows in its alpha.
    pub grain_dark: LinearRgba,
    pub grain_pale: LinearRgba,
    /// 0 for the beach round the board, 1 for the board's own sand, where
    /// `low` and `high` are its darker and lighter tile.
    pub mode: u32,
}

impl SandUniform {
    /// The beach round the board: warm, and a shade darker and more
    /// saturated than the board's own sand, so the play stays the
    /// brightest thing on screen.
    fn beach() -> SandUniform {
        SandUniform {
            low: SAND.into(),
            high: Color::srgb(0.85, 0.73, 0.53).into(),
            grain_dark: Color::srgba(0.45, 0.36, 0.24, 0.35).into(),
            grain_pale: Color::srgba(0.98, 0.94, 0.84, 0.4).into(),
            mode: 0,
        }
    }

    /// The board's sand: the two tiles' colours, and grain that follows
    /// the sand's shade. The dark speck is the sand times 0.785 in linear
    /// light, "a twelfth darker" (211/230 in sRGB); the pale one a third of
    /// the way to white. Both are linear-light factors: an sRGB-sized one
    /// (0.92) barely darkens a speck, and halfway to white reads as white
    /// flecks.
    fn board() -> SandUniform {
        SandUniform {
            low: Color::srgb(0.898, 0.808, 0.631).into(),
            high: Color::srgb(0.925, 0.847, 0.690).into(),
            grain_dark: LinearRgba::new(0.0, 0.0, 0.0, 0.215),
            grain_pale: LinearRgba::new(1.0, 1.0, 1.0, 0.33),
            mode: 1,
        }
    }
}

#[derive(Asset, TypePath, AsBindGroup, Clone, Debug)]
pub struct SandMaterial {
    #[uniform(0)]
    pub sand: SandUniform,
    /// The smooth noise the sand's dunes, damp patches, ripple warp and
    /// tone drift are read from (see [`sand_noise`]).
    #[texture(1)]
    #[sampler(2)]
    pub noise: Handle<Image>,
}

/// How many texels a side the sand's noise texture has, and how many
/// noise cells it spans, so a cell is `NOISE_PX / NOISE_CELLS` texels.
/// Keep `NOISE_CELLS` in step with sand.wgsl's.
const NOISE_PX: u32 = 256;
const NOISE_CELLS: u32 = 16;

/// The smooth noise the sand shader reads instead of working it out at
/// every pixel: three channels of tileable value noise, built once.
///
/// Worked out per pixel it is about twenty integer hashes on every board
/// and beach pixel of every frame, a fifth of a frame or more on the
/// integrated graphics a LAN evening brings; sampled from here it is two
/// reads on the beach and three on the board.
///
/// It tiles every `NOISE_CELLS` noise cells, a few thousand world units at
/// the scales the shader reads it at, which is wider than any board.
///
/// Red and green are one octave each (tone drift, ripple warp); blue is
/// two (dunes, damp patches), the finer at half the weight.
pub fn sand_noise() -> Image {
    use bevy::asset::RenderAssetUsages;
    use bevy::image::{ImageAddressMode, ImageSampler, ImageSamplerDescriptor};
    use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
    let octave = |cells: u32, seed: u64| -> Vec<f32> {
        // A lattice of random heights, wrapped, eased between.
        let mut state = seed;
        let mut next = || {
            // splitmix64
            state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
            let mut z = state;
            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
            ((z ^ (z >> 31)) >> 40) as f32 / (1u64 << 24) as f32
        };
        let lattice: Vec<f32> = (0..cells * cells).map(|_| next()).collect();
        let at = |x: u32, y: u32| lattice[((y % cells) * cells + (x % cells)) as usize];
        let per = NOISE_PX as f32 / cells as f32;
        (0..NOISE_PX * NOISE_PX)
            .map(|i| {
                let (px, py) = ((i % NOISE_PX) as f32 + 0.5, (i / NOISE_PX) as f32 + 0.5);
                let (fx, fy) = (px / per, py / per);
                let (x0, y0) = (fx.floor() as u32, fy.floor() as u32);
                let ease = |t: f32| t * t * (3.0 - 2.0 * t);
                let (u, v) = (ease(fx.fract()), ease(fy.fract()));
                let top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * u;
                let bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * u;
                top + (bottom - top) * v
            })
            .collect()
    };
    let red = octave(NOISE_CELLS, 1);
    let green = octave(NOISE_CELLS, 2);
    let (blue_a, blue_b) = (octave(NOISE_CELLS, 3), octave(NOISE_CELLS * 2, 4));
    let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
    let data = (0..(NOISE_PX * NOISE_PX) as usize)
        .flat_map(|i| {
            let blue = (2.0 * blue_a[i] + blue_b[i]) / 3.0;
            [byte(red[i]), byte(green[i]), byte(blue), 255]
        })
        .collect();
    let mut image = Image::new(
        Extent3d {
            width: NOISE_PX,
            height: NOISE_PX,
            depth_or_array_layers: 1,
        },
        TextureDimension::D2,
        data,
        // Linear: these are numbers, not colours.
        TextureFormat::Rgba8Unorm,
        RenderAssetUsages::RENDER_WORLD,
    );
    image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
        address_mode_u: ImageAddressMode::Repeat,
        address_mode_v: ImageAddressMode::Repeat,
        ..ImageSamplerDescriptor::linear()
    });
    image
}

impl Material2d for SandMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/sand.wgsl".into()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The sand's noise wraps without a seam: the step from the last
    /// column to the first, and the last row to the first, is no bigger
    /// than steps inside the texture, or the beach would draw a straight
    /// line every few thousand world units.
    #[test]
    fn the_sand_noise_tiles_without_a_seam() {
        let image = sand_noise();
        let data = image.data.as_ref().expect("built with its data");
        let n = NOISE_PX as usize;
        let at = |x: usize, y: usize, c: usize| i16::from(data[(y * n + x) * 4 + c]);
        for c in 0..3 {
            let mut inner = 0;
            let mut seam = 0;
            for i in 0..n {
                inner = inner.max((at(n / 2, i, c) - at(n / 2 - 1, i, c)).abs());
                inner = inner.max((at(i, n / 2, c) - at(i, n / 2 - 1, c)).abs());
                seam = seam.max((at(0, i, c) - at(n - 1, i, c)).abs());
                seam = seam.max((at(i, 0, c) - at(i, n - 1, c)).abs());
            }
            assert!(
                seam <= inner + 2,
                "channel {c}: seam {seam} against {inner} inside"
            );
        }
    }
}