diff --git a/examples/condenser/.gitignore b/examples/condenser/.gitignore new file mode 100644 index 00000000..7781e868 --- /dev/null +++ b/examples/condenser/.gitignore @@ -0,0 +1 @@ +.labelle/ diff --git a/examples/condenser/README.md b/examples/condenser/README.md new file mode 100644 index 00000000..436efcee --- /dev/null +++ b/examples/condenser/README.md @@ -0,0 +1,92 @@ +# COND-07 condenser — contained water on reference-resolution artwork + +Two condenser units show the engine's built-in `PixelWater` component, using one +reservoir prefab with independent fill levels. Drops create localized fading +ripples. Continuous waves and reflection drift are disabled. Mist is stationary +and baked into the machine artwork. + +![Windows/Vulkan capture](preview.png) + +## Run + +Use CLI 1.67.0 or newer: + +```sh +labelle run +``` + +The example uses the in-tree assembler from this branch, core 1.32.0, engine +2.22.0, gfx 1.36.0 and BGFX 0.20.0. A monorepo assembler can discover sibling +package checkouts instead of these pins; read its package-resolution output. + +## Pixel density + +The original 620x330 cropped reference is kept in +`assets/reference/condenser-detail.gif`. The wider room image is not included. +Source: https://raw.githubusercontent.com/labelle-toolkit/labelle-bgfx/6a1ce3a24f42adf75b41dec81ec33921b6a27918/docs/issue-references/condenser-100/condenser-detail.gif + +The old asset pipeline reduced the art to 103x55 and enlarged it sixfold. This +lost grain and irregular small edges because the painting has no uniform native +pixel grid. The new pipeline preserves source-resolution pixels and renders +sprites at scale 1. Two left border columns are cropped to keep the existing +618x330 unit layout and drop alignment; the two-unit window is 1236x330. + +A temporal median across the reference's 72 frames removes moving drops without +spatial downsampling. Static mist remains in this plate. The derivation asserts +that the layered composition reconstructs the median plate pixel-for-pixel. +This is preservation of the static plate, not an assertion that the reference's +animation or inferred occluder silhouettes were recovered exactly. + +## Artwork versus water grid + +Artwork resolution and effect resolution are independent: + +| Quantity | Texture/screen pixels | Water/effect coordinates | +|---|---|---| +| Unit canvas | 618x330 | 103x55 | +| Basin origin | 24, 288 | 4, 48 | +| Basin size | 558x36 | 93x6 | +| Drop sprite | 6x30 at scale 1 | 1x5 | +| Impact X positions within basin | 126/168/258/306/390 | 21/28/43/51/65 | + +The shader still uses `logical_size: [93, 6]`, `grid_pixels: 1`. Its impacts remain +six screen pixels per effect cell while the underlying artwork keeps its finer +detail. Both the drawn reservoir texture and its standalone mask cover the same +558x36 rectangle. Mask and reflection are standalone `.image` resources; drawn +sprites use an atlas. + +Unit A is 5/6 full; unit B is half full. The six-row effect grid has limited fill +resolution. Drops use the live fill level to find the contact plane and call +`game.addWaterRipple` once per impact. The foreground cooler and frame cover +water and drops in front-to-back layer order. + +## Rebuild assets + +Install Pillow and numpy, then from this example directory: + +```sh +python3 tools/derive_layers.py +python3 tools/pack_atlas.py +``` + +`derive_layers.py` creates the full-resolution layers and verifies their static +composition. `pack_atlas.py` crops the basin textures and rebuilds the atlas. +The reflection is authored from the coil band; hidden water under the cooler is +reconstructed. Rectangular occlusion boundaries are inherited from the asset +investigation in labelle-bgfx#106. They are assumptions, not original layered art. + +## Validation + +The updated example builds and runs on Windows/Vulkan. Logs confirm both live +water instances at 0.8333 and 0.5, the water program initializes, and drops emit +ripples. `preview.png` is an engine screenshot of this full-resolution revision. +The earlier branch's Metal verification used the older downsampled assets. + +`CONDENSER_WATER_OFF=1` disables water shading for a static-art control. Example: + +```sh +labelle run --timeout=5s --screenshot=condenser.png --after=2s +``` + +The main authoring files are `prefabs/reservoir.jsonc`, `scenes/main.jsonc`, +`scripts/playing/10_drops.zig` and `scripts/playing/30_water_probe.zig`. \ No newline at end of file diff --git a/examples/condenser/assets/condenser.json b/examples/condenser/assets/condenser.json new file mode 100644 index 00000000..5990004c --- /dev/null +++ b/examples/condenser/assets/condenser.json @@ -0,0 +1,104 @@ +{ + "frames": { + "machine_interior.png": { + "frame": { + "x": 0, + "y": 0, + "w": 618, + "h": 330 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 618, + "h": 330 + }, + "sourceSize": { + "w": 618, + "h": 330 + } + }, + "cooler_foreground.png": { + "frame": { + "x": 0, + "y": 334, + "w": 618, + "h": 330 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 618, + "h": 330 + }, + "sourceSize": { + "w": 618, + "h": 330 + } + }, + "frame_foreground.png": { + "frame": { + "x": 0, + "y": 668, + "w": 618, + "h": 330 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 618, + "h": 330 + }, + "sourceSize": { + "w": 618, + "h": 330 + } + }, + "drop.png": { + "frame": { + "x": 0, + "y": 1002, + "w": 6, + "h": 30 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 6, + "h": 30 + }, + "sourceSize": { + "w": 6, + "h": 30 + } + }, + "reservoir_art.png": { + "frame": { + "x": 0, + "y": 1036, + "w": 558, + "h": 36 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 558, + "h": 36 + }, + "sourceSize": { + "w": 558, + "h": 36 + } + } + } +} \ No newline at end of file diff --git a/examples/condenser/assets/condenser.png b/examples/condenser/assets/condenser.png new file mode 100644 index 00000000..793abd18 Binary files /dev/null and b/examples/condenser/assets/condenser.png differ diff --git a/examples/condenser/assets/layers/cooler_foreground.png b/examples/condenser/assets/layers/cooler_foreground.png new file mode 100644 index 00000000..26687306 Binary files /dev/null and b/examples/condenser/assets/layers/cooler_foreground.png differ diff --git a/examples/condenser/assets/layers/drop.png b/examples/condenser/assets/layers/drop.png new file mode 100644 index 00000000..d8c9f08d Binary files /dev/null and b/examples/condenser/assets/layers/drop.png differ diff --git a/examples/condenser/assets/layers/frame_foreground.png b/examples/condenser/assets/layers/frame_foreground.png new file mode 100644 index 00000000..a6255a29 Binary files /dev/null and b/examples/condenser/assets/layers/frame_foreground.png differ diff --git a/examples/condenser/assets/layers/machine_interior.png b/examples/condenser/assets/layers/machine_interior.png new file mode 100644 index 00000000..1026a326 Binary files /dev/null and b/examples/condenser/assets/layers/machine_interior.png differ diff --git a/examples/condenser/assets/layers/reflection.png b/examples/condenser/assets/layers/reflection.png new file mode 100644 index 00000000..5c948d95 Binary files /dev/null and b/examples/condenser/assets/layers/reflection.png differ diff --git a/examples/condenser/assets/layers/reservoir_mask.png b/examples/condenser/assets/layers/reservoir_mask.png new file mode 100644 index 00000000..c4960388 Binary files /dev/null and b/examples/condenser/assets/layers/reservoir_mask.png differ diff --git a/examples/condenser/assets/layers/reservoir_static.png b/examples/condenser/assets/layers/reservoir_static.png new file mode 100644 index 00000000..d45025f7 Binary files /dev/null and b/examples/condenser/assets/layers/reservoir_static.png differ diff --git a/examples/condenser/assets/reference/condenser-detail.gif b/examples/condenser/assets/reference/condenser-detail.gif new file mode 100644 index 00000000..207ebf47 Binary files /dev/null and b/examples/condenser/assets/reference/condenser-detail.gif differ diff --git a/examples/condenser/assets/reservoir_art.png b/examples/condenser/assets/reservoir_art.png new file mode 100644 index 00000000..b588ea46 Binary files /dev/null and b/examples/condenser/assets/reservoir_art.png differ diff --git a/examples/condenser/assets/reservoir_mask_rect.png b/examples/condenser/assets/reservoir_mask_rect.png new file mode 100644 index 00000000..20bdae05 Binary files /dev/null and b/examples/condenser/assets/reservoir_mask_rect.png differ diff --git a/examples/condenser/components/drop.zig b/examples/condenser/components/drop.zig new file mode 100644 index 00000000..e224462c --- /dev/null +++ b/examples/condenser/components/drop.zig @@ -0,0 +1,34 @@ +//! One condensation drop: it falls under gravity from the machine's coils, +//! contacts the reservoir's CURRENT water surface, emits exactly ONE ripple, +//! and waits before the next release. +//! +//! Geometry uses the original 103x55 EFFECT coordinate system; the script +//! multiplies by six for screen positions. Static artwork is independently +//! stored at reference resolution and drawn at scale 1. +pub const Drop = struct { + pub const save = @import("labelle-core").Saveable(.transient, @This(), .{}); + /// The reservoir (`Reservoir.unit`) this drop falls into. + unit: u32 = 0, + /// Impact X in RESERVOIR-LOCAL native px — what `addWaterRipple` takes. + /// The five measured emitters are local x 21, 28, 43, 51, 65. + local_x: f32 = 0, + /// Native canvas Y the drop is released from (the reference releases at + /// native y 2..5). + release_y: f32 = 2, + /// Downward acceleration in native px/s^2. The reference's fall is + /// accelerating, not grid-stepped. + gravity: f32 = 230, + /// Seconds between one impact and the next release. + interval: f32 = 1.6, + /// Start-of-run offset so the five emitters do not drip in unison. + phase: f32 = 0, + + // ── Runtime ────────────────────────────────────────────────────── + /// Seconds since this drop was released (negative while waiting). + t: f32 = 0, + /// Set the instant the drop crosses the surface, so exactly one ripple + /// is emitted per fall no matter how many frames the crossing spans. + rippled: bool = false, + /// Whether `t` has been seeded from `phase` yet. + started: bool = false, +}; diff --git a/examples/condenser/components/reservoir.zig b/examples/condenser/components/reservoir.zig new file mode 100644 index 00000000..f0cd00ae --- /dev/null +++ b/examples/condenser/components/reservoir.zig @@ -0,0 +1,12 @@ +//! Marker identifying one condenser unit's reservoir, so the drop script can +//! pair a falling drop with the `PixelWater` entity it should ripple. +//! +//! Deliberately NOT a copy of any water state: the authoritative fill level +//! lives on the built-in `PixelWater` component and is read back through +//! `game.pixelWater(entity)`, which is what makes the drops land on the +//! CURRENT surface rather than on a number the script happens to remember. +pub const Reservoir = struct { + pub const save = @import("labelle-core").Saveable(.transient, @This(), .{}); + /// Which condenser unit this basin belongs to (0 = left, 1 = right). + unit: u32 = 0, +}; diff --git a/examples/condenser/labelle.lock b/examples/condenser/labelle.lock new file mode 100644 index 00000000..e0c36e75 --- /dev/null +++ b/examples/condenser/labelle.lock @@ -0,0 +1,12 @@ +// labelle.lock — resolved dependency versions +// Generated by labelle-cli. Commit this file to git. +.{ + .cli_version = "1.67.0", + .resolved = .{ + .core = .{ .version = "1.32.0" }, + .engine = .{ .version = "2.22.0" }, + .gfx = .{ .version = "1.36.0" }, + .labelle = .{ .version = "1.67.0" }, + .ecs = .{ .name = "zig_ecs" }, + }, +} diff --git a/examples/condenser/prefabs/reservoir.jsonc b/examples/condenser/prefabs/reservoir.jsonc new file mode 100644 index 00000000..eb5e8c2d --- /dev/null +++ b/examples/condenser/prefabs/reservoir.jsonc @@ -0,0 +1,79 @@ +// The COND-07 reservoir — a game-root prefab whose whole point is the +// built-in `PixelWater` block below. +// +// A prefab file is a flat components block (same shape as +// `examples/fantasy-dungeon/plugin/packs/props/prefabs/torch.jsonc`). The +// scene instantiates it with `{ "prefab": "reservoir", ... }` and patches +// individual fields at the instance site; unmentioned fields are inherited +// (engine RFC-OVERRIDES-MERGE-RULES, deep merge). +{ + // Placed by the scene — (4, 48) native px within each condenser unit, + // times the x6 enlargement. + "Position": { "x": 24, "y": 288 }, + + // The water sprite's OWN texture: the authored static reservoir art, + // cropped to the logical rectangle. `fs_pixel_water` samples it as + // `s_tex` and composites the water OVER it, so it is also exactly what + // you see if the water program ever degrades — which is why this + // example verifies the effect by DIFFING FRAMES, not by eyeballing a + // screenshot. + "Sprite": { + "sprite_name": "reservoir_art.png", + "pivot": "top_left", + "layer": "water", + "scale_x": 1.0, + "scale_y": 1.0 + }, + + // ── The built-in engine component (engine v2.22.0) ──────────────── + // Authored here, in the prefab's JSON config. The JSONC apply path + // routes it through `game.addPixelWater`, which validates every field + // (a bad value reports `field '' ` instead of a GPU-side + // mystery), acquires the mask/reflection in the AssetCatalog and + // creates the gfx water instance. + "PixelWater": { + // AssetCatalog `.image` keys (declared in project.labelle), each + // spanning exactly the logical rectangle. + "mask": "reservoir_mask", + "reflection": "reservoir_reflection", + + // MEASURED reservoir rect: x 4, y 48, w 93, h 6 on the 103x55 + // native canvas. `grid_pixels` is 1 because a native art pixel IS + // an effect cell here — the reference's "6x6" is the x6 screen + // enlargement, not the cell size. + "logical_size": [93, 6], + "grid_pixels": 1, + + // Default fill. The basin is only 6 native rows tall, so the level + // has 6 quantisation steps; 5/6 keeps one dry row of headroom so a + // +-1 px wave/ripple crest is actually visible (at level 1.0 the + // shader clamps the displaced surface to the top edge and ripples + // would be invisible). Overridden per instance in the scene. + "water_level": 0.8333, + + // Measured sRGB palette (assets branch README, "Water-specific"). + // The engine keeps these as authored hex and converts to linear + // exactly once, at the gfx seam. + "deep_color": "#0F1719", + "surface_color": "#7AA5BB", + "highlight_color": "#425F6C", + + // Contained water rests between impacts: no travelling base wave. + "wave_amplitude_pixels": 0.0, + "wave_period_seconds": 3.0, + "waves_enabled": false, + + // Keep the supplied reflection fixed; only drop ripples move water. + "reflection_opacity": 0.35, + "distortion_pixels": 0.0, + + // Drop impacts: one ripple per drop, from `game.addWaterRipple`. + "ripple_duration_seconds": 0.9, + "ripple_radius_pixels": 14.0, + "ripple_strength_pixels": 1.0 + }, + + // Marker so the drop script can find its reservoir (unit 0 = left + // condenser). Overridden to 1 on the second instance. + "Reservoir": { "unit": 0 } +} diff --git a/examples/condenser/preview.png b/examples/condenser/preview.png new file mode 100644 index 00000000..cd9fe100 Binary files /dev/null and b/examples/condenser/preview.png differ diff --git a/examples/condenser/project.labelle b/examples/condenser/project.labelle new file mode 100644 index 00000000..0e90f017 --- /dev/null +++ b/examples/condenser/project.labelle @@ -0,0 +1,75 @@ +// COND-07 condenser — the built-in `PixelWater` reservoir effect on real +// artwork (labelle-bgfx#100's acceptance criterion: "a runnable Labelle +// example using this condenser's artwork"). +// +// WHAT IS BEING EXERCISED +// +// `PixelWater` is a BUILT-IN engine component as of engine v2.22.0: the +// JSONC apply path routes it through `game.addPixelWater`, which validates +// the candidate, pins the mask/reflection in the AssetCatalog and creates +// the gfx-owned water instance; `MaterialEffect.pixel_water` then selects +// bgfx's `fs_pixel_water` program at draw time. Nothing in this project +// registers a component, a material or a shader by hand. +// +// The reservoir itself is authored as a PREFAB (`prefabs/reservoir.jsonc`) +// and instantiated TWICE from the scene with a per-instance `water_level` +// override, so the prefab-defaults -> scene-overrides deep merge (engine +// RFC-OVERRIDES-MERGE-RULES) runs on a built-in component, and the two +// reservoirs prove the engine creates INDEPENDENT gfx water instances +// (ripples must not leak between them). +// +// GEOMETRY. Full-resolution art is 618x330 per unit, drawn at scale 1. +// Only the two left border columns of the 620x330 reference are cropped. +// The shader's separate 93x6 logical basin retains six screen pixels/cell. +// Two units side by side fill the 1236x330 window. +// +// DRAW ORDER (the foreground occlusion the issue asks for) is layer order: +// machine interior (static mist baked in) -> water -> drops -> foreground. +// Emitter A (reservoir-local x 21) sits behind the cooler, so its drop is +// occluded on the way down — that is the order holding, not a special case. +.{ + .name = "condenser", + .title = "LaBelle — COND-07 Condenser (pixel water)", + .width = 1236, + .height = 330, + .target_fps = 60, + // bgfx is the backend that ships `fs_pixel_water` (0.20.0). + .backend = .bgfx, + .backend_package = .{ .name = "bgfx", .repo = "github.com/labelle-toolkit/labelle-bgfx", .version = "0.20.0" }, + // The art is authored +Y down (native y 0 is the top of the canvas, + // y 48 is the reservoir lip) — declare it rather than flipping every + // measured coordinate. + .y_axis = .down, + .ecs = .zig_ecs, + .initial_prefab = "main", + .states = .{"playing"}, + .resources = .{ + // Drawn layers, packed into one atlas by `tools/pack_atlas.py`. + .{ .name = "condenser", .json = "assets/condenser.json", .texture = "assets/condenser.png" }, + // The pixel-water MASK and REFLECTION are STANDALONE images, not + // atlas frames: `PixelWater.mask` / `.reflection` are AssetCatalog + // `.image` keys, and the engine's `catalogTexture` refuses anything + // whose `loader_kind` is not `.image`. Both span exactly the + // logical reservoir rectangle (93x6) because the shader samples + // them in reservoir-local UV. + .{ .name = "reservoir_mask", .image = "assets/reservoir_mask_rect.png" }, + .{ .name = "reservoir_reflection", .image = "assets/layers/reflection.png" }, + }, + // SCREEN-space layers, not world: this is a fixed, pixel-exact + // composition, so a scene coordinate must be a window coordinate. On a + // WORLD layer the default camera centres the world origin in the + // window, which puts the measured native-px coordinates half a screen + // off — correct behaviour for a camera, wrong for a machine panel. + .layers = .{ + .{ .name = "machine", .order = 0, .space = .screen }, + .{ .name = "water", .order = 1, .space = .screen }, + .{ .name = "drops", .order = 3, .space = .screen }, + .{ .name = "foreground", .order = 4, .space = .screen }, + }, + .core_version = "1.32.0", + .engine_version = "2.22.0", + .gfx_version = "1.36.0", + .labelle_version = "1.67.0", + // In-tree assembler (the examples convention). + .assembler_version = "local:../../", +} diff --git a/examples/condenser/scenes/main.jsonc b/examples/condenser/scenes/main.jsonc new file mode 100644 index 00000000..e322efd5 --- /dev/null +++ b/examples/condenser/scenes/main.jsonc @@ -0,0 +1,173 @@ +// COND-07 condenser — two units side by side. +// +// Each unit is the same six-layer stack, drawn in the order the assets +// branch's README specifies: +// +// machine_interior (static mist baked in) -> water -> drops -> cooler -> frame +// +// which here is simply layer order (project.labelle `.layers`). Emitter A +// (native canvas x 25 = reservoir-local x 21) sits BEHIND the cooler, so +// its drop is occluded on the way down and only its ripple shows. +// +// Every sprite is `pivot: top_left` at `scale 1`; artwork is reference-resolution. +// Scene coordinates and the logical water grid retain their existing alignment. +// +// The reservoirs are PREFAB INSTANCES (`prefabs/reservoir.jsonc`). Each one +// patches `water_level` and the `Reservoir.unit` marker at the instance +// site with flat PascalCase override keys (RFC #596) — the same shape +// `examples/fantasy-dungeon/scenes/main.jsonc` uses. Everything else about +// the PixelWater block (mask, reflection, logical_size, grid, colours, +// waves, ripple parameters) is INHERITED from the prefab: that deep merge +// on a BUILT-IN component is one of the two things this example exists to +// prove. The other is that the two instances are independent gfx water +// instances — different levels, and a ripple in one never appears in the +// other. +{ + "name": "main", + "children": [ + + // ── Condenser unit A (native canvas x 0..103) ───────────────── + { + "name": "interior_a", + "components": { + "Position": { "x": 0, "y": 0 }, + "Sprite": { "sprite_name": "machine_interior.png", "pivot": "top_left", "layer": "machine", "scale_x": 1.0, "scale_y": 1.0 } + } + }, + // Prefab instance — overrides ONLY the fill level and the unit + // marker; the measured 5/6 fill. + { + "prefab": "reservoir", + "name": "reservoir_a", + "Position": { "x": 24, "y": 288 }, + "PixelWater": { "water_level": 0.8333 }, + "Reservoir": { "unit": 0 } + }, + { + "name": "drop_a0", + "components": { + "Position": { "x": 150, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 0, "local_x": 21.0, "release_y": 2.0, "interval": 1.4, "phase": 0.0, "gravity": 230.0 } + } + }, + { + "name": "drop_a1", + "components": { + "Position": { "x": 192, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 0, "local_x": 28.0, "release_y": 2.0, "interval": 1.77, "phase": 0.31, "gravity": 230.0 } + } + }, + { + "name": "drop_a2", + "components": { + "Position": { "x": 282, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 0, "local_x": 43.0, "release_y": 2.0, "interval": 2.14, "phase": 0.62, "gravity": 230.0 } + } + }, + { + "name": "drop_a3", + "components": { + "Position": { "x": 330, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 0, "local_x": 51.0, "release_y": 2.0, "interval": 2.51, "phase": 0.93, "gravity": 230.0 } + } + }, + { + "name": "drop_a4", + "components": { + "Position": { "x": 414, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 0, "local_x": 65.0, "release_y": 2.0, "interval": 2.88, "phase": 1.24, "gravity": 230.0 } + } + }, + { + "name": "cooler_a", + "components": { + "Position": { "x": 0, "y": 0 }, + "Sprite": { "sprite_name": "cooler_foreground.png", "pivot": "top_left", "layer": "foreground", "z_index": 0, "scale_x": 1.0, "scale_y": 1.0 } + } + }, + { + "name": "frame_a", + "components": { + "Position": { "x": 0, "y": 0 }, + "Sprite": { "sprite_name": "frame_foreground.png", "pivot": "top_left", "layer": "foreground", "z_index": 1, "scale_x": 1.0, "scale_y": 1.0 } + } + }, + + // ── Condenser unit B (native canvas x 103..206) ───────────────── + { + "name": "interior_b", + "components": { + "Position": { "x": 618, "y": 0 }, + "Sprite": { "sprite_name": "machine_interior.png", "pivot": "top_left", "layer": "machine", "scale_x": 1.0, "scale_y": 1.0 } + } + }, + // Prefab instance — overrides ONLY the fill level and the unit + // marker; half full. + { + "prefab": "reservoir", + "name": "reservoir_b", + "Position": { "x": 642, "y": 288 }, + "PixelWater": { "water_level": 0.5 }, + "Reservoir": { "unit": 1 } + }, + { + "name": "drop_b0", + "components": { + "Position": { "x": 768, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 1, "local_x": 21.0, "release_y": 2.0, "interval": 2.0, "phase": 0.9, "gravity": 230.0 } + } + }, + { + "name": "drop_b1", + "components": { + "Position": { "x": 810, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 1, "local_x": 28.0, "release_y": 2.0, "interval": 2.37, "phase": 1.21, "gravity": 230.0 } + } + }, + { + "name": "drop_b2", + "components": { + "Position": { "x": 900, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 1, "local_x": 43.0, "release_y": 2.0, "interval": 2.74, "phase": 1.52, "gravity": 230.0 } + } + }, + { + "name": "drop_b3", + "components": { + "Position": { "x": 948, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 1, "local_x": 51.0, "release_y": 2.0, "interval": 3.11, "phase": 1.83, "gravity": 230.0 } + } + }, + { + "name": "drop_b4", + "components": { + "Position": { "x": 1032, "y": -60 }, + "Sprite": { "sprite_name": "drop.png", "pivot": "top_left", "layer": "drops", "scale_x": 1.0, "scale_y": 1.0 }, + "Drop": { "unit": 1, "local_x": 65.0, "release_y": 2.0, "interval": 3.48, "phase": 2.14, "gravity": 230.0 } + } + }, + { + "name": "cooler_b", + "components": { + "Position": { "x": 618, "y": 0 }, + "Sprite": { "sprite_name": "cooler_foreground.png", "pivot": "top_left", "layer": "foreground", "z_index": 0, "scale_x": 1.0, "scale_y": 1.0 } + } + }, + { + "name": "frame_b", + "components": { + "Position": { "x": 618, "y": 0 }, + "Sprite": { "sprite_name": "frame_foreground.png", "pivot": "top_left", "layer": "foreground", "z_index": 1, "scale_x": 1.0, "scale_y": 1.0 } + } + } + ] +} diff --git a/examples/condenser/scripts/playing/10_drops.zig b/examples/condenser/scripts/playing/10_drops.zig new file mode 100644 index 00000000..db233286 --- /dev/null +++ b/examples/condenser/scripts/playing/10_drops.zig @@ -0,0 +1,123 @@ +//! Condensation drops: fall, contact the reservoir's CURRENT water surface, +//! and emit exactly ONE ripple each through the engine's built-in +//! `game.addWaterRipple`. +//! +//! Nothing here animates water. The script's entire contact with the effect +//! is the single `addWaterRipple(reservoir, local_x, strength)` call below — +//! the surface, the wave, the ripple's shape and its decay are all +//! `fs_pixel_water` on the GPU, driven by the engine's water tick. +//! +//! "CURRENT surface" is meant literally: the surface Y is recomputed every +//! frame from the live `PixelWater.water_level` (`surface_y = 6 * (1 - +//! level)` on the measured 6-row basin), so the two reservoirs' different +//! fill levels are honoured by the same code, and a runtime +//! `game.setWaterLevel` would move the impact point with no change here. + +const std = @import("std"); + +const Drop = @import("../../components/drop.zig").Drop; +const Reservoir = @import("../../components/reservoir.zig").Reservoir; + +pub const game_states = .{"playing"}; + +/// Native art px per screen px — every sprite in this game is `scale 6`. +const SCALE: f32 = 6.0; +/// Measured reservoir rect on the native canvas: x 4, y 48, w 93, h 6. +const RESERVOIR_TOP_Y: f32 = 48.0; +const RESERVOIR_H: f32 = 6.0; +/// The drop trail is 5 native px tall with the bright head at the bottom. +const DROP_HEAD_OFFSET: f32 = 4.0; +/// Scene Y to park a waiting drop at — above the window, out of sight. +const PARKED_Y: f32 = -60.0; + +pub fn State(comptime EcsBackend: type) type { + _ = EcsBackend; + return struct { + frame: u32 = 0, + ripples: u32 = 0, + }; +} + +pub fn tick(game: anytype, state: anytype, _: anytype, dt: f32) void { + state.frame += 1; + const ecs = &game.active_world.ecs_backend; + + // Resolve unit -> reservoir entity. Two units, so a fixed-size array + // beats a map; a missing one simply parks its drops. + const Entity = @TypeOf(game.*).EntityType; + var basins: [2]?Entity = .{ null, null }; + { + var rv = ecs.view(.{Reservoir}, .{}); + defer rv.deinit(); + while (rv.next()) |e| { + const res = ecs.getComponent(e, Reservoir) orelse continue; + if (res.unit < basins.len) basins[res.unit] = e; + } + } + + var v = ecs.view(.{Drop}, .{}); + defer v.deinit(); + while (v.next()) |e| { + const drop = ecs.getComponent(e, Drop) orelse continue; + + if (!drop.started) { + // Stagger the first release rather than dropping five at once. + drop.t = -drop.phase; + drop.started = true; + } + drop.t += dt; + + const basin = basins[@min(drop.unit, basins.len - 1)] orelse continue; + + // Waiting between drips: keep it off screen. + if (drop.t < 0) { + var p = game.getPosition(e); + p.y = PARKED_Y; + game.setPosition(e, p); + continue; + } + + // The LIVE fill level, read off the built-in component every frame. + const water = game.pixelWater(basin) orelse continue; + const level = std.math.clamp(water.water_level, 0.0, 1.0); + const surface_native = RESERVOIR_TOP_Y + RESERVOIR_H * (1.0 - level); + + // Accelerating fall, in native art px. + const y_native = drop.release_y + 0.5 * drop.gravity * drop.t * drop.t; + const head_native = y_native + DROP_HEAD_OFFSET; + + if (!drop.rippled and head_native >= surface_native) { + drop.rippled = true; + // ONE ripple per fall. `strength` is a DIMENSIONLESS [0,1] + // scale on the authored `ripple_strength_pixels`; a faster + // drop hits harder, capped at 1. + const speed = drop.gravity * drop.t; + const strength = std.math.clamp(speed / 140.0, 0.35, 1.0); + game.addWaterRipple(basin, drop.local_x, strength) catch |err| { + game.log.err("[condenser] addWaterRipple(unit={d}, x={d}) failed: {s}", .{ + drop.unit, drop.local_x, @errorName(err), + }); + continue; + }; + state.ripples += 1; + } + + if (head_native >= surface_native) { + // Impact consumed: hide and wait out the interval. + drop.t = -drop.interval; + drop.rippled = false; + var p = game.getPosition(e); + p.y = PARKED_Y; + game.setPosition(e, p); + continue; + } + + var p = game.getPosition(e); + p.y = y_native * SCALE; + game.setPosition(e, p); + } + + if (state.frame % 120 == 0) { + game.log.info("[condenser] frame={d} ripples_emitted={d}", .{ state.frame, state.ripples }); + } +} diff --git a/examples/condenser/scripts/playing/30_water_probe.zig b/examples/condenser/scripts/playing/30_water_probe.zig new file mode 100644 index 00000000..324295b6 --- /dev/null +++ b/examples/condenser/scripts/playing/30_water_probe.zig @@ -0,0 +1,85 @@ +//! Verification probe — and the CONTROL switch. +//! +//! The pixel-water effect degrades to the authored static reservoir sprite +//! when the shader program cannot link, so "it looked like water in a +//! screenshot" proves nothing at all. This script prints the two facts that +//! do, once each: +//! +//! * whether the ENGINE created a gfx water instance for each reservoir +//! (`game.waterInstance(entity)` — the id the sprite's draw resolves), +//! and the level it is running at, and +//! * `CONDENSER_WATER_OFF=1` sets both reservoirs to level 0, which is the +//! shader's explicit "render no water" gate: the sprite reduces to the +//! plain static art. That is the control run — the SAME binary, the same +//! scene, the same sprites, with only the effect switched off — so a +//! frame-to-frame diff over the reservoir band that is large with water +//! on and ~0 with it off cannot be anything but the effect. +//! +//! The renderer-side half of the evidence is bgfx's own log line, +//! `bgfx: pixel-water program initialized (renderer: ...)`, and the absence +//! of `fs_pixel_water failed to link` / `pixel_water not supported` / +//! `no resolvable water instance`. + +const std = @import("std"); + +const Reservoir = @import("../../components/reservoir.zig").Reservoir; + +pub const game_states = .{"playing"}; + +fn envIsSet(comptime name: [:0]const u8) bool { + const raw = std.c.getenv(name.ptr) orelse return false; + const val = std.mem.span(raw); + return val.len > 0 and !std.mem.eql(u8, val, "0"); +} + +pub fn State(comptime EcsBackend: type) type { + _ = EcsBackend; + return struct { + frame: u32 = 0, + reported: bool = false, + control_applied: bool = false, + }; +} + +pub fn tick(game: anytype, state: anytype, _: anytype, _: f32) void { + state.frame += 1; + const ecs = &game.active_world.ecs_backend; + const water_off = envIsSet("CONDENSER_WATER_OFF"); + + var v = ecs.view(.{Reservoir}, .{}); + defer v.deinit(); + while (v.next()) |e| { + const res = ecs.getComponent(e, Reservoir) orelse continue; + + if (water_off and !state.control_applied) { + // Level 0 is the shader's explicit "no water" gate. + game.setWaterLevel(e, 0.0) catch |err| { + game.log.err("[condenser] control: setWaterLevel(0) failed: {s}", .{@errorName(err)}); + }; + } + + // The instance is created when the mask texture is resident, which + // may be a frame or two after load; report once it has had the + // chance rather than on frame 1, so a "no instance" line is a real + // failure and not a race. + if (state.reported or state.frame < 30) continue; + const comp = game.pixelWater(e) orelse { + game.log.err("[condenser] unit {d}: NO PixelWater component on the reservoir entity", .{res.unit}); + continue; + }; + if (game.waterInstance(e)) |id| { + game.log.info( + "[condenser] unit {d}: water instance {any} LIVE — level={d:.4} mask='{s}' reflection='{s}' logical={d}x{d} grid={d}", + .{ res.unit, id, comp.water_level, comp.mask, comp.reflection, comp.logical_size[0], comp.logical_size[1], comp.grid_pixels }, + ); + } else { + game.log.err( + "[condenser] unit {d}: PixelWater present but NO gfx water instance — the sprite is drawing the static art", + .{res.unit}, + ); + } + } + + if (water_off) state.control_applied = true; + if (state.frame >= 30) state.reported = true; +} diff --git a/examples/condenser/tools/derive_layers.py b/examples/condenser/tools/derive_layers.py new file mode 100644 index 00000000..adaedc96 --- /dev/null +++ b/examples/condenser/tools/derive_layers.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Rebuild at reference pixel density (Pillow + numpy), without spatial filtering. + +Temporal median removes animated drops. Only two left border columns are +cropped to retain the existing 618x330 scene geometry. Mist remains static. +""" +from pathlib import Path +import numpy as np +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +LAYERS = ROOT / 'assets/layers' +with Image.open(ROOT / 'assets/reference/condenser-detail.gif') as gif: + assert gif.size == (620, 330) + frames = [] + for i in range(gif.n_frames): + gif.seek(i) + frames.append(np.asarray(gif.convert('RGB'))[:, 2:620]) +plate = np.median(np.stack(frames), axis=0).astype(np.uint8) +y, x = np.mgrid[:330, :618] +window = (x >= 24) & (x < 582) & (y >= 18) & (y < 324) +basin = (x >= 24) & (x < 582) & (y >= 288) & (y < 324) +cooler = (x >= 102) & (x < 186) & (y >= 192) & window + +def layer(name, rgb, mask): + rgba = np.zeros((330, 618, 4), dtype=np.uint8) + rgba[:, :, :3] = rgb + rgba[:, :, 3] = mask.astype(np.uint8) * 255 + Image.fromarray(rgba).save(LAYERS / name) + return rgba + +parts = [layer('frame_foreground.png', plate, ~window), + layer('cooler_foreground.png', plate, cooler), + layer('machine_interior.png', plate, window & ~basin & ~cooler)] +reservoir = plate.copy() +# Reconstructed hidden water, covered by the cooler in the final composition. +for column in range(114, 180): + reservoir[288:324, column] = plate[288:324, min(180 + 179 - column, 581)] +parts.append(layer('reservoir_static.png', reservoir, basin)) +layer('reservoir_mask.png', np.full_like(plate, 255), basin) +reflection = plate[108:144, 24:582][::-1].astype(np.float32) +reflection = (reflection * 0.8 + np.array([23, 45, 54]) * 0.2).astype(np.uint8) +Image.fromarray(reflection).convert('RGBA').save(LAYERS / 'reflection.png') +# The animated overlay does use six-pixel-wide drops, unlike the static plate. +ramp = [(82,117,135,170), (82,117,135,205), (82,117,135,215), + (127,171,195,235), (173,202,198,255)] +drop = np.repeat(np.repeat(np.array(ramp, dtype=np.uint8)[:, None], 6, axis=0), 6, axis=1) +Image.fromarray(drop).save(LAYERS / 'drop.png') +composite = np.zeros_like(plate) +for index in [2, 3, 1, 0]: + rgba = parts[index] + opaque = rgba[:, :, 3] == 255 + composite[opaque] = rgba[:, :, :3][opaque] +assert np.array_equal(composite, plate), 'static layers must preserve every plate pixel' +print('618x330 layers; exact full-resolution static-plate reconstruction verified') diff --git a/examples/condenser/tools/pack_atlas.py b/examples/condenser/tools/pack_atlas.py new file mode 100644 index 00000000..e7b829d9 --- /dev/null +++ b/examples/condenser/tools/pack_atlas.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Pack the COND-07 condenser layers into the single `condenser` atlas. + +The layer PNGs in `assets/layers/` are rebuilt by `derive_layers.py` from +the cropped reference at full resolution. Two are cropped to the basin +rectangle (x 24, y 288, w 558, h 36): + + * `reservoir_mask_rect.png` — the pixel-water silhouette MASK. The shader + samples it in reservoir-LOCAL uv, so it must span exactly the logical + rectangle, not the full 618x330 canvas. + * `reservoir_art.png` — the authored static reservoir, which is the + water sprite's own texture (`s_tex`) and the degrade fallback. + +Run from the example root: python3 tools/pack_atlas.py +""" +import json, os +from PIL import Image + +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC = os.path.join(HERE, "assets", "layers") +DST = os.path.join(HERE, "assets") + +# Reference-resolution reservoir; shader logical coordinates remain 93x6. +RESERVOIR = (24, 288, 582, 324) +PAD = 4 + +def crop(name, out): + Image.open(os.path.join(SRC, name)).convert("RGBA").crop(RESERVOIR).save(os.path.join(DST, out)) + +crop("reservoir_mask.png", "reservoir_mask_rect.png") +crop("reservoir_static.png", "reservoir_art.png") + +frames = ["machine_interior.png", "cooler_foreground.png", + "frame_foreground.png", "drop.png"] +images = [(n, Image.open(os.path.join(SRC, n)).convert("RGBA")) for n in frames] +images.append(("reservoir_art.png", Image.open(os.path.join(DST, "reservoir_art.png")))) + +width = max(im.width for _, im in images) +height = sum(im.height + PAD for _, im in images) - PAD +sheet = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + +manifest = {"frames": {}} +y = 0 +for name, im in images: + sheet.paste(im, (0, y)) + manifest["frames"][name] = { + "frame": {"x": 0, "y": y, "w": im.width, "h": im.height}, + "rotated": False, "trimmed": False, + "spriteSourceSize": {"x": 0, "y": 0, "w": im.width, "h": im.height}, + "sourceSize": {"w": im.width, "h": im.height}, + } + y += im.height + PAD + +sheet.save(os.path.join(DST, "condenser.png")) +with open(os.path.join(DST, "condenser.json"), "w", encoding="utf-8", newline="\n") as f: + json.dump(manifest, f, indent=1) +print("packed", width, "x", height, "->", list(manifest["frames"]))