diff --git a/.gitignore b/.gitignore index 10ec48306..7b4003bde 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ package-lock.json # the generated API reference, not every directory named `docs` /packages/melonjs/docs/ .turbo +backend-shots/ diff --git a/packages/examples/LICENSE.md b/packages/examples/LICENSE.md index 0074c3e29..da9f81d46 100644 --- a/packages/examples/LICENSE.md +++ b/packages/examples/LICENSE.md @@ -106,3 +106,29 @@ The dialogue panel `panel.png` and the web font `kenvector_future.woff2` in the same folder are from UI packs published by **Kenney** (), released under **CC0 1.0 Universal (Public Domain Dedication)** — no attribution legally required, credited here as a courtesy. + +### `jungleRabbit` example + +The 3D models in `public/assets/jungleRabbit/` — boat, carrot, log, rock, palm, +fern, leaves, flowers and bird — were modelled for this example and are covered +by the MIT license above, as is the procedurally generated terrain and water. + +The music track `bgm/jungle-theme.mp3` is by **Vlad Krotov**, published on +Pixabay: + + + +Released under the **Pixabay Content License** — free for commercial and +non-commercial use, no attribution required. Credited on the example's title +screen and here as a courtesy. + +The display face `font/Crang.woff2` was created by **Caveras** with FontStruct: + + + +⚠️ Released under **CC BY-NC-SA 4.0** — attribution, share-alike, and +**non-commercial use only**. This is the one asset in the examples that is not +free for commercial reuse: if you copy this example into a commercial game, you +must replace this font or obtain a commercial license from the author +(cava@caveras.net). The full license text ships alongside the font as +`font/Crang-LICENSE.txt`. diff --git a/packages/examples/package.json b/packages/examples/package.json index 23cab82d7..cc38b7ef7 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "backends": "node scripts/backends.mjs", "test:types": "tsc" }, "dependencies": { diff --git a/packages/examples/public/assets/jungleRabbit/bgm/jungle-theme.mp3 b/packages/examples/public/assets/jungleRabbit/bgm/jungle-theme.mp3 new file mode 100644 index 000000000..f04e2a568 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/bgm/jungle-theme.mp3 differ diff --git a/packages/examples/public/assets/jungleRabbit/bigleaf.glb b/packages/examples/public/assets/jungleRabbit/bigleaf.glb new file mode 100644 index 000000000..88b72ac03 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/bigleaf.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/bird.glb b/packages/examples/public/assets/jungleRabbit/bird.glb new file mode 100644 index 000000000..b0988da83 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/bird.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/boat.glb b/packages/examples/public/assets/jungleRabbit/boat.glb new file mode 100644 index 000000000..33576233a Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/boat.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/carrot.glb b/packages/examples/public/assets/jungleRabbit/carrot.glb new file mode 100644 index 000000000..c7920ec42 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/carrot.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/fern.glb b/packages/examples/public/assets/jungleRabbit/fern.glb new file mode 100644 index 000000000..7e12f0a8b Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/fern.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/flower-pink.glb b/packages/examples/public/assets/jungleRabbit/flower-pink.glb new file mode 100644 index 000000000..82f664eab Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/flower-pink.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/flower-red.glb b/packages/examples/public/assets/jungleRabbit/flower-red.glb new file mode 100644 index 000000000..d213d39ca Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/flower-red.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/font/Crang-LICENSE.txt b/packages/examples/public/assets/jungleRabbit/font/Crang-LICENSE.txt new file mode 100644 index 000000000..4dff45e56 --- /dev/null +++ b/packages/examples/public/assets/jungleRabbit/font/Crang-LICENSE.txt @@ -0,0 +1,13 @@ +License for Crang font + +This font was created by Caveras with FontStruct and is licensed +under a Creative Commons Attribution Non-commercial Share Alike +license. + +You are not allowed to use this font for any commercial purposes. +If you wish to obtain a commercial license, please contact me via email: +cava@caveras.net + +https://caveras.net +https://fontstruct.com/fontstructors/caveras +https://creativecommons.org/licenses/by-nc-sa/4.0/ \ No newline at end of file diff --git a/packages/examples/public/assets/jungleRabbit/font/Crang.woff b/packages/examples/public/assets/jungleRabbit/font/Crang.woff new file mode 100644 index 000000000..d3939460e Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/font/Crang.woff differ diff --git a/packages/examples/public/assets/jungleRabbit/font/Crang.woff2 b/packages/examples/public/assets/jungleRabbit/font/Crang.woff2 new file mode 100644 index 000000000..7a307ca35 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/font/Crang.woff2 differ diff --git a/packages/examples/public/assets/jungleRabbit/leaf.glb b/packages/examples/public/assets/jungleRabbit/leaf.glb new file mode 100644 index 000000000..04964ecf3 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/leaf.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/log.glb b/packages/examples/public/assets/jungleRabbit/log.glb new file mode 100644 index 000000000..0cba68ca9 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/log.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/palm.glb b/packages/examples/public/assets/jungleRabbit/palm.glb new file mode 100644 index 000000000..17af02228 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/palm.glb differ diff --git a/packages/examples/public/assets/jungleRabbit/rock.glb b/packages/examples/public/assets/jungleRabbit/rock.glb new file mode 100644 index 000000000..6fe1b7ad4 Binary files /dev/null and b/packages/examples/public/assets/jungleRabbit/rock.glb differ diff --git a/packages/examples/scripts/backends.mjs b/packages/examples/scripts/backends.mjs new file mode 100644 index 000000000..1310260e6 --- /dev/null +++ b/packages/examples/scripts/backends.mjs @@ -0,0 +1,154 @@ +/** + * Screenshot one example on every backend it can actually reach. + * + * Headless Chromium has no GPU and falls back to WebGL 2 on SwiftShader, so a + * headless-only check cannot see a WebGPU bug at all — which is how glyph tops + * shorn off on Safari survived a full example sweep. Real Chrome and WebKit + * both take the WebGPU path on a Mac, and they do not agree with each other: + * the same texture mistake was invisible on one and obvious on the other. + * + * Prints the renderer each target actually selected, so a "verified" claim can + * name the backend it was verified on. + * + * Usage (from packages/examples, with the dev server running): + * node scripts/backends.mjs text + * node scripts/backends.mjs jungle-rabbit --wait 10000 + * node scripts/backends.mjs text --clip 200,300,320,90 + * node scripts/backends.mjs text --only webkit + * + * Every run writes `...png`, with one stamp shared by + * the run's targets. A retry therefore lands NEXT TO the baseline instead of + * overwriting it, which is what makes a backend bug readable: the shots only + * say anything as a before/after pair. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + */ + +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { chromium, webkit } from "playwright"; + +const args = process.argv.slice(2); +const route = args.find((a) => !a.startsWith("--")); + +if (!route) { + console.error( + "usage: node scripts/backends.mjs [--wait ms] [--clip x,y,w,h] [--out dir] [--only name]", + ); + process.exit(1); +} + +/** + * Read a `--flag value` pair off the argument list. + * @param name - the flag, without dashes + * @param fallback - what to use when it is absent + * @returns the value, or the fallback + */ +const flag = (name, fallback) => { + const i = args.indexOf(`--${name}`); + return i === -1 ? fallback : args[i + 1]; +}; + +const wait = Number(flag("wait", 8000)); +const outDir = resolve(flag("out", "backend-shots")); +const only = flag("only", null); +const clip = flag("clip", null) + ? (() => { + const [x, y, width, height] = flag("clip").split(",").map(Number); + return { x, y, width, height }; + })() + : undefined; + +/** + * The targets worth checking, and why each one earns its place. + * + * `headless` is the cheap gate that CI-style sweeps use. The other two are the + * ones that find backend bugs, and they must be headed: a headless browser gets + * no GPU, so it silently falls back and stops testing what you think it tests. + */ +const TARGETS = [ + { + name: "headless", + note: "no GPU — falls back to WebGL 2 (SwiftShader)", + open: () => chromium.launch(), + }, + { + name: "chrome", + note: "real GPU — WebGPU", + open: () => chromium.launch({ channel: "chrome", headless: false }), + }, + { + name: "webkit", + note: "real GPU — WebGPU, and it disagrees with Chrome", + open: () => webkit.launch({ headless: false }), + }, +]; + +// One local-time stamp for the whole run, so a run's targets stay grouped and +// a listing sorts oldest-first within a route. +const runStamp = (() => { + const now = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`; + return `${day}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; +})(); + +mkdirSync(outDir, { recursive: true }); + +let failed = false; + +for (const target of TARGETS) { + if (only && only !== target.name) { + continue; + } + + let browser; + let renderer = "unknown"; + const errors = []; + + try { + browser = await target.open(); + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 2, + }); + page.on("pageerror", (e) => { + errors.push(e.message.slice(0, 160)); + }); + page.on("console", (m) => { + const text = m.text(); + // the engine announces the backend it resolved to on boot + if (/renderer \(/i.test(text)) { + renderer = text.split("|")[0].trim(); + } + if (m.type() === "error") { + errors.push(text.slice(0, 160)); + } + }); + + await page.goto(`http://localhost:5173/#/${route}`, { + waitUntil: "commit", + }); + await page.waitForSelector("canvas", { timeout: 30000 }); + await page.waitForTimeout(wait); + await page.screenshot({ + path: `${outDir}/${route}.${target.name}.${runStamp}.png`, + ...(clip ? { clip } : {}), + }); + } catch (e) { + errors.push(String(e).slice(0, 160)); + } finally { + await browser?.close(); + } + + const status = errors.length > 0 ? `FAIL (${errors[0]})` : "ok"; + if (errors.length > 0) { + failed = true; + } + console.log( + `${target.name.padEnd(9)} ${renderer.padEnd(34)} ${status}\n${" ".repeat(10)}${target.note}`, + ); +} + +console.log(`\nshots in ${outDir} — this run: ${route}.*.${runStamp}.png`); +process.exit(failed ? 1 : 0); diff --git a/packages/examples/src/examples/jungleRabbit/ExampleJungleRabbit.tsx b/packages/examples/src/examples/jungleRabbit/ExampleJungleRabbit.tsx new file mode 100644 index 000000000..c0405333a --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/ExampleJungleRabbit.tsx @@ -0,0 +1,9 @@ +/** + * melonJS — Jungle Rabbit showcase. + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { createExampleComponent } from "../utils"; +import { createGame } from "./createGame"; + +export const ExampleJungleRabbit = createExampleComponent(createGame); diff --git a/packages/examples/src/examples/jungleRabbit/GameOverStage.ts b/packages/examples/src/examples/jungleRabbit/GameOverStage.ts new file mode 100644 index 000000000..4215e20c8 --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/GameOverStage.ts @@ -0,0 +1,149 @@ +/** + * melonJS — Jungle Rabbit: the game-over screen. + * + * A stage rather than a banner over the frozen run. The state manager's fade + * carries the change, the result gets room to be read, and the run's own + * scenery keeps rendering behind it — which is why the arguments come in + * through `state.change`: this stage tears the world down and rebuilds it, so + * it cannot read anything off the one that ended. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { + type Application, + type Camera3d, + input, + Stage, + state, + Tween, + Vector3d, +} from "melonjs"; +import { getRipples } from "./assets"; +import { + HUD_Z, + PROMPT_BLINK_MS, + PROMPT_DIM, + TITLE_CARROT_BOB, + TITLE_CARROT_BOB_RATE, + TITLE_CARROT_SPIN, + TITLE_CARROT_Y, + TITLE_DRIFT, + VIEW_H, + VIEW_W, +} from "./constants"; +import { menuLines, menuText } from "./menuText"; +import { bestMetres, bestScore, initRecords } from "./records"; +import { aimFlare, buildBackdrop } from "./scenery"; +import { driftWaterPlane } from "./terrain"; + +/** the axis the carrots turn about — Y, so they spin where they float */ +const AXIS_Y = new Vector3d(0, 1, 0); + +export class GameOverStage extends Stage { + /** latched once a destination has been chosen — one press, one transition */ + private leaving = false; + /** the pieces of the backdrop this stage animates */ + private backdrop!: ReturnType; + /** kept from the reset: `aimFlare` needs it every frame */ + private camera!: Camera3d; + private elapsed = 0; + private carrotAngle = 0; + + /** + * @param app - the running application + * @param score - carrots collected in the run that just ended + * @param metres - how far it got + * @param beatRecord - whether it improved on the stored best + */ + onResetEvent(app: Application, score = 0, metres = 0, beatRecord = false) { + this.backdrop = buildBackdrop(app); + this.camera = app.viewport as Camera3d; + initRecords(); + this.elapsed = 0; + this.carrotAngle = 0; + + const world = app.world; + world.addChild( + menuText(VIEW_W / 2, 84, 30, beatRecord ? "NEW BEST!" : "CAPSIZED!"), + HUD_Z, + ); + for (const line of menuLines( + VIEW_W / 2, + 134, + 15, + `${score} CARROTS\n${metres}M`, + )) { + world.addChild(line, HUD_Z); + } + // The stats sit still; only the line the player is waiting to act on + // breathes — pulsing the record alongside it would make the whole + // panel flicker and read as a fault. + world.addChild( + menuText( + VIEW_W / 2, + VIEW_H - 55, + 13, + `BEST ${bestScore()} CARROTS ${bestMetres()}M`, + ), + HUD_Z, + ); + const prompt = menuText( + VIEW_W / 2, + VIEW_H - 27, + 13, + "SPACE TO PADDLE AGAIN ESC FOR MENU", + ); + world.addChild(prompt, HUD_Z); + new Tween(prompt) + .to({ alpha: PROMPT_DIM }, { duration: PROMPT_BLINK_MS / 2 }) + .easing(Tween.Easing.Sinusoidal.InOut) + .yoyo(true) + .repeat(Number.POSITIVE_INFINITY) + .start(); + + input.bindKey(input.KEY.SPACE, "again", true); + input.bindKey(input.KEY.ESC, "menu", true); + this.leaving = false; + } + + update(dt: number) { + super.update(dt); + aimFlare(this.camera, this.backdrop.sunDisc); + this.elapsed += dt; + const t = this.elapsed / 1000; + // the same live backdrop as the title: the river flows, the crests + // travel and the pair of carrots turns. A results screen over a frozen + // photograph reads as the game having crashed rather than ended. + driftWaterPlane(this.backdrop.waterPlane, t * TITLE_DRIFT); + getRipples().setTime(t); + const turned = t * TITLE_CARROT_SPIN; + for (const [i, carrot] of this.backdrop.carrots.entries()) { + carrot.rotate((i === 0 ? 1 : -1) * (turned - this.carrotAngle), AXIS_Y); + carrot.pos.y = + TITLE_CARROT_Y + + Math.sin(t * TITLE_CARROT_BOB_RATE + i * Math.PI) * TITLE_CARROT_BOB; + } + this.carrotAngle = turned; + // Latched for the same reason as the title screen: `state.change` under + // a fade hands over only once the fade is done, and this `update` keeps + // running until then — so a second tap, or SPACE and ESC together, + // queues a second change and the target stage is built twice. + if (this.leaving === true) { + return true; + } + if (input.isKeyPressed("again")) { + this.leaving = true; + state.change(state.PLAY); + } else if (input.isKeyPressed("menu")) { + this.leaving = true; + state.change(state.MENU); + } + return true; + } + + onDestroyEvent() { + input.unbindKey(input.KEY.SPACE); + input.unbindKey(input.KEY.ESC); + } +} diff --git a/packages/examples/src/examples/jungleRabbit/GameStage.ts b/packages/examples/src/examples/jungleRabbit/GameStage.ts new file mode 100644 index 000000000..0271ef8e8 --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/GameStage.ts @@ -0,0 +1,2207 @@ +/** + * melonJS — Jungle Rabbit: the scene and the per-frame tick. + * + * An endless river runner: the boat holds station on screen while the course + * is recycled past it, so the world is a handful of pooled objects rather than + * a level that grows. `travelled` is the only thing that really moves. + * + * ## Which melonJS features this example leans on + * + * | area | API | + * | --- | --- | + * | 3D scene | `Camera3d`, `Mesh`, `Sprite3d`, `GLTFModel`, `Light3d` | + * | drawing at scale | `InstancedMesh` — a whole class of scenery per draw call | + * | collision | `Box3d` + `bodyDef` sensors, dispatched by `onCollisionStart` | + * | effects | `ShaderEffect` (the water), `NoiseTexture2d`, `ParticleEmitter` | + * | motion | `math.damp` camera follow, `Tween` for the hull bob | + * | feel | `viewport.shake`, `app.freeze` for hit-stop | + * | screen space | `Camera3d.worldToScreen` to float a "+N" over a pickup | + * | the rest | `state`, `input`, `loader`, `audio` | + * + * What the game still owns by choice: the boat's motion (its body is a + * SENSOR — the engine reports contacts, the game decides what they mean) and + * the course layout. See `addProp` for the collision setup and `drive` for the + * per-frame simulation. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { + type Application, + audio, + Box3d, + type Camera3d, + Color, + collision, + GLTFModel, + InstancedMesh, + input, + Light3d, + loader, + Matrix3d, + Mesh, + math, + type NoiseTexture2d, + ParticleEmitter, + type Sprite3d, + Stage, + state, + Text, + Tween, + Vector2d, + Vector3d, +} from "melonjs"; +import { + geometry, + getGround, + getPalette, + getPuff, + getRipples, + getWater, +} from "./assets"; +import { + BIRD_AHEAD, + BIRD_COUNT, + BIRD_HEIGHT, + BIRD_HEIGHT_SPREAD, + BIRD_SCALE, + BIRD_SPAN, + BIRD_SPEED, + BOAT_HALF, + BOAT_SCALE, + BOB_RISE, + BUSH_COUNT, + CAM_BACK, + CAM_DAMP, + CAM_FOV, + CAM_PITCH, + CAM_UP, + CARROT_COUNT, + CARROT_HALF, + CARROT_SCORE, + CLEAR_HEIGHT, + COMBO_BLINK_MS, + COMBO_MAX, + COMBO_STEP, + COMBO_WARN_MS, + COMBO_WINDOW_MS, + FERN_COUNT, + FLOWER_COUNT, + FOG_COLOR, + FOG_FAR, + FOG_HEIGHT_FALLOFF, + FOG_NEAR, + FOV_PUNCH, + GRAVITY, + GROUND_Y, + HALF_W, + HIT_STOP_MS, + HUD_Z, + INVULN_MS, + JUMP_VELOCITY, + LEAF_BOB, + LEAF_BOB_RATE, + LEAF_COUNT, + LEAF_SWAY, + LEAF_SWAY_RATE, + LEAF_TURN, + LOG_COUNT, + LOG_HALF, + LOG_SWAY_MAX, + LOG_SWAY_MIN, + LOG_SWAY_RATE_MAX, + LOG_SWAY_RATE_MIN, + LURCH_IMPULSE, + MODEL_SCALE, + MULT_PUNCH, + MULT_PUNCH_MS, + MULT_RAMP_BOTTOM, + MULT_RAMP_TOP, + MUSIC_VOLUME, + PADDLE_TEMPO_GAIN, + PADDLE_TEMPO_MIN, + PALM_COUNT, + PITCH_DAMP, + PITCH_SPRING, + POP_MS, + POP_POOL, + POP_RISE, + REFLECT_SINK, + REFLECT_SQUASH, + REFLECT_TINT, + RIDE_Y, + ROCK_COUNT, + ROCK_HALF, + SHADOW_LIFT, + SKY, + SPAWN_AHEAD, + SPAWN_BEHIND, + SPAWN_MIN_FRACTION, + SPEED_MAX, + SPEED_RAMP, + SPEED_START, + START_LIVES, + STEER_ACCEL, + STEER_DRAG, + STEER_LIMIT, + STEER_MAX, + SUN_AHEAD, + TEXT_RAMP_BOTTOM, + TEXT_RAMP_TOP, + TILE_LEN, + TRAIL_FOAM, + TRAIL_LIFT, + TRAIL_POINTS, + TRAIL_RIVER, + TRAIL_SORT_BIAS, + TRAIL_SPREAD_FAR, + TRAIL_SPREAD_NEAR, + TRAIL_STEP, + TRAIL_WIDTH_FAR, + TRAIL_WIDTH_NEAR, + TREE_SPAWN_AHEAD, + TUMBLE_RATE, + VIEW_W, + WARMUP_DISTANCE, + WATER_LEVEL, +} from "./constants"; +import { menuText } from "./menuText"; +import type { Geometry } from "./props"; +import { PALETTE_CELLS, WHITE_CELL } from "./props"; +import { bestMetres, bestScore, initRecords, submitRun } from "./records"; +import { aimFlare, createSun } from "./scenery"; +import { + playCapsize, + playHit, + playJump, + playPickup, + playRiver, + playSplash, +} from "./sfx"; +import { + createTerrainTile, + createWaterPlane, + scrollWaterPlane, + valleyY, +} from "./terrain"; + +/** + * The parsed descriptor for a preloaded glTF, or a loud failure. + * + * `loader.getGLTF` answers `null` for a name that was never preloaded, and a + * model built from `null` fails somewhere far away from the typo that caused + * it. Refusing here names the asset instead. + * @param name - the name the asset was preloaded under + */ +const gltf = (name: string) => { + const data = loader.getGLTF(name); + if (data === null) { + throw new Error(`jungleRabbit: glTF "${name}" was not preloaded`); + } + return data; +}; + +/** + * A renderable that implements the modern collision hook. + * + * The engine deliberately does NOT declare `onCollisionStart` on the base + * class: a stub there would make every renderable look like it implements + * every hook, and the dispatcher decides what to fire by testing for the + * method. So a subscriber declares the one it implements, which is what this + * says. + */ +type CollisionAware = { + onCollisionStart?: (response: object, other: Mesh) => void; +}; + +/** scratch for projecting a pickup to the screen — zero allocation per pop */ +const _popAt = new Vector3d(); +const _popScreen = new Vector2d(); + +/** scratch for projecting the sun each frame — zero allocation per frame */ + +/** one reused matrix for every instance placement — zero allocation per frame */ +const _placement = new Matrix3d(); + +/** the axis the hull lists about — Z, the direction of travel */ +const AXIS_Z = new Vector3d(0, 0, 1); + +/** the axis a planted instance turns about — Y, so a tree spins where it stands */ +const AXIS_Y = new Vector3d(0, 1, 0); + +/** the axis the hull PITCHES about — X, so the bow rises and the stern drops */ +const AXIS_X = new Vector3d(1, 0, 0); + +/** + * A pickup the course recycles ahead of the boat. + * + * Carrots stay one `Mesh` each on purpose. They are collected individually, + * and `InstancedMesh#removeInstance` swaps the last instance into the hole — + * so every pickup would permute the array and the bookkeeping to repair it + * costs more than the draw call saves at this count. See the scatters below + * for the other side of that trade. + */ +interface Prop { + sprite: Mesh; + kind: "carrot" | "log" | "rock"; + /** + * Radians per second this one turns about its own axis, and the angle it + * has turned so far. `rotate()` is RELATIVE, so the angle has to be tracked + * to feed it a delta — handing it the absolute value winds the mesh up like + * a spring within seconds. + */ + spin: number; + angle: number; + /** + * A log does not spin, it SWAYS: the current keeps turning it, so it + * drifts a few degrees either side of the angle it settled at. `sway` is + * how far, `swayRate` how fast, and `phase` is what keeps six of them from + * swinging in step — the giveaway that they are one animation, not six + * pieces of timber on the same river. + */ + sway: number; + swayRate: number; + phase: number; + /** + * The heading a log settled at, which its sway swings either side of. + * Carries the half-turn that puts the stub branch on the other end, so a + * pool of six does not read as one asset repeated six times. + */ + baseAngle: number; +} + +/** + * Scenery drawn as one `InstancedMesh`: hundreds of copies of one geometry in + * a single draw call. + * + * The game never addresses a tree individually, and a boulder only by the + * position recorded here — which instancing does not take away. What it does + * take away is per-object ground shadows (an instanced set gets ONE shadow + * plane, and this floor is a parabola) and a per-object depth sort key, and + * neither matters for opaque scenery resolved by the depth buffer. + */ +interface Scatter { + mesh: InstancedMesh; + /** + * Where each instance sits, so the game can read positions back — plus the + * pose it was placed with. `yaw`/`jitter` used to be rolled inline and + * thrown away, which is fine for scenery that never moves again; a set that + * is rewritten every frame has to be able to rebuild the SAME matrix, or + * each leaf re-rolls its rotation and size per frame and the set boils. + */ + items: { + x: number; + y: number; + z: number; + yaw: number; + jitter: number; + /** so a set does not bob and turn in unison */ + phase: number; + }[]; + /** plants hug the banks; boulders sit in the navigable middle */ + onWall: boolean; + /** + * For bank planting, the span of the bank it occupies, as a fraction of + * `HALF_W`. Layering these is what makes the gorge read as jungle rather + * than as a row of trees: ferns down at the waterline, broad leaves above + * them, palms overhead and furthest out. + */ + band: [number, number]; + /** how far down the course this set is recycled to */ + spawnAhead: number; + /** give each instance its own yaw, so a radial model does not repeat */ + spin: boolean; + /** rewritten every frame: turns and bobs on the current, for things afloat */ + drift: boolean; + /** + * The furthest z placed on each side, `[left, right]`. + * + * Each respawn is placed relative to the LAST one rather than to the boat. + * A window measured from the boat cannot hold a spacing however evenly it + * is filled, because the window moves with the boat and the run speeds up — + * so arrivals scatter more and more. Measuring from a frontier makes the + * spacing something this code controls, and speed drops out of it. + */ + frontier: [number, number]; + /** target mean gap between neighbours on one side */ + spacing: number; +} + +export class GameStage extends Stage { + private app!: Application; + + // ── the cast: everything the scene draws ──────────────────────────────── + + /** the boat and its rabbit — one animated rig, and the only body that moves */ + private rabbit!: GLTFModel; + private tiles: ReturnType[] = []; + /** the translucent river surface — one plane that travels with the boat */ + private waterPlane!: ReturnType; + /** the one scatter that is animated per frame */ + private leaves!: Scatter; + /** pooled "+N" labels, cycled so a fast run never allocates one mid-frame */ + private pops: { label: Text; age: number; x: number; y: number }[] = []; + private nextPop = 0; + /** the boat's reflection: the same rig, upside down under the surface */ + private reflection!: GLTFModel; + /** ambient birds crossing the gorge, each flapping on its own beat */ + private birds: { model: GLTFModel; x: number; dir: number; phase: number }[] = + []; + /** hull pitch: sprung back to level after a hit, free-running on a capsize */ + private pitch = 0; + private pitchVel = 0; + private lastPitch = 0; + /** set once the last life is gone — the boat goes over as the fade runs */ + private tumbling = false; + /** carrots to collect and rocks to dodge — the only things with bodies */ + private props: Prop[] = []; + /** + * The furthest z each KIND of prop has been placed at. + * + * Same reasoning as the scatters' frontier: a window measured from the boat + * cannot hold a spacing, and a long stretch with no obstacle in the lane + * reads as the collision being broken rather than as a gap in the course. + * + * One frontier per kind, so a run of carrot respawns cannot starve the + * boulders (or the reverse). + */ + private propFrontier: Record = { + carrot: 0, + log: 0, + rock: 0, + }; + /** every instanced set, in one list — recycling and restart walk this */ + private scatters: Scatter[] = []; + /** the river surface — a static field; the ripple motion is the UV scroll */ + private water!: NoiseTexture2d; + private sunDisc!: Sprite3d; + /** fired on impact and on pickup — never streamed */ + private splash!: ParticleEmitter; + private sparkle!: ParticleEmitter; + /** the boat's wake: two stern ribbons, rebuilt each frame from where it has been */ + private trail!: Mesh; + private trailPath: { x: number; z: number }[] = []; + private trailColors!: Uint32Array; + private trailVerts!: Float32Array; + private lastTrailZ = 0; + private sun!: Light3d; + private ambient!: Light3d; + /** LIVES — rebaked only when a life is lost */ + private hudLives!: Text; + /** CARROTS — rebaked only on a pickup */ + private hudCarrots!: Text; + /** + * The multiplier, as its OWN label: one `Text` carries one `fillStyle`, so + * a second colour in the same line needs a second label. It also lets the + * two animate independently — the score ticks constantly, the multiplier + * changes rarely, and only the rare one is worth punching. + */ + private hudMultiplier!: Text; + /** what the multiplier label is currently showing, to notice a step */ + private shownMultiplier = 1; + /** when the current run of carrots goes stale, measured against `elapsed` */ + private comboExpires = 0; + /** how far through the step animation, in ms; at or past the duration = idle */ + private multPunchAge = MULT_PUNCH_MS; + /** the distance, the one line that really changes every frame */ + private hudDistance!: Text; + /** the persisted best, top right */ + private bestLabels: Text[] = []; + private beatRecord = false; + private banner!: Text; + + // ── the run: the whole simulation, in a dozen numbers ─────────────────── + + /** how far the course has been pulled past the boat — the only real motion */ + private travelled = 0; + private forwardSpeed = SPEED_START; + private steerVel = 0; + private verticalVel = 0; + private height = 0; + private score = 0; + /** + * Carrots taken since the last hit. The multiplier is derived from it + * rather than stored, so there is one number to reset and no way for the + * two to disagree. + */ + private combo = 0; + private lives = START_LIVES; + private invulnUntil = 0; + private elapsed = 0; + private over = false; + private camTarget = new Vector3d(); + /** + * What the hull is doing on the water, driven by a `Tween` rather than by + * a sine in `update`: rise is a vertical bob, roll a slight list to one + * side. A boat held at a fixed height reads as a sled that happens to be + * on a river. + */ + private bob = { rise: 0, roll: 0 }; + private lastRoll = 0; + private nextRiverSound = 0; + + onResetEvent(app: Application) { + this.app = app; + const world = app.world; + + // A `Stage` instance is registered once and RE-ENTERED on every switch + // back to it, so these arrays still hold the previous run's objects — + // whose renderables the world reset has already destroyed. Rebuilding + // on top of them crashes on the first recycle, reaching into a sprite + // that no longer has a position. Everything below is rebuilt from + // scratch, so everything here must start empty. + this.tiles = []; + this.props = []; + this.propFrontier = { carrot: 0, log: 0, rock: 0 }; + this.scatters = []; + this.trailPath = []; + this.lastTrailZ = 0; + this.travelled = 0; + this.forwardSpeed = SPEED_START; + this.steerVel = 0; + this.verticalVel = 0; + this.height = 0; + this.score = 0; + this.combo = 0; + this.comboExpires = 0; + this.shownMultiplier = 1; + this.multPunchAge = MULT_PUNCH_MS; + this.lives = START_LIVES; + this.elapsed = 0; + this.invulnUntil = 0; + this.over = false; + this.pitch = 0; + this.pitchVel = 0; + this.lastPitch = 0; + this.tumbling = false; + this.beatRecord = false; + this.lastRoll = 0; + this.nextRiverSound = 0; + + // Depth IS the Z coordinate under a Camera3d, so the container must not + // be allowed to overwrite it with a child index. + world.autoDepth = false; + app.renderer.backgroundColor.parseCSS(SKY); + + // the one texture every model in the scene samples + const palette = getPalette(); + + this.buildLighting(world); + this.buildRiver(world); + this.buildScenery(palette); + this.buildBoat(world); + this.buildEffects(world, palette); + this.buildHud(world); + this.setupCamera(app); + this.bindInputAndAmbience(world); + } + + /** + * The sun and the ambient fill. + * @param world - the world to add them to + */ + private buildLighting(world: Application["world"]) { + // Y-down: a sun pointing down-and-forward lights the valley floor and + // leaves the far wall a shade darker, which is what gives the parabola + // its shape. + this.sun = new Light3d({ + type: "directional", + direction: [-0.35, 0.82, 0.45], + color: "#fff6df", + // diffuse and ambient SUM, so this is set against the ambient + // below rather than on its own: together they land just under + // the point where the water clips to flat white and takes its + // texture, and the shading across the valley walls, with it + intensity: 0.85, + }); + // Green rather than blue: everything not in direct sun here is lit by + // light bounced off the canopy. Dim enough that the lit and unlit faces + // of the gorge still differ — a bright ambient flattens it back out. + this.ambient = new Light3d({ type: "ambient", color: "#6f9179" }); + world.addChild(this.sun, 0); + world.addChild(this.ambient, 0); + } + + /** + * The riverbed: terrain tiles that leapfrog, and the water plane over them. + * @param world - the world to add them to + */ + private buildRiver(world: Application["world"]) { + const ground = getGround(); + this.water = getWater(); + for (let i = 0; i < 4; i++) { + const tile = createTerrainTile(ground); + tile.depth = i * TILE_LEN; + world.addChild(tile, tile.depth); + this.tiles.push(tile); + } + this.waterPlane = createWaterPlane(this.water); + // Travelling crests, as a ShaderEffect hosted on the MESH. The engine + // splices the body into its own mesh shader, so the surface keeps its + // placement, lighting and fog and this only colours the result. + // `addPostEffect` with a single effect on a plain renderable takes the + // no-FBO fast path — the effect becomes the mesh's shader for the + // draw, which is exactly what hosting means here. + this.waterPlane.addPostEffect(getRipples()); + world.addChild(this.waterPlane, 0); + } + + /** + * Everything that decorates the course — instanced scenery, and the pooled props the boat actually collides with. + * @param palette - the shared model texture + */ + private buildScenery(palette: HTMLCanvasElement) { + // Every prop is an authored model now, and every set is one instanced + // draw. The bank layers overlap on purpose: a fern band that stops + // where the leaves begin reads as three stripes, and the eye picks the + // seams out immediately. Overlapping them reads as undergrowth. + // Boulders in the channel. Individual renderables rather than one + // `InstancedMesh`, because an instance is not a renderable and so + // cannot carry a body — and these need one to reach the narrowphase. + // At `ROCK_COUNT` that is a handful of extra draw calls against a + // scene that already issues one per planting band. + for (let i = 0; i < ROCK_COUNT; i++) { + this.addProp("rock", geometry("rock"), palette, MODEL_SCALE, ROCK_HALF); + } + + // Drifting logs: the obstacle you JUMP rather than steer around. Far + // fewer than the boulders, and on their own frontier, so meeting one is + // an event rather than more of the same clutter. + for (let i = 0; i < LOG_COUNT; i++) { + this.addProp("log", geometry("log"), palette, MODEL_SCALE, LOG_HALF); + } + + // Ferns first, at the waterline — but no further in than that. These + // are low and flat, so a band that reaches into the channel does not + // read as planting at all: the parabola is nearly level down there and + // the fronds lie on the surface like lily pads. + this.addScatter( + geometry("fern"), + palette, + MODEL_SCALE, + FERN_COUNT, + true, + TREE_SPAWN_AHEAD, + [0.55, 1.34], + true, + ); + // broad leaves through the middle of the bank + this.addScatter( + geometry("bigleaf"), + palette, + MODEL_SCALE, + BUSH_COUNT, + true, + TREE_SPAWN_AHEAD, + [0.68, 1.52], + true, + ); + // Flowers, in two colours, threaded through the same band as the + // leaves. Nothing about the bank was any colour but green before, and + // a jungle floor that is uniformly green reads as a texture rather + // than as planting. + for (const bloom of ["flower-red", "flower-pink"]) { + this.addScatter( + geometry(bloom), + palette, + MODEL_SCALE, + FLOWER_COUNT, + true, + TREE_SPAWN_AHEAD, + [0.58, 1.3], + true, + ); + } + // Leaves on the water, in the channel rather than on the bank — and the + // one set that is rewritten every frame, so they turn and ride the + // surface instead of sitting on it like decals. + this.leaves = this.addScatter( + geometry("leaf"), + palette, + MODEL_SCALE, + LEAF_COUNT, + false, + SPAWN_AHEAD, + undefined, + true, + true, + ); + // palms overhead and furthest out, leaning over the gorge + this.addScatter( + geometry("palm"), + palette, + MODEL_SCALE, + PALM_COUNT, + true, + TREE_SPAWN_AHEAD, + [0.6, 1.64], + true, + ); + for (let i = 0; i < CARROT_COUNT; i++) { + this.addProp( + "carrot", + geometry("carrot"), + palette, + MODEL_SCALE, + CARROT_HALF, + ); + } + // Seed the WHOLE live window — `SPAWN_BEHIND` back through `spawnAhead` + // out — not just the stretch ahead. A fill that starts at the boat + // leaves recycling dormant until the first instance falls behind, and + // while it sleeps the min-spawn floor keeps moving with the boat until + // it passes the frontier — skipping a band that nothing ever plants, + // which rides through the near field as bare banks once per run. + for (const scatter of this.scatters) { + for (let i = 0; i < scatter.items.length; i++) { + this.placeInstance( + scatter, + i, + math.random(-SPAWN_BEHIND, scatter.spawnAhead), + ); + } + } + for (const prop of this.props) { + this.recycle(prop, math.random(-SPAWN_BEHIND, SPAWN_AHEAD)); + } + } + + /** + * The boat, its reflection, and the hull's idle bob. + * @param world - the world to add them to + */ + private buildBoat(world: Application["world"]) { + // An authored model rather than built geometry: the rabbit, the hull + // and the paddle are one rig, and the paddle stroke is a clip baked + // into the asset instead of a tween the game has to drive. Placing it + // is ordinary renderable work — `pos`, `depth` and `rotate` move the + // whole rig, and compose with whatever the clip is doing. + this.rabbit = new GLTFModel(gltf("boat"), { + scale: BOAT_SCALE, + // unlit, like the carrots: the faces that point at the camera are + // the ones the sun misses, so lighting the character turns its + // ears and face into dark silhouettes. The palette already carries + // a lit and a shaded fur tone, which is enough shape at this size. + lit: false, + castGroundShadow: true, + shadowGroundY: WATER_LEVEL + SHADOW_LIFT, + }); + // The hull's own box. A SENSOR: the engine reports the contact and the + // game decides what it means (a life, a lurch, a hit-stop, a pickup) — + // a push-out would fight the steering, which drives `pos` directly. + this.rabbit.bodyDef = { + // DYNAMIC, not kinematic: a broadphase pairs a body against the + // world only when one side actually moves under the simulation, + // and a kinematic hull against static props produced no contacts + // at all. `gravityScale: 0` keeps it weightless — the game owns + // where the boat is, and writes `pos` after the world step. + type: "dynamic", + gravityScale: 0, + shapes: [ + new Box3d( + 0, + 0, + 0, + BOAT_HALF[0] * 2, + BOAT_HALF[1] * 2, + BOAT_HALF[2] * 2, + ), + ], + collisionType: collision.types.PLAYER_OBJECT, + collisionMask: + collision.types.ENEMY_OBJECT | collision.types.COLLECTABLE_OBJECT, + isSensor: true, + }; + // The handler lives on the RENDERABLE that owns the body, not on the + // stage: the dispatcher calls it on the colliding object. None of the + // collision callbacks are declared on `Renderable` — it checks + // `typeof`, so assigning one is how you opt in. + // + // `onCollisionStart` rather than the legacy `onCollision`: it is + // receiver-symmetric (`other` is always the thing that was hit) and + // deduped to once per pair per side per frame. + (this.rabbit as CollisionAware).onCollisionStart = ( + _response: object, + other: Mesh, + ) => { + const prop = this.props.find((candidate) => { + return candidate.sprite === other; + }); + if (prop === undefined || this.over) { + return; + } + if (prop.kind === "carrot") { + this.eatCarrot(prop); + } else { + this.hitRock(prop); + } + }; + this.rabbit.pos.set(0, WATER_LEVEL + RIDE_Y); + this.rabbit.setCurrentAnimation("paddle", { loop: true }); + world.addChild(this.rabbit, 0); + + // The reflection: a second instance of the same rig, turned upside + // down and parked under the surface. + // + // Turned with a HALF TURN ABOUT Z rather than a negative Y scale. That + // rotation maps (x, y, z) to (-x, -y, z) — a mirror in Y and a mirror + // in X — and the hull is symmetric left to right, so the extra X + // mirror is invisible and the result is the reflection you want, + // without a negative scale flipping every face winding. + // + // It runs its own copy of the paddle clip, so the reflected rabbit + // paddles in step rather than sitting frozen under a moving boat. + this.reflection = new GLTFModel(gltf("boat"), { + scale: BOAT_SCALE, + lit: false, + castGroundShadow: false, + }); + this.reflection.rotate(Math.PI, AXIS_Z); + // Darkened toward the water. The TINT has to be assigned onto the + // parts: opacity cascades, but a tint colour does not — each child's + // `preDraw` copies its own `tint` over the renderer's, so a colour set + // on the container alone would be overwritten by every part. Assigning + // the same colour at every level is idempotent, unlike alpha. + // + // The tint is what makes this read as a reflection whichever way the + // transparent pass sorts it against the water. Sorting is per OBJECT + // (see the 20.4.0 notes), so steering flips the order mid-turn: under + // the river it looked like a reflection, over it like a second rabbit + // at half opacity. Tinted down, both orderings read correctly. + this.reflection.setChildsProperty( + "tint", + new Color(REFLECT_TINT[0], REFLECT_TINT[1], REFLECT_TINT[2]), + true, + ); + // OPAQUE, which is what keeps it looking the same frame to frame. + // + // The transparent pass sorts per object by the squared view-space + // distance of each mesh's ORIGIN. The water plane's origin sits at + // x = 0 while the reflection's follows the boat — and so does the + // camera — so steering changed which of the two was "nearer" and + // flipped the order. Drawn under the river it read as a reflection; + // drawn over it, as a solid block. Opaque geometry is drawn before + // the whole transparent pass, so the water now composites over it + // every frame, whatever the boat is doing. + this.reflection.setChildsProperty("transparent", false, true); + this.reflection.setCurrentAnimation("paddle", { loop: true }); + world.addChild(this.reflection, 0); + + // yoyo + infinite repeat: one tween for the whole run, and the boat + // never settles. The two axes share it, so the list is always in step + // with the rise, the way a hull actually moves. + new Tween(this.bob) + .to({ rise: BOB_RISE, roll: 0.05 }, { duration: 1500 }) + .easing(Tween.Easing.Sinusoidal.InOut) + .yoyo(true) + .repeat(Number.POSITIVE_INFINITY) + .start(); + } + + /** + * The sun disc, the two particle bursts, and the wake ribbon. + * @param world - the world to add them to + * @param palette - the shared model texture + */ + private buildEffects( + world: Application["world"], + palette: HTMLCanvasElement, + ) { + const puff = getPuff(); + // The sun and its flare, built by the same helper the menu screens use so + // the run and the title share one sky rather than two that drift apart. + this.sunDisc = createSun(world).sunDisc; + + // No hull spray. It streamed a soft radial puff off the bow, which at + // this camera distance resolved into a white ball sitting behind the + // rabbit rather than into spray. The wake ribbons say the hull is in + // the water, and they say it in the shape water actually takes. + + // Two emitters that never stream: they are fired with + // `burstParticles` at the moment of an impact or a pickup, and sit + // idle otherwise. `referenceSpace: "world"` leaves each particle where + // it was born, so a splash stays on the water the boat has already + // left rather than travelling along with it. + this.splash = new ParticleEmitter(0, WATER_LEVEL, { + image: puff, + referenceSpace: "world", + totalParticles: 60, + maxParticles: 24, + minLife: 320, + maxLife: 620, + speed: 0.45, + speedVariation: 0.3, + angle: Math.PI / 2, + angleVariation: Math.PI, + minStartScale: 0.8, + maxStartScale: 1.8, + minEndScale: 0, + maxEndScale: 0.4, + tint: "#e8fbff", + }); + world.addChild(this.splash, 0); + + this.sparkle = new ParticleEmitter(0, WATER_LEVEL, { + image: puff, + referenceSpace: "world", + totalParticles: 40, + maxParticles: 16, + minLife: 260, + maxLife: 500, + speed: 0.3, + speedVariation: 0.2, + angle: Math.PI / 2, + angleVariation: Math.PI, + minStartScale: 0.5, + maxStartScale: 1.1, + minEndScale: 0, + maxEndScale: 0.2, + tint: "#ffd447", + }); + world.addChild(this.sparkle, 0); + + // Two ribbons off the stern corners rather than one down the middle: + // that is what a hull does — it pushes water out to either side and + // the disturbance spreads behind it as a V. A single centre trail + // reads as exhaust. + // + // Both strips live in ONE mesh, so it is one draw and the pair can + // never disagree about where the boat has been. Kept SHORT on purpose: + // a long ribbon mostly lives behind the camera (which trails + // `CAM_BACK` back), and what remains in front has to win a per-object + // sort against the translucent water it lies on. + // + // The fade is per-vertex colour, not per-object alpha: the near end + // has to be strong and the tail gone, and `alpha` is one number for + // the whole mesh. + const rungs = TRAIL_POINTS * 2; + const strip = rungs * 2; + const trailVerts = new Float32Array(strip * 3); + const trailUVs = new Float32Array(strip * 2); + const trailIndices = new Uint16Array(TRAIL_POINTS * 2 * 6); + const trailColors = new Uint32Array(strip); + let quad = 0; + for (let r = 0; r < rungs; r++) { + for (let k = 0; k < 2; k++) { + // both vertices sample the palette's white cell — the colour + // comes from the vertex tint, not from the texture + trailUVs[(r * 2 + k) * 2] = (WHITE_CELL + 0.5) / PALETTE_CELLS; + trailUVs[(r * 2 + k) * 2 + 1] = 0.5; + } + // no quad spanning the seam between the two ribbons + if (r % TRAIL_POINTS === TRAIL_POINTS - 1) { + continue; + } + const a = r * 2; + trailIndices[quad] = a; + trailIndices[quad + 1] = a + 1; + trailIndices[quad + 2] = a + 2; + trailIndices[quad + 3] = a + 1; + trailIndices[quad + 4] = a + 3; + trailIndices[quad + 5] = a + 2; + quad += 6; + } + this.trail = new Mesh(0, WATER_LEVEL, { + vertices: trailVerts, + uvs: trailUVs, + indices: trailIndices, + texture: palette, + vertexColors: trailColors, + normalize: false, + scale: 1, + width: 240, + height: 200, + textureFilter: "nearest", + cullBackFaces: false, + lit: false, + castGroundShadow: false, + // OPAQUE, and the translucency is faked by the colour ramp below — + // see `TRAIL_FOAM`. In the transparent pass this loses its sort + // against the water plane it lies on and disappears under it. + transparent: false, + }); + world.addChild(this.trail, 0); + this.trailColors = this.trail.vertexColors as Uint32Array; + // `originalVertices` is the MODEL-space source: it is what the bounds + // are computed from and what the retained path uploads. `vertices` is + // the working copy a draw leaves behind, so writing there moves + // nothing and the camera culls the ribbon on its stale bounds. + this.trailVerts = this.trail.originalVertices; + this.trailPath = []; + this.lastTrailZ = 0; + } + + /** + * The three HUD labels. + * @param world - the world to add them to + */ + private buildHud(world: Application["world"]) { + // The ramp each HUD label fills with — a `Gradient` handed to `fillStyle`, + // which colours the fill and leaves the outline alone. + // + // One line box: `Text` re-anchors a gradient to each line by default, so + // every line of a multi-line label gets the same ramp. + const ramp = (size: number) => { + const g = this.app.renderer.createLinearGradient(0, 0, 0, size * 1.45); + g.addColorStop(0, TEXT_RAMP_TOP); + g.addColorStop(1, TEXT_RAMP_BOTTOM); + return g; + }; + // `Text` already anchors at (0, 0) — passing textAlign/textBaseline on + // top of that fights the default rather than helping + // sized for the 480×270 internal buffer, not for the window it is + // blown up into — a 26px face here would fill a third of the screen + // One label PER ENTRY, not one multi-line label. Each then carries its + // own ramp without the gradient having to span a block, and — the part + // that actually matters in a run — only the line whose value changed is + // re-baked. `setText` re-measures, re-rasterizes the offscreen canvas + // and invalidates the GPU texture on every call, so a distance counter + // ticking every frame used to drag LIVES and CARROTS through all of + // that with it. + const line = (index: number, align: "left" | "right" = "left") => { + const label = new Text(align === "left" ? 10 : VIEW_W - 10, 0, { + // the preloaded face, by the name it was registered under + font: "Crang", + size: align === "left" ? 15 : 13, + // Crang is a chunky display face and its glyphs fill the em box, + // so consecutive lines collide at the default 1.0 + lineHeight: 1.45, + fillStyle: ramp(align === "left" ? 15 : 13), + // The outline. `lineWidth` is what switches stroking on at all — + // a strokeStyle with zero width draws nothing — but keep it THIN: + // the stroke is drawn ON TOP of the fill and centred on the glyph + // outline, so half of it eats inward. At 3 on a 15px face the + // letters came out solid black. + strokeStyle: "#000000", + lineWidth: 1, + textAlign: align, + text: "", + }); + const size = align === "left" ? 15 : 13; + label.pos.y = (align === "left" ? 8 : 7) + index * size * 1.45; + // `floating` exempts it from the perspective projection, but NOT + // from the depth sort — z still means distance. A HUD wants to be + // NEAR the camera to draw last; parked at a large z it sorts to the + // far end of the valley and the trees draw over the score. + label.floating = true; + world.addChild(label, HUD_Z); + return label; + }; + + this.hudLives = line(0); + this.hudCarrots = line(1); + this.hudDistance = line(2); + + // Same row as the carrots, its own hotter ramp. Positioned in + // `refreshCounters` from the carrots line's measured width, because + // where it belongs depends on how many digits the score has. + const hot = this.app.renderer.createLinearGradient(0, 0, 0, 15 * 1.45); + hot.addColorStop(0, MULT_RAMP_TOP); + hot.addColorStop(1, MULT_RAMP_BOTTOM); + this.hudMultiplier = new Text(10, 8 + 15 * 1.45, { + font: "Crang", + size: 15, + lineHeight: 1.45, + fillStyle: hot, + strokeStyle: "#000000", + lineWidth: 1, + textAlign: "left", + text: "", + }); + this.hudMultiplier.floating = true; + world.addChild(this.hudMultiplier, HUD_Z); + + // the record, opposite the live score so the two read as a pair + initRecords(); + this.bestLabels = [line(0, "right"), line(1, "right"), line(2, "right")]; + // The persisted record does not move during a run, so it is written + // once here rather than re-set every frame. + this.bestLabels[0].setText("BEST"); + this.bestLabels[1].setText(`${bestScore()}`); + this.bestLabels[2].setText(`${bestMetres()}M`); + + this.banner = new Text(VIEW_W / 2, 105, { + font: "Crang", + size: 20, + lineHeight: 1.45, + fillStyle: ramp(20), + strokeStyle: "#000000", + // 1, not 1.5. Text metrics size the box from the line height alone + // and make no allowance for the stroke, and `textBaseline: "top"` + // puts the first line's ascenders at y = 0 — so the outer half of a + // thick stroke overflows the top of the box and is clipped away. + lineWidth: 1, + textAlign: "center", + text: "", + }); + this.banner.floating = true; + world.addChild(this.banner, HUD_Z); + } + + /** + * Point the `Camera3d` down the river and frame the boat. + * @param app - the running application + */ + private setupCamera(app: Application) { + const camera = app.viewport as Camera3d; + camera.setClipPlanes?.(1, 9000); + // Distance fog. The colour is omitted on purpose: it tracks + // `renderer.backgroundColor`, which is already the sky, so the valley + // dissolves into the horizon and props arrive without a hard edge. + camera.setFog?.({ + near: FOG_NEAR, + far: FOG_FAR, + // A humid green rather than the sky's blue. Left to default the + // haze tracks `backgroundColor`, which washes the far gorge to the + // same pale tone as the sky and loses the horizon altogether. + color: FOG_COLOR, + // Mist pools in the run rather than hanging at every altitude + // equally: density falls off above the river, so the canopy stays + // crisp while the bottom of the gorge fills in. + // Y-down, so `fogHeight` is the floor and density rises below it. + fogHeight: WATER_LEVEL, + heightFalloff: FOG_HEIGHT_FALLOFF, + }); + // A light grade — one line, and the frame stops reading as a default + // render. A vignette was tried alongside it and dropped: the gorge + // already frames the shot, and darkening the corners buried the HUD. + camera.colorMatrix.contrast(1.06).saturate(1.14); + camera.fov = CAM_FOV; + camera.pitch = CAM_PITCH; + this.camTarget.set(0, WATER_LEVEL + RIDE_Y + CAM_UP, CAM_BACK); + camera.pos.set(this.camTarget.x, this.camTarget.y); + camera.depth = this.camTarget.z; + } + + /** + * Key bindings, the pooled "+N" labels, the birds, and the music. + * @param world - the world to add them to + */ + private bindInputAndAmbience(world: Application["world"]) { + input.bindKey(input.KEY.LEFT, "left"); + input.bindKey(input.KEY.A, "left"); + input.bindKey(input.KEY.RIGHT, "right"); + input.bindKey(input.KEY.D, "right"); + input.bindKey(input.KEY.SPACE, "jump", true); + + // A pooled set of "+N" labels. Pooled rather than created on pickup: + // a `Text` rasterizes its glyphs on construction, and doing that in + // the middle of a run is a visible hitch for the sake of two + // characters. Six is more than a fast run ever has in the air at once. + this.pops = []; + this.nextPop = 0; + for (let i = 0; i < POP_POOL; i++) { + const label = menuText(0, 0, 13, ""); + label.alpha = 0; + world.addChild(label, HUD_Z); + // `age` past POP_MS means "retired" — nothing to draw and nothing + // to advance + this.pops.push({ label, age: POP_MS, x: 0, y: 0 }); + } + + // Birds crossing the gorge. The upper half of the frame is sky and + // canopy and nothing else moves in it; a few silhouettes drifting + // across give the shot a scale it does not otherwise have. + // + // `GLTFModel` rather than one `InstancedMesh`: an instanced set shares + // one geometry, so every copy would beat its wings in lockstep — and a + // flock flapping in perfect unison reads as a machine. Each bird runs + // its own clip at its own rate instead. Seven rigs of three parts is + // the price of that, and it buys the one thing the sky was missing. + this.birds = []; + for (let i = 0; i < BIRD_COUNT; i++) { + const model = new GLTFModel(gltf("bird"), { + scale: BIRD_SCALE, + // silhouettes against the sky: lighting them would tint them + // with the scene's green bounce and lose the read + lit: false, + castGroundShadow: false, + }); + const dir = i % 2 === 0 ? 1 : -1; + // authored nose-forward (+Z after the axis bridge), so one flying + // the other way is turned about once, here + model.rotate(dir > 0 ? -Math.PI / 2 : Math.PI / 2, AXIS_Y); + model.setCurrentAnimation("flap", { loop: true }); + // each on its own beat, so the flock never flaps in unison + model.animationspeed = math.randomFloat(0.8, 1.35); + world.addChild(model, 0); + this.birds.push({ + model, + x: math.randomFloat(-BIRD_SPAN, BIRD_SPAN), + dir, + phase: math.randomFloat(0, Math.PI * 2), + }); + } + + // seed the counters; from here they are written by the events + this.refreshCounters(); + this.refreshHud(); + + // The track belongs to the run, not the session: it starts here and is + // cut on the capsize, so the menus stay quiet and every run opens on + // the same bar. Browsers hold audio until a user gesture — the SPACE + // that got us here is one. + audio.playTrack("jungle-theme", MUSIC_VOLUME); + } + + // ── the endless course: placing, spacing and recycling ───────────────── + // + // Nothing is created or destroyed during a run. A fixed pool of props and + // instanced scenery is moved back out in front of the boat once it falls + // behind, so the course is endless at a constant object count. + + /** + * One `InstancedMesh` for a whole class of scenery — every tree in one + * draw call, every boulder in another. + * @param geometry - the shape every instance shares + * @param palette - the shared texture + * @param scale - model-to-world scale for the group + * @param count - how many instances to place + * @param onWall - true to scatter up the banks, false for the river bed + * @param spawnAhead - how far down the course this set is recycled to + * @param band - for bank planting, the span of bank it occupies + * @param spin - turn each instance to its own random heading + */ + private addScatter( + geometry: Geometry, + palette: HTMLCanvasElement, + scale: number, + count: number, + onWall: boolean, + spawnAhead: number, + band: [number, number] = [0.58, 1.05], + spin = false, + drift = false, + ): Scatter { + const mesh = new InstancedMesh(0, GROUND_Y, { + ...geometry, + texture: palette, + normalize: false, + scale, + width: 120, + height: 200, + textureFilter: "nearest", + cullBackFaces: false, + lit: true, + instanceCount: count, + // An instanced set gets ONE ground-shadow plane for all of it, and + // this floor is a parabola — every instance would cast onto the + // same height and the blobs would slide off the slope. The carrots + // and the boat still cast, being ordinary meshes. + castGroundShadow: false, + }); + this.app.world.addChild(mesh, 0); + const items = []; + for (let i = 0; i < count; i++) { + items.push({ x: 0, y: GROUND_Y, z: 0, yaw: 0, jitter: 1, phase: 0 }); + } + // Instances live from `SPAWN_BEHIND` behind the boat out to `spawnAhead`, + // and each side carries half of them — so this is the gap the set can + // actually sustain. Placing closer than this would run the set out of + // instances before the far end; further apart leaves holes. + const live = spawnAhead + SPAWN_BEHIND; + const scatter: Scatter = { + mesh, + items, + onWall, + spawnAhead, + band, + frontier: [0, 0], + // Per FRONTIER, not per set: a frontier is kept per x-sign, so each + // carries half the instances. Dividing by the full count advances + // each frontier at half the boat's speed, which lets the min-spawn + // floor become the placement rule again — the very model the + // frontier exists to replace — and a bare stretch then repeats + // forever at a fixed period. + spacing: live / Math.max(1, count / 2), + spin, + drift, + }; + this.scatters.push(scatter); + return scatter; + } + + /** + * Write one instance's transform from its stored pose. + * + * An instance transform is applied in the GROUP's local space, and the + * group's model matrix is `diag(s, -s, s)` — the mesh scale with the Y-axis + * bridge folded in. So a world position has to be divided back through it + * and its Y negated. Left in world units, every instance lands `scale` times + * too far away: mispositioned, and fogged as if it were past the far plane. + * + * Rotating AFTER the translate turns the instance about its own trunk + * rather than swinging it around the group origin. + * @param scatter - the set being written + * @param index - which instance + * @param turn - extra yaw, for a set that drifts + * @param sway - extra world-X offset, ditto + * @param bob - extra world-Y offset, ditto + */ + private writeInstance( + scatter: Scatter, + index: number, + turn = 0, + sway = 0, + bob = 0, + ) { + const item = scatter.items[index]; + const s = scatter.mesh.meshScale; + _placement + .identity() + .translate((item.x + sway) / s, -(item.y + bob) / s, item.z / s); + if (scatter.spin) { + _placement.rotate(item.yaw + turn, AXIS_Y); + _placement.scale(item.jitter, item.jitter, item.jitter); + } else if (turn !== 0) { + _placement.rotate(turn, AXIS_Y); + } + scatter.mesh.setInstance(index, _placement); + } + + /** + * Where the next prop of this kind goes, spread rather than random. + * + * Measured from the KIND's own frontier rather than from the boat, so the + * spacing is what this code controls and the run's speed drops out of it. + * @param kind - carrot or rock + * @returns a distance ahead of the boat + */ + private nextPropDistance(kind: Prop["kind"]) { + const count = + kind === "rock" ? ROCK_COUNT : kind === "log" ? LOG_COUNT : CARROT_COUNT; + // the stretch the pool has to cover, so this is the gap it can sustain + const spacing = (SPAWN_AHEAD + SPAWN_BEHIND) / Math.max(1, count); + // jittered, or the course reads as a slalom gate + const step = spacing * math.randomFloat(0.55, 1.45); + const z = Math.max( + this.propFrontier[kind] + step, + // never inside the haze — anything closer pops in visibly + this.travelled + SPAWN_AHEAD * SPAWN_MIN_FRACTION, + ); + this.propFrontier[kind] = z; + return z - this.travelled; + } + + private nextSpawnDistance(scatter: Scatter) { + // Serve whichever side has fallen furthest behind. Choosing at random + // lets one bank starve for a stretch purely by chance, which is the + // other half of how a bald patch forms. + const i = scatter.frontier[0] <= scatter.frontier[1] ? 0 : 1; + // Jittered, or the bank reads as a picket fence. The frontier advances + // by the mean either way, so the jitter costs no density. + const step = scatter.spacing * math.randomFloat(0.55, 1.45); + // Never inside the haze: an instance that appears in clear sight reads + // as a pop-in however well spaced it is. + const z = Math.max( + scatter.frontier[i] + step, + this.travelled + scatter.spawnAhead * SPAWN_MIN_FRACTION, + ); + scatter.frontier[i] = z; + this.spawnSide = i === 0 ? -1 : 1; + return z - this.travelled; + } + + /** which bank `nextSpawnDistance` just chose; read by `placeInstance` */ + private spawnSide: -1 | 1 = -1; + + /** + * Move one instance to a fresh spot ahead of the boat. + * + * The position is recorded in `items` as well as written to the instance + * buffer: the game reads positions back for collision, and an instance + * buffer is write-only as far as the CPU is concerned. + * @param scatter - the set to place into + * @param index - which instance + * @param aheadOfSkier - how far down the course to put it + */ + private placeInstance( + scatter: Scatter, + index: number, + aheadOfSkier: number, + side?: -1 | 1, + ) { + // Bank planting fills its whole band from the start. Obstacles in the + // channel are BIASED outward early on rather than excluded from the + // middle: a hard clear lane read as two tidy rows down the banks, + // which looks authored rather than natural. Raising a uniform sample + // to a power below one crowds it toward the edge while still letting + // the occasional rock sit mid-river, and the exponent walks back to 1 + // — a plain uniform spread — as the run goes on. + const warmup = Math.min(1, this.travelled / WARMUP_DISTANCE); + const bias = 0.3 + 0.7 * warmup; + const dir = side ?? (Math.random() < 0.5 ? -1 : 1); + const x = scatter.onWall + ? math.randomFloat(HALF_W * scatter.band[0], HALF_W * scatter.band[1]) * + dir + : STEER_LIMIT * Math.random() ** bias * dir; + const item = scatter.items[index]; + item.x = x; + // bank planting stands on the bed; anything in the channel floats at + // the surface, which is a flat height rather than the parabola + item.y = scatter.onWall ? GROUND_Y + valleyY(x) : WATER_LEVEL; + item.z = this.travelled + aheadOfSkier; + // Keep the frontier honest even on the initial fill, or the first + // respawns measure from zero and pile up at the near edge. + const f = dir < 0 ? 0 : 1; + if (item.z > scatter.frontier[f]) { + scatter.frontier[f] = item.z; + } + // One geometry stamped out hundreds of times reads as one tree copied + // hundreds of times unless each copy faces its own way. The pose is + // STORED rather than applied and forgotten, so a drifting set can + // rebuild the same matrix every frame with only the current added. + if (scatter.spin) { + item.yaw = math.randomFloat(0, Math.PI * 2); + item.jitter = math.randomFloat(0.82, 1.18); + } + item.phase = math.randomFloat(0, Math.PI * 2); + this.writeInstance(scatter, index); + } + + /** + * Build one pooled prop — a carrot to collect or a rock to dodge. + * + * Both carry a `Box3d` body, which is what puts them in the engine's 3D + * narrowphase: `Box3d`-vs-`Box3d` is the only contact that resolves in + * three dimensions, and it is what a course of obstacles laid out in XZ + * under a `Camera3d` wants. The alternative — a hand-rolled distance + * check — throws away the penetration the response already carries and + * has to be kept in step with the art by hand. + * + * Declared as `bodyDef` rather than `new Body(...)`: the engine hands it + * to whichever adapter is active when the renderable is added, so the + * example is not pinned to the built-in world. + * + * Sensors, both of them. A rock costs a life and a carrot is eaten — + * neither should shove the hull, and the game's own response (the lurch, + * the hit-stop, the speed rewind) is the interesting part. + * @param kind - what this prop is + * @param geometry - the shape it draws + * @param palette - the shared texture + * @param scale - model-to-world scale + * @param half - half-extents for its collision box, in world units + */ + private addProp( + kind: Prop["kind"], + geometry: Geometry, + palette: HTMLCanvasElement, + scale: number, + half: [number, number, number], + ) { + const sprite = new Mesh(0, GROUND_Y, { + ...geometry, + texture: palette, + normalize: false, + scale, + width: 120, + height: 200, + textureFilter: "nearest", + cullBackFaces: false, + // A carrot's sides slope outward as they rise, so their normals + // tip AWAY from a sun that shines down and the whole pickup reads + // as a dark brown lump. Pickups want to pop anyway — leave them + // fullbright rather than lighting them badly. A rock is a broad + // flat boulder and lights correctly. + lit: kind !== "carrot", + castGroundShadow: true, + shadowGroundY: WATER_LEVEL + SHADOW_LIFT, + // The engine default (0.45), for both kinds. A carrot used to be + // darkened to 0.78 here because its shadow was barely there — but + // that was the renderer replaying the river plane over the top of + // it, not the opacity being wrong. With the transparent pass + // sorting on geometry rather than origin the shadow composites as + // authored, and 0.78 reads as a painted-on hole rather than as + // something cast on moving water. + }); + sprite.bodyDef = { + type: "static", + shapes: [new Box3d(0, 0, 0, half[0] * 2, half[1] * 2, half[2] * 2)], + collisionType: + kind === "rock" + ? collision.types.ENEMY_OBJECT + : collision.types.COLLECTABLE_OBJECT, + collisionMask: collision.types.PLAYER_OBJECT, + isSensor: true, + }; + this.app.world.addChild(sprite, 0); + // Mixed directions and rates: a field of props all turning together + // reads as one mechanism rather than as scattered objects. Rocks sit + // still — a tumbling boulder reads as debris, not as an obstacle. + const spin = + kind === "carrot" + ? math.randomFloat(1.1, 2.2) * (Math.random() < 0.5 ? -1 : 1) + : 0; + this.props.push({ + sprite, + kind, + spin, + angle: 0, + sway: 0, + swayRate: 0, + phase: 0, + baseAngle: 0, + }); + } + + /** move a carrot to a fresh spot ahead of the boat */ + private recycle(prop: Prop, aheadOfSkier: number) { + const { sprite } = prop; + // Carrots drift in the navigable middle, where the boat can reach + // them. Boulders are BIASED OUTWARD rather than excluded from the + // middle: a hard clear lane reads as two tidy rows down the banks, + // which looks authored rather than natural. Raising a uniform sample + // to a power below one crowds it toward the edge while still letting + // the occasional rock sit mid-river, and the exponent walks back to 1 + // — a plain uniform spread — as the run goes on. + let x: number; + if (prop.kind === "rock") { + const warmup = Math.min(1, this.travelled / WARMUP_DISTANCE); + const bias = 0.3 + 0.7 * warmup; + const dir = Math.random() < 0.5 ? -1 : 1; + x = dir * STEER_LIMIT * Math.random() ** bias; + } else { + x = math.randomFloat(-STEER_LIMIT, STEER_LIMIT); + } + + sprite.pos.x = x; + sprite.pos.y = WATER_LEVEL; + // Render space is Y-DOWN, so the floor an object stands on is a GREATER + // y than the object: the shadow plane is `pos.y + LIFT`, never minus. + sprite.shadowGroundY = sprite.pos.y + SHADOW_LIFT; + sprite.depth = this.travelled + aheadOfSkier; + // keep the frontier honest even on the initial fill, or the first + // respawns measure from zero and pile up at the near edge + if (sprite.depth > this.propFrontier[prop.kind]) { + this.propFrontier[prop.kind] = sprite.depth; + } + // a fresh spin each time, so the same mesh does not come back at the + // same rate in the same phase. Rocks stay still — a tumbling boulder + // reads as debris rather than as something to steer around. + prop.spin = + prop.kind === "carrot" + ? math.randomFloat(1.1, 2.2) * (Math.random() < 0.5 ? -1 : 1) + : 0; + + // A log is modelled lying along X, which is across the current — the + // orientation that makes it something to jump. Nudged off square by a + // little each time, because six identical bars laid perpendicular read + // as hurdles someone placed rather than timber the river brought down. + if (prop.kind === "log") { + // It is not anchored — the current keeps working on it, so it + // swings a few degrees either side of square rather than holding + // one heading. A fresh amplitude, rate and phase each time it comes + // round, so the same six never fall into step with each other. + prop.sway = math.randomFloat(LOG_SWAY_MIN, LOG_SWAY_MAX); + prop.swayRate = math.randomFloat(LOG_SWAY_RATE_MIN, LOG_SWAY_RATE_MAX); + prop.phase = math.randomFloat(0, Math.PI * 2); + // Off square by a little, and half of them turned end for end. A + // HALF TURN rather than a mirrored scale: the log is symmetric + // apart from its stub, so a rotation puts the branch on the other + // side without inverting the winding or the normals with it. + prop.baseAngle = + math.randomFloat(-0.42, 0.42) + (Math.random() < 0.5 ? 0 : Math.PI); + } + } + + // ── the per-frame tick ───────────────────────────────────────────────── + + /** + * One frame: simulate, then place everything that follows from it. + * + * The order matters. `drive` advances `travelled` and the boat, and every + * scroll, respawn and camera move below reads it — so the simulation runs + * first and the scene is placed from the result, never the other way round. + * + * A `Stage`'s own `update` is NOT gated by the pause (only `Container` + * checks it, for its children), which is why this returns early on a paused + * state rather than relying on the engine to skip it. + * @param dt - milliseconds since the last frame + * @returns true, so the stage is always considered dirty + */ + update(dt: number) { + super.update(dt); + + // A Stage's own `update` is NOT gated by the pause, unlike its world's + // children: `Application.update` calls `world.update()` and + // `state.current().update()` unconditionally, and only `Container` + // checks `state.isPaused()` (skipping children without + // `updateWhenPaused`). A game that keeps its simulation in entities + // therefore pauses for free — this one keeps it here, so without this + // guard the run carried on through a lost window: `travelled` and the + // camera advanced while every world child was frozen, which left the + // boat standing still in world space and scrolling off the screen. + if (state.isPaused()) { + return true; + } + + const seconds = dt / 1000; + this.elapsed += dt; + // one clock for every animated set in the frame — the birds, the + // leaves and the ripple effect all read the same value + const t = this.elapsed / 1000; + + // The surface is a fixed plane the boat travels over, so its movement + // is parallax against the banks rather than anything animating in the + // texture. The field itself is deliberately static — see the dead ends + // recorded on `makeWater`. + scrollWaterPlane(this.waterPlane, this.travelled); + + // The river, retriggered as a bed. Overlapping attacks and decays make + // a continuous wash out of short bursts, which needs no looping source + // and no asset. + if (this.elapsed > this.nextRiverSound && !this.over) { + this.nextRiverSound = this.elapsed + 700; + playRiver(); + } + + // The sun is scenery, not a prop: parked at a fixed depth it is left + // behind within seconds. Kept far up the gorge so it sits on the + // horizon and never arrives. + this.sunDisc.depth = this.travelled + SUN_AHEAD; + + if (!this.over) { + this.drive(seconds, t); + } + + // Hull pitch. A spring while the boat is alive — the kick from a rock + // decays back to level — and a free run once it has capsized. + if (this.tumbling) { + this.pitch += TUMBLE_RATE * seconds; + } else { + this.pitchVel -= PITCH_SPRING * this.pitch * seconds; + this.pitchVel -= this.pitchVel * Math.min(1, PITCH_DAMP * seconds); + this.pitch += this.pitchVel * seconds; + } + // relative, like the roll above it: `rotate` takes a delta, and an + // absolute angle would wind the hull up a turn per frame + this.rabbit.rotate(this.pitch - this.lastPitch, AXIS_X); + this.lastPitch = this.pitch; + + // Birds drift across and wrap round when they leave the gorge. Their + // depth rides with the run, so they never fall behind it. + for (const bird of this.birds) { + bird.x += bird.dir * BIRD_SPEED * seconds; + if (bird.x > BIRD_SPAN) { + bird.x = -BIRD_SPAN; + } else if (bird.x < -BIRD_SPAN) { + bird.x = BIRD_SPAN; + } + bird.model.pos.x = bird.x; + // Y-down: subtracting lifts it. Each holds its own altitude and + // rides a slow sine on top, so the flock never flies as a rigid + // formation. + bird.model.pos.y = + WATER_LEVEL - + BIRD_HEIGHT - + ((bird.phase / (Math.PI * 2)) * BIRD_HEIGHT_SPREAD + + Math.sin(t + bird.phase) * 18); + // near enough to read as birds rather than as specks, far enough + // that they never cross in front of the boat + bird.model.depth = this.travelled + BIRD_AHEAD; + } + + this.updatePops(dt); + + // A combo has to be KEPT UP, not merely not-lost: stop taking carrots + // and it goes stale on its own, so dodging everything for a minute does + // not park the multiplier at its ceiling for free. + if (this.combo > 0 && this.elapsed > this.comboExpires) { + this.combo = 0; + this.refreshCounters(); + } + + // Running out: blink, faster the closer it gets. A blink cannot say HOW + // long is left the way a draining bar does, so the acceleration is what + // carries that — steady means "soon", frantic means "now". + const nearlyGone = + this.combo > 0 && this.comboExpires - this.elapsed < COMBO_WARN_MS; + this.hudMultiplier.alpha = + nearlyGone && Math.floor(this.elapsed / COMBO_BLINK_MS) % 2 === 1 + ? 0.15 + : 1; + + // The step itself: a scale that starts big and settles back. Only this + // label gets one — the score and the distance tick every frame, and + // animating those would be noise rather than emphasis. + if (this.multPunchAge < MULT_PUNCH_MS) { + this.multPunchAge += dt; + const k = Math.min(1, this.multPunchAge / MULT_PUNCH_MS); + // out-quadratic, so most of the shrink happens at once and it reads + // as a snap rather than a slow deflate + const punch = 1 + MULT_PUNCH * (1 - k) * (1 - k); + // `preDraw` pivots a renderable's transform around its own `pos`, + // so this grows out of where the label already is + this.hudMultiplier.currentTransform.identity(); + this.hudMultiplier.currentTransform.scale(punch, punch); + } + + this.followCamera(seconds); + // Pickups turn on the spot — a carrot that just slides toward you reads + // as scenery, and the turn is what marks it as something to collect. + for (const prop of this.props) { + // A carrot spins at a constant rate; a log is pushed back and forth + // about where it settled. Both end up as a delta, because `rotate` + // is relative and handing it an absolute angle winds the mesh up. + const turned = + prop.kind === "log" + ? prop.baseAngle + + prop.sway * Math.sin(t * prop.swayRate + prop.phase) + : prop.angle + prop.spin * seconds; + prop.sprite.rotate(turned - prop.angle, AXIS_Y); + prop.angle = turned; + } + + this.placeFlare(); + + this.recycleCourse(); + this.refreshHud(); + return true; + } + + /** steering, jumping, forward motion, and everything they touch */ + private drive(seconds: number, t: number) { + // forward speed ramps toward its ceiling, so the run gets harder + this.forwardSpeed = math.lerp( + SPEED_START, + SPEED_MAX, + Math.min(1, this.elapsed / 1000 / SPEED_RAMP), + ); + this.travelled += this.forwardSpeed * seconds; + + // The leaves ride the surface. Only this set is rewritten per frame — + // 90 instances against the banks' several thousand, which is why it is + // worth doing for the things actually afloat and not for the planting. + // Each leaf carries its own `phase`, so the set never turns or rises in + // unison; the alternating sign gives neighbours opposite spins, which + // is what stops a drifting field reading as one rotating sheet. + // The engine never advances an effect's clock — animation is opt-in, + // the same contract as re-baking a NoiseTexture2d. + getRipples().setTime(t); + for (let i = 0; i < this.leaves.items.length; i++) { + const { phase } = this.leaves.items[i]; + const sign = i % 2 === 0 ? 1 : -1; + this.writeInstance( + this.leaves, + i, + sign * (t * LEAF_TURN + phase), + Math.sin(t * LEAF_SWAY_RATE + phase) * LEAF_SWAY, + Math.sin(t * LEAF_BOB_RATE + phase) * LEAF_BOB, + ); + } + + // steering + let steering = 0; + if (input.isKeyPressed("left")) { + steering -= 1; + } + if (input.isKeyPressed("right")) { + steering += 1; + } + if (steering !== 0) { + this.steerVel = math.clamp( + this.steerVel + steering * STEER_ACCEL * seconds, + -STEER_MAX, + STEER_MAX, + ); + } else { + this.steerVel -= this.steerVel * Math.min(1, STEER_DRAG * seconds); + } + const x = math.clamp( + this.rabbit.pos.x + this.steerVel * seconds, + -STEER_LIMIT, + STEER_LIMIT, + ); + if (x !== this.rabbit.pos.x + this.steerVel * seconds) { + // scraped a wall — kill the sideways momentum rather than sticking + this.steerVel = 0; + } + this.rabbit.pos.x = x; + + // jumping + if (input.isKeyPressed("jump") && this.height === 0) { + this.verticalVel = JUMP_VELOCITY; + playJump(); + } + if (this.verticalVel !== 0 || this.height !== 0) { + this.verticalVel += GRAVITY * seconds; + this.height += this.verticalVel * seconds; + if (this.height >= 0) { + // landing: only worth a splash if it was a real fall, or every + // bob against the floor triggers one + if (this.verticalVel > 400) { + playSplash(); + this.splash.pos.x = this.rabbit.pos.x; + this.splash.pos.y = WATER_LEVEL - 4; + this.splash.depth = this.travelled; + this.splash.burstParticles(10); + } + this.height = 0; + this.verticalVel = 0; + } + } + + // A boat floats at a FLAT height. Following the bed's parabola was + // right for a sled on snow — it climbed the banks as it steered — and + // is wrong here: water finds its own level, so steering across the + // river changes nothing about how high the hull sits. + this.rabbit.pos.y = WATER_LEVEL + RIDE_Y + this.height - this.bob.rise; + + // The field of view opens slightly as the run speeds up. A few degrees + // is enough: the periphery stretches, the banks rush, and the sense of + // speed comes from the lens rather than from the numbers. + const pace = (this.forwardSpeed - SPEED_START) / (SPEED_MAX - SPEED_START); + (this.app.viewport as Camera3d).fov = CAM_FOV * (1 + 0.16 * pace); + // The list is the bob PLUS a lean into the turn, so hard steering banks + // the boat. Fed to `rotate` as a DELTA, because `rotate` is relative — + // an absolute angle winds the hull up like a spring within seconds. + // + // PLUS, not minus: render space is Y-DOWN and the mesh model matrix + // carries the axis bridge `diag(s, -s, s)`, which negates vertex Y, so + // a positive turn about +Z reads on screen as the maths-book negative. + const lean = this.bob.roll + (this.steerVel / STEER_MAX) * 0.22; + this.rabbit.rotate(lean - this.lastRoll, AXIS_Z); + this.lastRoll = lean; + + // The clip has one authored tempo; the run does not. Driving playback + // off the pace is what makes the rabbit look like it is working for + // the speed rather than paddling to a metronome. + this.rabbit.animationspeed = PADDLE_TEMPO_MIN + PADDLE_TEMPO_GAIN * pace; + + this.rabbit.depth = this.travelled; + + // The reflection tracks the hull, mirrored about the waterline. + // `REFLECT_SQUASH` pulls it back toward the surface: a 1:1 mirror + // reads as a second boat hanging in the water at this camera angle, + // and foreshortening is what a low sun actually does to a reflection. + this.reflection.pos.x = this.rabbit.pos.x; + this.reflection.pos.y = + WATER_LEVEL + + (WATER_LEVEL - this.rabbit.pos.y) * REFLECT_SQUASH + + REFLECT_SINK; + this.reflection.depth = this.travelled; + this.reflection.animationspeed = this.rabbit.animationspeed; + // Full alpha: the darkness is the TINT's job, and the water drawn over + // it supplies the translucency. An opaque draw has blending off, so a + // fractional alpha there would darken toward black rather than fade — + // the reflection would go muddy instead of soft. + // + // It still blinks out with the boat during the invulnerability flash, + // by hiding rather than fading, for the same reason. + // `alpha`, not `isRenderable`: that flag only gates bounds updates on a + // Container, so it never hid anything. Zero and one rather than a ramp + // keeps this a blink rather than a fade. + this.reflection.alpha = this.rabbit.alpha > 0.5 ? 1 : 0; + + // The wake is laid down only while the hull is actually in the water — + // a boat in mid-jump is not disturbing anything. + if (this.height >= -40) { + this.updateTrail(x); + } + + this.afterCollide(); + } + + // ── what a contact means ─────────────────────────────────────────────── + // + // Both bodies are SENSORS: the engine reports the overlap and these decide + // what it costs. Nothing here adds or removes a renderable — a prop is + // recycled by moving it, which is safe to do inside contact dispatch. + + /** + * React to a contact the engine reported. + * + * Dispatched by the physics step, once per contact, with the receiver as + * `response.a` — so `other` is always the thing that was hit. Which branch + * to take comes off the body's collision type rather than any bookkeeping + * the game keeps, which is the whole reason the props carry bodies. + * + * Nothing here mutates the world: recycling a prop moves it, and the life + * and score changes are plain fields. Adding or removing a renderable from + * inside contact dispatch is what the physics skill warns against. + * @param _response - the contact, unused: these are sensors, so there is + * no penetration to resolve — the game's own reaction is the response + * @param other - the prop that was hit + * @returns false, so the built-in push-out is skipped + */ + private hitRock(prop: Prop) { + // a jump clears a boulder outright — the hull is not in the water + if (this.height < -CLEAR_HEIGHT || this.elapsed <= this.invulnUntil) { + return; + } + const px = prop.sprite.pos.x; + // the run of carrots ends here — that is what makes a boulder cost + // something even while you still have lives in hand + this.combo = 0; + this.lives -= 1; + this.refreshCounters(); + + // A hit costs speed by winding the ramp back, since `drive` derives + // `forwardSpeed` from `elapsed` at the top of every frame — assigning + // the speed here would be overwritten before anything read it. + // + // Every deadline measured against `elapsed` has to be rebased with it, + // so the rewind happens FIRST: set after it, the invulnerability + // window would inherit the rewind and last many times its length. + const rewind = Math.min(this.elapsed, 12000); + this.elapsed -= rewind; + this.nextRiverSound = Math.max(0, this.nextRiverSound - rewind); + this.invulnUntil = this.elapsed + INVULN_MS; + + // the hit, felt rather than counted: the camera takes the + // blow, the hull throws water, and it is audible + const pan = Math.max(-1, Math.min(1, px / STEER_LIMIT)); + this.app.viewport.shake(9, 260); + this.splash.pos.x = px; + this.splash.pos.y = WATER_LEVEL - 4; + this.splash.depth = this.travelled; + this.splash.burstParticles(18); + if (this.lives <= 0) { + playCapsize(); + this.gameOver(); + } else { + playHit(pan); + // Hit-stop. The shake and the splash say something struck + // the hull; holding the whole run still for a few frames is + // what gives it weight, and it is the one cue that reads + // even when the collision happens off to the side of the + // frame. Only on a survivable hit — the fatal one already + // has the game-over fade to land on, and stacking a freeze + // in front of that just delays the result. + // `music` is left false so the track plays through it. + void this.app.freeze(HIT_STOP_MS); + // and the bow kicks. Added to the velocity rather than + // assigned, so a second rock while the first kick is still + // settling compounds instead of restarting it. + this.pitchVel -= LURCH_IMPULSE; + } + } + + /** the boat reached a carrot */ + private eatCarrot(prop: Prop) { + // recycled rather than hidden: `alpha = 0` on a Mesh draws it black + // instead of hiding it, which left an eaten carrot as a dark + // silhouette lying on the water + const pickX = prop.sprite.pos.x; + // read before `recycle` moves the carrot away + const pickY = prop.sprite.pos.y; + const pickZ = prop.sprite.depth; + this.recycle(prop, this.nextPropDistance(prop.kind)); + // the multiplier THIS carrot earns, read before the count advances, so + // the number that floats up is the number that was added + const worth = CARROT_SCORE * this.multiplier; + this.combo += 1; + this.comboExpires = this.elapsed + COMBO_WINDOW_MS; + this.score += worth; + this.refreshCounters(); + this.sparkle.pos.x = pickX; + this.sparkle.pos.y = WATER_LEVEL - 40; + this.sparkle.depth = this.travelled; + this.sparkle.burstParticles(12); + playPickup(Math.max(-1, Math.min(1, pickX / STEER_LIMIT)), this.multiplier); + this.popScore(pickX, pickY, pickZ, worth); + } + + /** the per-frame tail of what `collide` used to do */ + private afterCollide() { + // flash while the hit still counts as recent + this.rabbit.alpha = + this.elapsed < this.invulnUntil && Math.floor(this.elapsed / 90) % 2 === 0 + ? 0.35 + : 1; + } + + /** + * Point the lens flare at wherever the sun currently lands on screen. + * + * Run AFTER the camera has moved, or the flare trails the frame by one + * update — the one place where a flare stops looking like glass and starts + * looking like a bug. + */ + private placeFlare() { + aimFlare(this.app.viewport as Camera3d, this.sunDisc); + } + + /** leapfrog the terrain tiles and re-seed props that fell behind */ + private recycleCourse() { + for (const tile of this.tiles) { + if (tile.depth + TILE_LEN < this.travelled - SPAWN_BEHIND) { + tile.depth += TILE_LEN * this.tiles.length; + } + } + for (const scatter of this.scatters) { + for (let i = 0; i < scatter.items.length; i++) { + if (scatter.items[i].z < this.travelled - SPAWN_BEHIND) { + this.placeInstance( + scatter, + i, + this.nextSpawnDistance(scatter), + this.spawnSide, + ); + } + } + } + for (const prop of this.props) { + if (prop.sprite.depth < this.travelled - SPAWN_BEHIND) { + this.recycle(prop, this.nextPropDistance(prop.kind)); + } + } + } + + // ── the scene that follows from the simulation ───────────────────────── + + /** + * Rebuild the wake from where the hull has been. + * + * A sample is taken every `TRAIL_STEP` of travel rather than every frame: + * the ribbon then has a fixed length in WORLD units regardless of frame + * rate or speed, instead of shortening whenever the game runs fast. + * @param x - the hull's current x + */ + private updateTrail(x: number) { + if (this.travelled - this.lastTrailZ >= TRAIL_STEP) { + this.lastTrailZ = this.travelled; + this.trailPath.unshift({ x, z: this.travelled }); + if (this.trailPath.length > TRAIL_POINTS) { + this.trailPath.length = TRAIL_POINTS; + } + } + const verts = this.trailVerts; + const count = this.trailPath.length; + const trailDepth = this.travelled - TRAIL_SORT_BIAS; + for (let side = 0; side < 2; side++) { + const sign = side === 0 ? -1 : 1; + for (let i = 0; i < TRAIL_POINTS; i++) { + // Past the end of the history every remaining rung pins to the + // oldest real sample, so a young wake collapses into a point + // rather than fanning out from the origin. + const point = this.trailPath[Math.min(i, count - 1)] ?? { + x, + z: this.travelled, + }; + const age = i / (TRAIL_POINTS - 1); + // the V: each ribbon walks outward as it falls behind + const offset = + sign * + (TRAIL_SPREAD_NEAR + (TRAIL_SPREAD_FAR - TRAIL_SPREAD_NEAR) * age); + // It widens as it falls behind, and pinches out at the very + // end so the ribbon has a tip rather than a cut edge. + const taper = 1 - age * age * age; + const half = + (TRAIL_WIDTH_NEAR + (TRAIL_WIDTH_FAR - TRAIL_WIDTH_NEAR) * age) * + (count > 1 ? taper : 0); + // and it thins toward the river's own colour as it goes: + // squared, so the foam holds just behind the stern and then + // lets the water back through + const fade = (1 - age) * (1 - age); + const r = Math.round( + TRAIL_RIVER[0] + (TRAIL_FOAM[0] - TRAIL_RIVER[0]) * fade, + ); + const g = Math.round( + TRAIL_RIVER[1] + (TRAIL_FOAM[1] - TRAIL_RIVER[1]) * fade, + ); + const b = Math.round( + TRAIL_RIVER[2] + (TRAIL_FOAM[2] - TRAIL_RIVER[2]) * fade, + ); + // packed as `Color.toUint32` does it: A, then R, G, B + const packed = ((255 << 24) | (r << 16) | (g << 8) | b) >>> 0; + const rung = side * TRAIL_POINTS + i; + for (let k = 0; k < 2; k++) { + const v = (rung * 2 + k) * 3; + verts[v] = point.x + offset + (k === 0 ? -half : half); + // POSITIVE lifts it clear of the surface, which looks + // backwards in a Y-down world and is not: a mesh's model + // matrix carries the axis bridge `diag(s, -s, s)`, so + // vertex Y is NEGATED on the way to world space — the same + // reason the instance placements pass `-item.y / s`. + verts[v + 1] = TRAIL_LIFT; + // measured from the BIASED origin, so biasing the sort key + // does not also shift the geometry down the river + verts[v + 2] = point.z - trailDepth; + this.trailColors[rung * 2 + k] = packed; + } + } + } + this.trail.depth = trailDepth; + this.trail.needsUpdate = true; + } + + /** + * Float a "+N" where the carrot was taken. + * + * Projected with `Camera3d.worldToScreen`, so the label appears on the + * pickup rather than at a fixed spot near the boat. It hands back screen + * pixels in the engine's own 2D draw space, which is exactly what a + * `floating` renderable wants, and `null` for a point at or behind the + * camera — the one case worth skipping rather than drawing at a mirrored + * coordinate. + * @param x - the carrot's world x + * @param y - its world y + * @param z - its world z + * @param worth - what it scored, multiplier already applied + */ + private popScore(x: number, y: number, z: number, worth: number) { + const camera = this.app.viewport as Camera3d; + const screen = camera.worldToScreen(_popAt.set(x, y, z), _popScreen); + if (screen === null) { + return; + } + const pop = this.pops[this.nextPop]; + this.nextPop = (this.nextPop + 1) % this.pops.length; + // The rise and the fade are driven from `age` in `update` rather than + // by a `Tween`. These labels are POOLED, so one can be re-fired long + // before its last flight finished — a fresh tween would then start + // from a half-risen position and run alongside the one still + // animating, with both writing the same `pos`. A timer just restarts. + pop.age = 0; + pop.x = screen.x; + // a line below the pickup, so the label clears the carrot it came from + pop.y = screen.y + 19; + pop.label.pos.set(pop.x, pop.y); + pop.label.depth = HUD_Z; + // the pool is built empty, so a label carries no text until it fires + pop.label.setText(`+${worth}`); + } + + /** + * Advance the "+N" labels: rise and fade, then retire. + * @param dt - frame time in milliseconds + */ + private updatePops(dt: number) { + for (const pop of this.pops) { + if (pop.age >= POP_MS) { + continue; + } + pop.age += dt; + const t = Math.min(1, pop.age / POP_MS); + // Drifts up as it fades. Out-quadratic on the rise, so it leaves + // the pickup quickly and settles rather than sliding at a constant + // speed; in-quadratic on the fade, so it holds long enough to read + // before going. + pop.label.pos.y = pop.y - POP_RISE * (1 - (1 - t) * (1 - t)); + pop.label.alpha = 1 - t * t; + } + } + + private gameOver() { + this.over = true; + // Over she goes. `drive` stops at `over`, so the pitch integrator in + // `update` is what carries this — it runs through the game-over fade, + // which is the window the tumble is tuned to fill. + this.tumbling = true; + audio.stopTrack(); + // recorded once, at the end: writing every frame would hammer + // localStorage for a number that only matters when the run stops + const metres = Math.floor(this.travelled / 10); + this.beatRecord = submitRun(this.score, metres); + // the banner says nothing until this moment, so this is the only place + // that writes it + this.banner.setText(this.beatRecord ? "NEW BEST!" : "CAPSIZED!"); + // Hand the result to the game-over stage rather than freezing here. + // It rebuilds the world, so nothing can be read back off this one — + // the numbers travel as `state.change` arguments. + state.change(state.GAMEOVER, false, this.score, metres, this.beatRecord); + } + + /** the camera trails the boat, damped so a hard turn reads as a turn */ + private followCamera(seconds: number) { + this.sun.depth = this.travelled; + this.ambient.depth = this.travelled; + const camera = this.app.viewport as Camera3d; + // Follow the boat's x exactly. A fixed fraction of it used to stand in + // for camera lag, but that lag GROWS with distance from the centre — + // at full steer it left the boat 59 units off-axis, which at this + // focal length is 451px of a 480px half-frame: hard against the edge, + // overlapping the far bank, and reading as though the boat had beached + // itself. The trailing feel belongs to `CAM_DAMP`, which lags only + // while the boat is actually moving across and settles centred. + this.camTarget.set( + this.rabbit.pos.x, + this.rabbit.pos.y + CAM_UP, + this.travelled + CAM_BACK, + ); + camera.pos.x = math.damp(camera.pos.x, this.camTarget.x, CAM_DAMP, seconds); + camera.pos.y = math.damp(camera.pos.y, this.camTarget.y, CAM_DAMP, seconds); + // depth is not damped: the boat must never outrun the camera + camera.depth = this.camTarget.z; + + // The field opens as the ramp climbs. Scrolling faster does not by + // itself feel faster — the frame looks the same and only the distance + // counter moves — but widening the view pushes the banks outward and + // pulls the horizon back, which is what the eye reads as acceleration. + // Damped rather than assigned, so a hit's speed rewind eases the view + // back in instead of snapping it. + const speedT = math.clamp( + (this.forwardSpeed - SPEED_START) / (SPEED_MAX - SPEED_START), + 0, + 1, + ); + camera.fov = math.damp( + camera.fov, + CAM_FOV + FOV_PUNCH * speedT, + CAM_DAMP, + seconds, + ); + } + + /** + * The live counter, the one label whose content actually changes per frame. + * + * `best` and `banner` are NOT touched here: they change only when a run + * starts or ends, so they are written from those events instead. `setText` + * re-measures, re-rasterizes the offscreen canvas and invalidates the GPU + * texture on every call — its early-out guards the assignment only — so a + * label that says the same thing should not be re-set at all. + */ + /** + * The distance, and only the distance. + * + * It is the one value that changes every frame. `LIVES` and `CARROTS` are + * written from the events that change them, and `BEST` once when the run + * starts — `setText` re-measures, re-rasterizes and re-uploads on every + * call, so a label that says the same thing should not be re-set at all. + */ + private refreshHud() { + this.hudDistance.setText(`${Math.floor(this.travelled / 10)}M`); + } + + /** LIVES and CARROTS, from the events that move them */ + /** what the next carrot is worth, derived from the run of them so far */ + private get multiplier() { + return Math.min(COMBO_MAX, 1 + Math.floor(this.combo / COMBO_STEP)); + } + + private refreshCounters() { + // No ♥ in a display face like this one — it renders as tofu, and + // spelling it out survives whatever font the game ends up with. + this.hudLives.setText(`LIVES ${Math.max(0, this.lives)}`); + this.hudCarrots.setText(`CARROTS ${this.score}`); + + // only once it is actually multiplying: a permanent "x1" is noise, and + // the label appearing is itself the signal that the run is going well + const mult = this.multiplier; + this.hudMultiplier.setText(mult > 1 ? `COMBO x${mult}` : ""); + // Sits after the score, so where it goes depends on how many digits + // that has — measured rather than guessed. Moving a `Text` is what + // this relies on, which is why it can follow the number as it grows. + this.hudMultiplier.pos.x = + this.hudCarrots.pos.x + this.hudCarrots.measureText().width + 9; + if (mult !== this.shownMultiplier) { + this.shownMultiplier = mult; + // restart the punch, including on the drop back to x1 — losing a + // combo is worth as much of a beat as earning one + this.multPunchAge = 0; + } + } + + onDestroyEvent() { + // belt and braces: `gameOver` already cuts it, but leaving the stage by + // any other route must not leave the track playing under the menus + audio.stopTrack(); + input.unbindKey(input.KEY.LEFT); + input.unbindKey(input.KEY.A); + input.unbindKey(input.KEY.RIGHT); + input.unbindKey(input.KEY.D); + input.unbindKey(input.KEY.SPACE); + } +} diff --git a/packages/examples/src/examples/jungleRabbit/TitleStage.ts b/packages/examples/src/examples/jungleRabbit/TitleStage.ts new file mode 100644 index 000000000..8e706086e --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/TitleStage.ts @@ -0,0 +1,168 @@ +/** + * melonJS — Jungle Rabbit: the title screen. + * + * A separate `Stage`, not an overlay on the game one. That is what lets the + * state manager's own fade carry the change: `state.transition("fade", …)` + * applies to every switch, so the title dissolving into the run costs one line + * rather than a hand-rolled alpha ramp. + * + * It reuses the river's own scenery — the same textures and geometry the run + * builds — so the title is a view OF the game rather than a picture beside it. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { + type Application, + type Camera3d, + input, + Stage, + state, + Tween, + Vector3d, +} from "melonjs"; +import { getRipples } from "./assets"; +import { + HUD_Z, + PROMPT_BLINK_MS, + PROMPT_DIM, + TITLE_CARROT_BOB, + TITLE_CARROT_BOB_RATE, + TITLE_CARROT_SPIN, + TITLE_CARROT_Y, + TITLE_DRIFT, + VIEW_H, + VIEW_W, +} from "./constants"; +import { menuLines, menuText } from "./menuText"; +import { bestMetres, bestScore, initRecords } from "./records"; +import { aimFlare, buildBackdrop } from "./scenery"; +import { driftWaterPlane } from "./terrain"; + +/** the axis the carrots turn about — Y, so they spin where they float */ +const AXIS_Y = new Vector3d(0, 1, 0); + +export class TitleStage extends Stage { + /** latched once the run has been asked for, so one press is one transition */ + private leaving = false; + /** the pieces of the backdrop this stage animates */ + private backdrop!: ReturnType; + /** kept from the reset: `aimFlare` needs it every frame */ + private camera!: Camera3d; + private elapsed = 0; + private carrotAngle = 0; + + onResetEvent(app: Application) { + this.backdrop = buildBackdrop(app); + this.camera = app.viewport as Camera3d; + this.elapsed = 0; + this.carrotAngle = 0; + initRecords(); + + const world = app.world; + world.addChild(menuText(VIEW_W / 2, 93, 34, "JUNGLE RABBIT"), HUD_Z); + // The prompt breathes rather than sitting dead on the screen — the + // arcade idiom, and the one element the player is waiting to act on. + // It dips to `PROMPT_DIM` and back rather than blinking to nothing: + // a prompt that vanishes reads as a glitch at this size, and the + // controls line underneath it has to stay legible throughout. + const promptLines = menuLines( + VIEW_W / 2, + VIEW_H - 90, + 15, + "PRESS SPACE TO PADDLE\nARROWS STEER SPACE JUMPS", + ); + for (const line of promptLines) { + world.addChild(line, HUD_Z); + } + // every line blinks, not just the first — one tween each, started + // together so they stay in step + for (const line of promptLines) { + new Tween(line) + .to({ alpha: PROMPT_DIM }, { duration: PROMPT_BLINK_MS / 2 }) + .easing(Tween.Easing.Sinusoidal.InOut) + .yoyo(true) + .repeat(Number.POSITIVE_INFINITY) + .start(); + } + // Credits, small and out of the way along the bottom. The music is + // Pixabay-licensed and asks for attribution, so this is not decoration + // — it is the condition of using the track. + for (const line of menuLines( + VIEW_W / 2, + VIEW_H - 29, + 9, + "MADE WITH MELONJS ASSETS MODELLED IN BLENDER\nMUSIC BY VLAD KROTOV - PIXABAY", + "center", + 0.35, + )) { + world.addChild(line, HUD_Z); + } + + // only once there is a record — a first run should not be greeted by + // a row of zeroes + if (bestScore() > 0 || bestMetres() > 0) { + for (const line of menuLines( + VIEW_W - 10, + 21, + 13, + `BEST\n${bestScore()}\n${bestMetres()}M`, + "right", + )) { + world.addChild(line, HUD_Z); + } + } + + input.bindKey(input.KEY.SPACE, "start", true); + this.leaving = false; + } + + update(dt: number) { + super.update(dt); + aimFlare(this.camera, this.backdrop.sunDisc); + this.elapsed += dt; + const t = this.elapsed / 1000; + + // The menu is a live scene, not a screenshot. Nothing here travels, so + // each piece of motion has to be driven by hand: the river's texture is + // baked (its flow IS the UV scroll), the crest effect has no clock of + // its own, and a `Mesh` does not spin unless something turns it. + // UVs only: the menu's plane must not move, or it sails away up + // the river and leaves the shot showing the bare riverbed + driftWaterPlane(this.backdrop.waterPlane, t * TITLE_DRIFT); + getRipples().setTime(t); + + // `rotate` is relative, so the turn is fed as a delta — handing it the + // absolute angle would wind the carrots up a full turn every frame + const turned = t * TITLE_CARROT_SPIN; + for (const [i, carrot] of this.backdrop.carrots.entries()) { + // opposite directions, so the pair reads as two objects rather + // than one mechanism + const sign = i === 0 ? 1 : -1; + carrot.rotate(sign * (turned - this.carrotAngle), AXIS_Y); + // and a slow bob, out of phase with each other + carrot.pos.y = + TITLE_CARROT_Y + + Math.sin(t * TITLE_CARROT_BOB_RATE + i * Math.PI) * TITLE_CARROT_BOB; + } + this.carrotAngle = turned; + + // One press, one transition. `state.change` under a fade does not switch + // immediately — it hands over when the fade completes — and a Stage's + // own `update` keeps running for that whole window, so a second tap + // inside it queues a SECOND change. The stage is then built, destroyed + // and rebuilt: a visible hitch, and `GameStage.onDestroyEvent` stops + // the music, which can land after the rebuild has started it and leave + // the run silent. `bindKey(..., true)` locks the key per press, so it + // is genuinely two presses that get through — this latches the intent. + if (this.leaving === false && input.isKeyPressed("start")) { + this.leaving = true; + state.change(state.PLAY); + } + return true; + } + + onDestroyEvent() { + input.unbindKey(input.KEY.SPACE); + } +} diff --git a/packages/examples/src/examples/jungleRabbit/assets.ts b/packages/examples/src/examples/jungleRabbit/assets.ts new file mode 100644 index 000000000..b6435594d --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/assets.ts @@ -0,0 +1,165 @@ +/** + * melonJS — Jungle Rabbit: shared handles to everything the preloader brought in. + * + * `loader.preload` fills the engine's asset cache; this is where a stage picks + * things back out of it. Two kinds live here: + * + * - **Derived** assets, built once by `initAssets()` after the preload + * resolves. The palette strip is a canvas, and the renderer's texture cache + * is keyed by the image object — so baking a fresh one per stage would + * upload a new GPU texture every time the title, the run and the game-over + * screen were entered, for twenty pixels that never change. + * - **Loaded** assets, read straight back out of the loader by name. Those are + * already cached, so the lookup is free and a stage can do it whenever. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { loader, type NoiseTexture2d, type ShaderEffect } from "melonjs"; +import { bakePalette, type Geometry } from "./props"; +import { bakeGround, bakePuff, bakeSun, makeWater } from "./textures"; + +let palette: HTMLCanvasElement | undefined; +let ground: HTMLCanvasElement | undefined; +let water: NoiseTexture2d | undefined; +let puff: HTMLCanvasElement | undefined; +let sun: HTMLCanvasElement | undefined; +let ripple: ShaderEffect | undefined; +let sunFlare: ShaderEffect | undefined; + +/** + * Build the derived assets. Call once, after `loader.preload` has resolved and + * before any stage is entered. + * + * This is the prewarm: it runs while the loading screen is still up, so the + * textures every stage samples exist before the first one is entered. Each of + * these used to be rebuilt on entry — a fresh canvas and a fresh + * `NoiseTexture2d` per stage, which the renderer keys by object identity and + * so uploaded as a brand new GPU texture (with its mip chain) every time the + * title, the run and the game-over screen came up. + */ +export const initAssets = () => { + palette ??= bakePalette(); + ground ??= bakeGround(); + water ??= makeWater(); + puff ??= bakePuff(); + sun ??= bakeSun(); + if (ripple === undefined) { + // Preloaded as a "shader" asset, so the compile lands inside the + // loading screen rather than on the first frame of a run. + ripple = loader.getShader("ripples") as ShaderEffect; + } + if (sunFlare === undefined) { + sunFlare = loader.getShader("flare") as ShaderEffect; + } +}; + +/** + * The soft dot every particle emitter draws — spray, splash, wake, sparkle. + * @returns the puff canvas + */ +export const getPuff = () => { + if (puff === undefined) { + throw new Error("Jungle Rabbit: initAssets() has not run yet."); + } + return puff; +}; + +/** + * The sun billboard's texture. + * @returns the sun canvas + */ +export const getSun = () => { + if (sun === undefined) { + throw new Error("Jungle Rabbit: initAssets() has not run yet."); + } + return sun; +}; + +/** + * The bank/bed texture the terrain tiles sample. + * @returns the ground canvas + */ +export const getGround = () => { + if (ground === undefined) { + throw new Error("Jungle Rabbit: initAssets() has not run yet."); + } + return ground; +}; + +/** + * The animated river surface. Shared, and deliberately never destroyed by a + * stage: it outlives all of them, and whichever stage is running drives its + * `update`. + * @returns the water noise texture + */ +export const getWater = () => { + if (water === undefined) { + throw new Error("Jungle Rabbit: initAssets() has not run yet."); + } + return water; +}; + +/** + * The palette strip every model samples — one canvas, and therefore one + * texture, shared by every instanced set in every stage. + * @returns the palette canvas + */ +export const getPalette = () => { + if (palette === undefined) { + throw new Error("Jungle Rabbit: initAssets() has not run yet."); + } + return palette; +}; + +/** + * Raw geometry for one preloaded model, ready to spread into a `Mesh` or an + * `InstancedMesh`. + * + * Every asset is authored as a single merged primitive with an identity node + * transform, which is what makes `nodes[0]` the whole model and lets its + * vertices be used without folding a node matrix in first. + * @param name - the preloaded asset name + * @returns vertices, uvs, normals and indices + */ +export const geometry = (name: string): Geometry => { + const node = loader.getGLTF(name)?.nodes[0]; + if (node === undefined) { + throw new Error(`Jungle Rabbit: "${name}" did not preload.`); + } + return { + vertices: node.vertices, + uvs: node.uvs, + normals: node.normals, + indices: node.indices, + }; +}; + +/** + * The river's travelling crests, hosted on the water mesh. + * + * Shared and never destroyed by a stage — `removePostEffect` destroys the + * effect it removes, and this one outlives every stage that shows water. + * @returns the shared ripple effect + */ +export const getRipples = () => { + if (ripple === undefined) { + throw new Error("Jungle Rabbit: initAssets() has not run yet."); + } + return ripple; +}; + +/** + * The sun's lens flare, hosted on a screen-filling quad. + * + * Shared and never destroyed by a stage, for the same reason as the ripples: + * `removePostEffect` destroys the effect it removes, and this one outlives + * every stage that shows a sky. + * @returns the shared flare effect + */ +export const getFlare = () => { + if (sunFlare === undefined) { + throw new Error("Jungle Rabbit: initAssets() has not run yet."); + } + return sunFlare; +}; diff --git a/packages/examples/src/examples/jungleRabbit/constants.ts b/packages/examples/src/examples/jungleRabbit/constants.ts new file mode 100644 index 000000000..f428acbe3 --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/constants.ts @@ -0,0 +1,700 @@ +/** + * melonJS — Jungle Rabbit: tuning constants. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ + +/** + * Internal render size, upscaled to fill the page. + * + * This is what makes the picture chunky: everything is drawn at 480×270 and + * blown up with `antiAlias: false`, so one source pixel lands as a visible + * block. Rendering at the real canvas size and merely filtering textures with + * "nearest" gives crisp texels on smooth geometry, which is a different look + * entirely — the stair-stepped edges are the point. + */ +export const VIEW_W = 480; +export const VIEW_H = 270; + +/** the sky. The haze has its own colour now, so this one can be sky blue */ +export const SKY = "#8fd0e6"; + +// ── the gorge ─────────────────────────────────────────────────────────── +/** half-width of the navigable river, in world units */ +export const HALF_W = 900; +/** + * How far the banks climb at the edge (Y-down, so this is subtracted). + * + * Lower than a ski slope wants. Steep walls close the frame in and leave a + * slot of sky at the top; a jungle river should show its sky, and the canopy + * reads better silhouetted against it than against more bank. + */ +export const WALL_H = 520; +/** world Y of the deepest point of the river bed */ +export const GROUND_Y = 0; + +/** + * How far the water surface sits above the deepest point of the bed. + * + * The river is its own surface now, laid over the bed rather than painted onto + * it. That gives the shoreline for free: the surface is flat, the bed is a + * parabola, and they meet where the parabola reaches this height — so the + * waterline is wherever the geometry says, instead of wherever a colour blend + * was tuned to. + */ +export const WATER_DEPTH = 150; + +/** world Y of the water surface (Y-down, so above the bed is negative) */ +export const WATER_LEVEL = GROUND_Y - WATER_DEPTH; + +/** + * Half-width of the water, solved from the bed's parabola rather than chosen. + * Change the depth or the bank height and the shore follows on its own. + */ +export const WATER_HALF_W = HALF_W * Math.sqrt(WATER_DEPTH / WALL_H); + +/** + * How far the river surface reaches behind and ahead of the boat. + * + * It is one plane that travels with the run, so this is its whole extent: far + * enough back to sit under the camera, far enough forward to reach past where + * the haze closes. + */ +export const WATER_BACK = -2400; +export const WATER_FRONT = 7600; + +/** + * How much of the boat's speed the river carries downstream with it. + * + * ZERO — the surface is anchored to the world, exactly like the bed, the + * boulders and the banks. + * + * A flowing river is the physically honest answer and it looked wrong, because + * everything IN the river is world-anchored: at 0.72 the boulders raced across + * a nearly-still surface and the whole thing read as a parallax layer that had + * come unstuck from the scene. Consistency with the objects sitting in the + * water matters more here than the fact that rivers move. + * + * The ripples are legible anyway now that the texture has real contrast and + * whitecaps — a rapid moving at speed is what this is meant to be. + */ +export const RIVER_FLOW = 0; + +/** + * World units per repeat of the water surface texture. + * + * Large. Small features scroll past too fast to be seen changing; big ones + * linger on screen long enough for the animation to register as movement of + * the water rather than movement past it. + */ +export const RIPPLE_UV = 520; + +/** + * How far above the water a blob shadow floats, in world units. + * + * The engine centres a blob under its caster and does not offset it by the + * light direction, so a boulder sitting in the shallows hides its own contact + * shadow completely from this camera. A small lift brings the near edge out + * from under the rock; too much and the blob rides up over the top of it. + */ +export const SHADOW_LIFT = 8; + +/** length of one terrain tile along +Z */ +export const TILE_LEN = 2400; +/** + * Columns across a tile, rows along it. + * + * The columns shape the parabola and carry the baked ambient occlusion, so + * they stay dense. The rows carried nothing but the old per-vertex distance + * haze — the profile, the normals, the UVs and the vertex colours are all + * constant along Z — so with `camera.setFog` doing that per fragment they came + * down from 9. Measured rather than assumed: the largest step in the fog + * gradient is under a quarter of one colour level at every value from 9 to 2. + */ +export const TILE_NX = 21; +export const TILE_NZ = 3; +/** + * World units per texture repeat. + * + * Small. At 520 a single band in the source was 130 units wide on the water, + * and under perspective a handful of bands that size converge into a fan of + * rays pointing at the horizon — the river read as a sunburst rather than as + * current. Tiling more often keeps the features under the size at which the + * eye starts reading them as one big shape. + */ +export const WATER_UV = 190; + +/** + * How far past `HALF_W` the terrain mesh reaches, as a multiple of it. + * + * Purely so the ground does not run out inside the frame: the planting bands + * go past `HALF_W`, and the profile is flat out there, so the extra width is + * two more columns of triangles and no new silhouette. + + */ +export const TERRAIN_OVERHANG = 1.7; + +/** where the haze starts — just past the boat, so the playfield stays crisp */ +export const FOG_NEAR = 2600; + +/** + * ...and where it hides the far end of the course entirely. + * + * This has to land INSIDE the spawn distance or props visibly appear before + * the fog has finished with them: they arrive `SPAWN_AHEAD` down the course + * with the camera `CAM_BACK` behind that, so a prop is ~6100 away the frame it + * pops in. Fog measured beyond that can never complete in time. + * + * The height falloff pulls it in further still. The camera rides ~450 above + * `fogHeight`, so density along its rays starts at `exp(-k · 450)` ≈ 0.5, and + * a wall tree's effective distance is a little over half its real one. That is + * the falloff doing its job — but it means this number is compared against + * roughly `0.55 ×` the geometric distance, not the distance itself. + */ +export const FOG_FAR = 6400; + +/** + * How fast the haze thins with altitude. + * + * Low, so it climbs. A strong falloff pools it in the channel and leaves the + * canopy and the far bend perfectly crisp, which is what made props visible + * as they arrived; 0 would be uniform at every height. + */ +export const FOG_HEIGHT_FALLOFF = 0.0005; + +/** + * Music level. Balanced against the procedural effects in `sfx.ts` by lifting + * THOSE rather than dropping this: at 0.5 the track buried them, but taking the + * music down to compensate just made it sound like it had failed to start. The + * effects now sit at roughly 0.09–0.37 and cut through a track at this level. + */ +export const MUSIC_VOLUME = 0.32; + +// ── the boat ──────────────────────────────────────────────────────────── +/** + * World units per glTF unit for the authored boat. The rig is modelled around + * a 1.4-unit rabbit, so this is what puts it back at the size the built + * geometry it replaced used to be. + */ +export const BOAT_SCALE = 44; +/** + * World units per glTF unit for every authored prop. The models are sized + * against this one number rather than each carrying its own, so a prop that + * looks wrong is a modelling problem and not a scaling one. + */ +export const MODEL_SCALE = 40; +/** + * Paddle playback, as a multiple of the authored tempo: the slowest stroke at + * a standing start, and how much faster it gets at full pace. + */ +export const PADDLE_TEMPO_MIN = 0.85; +export const PADDLE_TEMPO_GAIN = 0.9; +/** + * Where the boat model's ORIGIN sits relative to the river surface, in render + * space — so **positive is down**, and a positive value is what puts the hull + * *in* the water rather than above it. + * + * The authored hull's underside is 0.03 model units below that origin, which + * is 1.3 world units at `MODEL_SCALE`, so the draft is very nearly this number + * minus whatever the bob has lifted. `BOB_RISE` never exceeds it, which is + * what keeps the boat from leaving the water at the top of a swell. + */ +export const RIDE_Y = 9; +/** + * How far the swell lifts the hull. Kept below `RIDE_Y` + the hull's own + * underside so the boat rides the water instead of hopping off it. + */ +export const BOB_RISE = 7; +/** + * Forward speed at the start, and the ceiling it ramps to (units/second). + * + * The start is NOT a gentle one. This is a rapid, and the opening seconds have + * to read as one — at half this the river looked like a canal and the first + * stretch was something to sit through. The warm-up that keeps the opening + * readable is a DISTANCE band (see `WARMUP_DISTANCE`), not a timer, so a + * quicker start shortens how long the player waits without taking away the + * clear lane they learn the controls on. + */ +export const SPEED_START = 780; +export const SPEED_MAX = 1500; +/** how long, in seconds, it takes to reach SPEED_MAX */ +export const SPEED_RAMP = 70; +/** sideways acceleration and the top sideways speed */ +export const STEER_ACCEL = 2600; +export const STEER_MAX = 620; +/** sideways drag applied when nothing is held, per second */ +export const STEER_DRAG = 3.2; +/** how far from the centre the boat may get before it runs aground */ +export const STEER_LIMIT = WATER_HALF_W * 0.82; +/** jump impulse (negative = up) and gravity, units/second */ +export const JUMP_VELOCITY = -1150; +export const GRAVITY = 2900; + +// ── the camera ────────────────────────────────────────────────────────── +/** + * Behind (−Z) and above (−Y) the boat. + * + * Low and close. Riding high and far back reads as a map view: the horizon + * sinks to the middle of the frame, the banks flatten out and the boat becomes + * a detail in the picture. Down here the gorge walls loom either side and the + * horizon sits high, which is the difference between watching the run and + * being in it. + */ +export const CAM_BACK = -620; +export const CAM_UP = -230; +/** how fast the camera catches up; higher is tighter */ +export const CAM_DAMP = 6; +/** + * Downward tilt, in radians. Without it the camera looks dead level, the + * horizon sits across the middle of the frame and everything at ground level + * between the camera and the skier projects off the bottom edge — including + * the whole trail. + */ +export const CAM_PITCH = -0.1; + +/** + * Vertical field of view, in radians. + * + * Narrower than the 60° default. A wide angle exaggerates depth — the banks + * rush past and anything more than a few hundred units out shrinks to nothing + * — while pulling in flattens the run and keeps the scenery ahead readable at + * a size worth having drawn. + */ +export const CAM_FOV = Math.PI / 4; + +/** HUD depth: near the camera, so screen-space overlays draw last */ +export const HUD_Z = -150; + +// ── the course ────────────────────────────────────────────────────────── +/** how far ahead of the boat props are recycled to */ +export const SPAWN_AHEAD = 5200; + +/** + * The nearest a recycled prop may arrive, as a fraction of its spawn distance. + * + * Must land beyond where the fog has finished. At 0.55 a carrot could appear + * 2900 out — well inside the haze — and pop into view fully formed, which is + * the one thing an endless runner must never show. + */ +export const SPAWN_MIN_FRACTION = 0.84; +/** + * Boulders arrive further out too, for a reason of their own. + * + * The floor is a parabola, so a boulder out at `STEER_LIMIT` sits ~510 above + * the valley bottom — HIGHER than the camera — while one in the middle of the + * run sits at the bottom. The height falloff reads those two very differently: + * roughly 0.49 against 0.73 of the geometric distance. Tuned for the edge + * case, since that is the one that was arriving in plain sight. + */ +export const ROCK_SPAWN_AHEAD = 6600; + +/** + * Bank planting arrives further out than everything else. + * + * They stand on the walls, well above the valley floor, and the height falloff + * thins the fog exactly where they are — so a tree is the last thing to be + * fully hidden and the first thing seen arriving. Spawning them deeper buys + * the fog the extra distance it needs. Kept clear of the 9000 far clip plane: + * the camera trails `CAM_BACK` behind, so this plus ~880 is the real distance. + */ +export const TREE_SPAWN_AHEAD = 7200; +/** + * How far behind before a prop — or a terrain tile — is recycled. + * + * Must clear the CAMERA, not the boat. The camera trails `CAM_BACK` behind, + * and a low one looks at ground close to and behind itself, so a margin + * measured from the boat leaves the bottom of the frame hanging over the edge + * of the world: the river ends in a pale arc with the sky showing under it. + */ +export const SPAWN_BEHIND = 1800; +/** + * Boulders and trees are drawn as instanced sets — one draw call each — so the + * counts are set by how the slope should LOOK rather than by what the frame + * budget can afford. As individual meshes these were 20 and 40. + */ +/** + * Deliberately sparse. The river is the obstacle course on its own — at 90 the + * rocks formed a near-continuous slalom with no line through it, which reads + * as unfair rather than difficult. Sparse enough now that every gap is + * takeable, and the boulders punctuate the run instead of blocking it. + */ +export const ROCK_COUNT = 24; +/** + * How many logs the course keeps in rotation. + * + * Far fewer than the boulders on purpose. Spacing is derived from the count — + * the pool has to cover `SPAWN_AHEAD + SPAWN_BEHIND`, so a smaller pool means a + * longer gap — which puts a log roughly every four boulders. It should read as + * something you meet once in a while, not a second kind of clutter. + */ +export const LOG_COUNT = 6; +/** + * The bank is three instanced sets in layers, not one: palms overhead, broad + * leaves at mid height, ferns down at the waterline. One draw call each, so + * the counts answer to how dense the jungle should LOOK and nothing else — + * and depth in planting is what separates jungle from a row of trees. + * + * They also have to cover the ground that EXISTS. The terrain runs out to + * `HALF_W * TERRAIN_OVERHANG`, and `valleyY` clamps past `HALF_W`, so beyond + * the wall the bank is a flat plateau — bands that stopped at ~1.1 left a bare + * green shelf above the tree line, which is what the gaps were. The bands below + * reach the terrain edge instead, and the counts rise with the area so the + * density near the water does not thin out to pay for it. + */ +export const PALM_COUNT = 460; +export const BUSH_COUNT = 460; +export const FERN_COUNT = 1150; +/** + * Blooms per colour, threaded through the leaf band. Kept well below the + * foliage counts: flowers are the accent, and a bank as dense in blooms as in + * leaves stops reading as jungle and starts reading as a flowerbed. + */ +export const FLOWER_COUNT = 120; + +/** leaves drifting on the river, purely to fill the middle distance */ +export const LEAF_COUNT = 90; +/** carrots stay one mesh each, being collected individually */ +export const CARROT_COUNT = 14; +/** + * How far the run goes before the boulders reach the middle of the river. + * + * The opening should be readable. For this first stretch rocks are placed out + * toward the banks only, leaving a clear lane down the centre that narrows as + * it goes — so the player learns the controls on an empty river and meets the + * real obstacle course once they can steer. Nothing is spawned or despawned to + * do it; the placement band simply opens up. + */ +export const WARMUP_DISTANCE = 9000; + +/** a rock only counts as a hit while the boat is below this height */ +export const CLEAR_HEIGHT = 120; +/** how long the boat flashes and cannot be hit again, in ms */ +export const INVULN_MS = 1900; + +export const START_LIVES = 3; +export const CARROT_SCORE = 25; + +/** + * Carrots in a row per multiplier step, and the ceiling it stops at. + * + * Short on purpose. A run here is measured in tens of seconds, so a combo that + * takes a minute to build is one nobody ever sees — four carrots puts the first + * step inside the opening stretch, which is where it has to be to change how + * the river gets read. A boulder or a log resets it to nothing. + */ +export const COMBO_STEP = 4; +export const COMBO_MAX = 4; + +/** the multiplier's own ramp — hotter than the HUD's cream-to-gold */ +export const MULT_RAMP_TOP = "#fff3c4"; +export const MULT_RAMP_BOTTOM = "#ff3b1f"; + +/** + * The punch the multiplier gives when it steps, and how long it takes to + * settle. + * + * It is the only HUD line that changes rarely, so it is the only one worth + * animating: a counter that ticks every frame would be noise. Scaling from + * its own position means it grows out of where it already was rather than + * jumping, which is what keeps the eye on it without the number moving. + */ +/** + * How long a combo survives without another carrot, in milliseconds. + * + * Without this the multiplier is free to hold: dodge everything, never take a + * pickup, and it rides at its ceiling forever. The window makes it something + * you have to KEEP EARNING — the run has to stay greedy, which is the whole + * point of having it. + * + * Set against how often a carrot actually passes. Spacing is + * `(SPAWN_AHEAD + SPAWN_BEHIND) / CARROT_COUNT` = 500 units, and the boat + * covers that in 0.33-0.64s depending on how far into the speed ramp it is — + * so this is worth about three to six of them going by. At nearly twice this + * it was a safety net rather than a pressure: a combo would essentially only + * ever end by hitting something. + */ +export const COMBO_WINDOW_MS = 2200; + +/** + * How long before a combo lapses that the label starts blinking, and how fast. + * + * Score-chain games usually drain a bar, which says how much time is left + * rather than just that it is short. A blink is the cheap version of that and + * enough here — the window is under four seconds, so "it is flashing" and "it + * is nearly gone" are the same statement. + */ +export const COMBO_WARN_MS = 800; +export const COMBO_BLINK_MS = 110; + +export const MULT_PUNCH = 0.85; +export const MULT_PUNCH_MS = 380; + +// ── the rainbow ───────────────────────────────────────────────────────── +/** + * How far up the gorge the sun sits. + * + * Past the fog, and past the far clip plane's reach for anything else — it is + * exempt from both, so this only has to be far enough that it reads as sky + * rather than as an object in the scene the boat is closing on. + */ +export const SUN_AHEAD = 8200; + +/** + * The sun's lens flare: one entry per ghost. + * + * `at` is where the ghost sits along the line running from the sun's SCREEN + * position through the centre of the frame — 1 is on the sun, 0 is the centre, + * negative is the far side. That single number is the whole trick: steering + * swings the sun across the frame, and every ghost slides along the same line + * in response, which is what makes a flare read as lens glass rather than as + * decals pinned to the sky. + * + * Sizes and tints are deliberately mismatched. Real ghosts are images of the + * aperture formed by different elements, so they differ in both — a row of + * identical dots reads as a mistake. + */ +/** + * How far into the frame's edge the flare fades out, as a fraction of the + * frame. A flare that snapped off the instant the sun left the view would read + * as a bug; glass keeps scattering light for a moment after. + */ +export const FLARE_EDGE_FADE = 0.22; + +/** the haze: a humid green, not the sky's blue, so the gorge reads as jungle */ +export const FOG_COLOR = "#bcd8c4"; + +/** + * How fast a floating leaf turns on the current, in radians per second, before + * the per-instance jitter. Slow on purpose: a leaf that spins is a pinwheel, + * and what reads as water is the set turning at visibly DIFFERENT rates. + */ +export const LEAF_TURN = 0.34; + +/** world units a leaf sways across the current, and how fast it cycles */ +export const LEAF_SWAY = 6; +export const LEAF_SWAY_RATE = 0.6; + +/** + * How far a leaf rides up and down the swell. Kept under the boat's `BOB_RISE` + * — a leaf sitting ON the surface should move less than a hull driving through + * it, or the river reads as choppier than the boat is having to work for. + */ +export const LEAF_BOB = 3.2; +export const LEAF_BOB_RATE = 1.25; + +/** + * Hit-stop: how long the whole run holds still on a non-fatal collision, in + * milliseconds. Short on purpose — long enough to register as an impact, + * short enough that it never reads as a dropped frame. The fatal hit does + * not take one: the game-over fade is already the pause. + */ +export const HIT_STOP_MS = 90; + +/** + * How much wider the camera opens at `SPEED_MAX` than at `SPEED_START`, in + * radians. A river that scrolls faster does not, on its own, feel faster — + * the frame looks identical and only the numbers move. Widening the field as + * the ramp climbs pushes the banks outward and pulls the horizon back, which + * is what actually reads as acceleration. Kept small: past roughly 0.1 the + * perspective distortion starts to bend the bank into a fisheye. + */ +export const FOV_PUNCH = 0.075; + +/** model-to-world scale for a bird — wingspan is ~1.3 model units */ +export const BIRD_SCALE = 78; + +/** how many birds drift above the gorge */ +export const BIRD_COUNT = 7; +/** how far above the water they fly, and how far that varies */ +export const BIRD_HEIGHT = 520; +export const BIRD_HEIGHT_SPREAD = 260; +/** how far up the run they fly — near enough to read, far enough not to intrude */ +export const BIRD_AHEAD = 1750; + +/** their speed across the gorge, in world units per second */ +export const BIRD_SPEED = 95; +/** how far out from the centre line they range */ +export const BIRD_SPAN = 1700; + +/** how long one on/off cycle of the title's prompt takes, in milliseconds */ +export const PROMPT_BLINK_MS = 1100; +/** the prompt never fades to nothing — it dips to this and back */ +export const PROMPT_DIM = 0.45; + +/** + * The boat's reflection: how far its mirrored copy is squashed toward the + * surface, and how strongly it shows through. + * + * A true 1:1 mirror reads as a second boat hanging underwater at this + * viewing angle. Foreshortening it is both cheaper to look at and closer to + * what a low sun actually does to a reflection on moving water. + */ +export const REFLECT_SQUASH = 0.62; + +/** + * The reflection's tint, multiplied into the rig's own colours. + * + * This is where its darkness comes from — it is drawn OPAQUE, under the + * water, so it has no alpha of its own and the river supplies the + * translucency. + * + * Not decoration — it is what makes the reflection read as one whichever way + * the transparent pass happens to sort it. The reflection and the water plane + * are sorted per OBJECT, and steering changes their relative distance to the + * camera, so the order flips mid-turn: under the river it looked like a + * reflection, over it like a second rabbit at half opacity. Tinted down to a + * dark water blue it reads as a reflection in both orderings, and the sort + * stops mattering. + */ +export const REFLECT_TINT: [number, number, number] = [46, 104, 128]; +/** how far under the surface it sits, so it never z-fights the water */ +export const REFLECT_SINK = 1; + +/** + * The kick the bow takes on a survivable hit, in radians per second. + * + * A boat striking a rock pitches — it does not roll over and carry on. The + * impulse is sprung back to level rather than tweened, so a second hit while + * the first is still settling compounds instead of restarting, the way a real + * hull would keep getting knocked about. + */ +export const LURCH_IMPULSE = 3.1; +/** how hard the hull is pulled back to level, and how fast the kick dies */ +export const PITCH_SPRING = 46; +export const PITCH_DAMP = 7.5; + +/** + * The capsize: how fast the boat tumbles forward once the last life is gone, + * in radians per second. It plays out over the game-over fade, so this is + * tuned to land roughly a half-turn in that window — enough to read as going + * over, not so much that it spins like a coin. + */ +export const TUMBLE_RATE = 4.2; + +/** + * The title's river drifts even though nothing is travelling down it: the + * water texture is baked, so its motion is UV scroll, and the menu has no + * `travelled` to drive it. This is that stand-in, in world units per second. + * Slower than the run — a menu river should idle, not race. + */ +export const TITLE_DRIFT = 150; + +/** the pair of carrots flanking the title, and how fast they turn */ +export const TITLE_CARROT_X = 330; +export const TITLE_CARROT_Y = WATER_LEVEL - 168; +export const TITLE_CARROT_Z = 620; +export const TITLE_CARROT_SCALE = 43; +export const TITLE_CARROT_SPIN = 1.15; +/** how far they bob, and how fast */ +export const TITLE_CARROT_BOB = 12; +export const TITLE_CARROT_BOB_RATE = 1.6; + +/** + * The menu/HUD text ramp: light at the top falling into gold. + * + * Handed to `Text.fillStyle` as a `Gradient`, so it colours the glyph fill and + * leaves the black outline alone — `Text` strokes in a separate pass. + */ +export const TEXT_RAMP_TOP = "#fffdf0"; +/** the deep end of the same ramp */ +export const TEXT_RAMP_BOTTOM = "#ffa71d"; + +/** how many "+N" pops can be in the air at once */ +export const POP_POOL = 6; + +/** how long one stays up, in milliseconds */ +export const POP_MS = 620; + +/** how far it drifts up over that time, in buffer pixels */ +export const POP_RISE = 22; + +/** + * The boat's wake: two narrow ribbons off the stern corners, laid down on the + * surface along the path the hull actually took. + * + * Two rather than one down the middle, because that is what a hull does — it + * pushes water out to either side and the disturbance spreads behind it as a + * V. A single centre trail reads as exhaust. + * + * Kept SHORT on purpose. A long ribbon mostly lives behind the camera (which + * trails `CAM_BACK` back), and what does remain in front has to win a + * per-object sort against the translucent water plane it lies on. A few + * hundred units just behind the stern is the part you can actually see. + */ +export const TRAIL_POINTS = 20; +/** how far the boat travels between samples, in world units */ +export const TRAIL_STEP = 26; +/** how far out from the centre line each ribbon starts, and ends */ +export const TRAIL_SPREAD_NEAR = 26; +export const TRAIL_SPREAD_FAR = 82; +/** each ribbon's own half-width, at the stern and at the tail */ +export const TRAIL_WIDTH_NEAR = 5; +export const TRAIL_WIDTH_FAR = 13; +/** clear of the surface, so it never z-fights the water */ +export const TRAIL_LIFT = 3; + +/** + * The foam's colour at the stern and at the tail, as 0..255 triples. + * + * The wake is drawn OPAQUE and fakes its translucency with this ramp, which + * is not the first choice but is the one that works. In the transparent pass + * it is sorted per OBJECT against the water plane it lies on, and it loses + * that comparison however the tie is broken — by depth bias, by z order, by + * insertion — so the foam simply vanished under the river. Ramping toward the + * river's own tone instead reads as foam thinning into the water, and it + * draws every frame. + * + * `TRAIL_FOAM` is deliberately NOT pure white: even at the stern the wake + * should look like disturbed water rather than paint. + */ +export const TRAIL_FOAM: [number, number, number] = [236, 252, 255]; +export const TRAIL_RIVER: [number, number, number] = [116, 222, 240]; + +/** + * How far toward the camera the ribbon sorts, in world units. + * + * The transparent pass orders per OBJECT, back to front. The water plane and + * the wake both sit at `travelled`, and a tie is resolved by insertion order — + * the water is built first, so it drew last and painted over the foam. This + * biases the wake a couple of units nearer the camera so it sorts after the + * surface it lies on. It is a SORT key only: the vertices compensate, so + * nothing actually moves. + */ +export const TRAIL_SORT_BIAS = 2; + +/** + * Half-extents of each prop's `Box3d`, in world units. + * + * Sized to the part of the model that should actually stop a boat, not to its + * drawn bounds: a carrot's leaves and a boulder's sloping shoulders overhang + * the volume a hull can hit, and boxing those makes the course feel unfair. + */ +export const ROCK_HALF: [number, number, number] = [46, 34, 40]; +/** + * Half-extents of a drifting log, lying ACROSS the current. + * + * Wide and low: wide enough that steering around it is a commitment rather + * than a twitch, low enough that a jump clears it comfortably. That is the + * whole point of the second obstacle — a boulder asks you to steer, a log asks + * you to jump, and the two want different things from the same run. + */ +export const LOG_HALF: [number, number, number] = [88, 26, 26]; + +/** + * How far a drifting log swings either side of the heading it settled at, in + * radians, and how fast. + * + * Small and slow. This is a log being worked on by a current, not a compass + * needle — past about a fifth of a radian it stops reading as drift and starts + * reading as something powered. The range is what keeps six of them from + * looking like one animation played six times. + */ +export const LOG_SWAY_MIN = 0.07; +export const LOG_SWAY_MAX = 0.2; +export const LOG_SWAY_RATE_MIN = 0.35; +export const LOG_SWAY_RATE_MAX = 0.7; +export const CARROT_HALF: [number, number, number] = [22, 44, 22]; + +/** and the hull's own box — narrow, so a near miss reads as a near miss */ +export const BOAT_HALF: [number, number, number] = [26, 30, 46]; diff --git a/packages/examples/src/examples/jungleRabbit/createGame.ts b/packages/examples/src/examples/jungleRabbit/createGame.ts new file mode 100644 index 000000000..9838cf969 --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/createGame.ts @@ -0,0 +1,97 @@ +/** + * melonJS — Jungle Rabbit showcase. + * + * An endless river run built on the 3D tier: a `Camera3d` chase camera, a + * procedural valley `Mesh` under a travelling water plane, authored glTF + * scenery stamped out through `InstancedMesh`, and a rigged `GLTFModel` for + * the rabbit paddling its boat. + * + * Module layout: + * - `resources.ts` — the asset manifest + * - `constants.ts` — tuning + * - `props.ts` — the palette strip every model samples + * - `textures.ts` — the canvas-baked ground, water and sky textures + * - `terrain.ts` — the procedural valley and river surface + * - `scenery.ts` — the still backdrop the menus sit on + * - `TitleStage.ts` / `GameStage.ts` / `GameOverStage.ts` — the three screens + * + * Controls: arrows / A D to steer, space to jump. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { + Application, + audio, + Camera3d, + loader, + plugin, + state, + video, +} from "melonjs"; +import { initAssets } from "./assets"; +import { VIEW_H, VIEW_W } from "./constants"; +import { GameOverStage } from "./GameOverStage"; +import { GameStage } from "./GameStage"; +import { resources } from "./resources"; +import { TitleStage } from "./TitleStage"; + +export const createGame = async () => { + const app = new Application(VIEW_W, VIEW_H, { + parent: "screen", + renderer: video.AUTO, + scale: "auto", + cameraClass: Camera3d, + // chunky upscaled pixels are the look; smoothing would wash the + // hand-placed 1px details out of every texture + antiAlias: false, + textureFilter: "nearest", + // the boat's blob shadow is opted into per renderable, so the scenery + // that should not cast one can say so + castGroundShadow: false, + }); + await app.init(); + + // Toggle with S, or add `#debug` to the URL. Worth having on a scene like + // this one: the whole point of the instanced sets is that the draw-call + // count barely moves as the counts grow, and the panel is where you see + // that rather than take my word for it. + plugin.register(DebugPanelPlugin, "debugPanel"); + + // The 3D tier needs a programmable pipeline. Under the Canvas fallback a + // Camera3d renders a black frame, which reads as a broken example rather + // than as an unsupported browser — so say which it is. + if (app.renderer.shaderLanguage === null) { + throw new Error( + "Jungle Rabbit needs WebGL 2 or WebGPU — this browser fell back to Canvas.", + ); + } + + // Before preloading any audio: the loader builds each track's URL from the + // formats named here, so an `audio` asset declared without it resolves to + // nothing. + audio.init("mp3"); + + // Preload through the engine's own loading stage: with no third argument + // `preload` switches to `state.LOADING` itself and the built-in screen + // draws the progress bar until the last model is in. Awaited rather than + // given a callback — the promise form says "nothing below this line runs + // before the assets are here" in the control flow itself, instead of + // nesting the whole setup one level deeper. + await loader.preload(resources); + + // Derived assets, built once now that the cache is full — the stages share + // these rather than each rebuilding its own. + initAssets(); + + state.set(state.MENU, new TitleStage()); + state.set(state.PLAY, new GameStage()); + state.set(state.GAMEOVER, new GameOverStage()); + + // One fade covers every state change, so neither stage has to know about + // the other. + state.transition("fade", "#0d1f14", 420); + + state.change(state.MENU); +}; diff --git a/packages/examples/src/examples/jungleRabbit/flare.ts b/packages/examples/src/examples/jungleRabbit/flare.ts new file mode 100644 index 000000000..8aba6d4b1 --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/flare.ts @@ -0,0 +1,216 @@ +/** + * melonJS — Jungle Rabbit: the sun's lens flare. + * + * A `ShaderEffect` hosted on a screen-filling sprite rather than a post-effect + * on the camera. That is an ordering decision, not a performance one: a + * camera's post-effect brackets the WHOLE world draw, floating children + * included, so it would wash straight over the HUD. A quad ordered by depth + * draws after the world and before the score, which is where a flare belongs. + * + * Because the quad fills the frame, its own `uv` IS screen space — which is why + * this needs none of `screen_uv` / `screen_texture`, the builtins a hosted + * effect is not allowed (#1658). The sprite's own texture is never read either: + * `apply` ignores the incoming `color` and returns its own alpha, so the image + * exists only to give the effect a quad to run on. + * + * ## Why spikes, and why a shader at all + * + * The first version of this was four additive sprites. On a bright daylight sky + * they were invisible: additive light saturates every channel to white, and a + * soft disc at low alpha reads as a smudge. A sky has no STRUCTURE, so shape is + * what survives it — a thin bright ray is legible against pale blue where a + * round glow is not. Spikes are an angular function, which a handful of quads + * cannot express and a fragment shader gets for one `sin`. + * + * The ghosts survive here for the same reason they died as sprites only if they + * stay dim: they are a coloured bloom along the sun's line, not four more dots. + * + * Everything is driven by where the sun lands on screen, so steering and + * jumping sweep the whole flare across the frame — the only reason a flare is + * worth having. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ + +/* + * The parameters, written once per language because the two bodies must agree. + * `uSunX`/`uSunY` are the sun in the quad's uv space; `uIntensity` fades the + * lot as the sun nears the edge of the frame. + * + * Three scalars rather than a `vec2` and a float: all-`f32` members need no + * thought about WGSL's uniform alignment rules, and there are only three. + * + * No `uUVYDir`. It corrects bodies that do vertical UV arithmetic on the WebGL + * POOLED path, and a single effect on a plain renderable never takes it — the + * direct path has `uv.y` growing downward on both backends, same as WebGPU. + */ + +/** frame aspect (480x270): undo it, or a round burst comes out an ellipse */ +const ASPECT = "1.7777778"; + +/** + * Master opacity for the whole effect — burst, streak and ghosts alike. + * + * One knob rather than a dozen: the relative balance between the elements is + * tuned, so dimming the lot is a single multiply. Raise it and every part comes + * up together; the individual constants set proportion, this sets presence. + * + * Deliberately low. A flare is atmosphere, not a game element — the moment it + * is obvious enough to look AT, it is competing with the river for the player's + * attention, and the river is the thing being steered. + */ +const STRENGTH = "0.44"; + +/** where the ghost chain pivots in uv x — see `ghostAt` for why not 0.5 */ +const PIVOT_X = "0.425"; + +const glsl = ` +uniform float uSunX; +uniform float uSunY; +uniform float uIntensity; + +// Where a displaced ghost sits: on the line through the sun and the pivot, as +// a fraction of the distance between them — so at = 1 is the sun itself, +// at = 0 the pivot, and a negative value runs out the far side. Measured +// from the PIVOT, not the frame centre: offset the pivot while still measuring +// from the centre and the chain becomes a line parallel to the sun's axis but +// displaced off it, so the ghosts stop pointing at the sun at all. +// +// The chain would run through the frame centre — except the boat sits +// just below that centre, so a ghost landed squarely on the rabbit's head and +// pulled the eye off the thing being steered. Nudged off-axis: a flare's +// elements sitting slightly off the optical centre is ordinary, and it keeps +// the chain beside the player rather than on top of them. +vec2 ghostAt(vec2 sun, float at) { + vec2 pivot = vec2(${PIVOT_X}, 0.5); + return pivot + (sun - pivot) * at; +} + +// Chromatic dispersion. Glass refracts each wavelength by a different amount, +// so a flare element carries a warm fringe on one side and a cool one on the +// other. This is what makes the chain read as an optical artefact rather than +// as coloured dots — and, like the spikes, it survives a bright sky because it +// is an EDGE. A flat hue added to pale blue just saturates to white. +const vec3 DISPERSE = vec3(1.16, 1.0, 0.86); + +// a filled ghost, each channel at its own radius +vec3 ghost(vec2 uv, vec2 sun, float at, float size, vec3 tint) { + vec2 d = (uv - ghostAt(sun, at)) * vec2(${ASPECT}, 1.0); + vec3 sz = size * DISPERSE; + return tint * exp(-vec3(dot(d, d)) / (sz * sz)); +} + +// A RING ghost. Filled discs are what the sprite version died of: added to a +// bright sky they saturate to white and the colour that makes a ghost read as +// lens glass is the first thing lost. An annulus keeps an edge, and an edge +// survives a background a fill cannot. +vec3 ring(vec2 uv, vec2 sun, float at, float radius, float w, vec3 tint) { + vec2 d = (uv - ghostAt(sun, at)) * vec2(${ASPECT}, 1.0); + vec3 e = (vec3(length(d)) - radius * DISPERSE) / w; + return tint * exp(-e * e); +} + +vec4 apply(vec4 color, vec2 uv) { + vec2 sun = vec2(uSunX, uSunY); + vec2 d = (uv - sun) * vec2(${ASPECT}, 1.0); + float r = length(d); + float a = atan(d.y, d.x); + + // The burst. A high power on |sin| is what turns a smooth wave into thin + // spikes: by 22 the lobes are narrow and the gaps between them are flat. + // Two sets at different counts, offset in phase, so it does not read as a + // tidy fan. + // Ray lengths VARY. A perfectly even fan is the thing that reads as + // computer-generated — real glass scatters unevenly, so every ray reaching + // exactly as far as its neighbours is the first tell. Two incommensurate + // sines give each spike its own reach without needing a noise texture. + float vary = 0.70 + 0.30 * sin(a * 4.0 + 0.9) * sin(a * 7.0 + 2.1); + + float spikes = pow(abs(sin(a * 9.0)), 22.0) * exp(-r * 15.0 / vary) * 0.36; + float fine = pow(abs(sin(a * 21.0 + 0.6)), 30.0) * exp(-r * 22.0 / vary) * 0.21; + + // the glow the spikes sit in, and a tight core + float halo = exp(-r * 18.0) * 0.07; + float core = exp(-r * 46.0) * 0.24; + + // The anamorphic streak: a horizontal bar through the sun. This is the + // cue that says "lens" faster than anything else in the frame, and it is + // the one part of a flare an arcade game can push hard without looking + // broken. Cool-tinted, the way anamorphic glass actually flares. + float streak = exp(-abs(d.y) * 150.0) * exp(-abs(d.x) * 4.5) * 0.34; + + // warm, and held back in blue: this is ADDED to a pale blue sky, so a + // neutral tint arrives white and stops looking like sunlight + vec3 burst = vec3(1.0, 0.82, 0.55) * (spikes + fine + halo + core); + burst += vec3(0.45, 0.65, 1.0) * streak; + + // The ghosts, kept deliberately dim. Bright ones saturate to white on this + // sky and the colour that makes them read as lens glass is the first thing + // lost. + vec3 dots = ghost(uv, sun, 0.74, 0.018, vec3(0.38, 0.25, 0.08)); + dots += ghost(uv, sun, 0.56, 0.010, vec3(0.09, 0.31, 0.20)); + dots += ring(uv, sun, 0.36, 0.021, 0.007, vec3(0.26, 0.21, 0.24)); + dots += ring(uv, sun, 0.16, 0.012, 0.005, vec3(0.18, 0.21, 0.28)); + + return vec4((burst + dots) * uIntensity * ${STRENGTH}, 1.0); +} +`; + +const wgsl = ` +struct FlareUniforms { + uSunX : f32, + uSunY : f32, + uIntensity : f32, +}; +@group(3) @binding(0) var fx : FlareUniforms; + +fn ghostAt(sun : vec2f, at : f32) -> vec2f { + let pivot = vec2f(${PIVOT_X}, 0.5); + return pivot + (sun - pivot) * at; +} + +const DISPERSE = vec3f(1.16, 1.0, 0.86); + +fn ghost(uv : vec2f, sun : vec2f, at : f32, size : f32, tint : vec3f) -> vec3f { + let d = (uv - ghostAt(sun, at)) * vec2f(${ASPECT}, 1.0); + let sz = size * DISPERSE; + return tint * exp(-vec3f(dot(d, d)) / (sz * sz)); +} + +fn ring(uv : vec2f, sun : vec2f, at : f32, radius : f32, w : f32, tint : vec3f) -> vec3f { + let d = (uv - ghostAt(sun, at)) * vec2f(${ASPECT}, 1.0); + let e = (vec3f(length(d)) - radius * DISPERSE) / w; + return tint * exp(-e * e); +} + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + let sun = vec2f(fx.uSunX, fx.uSunY); + let d = (uv - sun) * vec2f(${ASPECT}, 1.0); + let r = length(d); + let a = atan2(d.y, d.x); + + let vary = 0.70 + 0.30 * sin(a * 4.0 + 0.9) * sin(a * 7.0 + 2.1); + + let spikes = pow(abs(sin(a * 9.0)), 22.0) * exp(-r * 15.0 / vary) * 0.36; + let fine = pow(abs(sin(a * 21.0 + 0.6)), 30.0) * exp(-r * 22.0 / vary) * 0.21; + + let halo = exp(-r * 18.0) * 0.07; + let core = exp(-r * 46.0) * 0.24; + + let streak = exp(-abs(d.y) * 150.0) * exp(-abs(d.x) * 4.5) * 0.34; + + var burst = vec3f(1.0, 0.82, 0.55) * (spikes + fine + halo + core); + burst += vec3f(0.45, 0.65, 1.0) * streak; + + var dots = ghost(uv, sun, 0.74, 0.018, vec3f(0.38, 0.25, 0.08)); + dots += ghost(uv, sun, 0.56, 0.010, vec3f(0.09, 0.31, 0.20)); + dots += ring(uv, sun, 0.36, 0.021, 0.007, vec3f(0.26, 0.21, 0.24)); + dots += ring(uv, sun, 0.16, 0.012, 0.005, vec3f(0.18, 0.21, 0.28)); + + return vec4f((burst + dots) * fx.uIntensity * ${STRENGTH}, 1.0); +} +`; + +/** the dual-language flare body, preloaded as a `"shader"` asset */ +export const flare = { glsl, wgsl }; diff --git a/packages/examples/src/examples/jungleRabbit/menuText.ts b/packages/examples/src/examples/jungleRabbit/menuText.ts new file mode 100644 index 000000000..036271e14 --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/menuText.ts @@ -0,0 +1,85 @@ +/** + * melonJS — Jungle Rabbit: the menus' shared text style. + * + * The white-into-gold ramp is a `Gradient` handed straight to `fillStyle` — + * the same object `Renderer#setColor` takes, built the way the canvas API + * builds one. It colours the FILL only, because `Text` strokes in a separate + * pass, so the black outline stays black without the game having to tell the + * two apart. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { game, Text } from "melonjs"; +import { TEXT_RAMP_BOTTOM, TEXT_RAMP_TOP } from "./constants"; + +/** + * A line of menu text: the display face, a thin black outline, and the + * white-into-gold ramp. + * @param x - screen x + * @param y - screen y + * @param size - font size in the internal buffer's pixels + * @param text - the string + * @param align - horizontal alignment + * @param outline - stroke width in buffer pixels. The stroke is drawn OVER the + * fill, so it eats into the glyph rather than sitting outside it — at the + * default 1 a 9px line is nearly 11% of the glyph height and closes the + * counters up entirely. Small text wants a proportionally thinner one. + * @returns the configured, floating text + */ +export const menuText = ( + x: number, + y: number, + size: number, + text: string, + align: "left" | "center" | "right" = "center", + outline = 1, +) => { + // One line box: `Text` re-anchors a gradient toeach line by default, so a + // multi-line label gets the same ramp on every line without this having to + // know how many there are. + const ramp = game.renderer.createLinearGradient(0, 0, 0, size * 1.45); + ramp.addColorStop(0, TEXT_RAMP_TOP); + ramp.addColorStop(1, TEXT_RAMP_BOTTOM); + + const label = new Text(x, y, { + font: "Crang", + size, + lineHeight: 1.45, + fillStyle: ramp, + strokeStyle: "#000000", + lineWidth: outline, + textAlign: align, + text, + }); + label.floating = true; + return label; +}; + +/** + * The same, as one `Text` PER LINE. + * + * A multi-line label bakes as one canvas, so a gradient authored for one line + * box would leave every line below the first past its last stop. The engine can + * repeat the ramp per line, but one label per line is simpler to reason about + * and re-bakes only the line that actually changed. + * @param x - screen x + * @param y - screen y of the FIRST line + * @param size - font size in the internal buffer's pixels + * @param text - the string; split on newlines + * @param align - horizontal alignment + * @param outline - stroke width in buffer pixels + * @returns one configured, floating label per line, top-down + */ +export const menuLines = ( + x: number, + y: number, + size: number, + text: string, + align: "left" | "center" | "right" = "center", + outline = 1, +) => { + return text.split("\n").map((line, i) => { + return menuText(x, y + i * size * 1.45, size, line, align, outline); + }); +}; diff --git a/packages/examples/src/examples/jungleRabbit/props.ts b/packages/examples/src/examples/jungleRabbit/props.ts new file mode 100644 index 000000000..393e5646f --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/props.ts @@ -0,0 +1,70 @@ +/** + * melonJS — Jungle Rabbit: the shared palette every model samples. + * + * The scenery is authored in a modelling tool and preloaded as glTF, but the + * colour is not baked into a drawn texture. Every model shares one material + * whose image is this strip — one pixel per shade — and each vertex's UV points + * at the centre of a cell. That way a single merged mesh can be green *and* + * brown *and* pink in one draw call, with nothing to bleed under + * `textureFilter: "nearest"`. + * + * The models carry the same strip embedded, so the two must agree: change a + * colour here and the assets need re-exporting against it. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ + +/** palette cells, in order; a model's UVs index into these */ +const PALETTE = [ + "#5ab84f", // 0 frond, lit + "#358c3f", // 1 frond, shaded + "#8a6136", // 2 palm trunk + "#4f8f3a", // 3 moss — wet river stone, and the base of every trunk + "#9aa6ae", // 4 stone light + "#6f7c86", // 5 stone dark + "#f08a2c", // 6 carrot + "#4fae55", // 7 carrot top + "#f7e8d2", // 8 fur, lit + "#e2cdae", // 9 fur, shaded + "#f2a6bd", // 10 ear lining, and the paler bloom + "#ffffff", // 11 cotton tail + "#b7793f", // 12 boat, planking + "#7d4f27", // 13 boat, hull below the waterline + "#e8595f", // 14 scarf + "#5b3a1f", // 15 paddle + "#6b4a2a", // 16 coconut + "#7fd04f", // 17 broad leaf, lit + "#3f9c46", // 18 broad leaf, shaded + "#e8556f", // 19 flower +]; + +/** how many cells the strip carries — every model's UVs are baked against it */ +export const PALETTE_CELLS = PALETTE.length; + +/** the white cell, for geometry that takes its colour from a vertex tint */ +export const WHITE_CELL = 11; + +/** the palette strip every model samples */ +export const bakePalette = () => { + const canvas = document.createElement("canvas"); + canvas.width = PALETTE.length; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + if (ctx !== null) { + PALETTE.forEach((color, i) => { + ctx.fillStyle = color; + ctx.fillRect(i, 0, 1, 1); + }); + } + return canvas; +}; + +/** raw geometry, ready for the `Mesh` constructor */ +export interface Geometry { + vertices: Float32Array; + uvs: Float32Array; + normals: Float32Array; + /** 16- or 32-bit, whichever the source glTF used for its index buffer */ + indices: Uint16Array | Uint32Array; +} diff --git a/packages/examples/src/examples/jungleRabbit/records.ts b/packages/examples/src/examples/jungleRabbit/records.ts new file mode 100644 index 000000000..38cd5b9fb --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/records.ts @@ -0,0 +1,42 @@ +/** + * melonJS — Jungle Rabbit: the persisted best run. + * + * `save.add()` hands back the namespace typed with the keys it just + * registered, so the records are read and written without a cast. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { save } from "melonjs"; + +/** the typed handle, set by `initRecords` */ +let store: ReturnType | undefined; + +const register = () => { + return save.add({ jungleRabbitBestScore: 0, jungleRabbitBestMetres: 0 }); +}; + +/** register the keys; existing values survive, missing ones take the default */ +export const initRecords = () => { + store ??= register(); +}; + +export const bestScore = () => store?.jungleRabbitBestScore ?? 0; +export const bestMetres = () => store?.jungleRabbitBestMetres ?? 0; + +/** + * Fold a finished run into the record. + * @param score - carrots collected this run + * @param metres - distance travelled this run + * @returns true when either half is a new best, so the caller can say so + */ +export const submitRun = (score: number, metres: number) => { + const s = store ?? register(); + const beat = + score > s.jungleRabbitBestScore || metres > s.jungleRabbitBestMetres; + // each half stands alone: a short run with a lot of carrots should not + // erase a long one, and vice versa + s.jungleRabbitBestScore = Math.max(s.jungleRabbitBestScore, score); + s.jungleRabbitBestMetres = Math.max(s.jungleRabbitBestMetres, metres); + return beat; +}; diff --git a/packages/examples/src/examples/jungleRabbit/resources.ts b/packages/examples/src/examples/jungleRabbit/resources.ts new file mode 100644 index 000000000..fa51dff5d --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/resources.ts @@ -0,0 +1,59 @@ +/** + * melonJS — Jungle Rabbit: the asset manifest. + * + * The terrain, the river surface and every texture are still generated at + * boot — what is loaded here is the authored geometry. Each model is a single + * merged primitive sharing one palette-strip material, which is what lets a + * whole scatter of them draw as one `InstancedMesh` call; the palette itself + * is embedded in the files, so it has to stay in step with `props.ts`. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { flare } from "./flare"; +import { ripples } from "./ripples"; + +const base = `${import.meta.env.BASE_URL}assets/jungleRabbit/`; + +export const resources = [ + /* Font. */ + { name: "Crang", type: "fontface", src: `${base}font/Crang.woff2` }, + + /* The label ramp, as an inline shader asset rather than a hand-built + effect: a "shader" resource is compiled AT LOAD TIME and the loader owns + it as a shared instance, so the GLSL compile lands inside the loading + screen and every label can share the one program. */ + + /* The river's travelling crests — a ShaderEffect hosted on the water + MESH, which needs the engine to splice it into the mesh shader. */ + { name: "ripples", type: "shader", data: ripples }, + + /* The sun's lens flare, hosted on a screen-filling quad rather than on the + camera, so it lands under the HUD instead of over it. */ + { name: "flare", type: "shader", data: flare }, + + /* Background music. `src` is a DIRECTORY for audio: the loader appends the + name plus each format given to `audio.init()`, and takes the first that + decodes. Credited on the title screen. */ + { name: "jungle-theme", type: "audio", src: `${base}bgm/` }, + + /* The rider — rabbit, hull and paddle as one rig, carrying a "paddle" clip. */ + { name: "boat", type: "glb", src: `${base}boat.glb` }, + + /* Bank planting, in layers: palms overhead, leaves and blooms at mid + height, ferns down at the waterline. */ + { name: "palm", type: "glb", src: `${base}palm.glb` }, + { name: "bigleaf", type: "glb", src: `${base}bigleaf.glb` }, + { name: "fern", type: "glb", src: `${base}fern.glb` }, + { name: "flower-red", type: "glb", src: `${base}flower-red.glb` }, + { name: "flower-pink", type: "glb", src: `${base}flower-pink.glb` }, + + /* In the channel: boulders to dodge, carrots to collect, leaves adrift. */ + { name: "rock", type: "glb", src: `${base}rock.glb` }, + { name: "log", type: "glb", src: `${base}log.glb` }, + { name: "carrot", type: "glb", src: `${base}carrot.glb` }, + { name: "leaf", type: "glb", src: `${base}leaf.glb` }, + + /* Overhead: birds crossing the gorge. */ + { name: "bird", type: "glb", src: `${base}bird.glb` }, +]; diff --git a/packages/examples/src/examples/jungleRabbit/ripples.ts b/packages/examples/src/examples/jungleRabbit/ripples.ts new file mode 100644 index 000000000..c9816330d --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/ripples.ts @@ -0,0 +1,122 @@ +/** + * melonJS — Jungle Rabbit: a rippling river surface. + * + * The water already carries a `NoiseTexture2d`, which gives the surface its + * broken, glittering texture — but noise has no DIRECTION and no relief, so + * on its own the river reads as sparkling rather than as moving water. + * + * This is a `ShaderEffect` hosted on a `Mesh` — the thing #1658 was opened + * about. A hosted effect is a COLOUR hook: it runs after the mesh has been + * placed, lit and textured, and it cannot move a vertex. So these ripples are + * not displacement — they are the SHADING displacement would have produced. + * + * A height field `h(uv, t)` is summed from three crossing wave trains, and + * because each term is a sine of an argument linear in `uv`, its gradient is + * available in closed form — one `cos` per wave, no sampling and no + * neighbour taps. That gradient becomes a surface normal, and lighting it + * gives every crest a lit face and a shaded one. + * + * That light/dark pairing travelling across the surface is what the eye reads + * as water deforming. A purely additive highlight — which is what this was + * first — reads instead as white patches sliding over a flat sheet, because a + * flat sheet is exactly what it is. + * + * Three trains rather than two, at frequencies with no common factor: two + * beat against each other and the interference repeats visibly, which reads + * as a rolling moiré rather than as a river. + * + * Frequencies are tuned to the plane's own UVs rather than to 0..1: the water + * samples a REPEATING texture, so `vRegion` runs about 0..2.9 across the + * channel and −4.6..14.6 along it (`RIPPLE_UV`). A 0..1-shaped shader would + * land dozens of cycles per pixel here and alias into noise. + * + * `color` arrives PREMULTIPLIED, so the shading scales `rgb` and leaves alpha + * alone, and the glint is scaled by coverage — the surface is translucent, + * and adding unscaled light would make the shallow edges glow brighter than + * the deep middle. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ + +const glsl = ` +uniform float uTime; + +vec4 apply(vec4 color, vec2 uv) { + // Each train contributes its height and its exact gradient: the + // derivative of A·sin(k·uv + φ) with respect to uv is A·k·cos(k·uv + φ). + float h = 0.0; + vec2 grad = vec2(0.0); + + vec2 d1 = vec2(0.87, 0.49); + float p1 = dot(uv, d1) * 5.3 + uTime * 1.55; + h += sin(p1) * 0.55; + grad += d1 * (5.3 * 0.55 * cos(p1)); + + vec2 d2 = vec2(-0.44, 0.90); + float p2 = dot(uv, d2) * 8.9 - uTime * 2.10; + h += sin(p2) * 0.32; + grad += d2 * (8.9 * 0.32 * cos(p2)); + + vec2 d3 = vec2(0.62, -0.78); + float p3 = dot(uv, d3) * 14.6 + uTime * 3.30; + h += sin(p3) * 0.14; + grad += d3 * (14.6 * 0.14 * cos(p3)); + + // the normal of the surface that height field describes. The middle term + // is the slope scale: larger flattens the water, smaller corrugates it + vec3 n = normalize(vec3(-grad.x, 2.4, -grad.y)); + + // A low, raking light. Coming in almost flat is what drops the far side + // of each crest into shade instead of lifting the whole surface. + vec3 lightDir = normalize(vec3(-0.45, 0.82, 0.36)); + float lambert = max(dot(n, lightDir), 0.0); + + // the lit/shaded pairing — the part that actually reads as relief + vec3 shaded = color.rgb * mix(0.80, 1.16, lambert); + + // and a tight glint riding the crests, so the surface still sparkles + float glint = pow(lambert, 42.0) * max(h, 0.0) * 0.55; + + return vec4(shaded + glint * color.a, color.a); +} +`; + +const wgsl = ` +struct RippleUniforms { + uTime : f32, +}; +@group(3) @binding(0) var fx : RippleUniforms; + +fn apply(color : vec4f, uv : vec2f) -> vec4f { + var h = 0.0; + var grad = vec2f(0.0, 0.0); + + let d1 = vec2f(0.87, 0.49); + let p1 = dot(uv, d1) * 5.3 + fx.uTime * 1.55; + h += sin(p1) * 0.55; + grad += d1 * (5.3 * 0.55 * cos(p1)); + + let d2 = vec2f(-0.44, 0.90); + let p2 = dot(uv, d2) * 8.9 - fx.uTime * 2.10; + h += sin(p2) * 0.32; + grad += d2 * (8.9 * 0.32 * cos(p2)); + + let d3 = vec2f(0.62, -0.78); + let p3 = dot(uv, d3) * 14.6 + fx.uTime * 3.30; + h += sin(p3) * 0.14; + grad += d3 * (14.6 * 0.14 * cos(p3)); + + let n = normalize(vec3f(-grad.x, 2.4, -grad.y)); + let lightDir = normalize(vec3f(-0.45, 0.82, 0.36)); + let lambert = max(dot(n, lightDir), 0.0); + + let shaded = color.rgb * mix(vec3f(0.80), vec3f(1.16), vec3f(lambert)); + let glint = pow(lambert, 42.0) * max(h, 0.0) * 0.55; + + return vec4f(shaded + glint * color.a, color.a); +} +`; + +/** the dual-language bodies for the river's rippling surface */ +export const ripples = { glsl, wgsl }; diff --git a/packages/examples/src/examples/jungleRabbit/scenery.ts b/packages/examples/src/examples/jungleRabbit/scenery.ts new file mode 100644 index 000000000..a42e9282c --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/scenery.ts @@ -0,0 +1,489 @@ +/** + * melonJS — Jungle Rabbit: the still backdrop the menus sit on. + * + * The title and the game-over screen both look at the river. Sharing this + * keeps them identical to each other and to the run itself — a menu built from + * different scenery than the game reads as a separate screen bolted on. + * + * Nothing here moves or recycles: the stages that use it are static, so the + * instances are placed once and left, which is all `InstancedMesh` needs. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { + type Application, + type Camera3d, + type Container as ContainerType, + event, + GLTFModel, + InstancedMesh, + Light3d, + loader, + Matrix3d, + Mesh, + math, + ParticleEmitter, + type Renderable, + Sprite, + Sprite3d, + Vector2d, + Vector3d, +} from "melonjs"; +import { + getFlare, + getGround, + getPalette, + getPuff, + getRipples, + getSun, + getWater, + geometry as modelGeometry, +} from "./assets"; +import { + BOAT_SCALE, + CAM_FOV, + CAM_PITCH, + FLARE_EDGE_FADE, + FOG_COLOR, + FOG_FAR, + FOG_HEIGHT_FALLOFF, + FOG_NEAR, + GROUND_Y, + HALF_W, + HUD_Z, + MODEL_SCALE, + RIDE_Y, + SHADOW_LIFT, + SKY, + SUN_AHEAD, + TILE_LEN, + TITLE_CARROT_SCALE, + TITLE_CARROT_X, + TITLE_CARROT_Y, + TITLE_CARROT_Z, + VIEW_H, + VIEW_W, + WATER_LEVEL, +} from "./constants"; +import type { Geometry } from "./props"; +import { createTerrainTile, createWaterPlane, valleyY } from "./terrain"; + +/** the axis a planted instance turns about — Y, so a plant spins where it stands */ +const AXIS_Y = new Vector3d(0, 1, 0); + +/* Scratch for `aimFlare`, which runs every frame: reused so projecting the sun + allocates nothing. */ +const _sunAt = new Vector3d(); +const _sunScreen = new Vector2d(); + +/** + * Draw the scene once while the loading screen is still up. + * + * The procedural build itself is cheap — the whole backdrop assembles in about + * 13ms, and the run's own scene in 6ms. What costs is the FIRST time that + * geometry reaches the GPU: the shader programs and pipeline state for the lit, + * instanced and blended mesh paths are created on first draw, and that is a + * ~375ms stall measured on the first entry versus the second (479ms worst frame + * against 102ms). It is one-time and it lives in the renderer's caches, so + * paying it here — behind a loading screen that is already on screen — is the + * whole trick. The menu and the run both come up warm afterwards. + * + * NOT WIRED UP. It works — it does link the programs and does save the stall — + * but it cannot coexist with the engine's built-in loading screen under a + * `Camera3d`. `DefaultLoadingScreen` is written for a `Camera2d`: its progress + * bar is a WORLD-space renderable (`floating === false`) that reads as an + * overlay only because that stage's world is otherwise empty. Add a 3D scene + * beside it and the bar is inside that scene, where the depth buffer buries it + * in the terrain. Raising its `z`, reordering with `moveToTop` and flipping it + * to screen space for the duration were all tried; the scene still covers it. + * + * Wiring this in wants a loading stage of the example's own, drawing its + * progress UI as floating renderables over the warm-up — at which point the + * screen could show the river loading in rather than a logo. Until then the + * ~180ms it saves is not worth a hidden loading screen. + * + * The scene is thrown away immediately; only the compiled programs are kept. + * @param app - the running application + * @returns a promise that settles once the scene has actually been drawn + */ +export const prewarmScene = (app: Application) => { + // Keep the loading screen looking like the loading screen. `buildBackdrop` + // sets the sky as the clear colour, and the scene's very first drawn frame + // has nothing else in it yet — which is precisely the flash of empty blue + // this exists to remove, so it must not be shown here either. + const clear = app.renderer.backgroundColor.clone(); + // Everything below is added to the world the LOADING stage owns, so note + // what was already in it. Tearing down with `world.reset()` took the + // loading screen's own logo and progress bar with it — the screen went + // blank for the rest of the load, which reads as no loading screen at all. + const existing = new Set(app.world.getChildren()); + buildBackdrop(app); + // The run adds one thing the backdrop never does: the rigged boat, whose + // mesh permutation is its own program. Draw it here too, or it links on the + // first frame of the first run instead. + const gltf = loader.getGLTF("boat"); + if (gltf === null) { + throw new Error('jungleRabbit: glTF "boat" was not preloaded'); + } + const boat = new GLTFModel(gltf, { + scale: BOAT_SCALE, + lit: false, + castGroundShadow: true, + shadowGroundY: WATER_LEVEL + SHADOW_LIFT, + }); + boat.pos.set(0, WATER_LEVEL + RIDE_Y); + boat.setCurrentAnimation("paddle", { loop: true }); + app.world.addChild(boat, 0); + + // The rest of what a run draws that a backdrop does not. A program is + // keyed on the combination of features a draw asks for, so what matters + // here is covering the KINDS — an additive transparent billboard, a + // particle emitter, a label carrying a post effect — not their values. + const sun = new Sprite3d(0, GROUND_Y - 1500, { + image: getSun(), + width: 620, + height: 620, + lit: false, + transparent: true, + fog: false, + castGroundShadow: false, + }); + // a PROPERTY, not a setting — only the renderer reads a `blendMode` + // setting, so in the literal above it would have done nothing + sun.blendMode = "additive"; + sun.depth = 3000; + app.world.addChild(sun, 0); + + const spray = new ParticleEmitter(0, WATER_LEVEL, { + image: getPuff(), + referenceSpace: "world", + totalParticles: 8, + maxParticles: 8, + minLife: 200, + maxLife: 200, + speed: 0.2, + angle: Math.PI / 2, + angleVariation: Math.PI, + }); + app.world.addChild(spray, 0); + spray.burstParticles(8); + + // an UNLIT, non-instanced, fogged mesh — the pickups' combination, and the + // one the bank planting (instanced) and the hull (its own rig) never ask for + const pickup = new Mesh(0, GROUND_Y, { + ...modelGeometry("carrot"), + texture: getPalette(), + normalize: false, + scale: MODEL_SCALE, + width: 120, + height: 200, + textureFilter: "nearest", + cullBackFaces: false, + lit: false, + castGroundShadow: true, + shadowGroundY: GROUND_Y, + }); + pickup.depth = 900; + app.world.addChild(pickup, 0); + + app.renderer.backgroundColor.setColor(clear.r, clear.g, clear.b, clear.alpha); + + // Put the loading screen back on top of the warm-up scene. + // + // The world sorts by `z` — `autoSort` is on, only `autoDepth` is off — and + // that sort is DEFERRED, so it is the z values that decide the final draw + // order, not the array order at this moment. For a Camera3d mesh `z` IS its + // world depth: the terrain tiles are added at 0…7200, against the loading + // screen's bar at 1 and logo at 2, so the scene sorts over the UI and hides + // it completely. The tiles cannot be pushed down without moving the + // geometry, so the UI goes up instead. + // + // `moveToTop` is no good here: it early-returns for a child already at + // index 0, and the loading screen's own children are exactly that — added + // before anything else. Setting `z` directly is what survives the sort. + // These are `floating` renderables drawn in screen space, so their `z` buys + // them nothing else and is free to move. + // The built-in loading screen is built for a Camera2d: its progress bar is + // a WORLD-space renderable (`floating === false`) that only looks like an + // overlay because that stage's world is otherwise empty. Put a 3D scene in + // beside it under a Camera3d and the bar is simply inside that scene — the + // depth buffer buries it in the terrain, whatever its `z`. Drawing it in + // screen space for the duration is what keeps it on top; it is restored + // with everything else when the warm-up tears down. + const wasFloating = new Map(); + const lift = () => { + for (const child of app.world.getChildren()) { + if (existing.has(child) && child.floating !== true) { + wasFloating.set(child, child.floating); + child.floating = true; + } + } + }; + lift(); + + return new Promise((resolve) => { + let drawn = 0; + const done = () => { + // the logo is added once its image decodes, which can land inside + // the warm-up — so keep catching newcomers + lift(); + // two frames: the first links the programs this scene touches, the + // second proves they draw without a further stall. More does not + // help — two of the run's programs come from renderables the + // backdrop simply does not have, and no amount of extra frames + // here will link those. + if (++drawn < 90) { + // TEMP: hold so the warm-up can be observed + return; + } + event.off(event.GAME_AFTER_DRAW, done); + // Only what this function put there — see `existing` above. + for (const child of [...app.world.getChildren()]) { + if (!existing.has(child)) { + app.world.removeChild(child); + } + } + for (const [child, was] of wasFloating) { + child.floating = was; + } + app.renderer.backgroundColor.setColor( + clear.r, + clear.g, + clear.b, + clear.alpha, + ); + resolve(); + }; + event.on(event.GAME_AFTER_DRAW, done); + }); +}; + +/** + * Build the menu backdrop into the world and frame the camera on it. + * @param app - the running application + */ + +/** + * The sun billboard and the screen-space quad its lens flare runs on. + * + * Shared by the run and the menu screens: a title built from a different sky + * reads as a separate screen bolted on, which is the same argument the scenery + * beds and the spinning carrots already make. + * @param world - the container to add both to + * @returns the billboard, so a caller that moves it can, and the quad + */ +export const createSun = (world: ContainerType) => { + // A billboard parked far up the gorge. `fog: false` is the whole trick: + // everything else dissolves into the haze at this distance, and a sun that + // dissolved with it would just be a pale smudge. + const sunDisc = new Sprite3d(0, GROUND_Y - 1500, { + image: getSun(), + width: 620, + height: 620, + lit: false, + // `transparent` is what makes a soft-edged texture composite at all + // here: the opaque mesh path disables blending, so the halo came out as + // a grey disc with a hard rim. Additive on top of that adds light to + // the sky instead of sitting on it. + transparent: true, + fog: false, + castGroundShadow: false, + }); + // a PROPERTY, not a setting: only the renderer reads a `blendMode` setting, + // so in the literal above it would have done nothing + sunDisc.blendMode = "additive"; + world.addChild(sunDisc, 0); + + // The flare. ONE screen-filling quad hosting a shader, not a stack of + // sprites: additive light saturates to white over a bright sky, and a soft + // disc has no shape to survive that. A starburst does, and spikes are an + // angular function no arrangement of quads can express. + // + // `floating` puts it in screen space, and the depth is the whole ordering + // argument: BELOW `HUD_Z`, so the pass lands after the world and before any + // label. Above it, the flare tints the text instead — exactly the failure a + // camera post-effect would have had, since a camera's pass brackets the + // whole world draw including floating children. + const puff = getPuff(); + const flare = new Sprite(0, 0, { image: puff }); + // the image is never sampled — the effect ignores the incoming colour — so + // the puff is here only to give the shader a quad of the right size + flare.anchorPoint.set(0, 0); + flare.scale(VIEW_W / puff.width, VIEW_H / puff.height); + flare.floating = true; + flare.blendMode = "additive"; + flare.addPostEffect(getFlare()); + world.addChild(flare, HUD_Z - 10); + + return { sunDisc, flare }; +}; + +/** + * Point the lens flare at wherever the sun currently lands on screen. + * + * Call once per frame, AFTER the camera has moved, or the flare trails the + * frame by one update. The effect is shared between stages, so whichever one is + * running has to keep claiming it — a stage that sets these once would show the + * previous stage's aim after a return to the menu. + * @param camera - the 3D camera the sun is projected through + * @param sunDisc - the billboard to aim at + */ +export const aimFlare = (camera: Camera3d, sunDisc: Sprite3d) => { + const sun = camera.worldToScreen( + _sunAt.set(sunDisc.pos.x, sunDisc.pos.y, sunDisc.depth), + _sunScreen, + ); + const fx = getFlare(); + // `null` means the sun is at or behind the camera — nothing to scatter + if (sun === null) { + fx.setUniform("uIntensity", 0); + return; + } + + // How far inside the frame the sun is, as a fraction of the fade band, + // taken from whichever edge it is nearest so it dims on the way out + // whichever way the view turned. + const bandX = VIEW_W * FLARE_EDGE_FADE; + const bandY = VIEW_H * FLARE_EDGE_FADE; + const inset = Math.min( + (sun.x + bandX) / bandX, + (VIEW_W - sun.x + bandX) / bandX, + (sun.y + bandY) / bandY, + (VIEW_H - sun.y + bandY) / bandY, + ); + + // the quad fills the frame, so screen space over its size IS its uv + fx.setUniform("uSunX", sun.x / VIEW_W); + fx.setUniform("uSunY", sun.y / VIEW_H); + fx.setUniform("uIntensity", Math.max(0, Math.min(1, inset - 1))); +}; + +export const buildBackdrop = (app: Application) => { + const world = app.world; + world.autoDepth = false; + app.renderer.backgroundColor.parseCSS(SKY); + + world.addChild( + new Light3d({ + type: "directional", + direction: [-0.35, 0.82, 0.45], + color: "#fff6df", + intensity: 0.85, + }), + 0, + ); + world.addChild(new Light3d({ type: "ambient", color: "#6f9179" }), 0); + + const ground = getGround(); + const water = getWater(); + // Starts one tile BEHIND the origin, because the menu camera does: it sits + // at z = -620, and tiles laid from z = 0 leave the near field — the bottom + // of the frame — with no ground under it at all. The river covers that gap, + // so it went unnoticed while the water was opaque; the moment the surface + // was transparent enough to see through, the background showed as a pale + // wedge in each bottom corner. + for (let i = -1; i < 3; i++) { + const tile = createTerrainTile(ground); + tile.depth = i * TILE_LEN; + world.addChild(tile, tile.depth); + } + // The same travelling crests the run has. Without them the menu river is + // a still photograph — the texture is baked, so every bit of motion here + // is either the UV scroll `TitleStage` drives or this effect's clock. + const waterPlane = createWaterPlane(water); + waterPlane.addPostEffect(getRipples()); + world.addChild(waterPlane, 0); + + const palette = getPalette(); + const placement = new Matrix3d(); + // The same authored models the run uses, planted the same way: a menu + // built from different scenery reads as a separate screen bolted on. + const beds: [string, number, [number, number]][] = [ + ["fern", 330, [0.55, 1.34]], + ["bigleaf", 175, [0.68, 1.52]], + ["flower-red", 75, [0.58, 1.3]], + ["flower-pink", 75, [0.58, 1.3]], + ["palm", 170, [0.6, 1.64]], + ]; + for (const [asset, count, band] of beds) { + const geometry: Geometry = modelGeometry(asset); + const mesh = new InstancedMesh(0, GROUND_Y, { + ...geometry, + texture: palette, + normalize: false, + scale: MODEL_SCALE, + width: 120, + height: 200, + textureFilter: "nearest", + cullBackFaces: false, + lit: true, + instanceCount: count, + castGroundShadow: false, + }); + world.addChild(mesh, 0); + for (let i = 0; i < count; i++) { + const x = + math.randomFloat(HALF_W * band[0], HALF_W * band[1]) * + (Math.random() < 0.5 ? -1 : 1); + const y = GROUND_Y + valleyY(x); + const z = math.random(200, TILE_LEN * 3); + placement + .identity() + .translate(x / MODEL_SCALE, -y / MODEL_SCALE, z / MODEL_SCALE) + // each plant faces its own way, so one model does not read as + // one plant copied a hundred times + .rotate(math.randomFloat(0, Math.PI * 2), AXIS_Y); + const jitter = math.randomFloat(0.82, 1.18); + placement.scale(jitter, jitter, jitter); + mesh.setInstance(i, placement); + } + } + + const camera = app.viewport as Camera3d; + camera.setClipPlanes?.(1, 9000); + camera.setFog?.({ + near: FOG_NEAR, + far: FOG_FAR, + color: FOG_COLOR, + fogHeight: WATER_LEVEL, + heightFalloff: FOG_HEIGHT_FALLOFF, + }); + camera.colorMatrix.contrast(1.06).saturate(1.14); + camera.fov = CAM_FOV; + camera.pitch = CAM_PITCH; + camera.pos.set(0, WATER_LEVEL - 230); + camera.depth = -620; + + // A carrot turning on each side of the logo. The pickup is the thing the + // whole game is about, and two of them framing the title say so faster + // than any amount of menu copy — the same model the run uses, at the same + // palette, so the menu reads as part of the game rather than a front end + // bolted onto it. + const carrots = [-1, 1].map((side) => { + const carrot = new Mesh(side * TITLE_CARROT_X, TITLE_CARROT_Y, { + ...modelGeometry("carrot"), + texture: palette, + normalize: false, + scale: TITLE_CARROT_SCALE, + width: 120, + height: 200, + textureFilter: "nearest", + cullBackFaces: false, + // unlit like the run's pickups: lighting a carrot from this sun + // turns its sloped sides into a dark brown lump + lit: false, + castGroundShadow: false, + }); + carrot.depth = TITLE_CARROT_Z; + world.addChild(carrot, 0); + return carrot; + }); + + const { sunDisc } = createSun(world); + sunDisc.depth = SUN_AHEAD; + + // handed back so the stage can animate what it owns: nothing in here runs + // a clock of its own + return { waterPlane, carrots, sunDisc }; +}; diff --git a/packages/examples/src/examples/jungleRabbit/sfx.ts b/packages/examples/src/examples/jungleRabbit/sfx.ts new file mode 100644 index 000000000..377d79aee --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/sfx.ts @@ -0,0 +1,151 @@ +/** + * melonJS — Jungle Rabbit: every sound, synthesised. + * + * `audio.tone` / `audio.noise` build these from oscillators and filtered noise + * at call time, so the example still ships with no audio assets — the same + * bargain the textures make. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { audio } from "melonjs"; + +/** + * A carrot: two quick rising notes. + * + * Rising because a pickup should feel like a gain — the same interval falling + * reads as a mistake, which is the whole vocabulary of arcade feedback. + * @param pan - -1 to 1, so a carrot taken to the left is heard to the left + */ +export const playPickup = (pan = 0, step = 1) => { + // Each multiplier step lifts the whole blip by a tone, so a run of carrots + // climbs and a reset audibly drops back. Capped by the caller, or a long + // combo would walk it off the top of the register. + const lift = 1.122 ** (step - 1); + // The arcade coin shape: two notes, the second a fifth up and a beat + // later, rather than both at once. `delay` sequences them on the AUDIO + // clock, so the interval holds through a frame spike — a `setTimeout` + // here would swing the second note late and turn a blip into a stumble. + audio.tone({ + freq: 988 * lift, + duration: 0.06, + wave: "square", + gain: 0.34, + attack: 0.001, + pan, + }); + audio.tone({ + freq: 1480 * lift, + duration: 0.13, + wave: "square", + gain: 0.3, + attack: 0.001, + pan, + delay: 0.055, + }); + // a sine doubling the second note two octaves up: it rounds the square's + // edge so the blip reads BRIGHT over the music rather than harsh + audio.tone({ + freq: 2960 * lift, + duration: 0.09, + gain: 0.12, + attack: 0.001, + pan, + delay: 0.055, + }); +}; + +/** hitting a boulder: a wooden thud with a splash of white water over it */ +export const playHit = (pan = 0) => { + // the impact itself, diving a bit over an octave + audio.tone({ + freq: 190, + duration: 0.26, + wave: "triangle", + gain: 0.58, + attack: 0.001, + pitchSlide: 0.3, + pan, + }); + // a square an octave above it, short — this is the "clunk" that carries + // over the music, where the triangle alone is felt more than heard + audio.tone({ + freq: 380, + duration: 0.09, + wave: "square", + gain: 0.28, + attack: 0.001, + pitchSlide: 0.4, + pan, + }); + // the water it throws up, the filter closing as it settles + audio.noise({ + type: "white", + duration: 0.32, + gain: 0.4, + attack: 0.002, + pan, + filter: { type: "lowpass", frequency: 2200, Q: 0.8 }, + filterSweep: 0.25, + }); +}; + +/** the boat leaving the water, and landing back in it */ +export const playJump = () => { + audio.tone({ + freq: 420, + duration: 0.16, + wave: "sine", + gain: 0.153, + attack: 0.004, + pitchSlide: 0.45, + }); +}; + +export const playSplash = () => { + audio.noise({ + type: "white", + duration: 0.26, + gain: 0.17, + attack: 0.003, + filter: { type: "bandpass", frequency: 900, Q: 0.9 }, + filterSweep: 0.7, + }); +}; + +/** + * The river itself: a short burst of filtered noise, re-triggered on a timer. + * + * A continuous bed would want a looping source; retriggering a soft burst + * every few hundred milliseconds costs nothing and, with the attack and decay + * overlapping, is indistinguishable from one at this volume. + */ +export const playRiver = () => { + audio.noise({ + type: "pink", + duration: 0.9, + gain: 0.085, + attack: 0.25, + filter: { type: "lowpass", frequency: 650, Q: 0.5 }, + }); +}; + +/** running out of lives */ +export const playCapsize = () => { + audio.tone({ + freq: 300, + duration: 0.7, + wave: "sawtooth", + gain: 0.238, + attack: 0.01, + pitchSlide: 0.6, + }); + audio.noise({ + type: "brown", + duration: 0.8, + gain: 0.204, + attack: 0.02, + filter: { type: "lowpass", frequency: 900, Q: 0.7 }, + filterSweep: 0.3, + }); +}; diff --git a/packages/examples/src/examples/jungleRabbit/terrain.ts b/packages/examples/src/examples/jungleRabbit/terrain.ts new file mode 100644 index 000000000..51b682c6a --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/terrain.ts @@ -0,0 +1,321 @@ +/** + * melonJS — Jungle Rabbit: the gorge, built as procedural `Mesh` geometry. + * + * The cross-section is a parabola — flat in the middle, climbing at the edges + * — and it does not vary along Z. That invariance is the whole trick behind + * the endless run: two identical tiles leapfrog each other in front of the + * boat and the seam is invisible, because both ends of every tile have the + * same profile. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { Color, Mesh, type NoiseTexture2d } from "melonjs"; +import { + GROUND_Y, + HALF_W, + RIPPLE_UV, + RIVER_FLOW, + TERRAIN_OVERHANG, + TILE_LEN, + TILE_NX, + TILE_NZ, + WALL_H, + WATER_BACK, + WATER_FRONT, + WATER_HALF_W, + WATER_LEVEL, + WATER_UV, +} from "./constants"; + +/** + * Height of the river bed at a given X, in **world** space. Y-down, so the + * banks are *negative* — the further from the centre line, the higher the + * ground. Every plant and boulder is placed with this. + */ +export const valleyY = (x: number) => { + const t = Math.min(1, Math.abs(x) / HALF_W); + return -WALL_H * t * t; +}; + +/** + * The same profile in **mesh source** space, which is Y-up. + * + * `Mesh` defaults `rightHanded` to `false`, and that bridge negates Y — so + * geometry authored directly in engine coordinates arrives upside down, and a + * valley renders as a hill. Authoring the profile flipped is the fix; the + * alternative, `rightHanded: true`, also negates Z and would send the tile + * behind the camera. + */ +const sourceY = (x: number) => { + return -valleyY(x); +}; + +/** dy/dx of the profile above, for the surface normal */ +const valleySlope = (x: number) => { + const t = Math.min(1, Math.abs(x) / HALF_W); + return t >= 1 ? 0 : (-2 * WALL_H * x) / (HALF_W * HALF_W); +}; + +/** + * World X of a terrain column. + * + * The mesh reaches wider than the gorge it describes. `valleyY` clamps past + * `HALF_W`, so the extra columns are a flat plateau at bank height — which + * costs almost nothing and is where the palms stand anyway. Without it the + * mesh ends at `±HALF_W`, the planting bands reach past that, and a low camera + * sees trees standing over open sky at the bottom corners of the frame. + */ +const columnX = (ix: number) => { + const t = ix / (TILE_NX - 1); + return (-1 + t * 2) * HALF_W * TERRAIN_OVERHANG; +}; + +/** + * One vertex colour per grid point: the bank tint, and a touch of ambient + * occlusion down in the crease where a gorge sees less sky than its ridges do. + * + * The tint is what makes one texture serve as both river and bank. Vertex + * colours MULTIPLY the texture, so they can only darken — there is no warm + * brown to be had from teal water. Killing most of the blue while keeping the + * green lands on a mossy bank green, which is where a jungle floor wants to be + * anyway, and the sparse foam of the water texture reads as leaf litter once + * it is tinted with it. + * + * Purely a function of the column, so it is baked once at construction. + */ +const bakeVertexTint = () => { + const colors = new Uint32Array(TILE_NX * TILE_NZ); + const shade = new Color(); + for (let ix = 0; ix < TILE_NX; ix++) { + // from the real world X, not from the column index: the mesh reaches + // past `HALF_W` and normalising against its own width would drag the + // waterline outward with it + const across = Math.min(1, Math.abs(columnX(ix)) / HALF_W); + // The waterline is GEOMETRY now — where the flat surface meets the + // parabola — so this only has to agree with it. Below it the bed is + // silt seen through water; above it, bank. + const shore = WATER_HALF_W / HALF_W; + const bank = Math.min(1, Math.max(0, (across - shore) / 0.16)); + const ao = 0.88 + 0.12 * across; + // silt is warm, the bank is green: pull the red down and leave the + // green, which turns the sandy bed into wet undergrowth + shade.setColor( + 255 * ao * (1 - 0.5 * bank), + 255 * ao * (1 - 0.12 * bank), + 255 * ao * (1 - 0.42 * bank), + ); + const packed = shade.toUint32(1); + for (let iz = 0; iz < TILE_NZ; iz++) { + colors[iz * TILE_NX + ix] = packed; + } + } + return colors; +}; + +/** + * One tile of river BED, spanning the full width and `TILE_LEN` along Z. + * @param ground - the tiling bed texture + * @returns a lit mesh whose geometry starts at local z = 0 + */ +export const createTerrainTile = (ground: HTMLCanvasElement) => { + const verts: number[] = []; + const uvs: number[] = []; + const norms: number[] = []; + const indices: number[] = []; + + for (let iz = 0; iz < TILE_NZ; iz++) { + const z = (iz / (TILE_NZ - 1)) * TILE_LEN; + for (let ix = 0; ix < TILE_NX; ix++) { + const x = columnX(ix); + verts.push(x, sourceY(x), z); + uvs.push((x + HALF_W) / WATER_UV, z / WATER_UV); + + // surface is y = f(x), constant in z, so the normal lies in the XY + // plane. Authored Y-up like the positions above, and negated by the + // same bridge on the way in. + const slope = -valleySlope(x); + const len = Math.hypot(slope, 1); + norms.push(-slope / len, 1 / len, 0); + } + } + + for (let iz = 0; iz < TILE_NZ - 1; iz++) { + for (let ix = 0; ix < TILE_NX - 1; ix++) { + const a = iz * TILE_NX + ix; + const b = a + 1; + const c = a + TILE_NX; + const d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + + const tile = new Mesh(0, GROUND_Y, { + // The bank tint and a little ambient occlusion, baked once. The + // distance haze that used to be written here every frame is the + // camera's `setFog` now, so these never change after construction. + vertexColors: bakeVertexTint(), + vertices: new Float32Array(verts), + uvs: new Float32Array(uvs), + normals: new Float32Array(norms), + indices: new Uint16Array(indices), + // `texture`, not `image`: the latter is a Sprite3d alias that Mesh does + // not read, and passing it leaves the mesh on the white-pixel fallback + texture: ground, + // raw world coordinates: no unit-cube fit, no rescale + normalize: false, + scale: 1, + width: HALF_W * 2 * TERRAIN_OVERHANG, + height: TILE_LEN, + // the UVs run well past 1 — without this the texture clamps to its + // edge texels and the whole river reads as one flat wash + textureRepeat: "repeat", + // linear, unlike the props: the river is seen at a grazing angle, so + // its texture is compressed along Z into far less than a texel per + // pixel. "nearest" opts out of mipmaps and anisotropy, and the foam + // aliases into an even wash exactly where the camera looks most. + textureFilter: "linear", + cullBackFaces: false, + lit: true, + }); + return tile; +}; + +/** + * The river surface: ONE plane that travels with the boat. + * + * Not tiled and not recycled, unlike the bed. A recycled surface has a seam + * every tile length and, worse, each tile has to pass a visibility test of its + * own — so the one the camera happens to be standing in can wink out and show + * dry bed in front of you, in a rhythm that follows the tile spacing. A single + * plane that follows the camera has no seams to misalign and no boundary to + * cross. It is the same trick a racing game plays with its road. + * + * The plane moves with the boat; the water drifts. `scrollWaterPlane` offsets + * the UVs by the part of the journey the river does not carry downstream with + * it, so the surface slides past gently instead of either rushing past or + * being dragged along rigidly. + * + * `transparent: true` is required, not decorative: the mesh pass is opaque and + * disables blending, so without it the surface composites as a solid sheet and + * hides the bed entirely. + * @param ripples - the seamless `NoiseTexture2d` standing in for the surface + * @returns a mesh spanning from behind the camera to past the fog + */ +/** + * Rows of vertices along the plane. + * + * Not one long quad. Fog depth is computed PER VERTEX and interpolated, so a + * quad spanning the whole visible run interpolates between one point behind + * the camera and one past the horizon — and every fragment between them comes + * out uniformly hazed, whatever its real distance. The symptom is a river the + * colour of the fog from the boat to the skyline, which looks like a texture + * or lighting fault and is neither. The bed never showed it because its tiles + * carry rows every twelve hundred units. + */ +const WATER_ROWS = 14; + +/** world z of a row, from behind the camera to past the haze */ +const rowZ = (iz: number) => { + return WATER_BACK + (iz / (WATER_ROWS - 1)) * (WATER_FRONT - WATER_BACK); +}; + +export const createWaterPlane = (ripples: NoiseTexture2d) => { + // Four columns, not two. The outer pair drops below the surface so the + // water tucks under the bank rather than ending on it — and, less + // obviously, so the mesh has THICKNESS. A perfectly flat plane has a + // degenerate bounding volume and fails the visibility test exactly when + // the camera is inside it, which for a river is every frame. + const halfW = WATER_HALF_W * 1.06; + const inner = WATER_HALF_W * 0.99; + const EDGE_DROP = 60; + const columns: [number, number][] = [ + [-halfW, -EDGE_DROP], + [-inner, 0], + [inner, 0], + [halfW, -EDGE_DROP], + ]; + const verts: number[] = []; + const uvs: number[] = []; + const norms: number[] = []; + const indices: number[] = []; + + for (let iz = 0; iz < WATER_ROWS; iz++) { + const z = rowZ(iz); + for (const [x, y] of columns) { + verts.push(x, y, z); + uvs.push((x + halfW) / RIPPLE_UV, z / RIPPLE_UV); + norms.push(0, 1, 0); + } + } + for (let iz = 0; iz < WATER_ROWS - 1; iz++) { + for (let ix = 0; ix < columns.length - 1; ix++) { + const a = iz * columns.length + ix; + const b = a + 1; + const c = a + columns.length; + const d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + + return new Mesh(0, WATER_LEVEL, { + vertices: new Float32Array(verts), + uvs: new Float32Array(uvs), + normals: new Float32Array(norms), + indices: new Uint16Array(indices), + texture: ripples, + normalize: false, + scale: 1, + width: halfW * 2, + height: WATER_FRONT - WATER_BACK, + textureRepeat: "repeat", + textureFilter: "linear", + cullBackFaces: false, + lit: true, + transparent: true, + }); +}; + +/** + * Keep the surface under the boat, with the water itself anchored to the + * world. + * + * The plane's depth follows the run, so it is always beneath the camera; the + * UVs are offset by the same distance in the opposite sense, which pins the + * texture to world space. Without the second half the pattern would ride along + * with the boat and the river would look frozen. + * + * Eight vertices, so rewriting the UV array per frame is free — but the GPU + * copy only refreshes when `needsUpdate` says so. + * @param plane - the mesh from {@link createWaterPlane} + * @param travelled - how far down the course the boat has come + */ +export const scrollWaterPlane = (plane: Mesh, travelled: number) => { + plane.depth = travelled; + driftWaterPlane(plane, travelled); +}; + +/** + * Scroll the surface WITHOUT moving the plane. + * + * The two halves of `scrollWaterPlane` are separable, and the menu needs only + * one of them: nothing travels there, so the plane must stay put while the + * water still flows past. Passing a growing distance to the full version + * sails the plane off up the river — after a minute on the title screen it is + * thousands of units away and the shot is left showing the bare riverbed. + * @param plane - the water surface + * @param distance - how far the water has flowed, in world units + */ +export const driftWaterPlane = (plane: Mesh, distance: number) => { + const travelled = distance; + const uvs = plane.uvs; + const columns = 4; + for (let iz = 0; iz < WATER_ROWS; iz++) { + // only the part of the travel the river does NOT carry with it + const v = (rowZ(iz) + travelled * (1 - RIVER_FLOW)) / RIPPLE_UV; + for (let ix = 0; ix < columns; ix++) { + uvs[(iz * columns + ix) * 2 + 1] = v; + } + } + plane.needsUpdate = true; +}; diff --git a/packages/examples/src/examples/jungleRabbit/textures.ts b/packages/examples/src/examples/jungleRabbit/textures.ts new file mode 100644 index 000000000..6846456ab --- /dev/null +++ b/packages/examples/src/examples/jungleRabbit/textures.ts @@ -0,0 +1,171 @@ +/** + * melonJS — Jungle Rabbit: every texture in this example is made here. + * + * No image assets: the whole scene is baked at boot into canvases and handed + * to `Sprite3d` / `Mesh` as `image`. The river is the exception — a + * `NoiseTexture2d`, baked once (`animated: false`); its apparent motion is the + * UV scroll in `scrollWaterPlane`. Chunky, deliberately low-resolution + * shapes upscaled with `textureFilter: "nearest"` give the pixel look without + * anyone opening an art tool. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ + +import { Gradient, NoiseTexture2d } from "melonjs"; + +/** a canvas of the given size, with image smoothing already off */ +const bake = ( + w: number, + h: number, + draw: (ctx: CanvasRenderingContext2D) => void, +) => { + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (ctx === null) { + // a blank canvas would reach the mesh as an untextured surface and say + // nothing about why, so refuse here instead + throw new Error("jungleRabbit: no 2d context — cannot bake textures"); + } + ctx.imageSmoothingEnabled = false; + draw(ctx); + return canvas; +}; + +/** filled rect helper */ +const px = ( + ctx: CanvasRenderingContext2D, + color: string, + x: number, + y: number, + w: number, + h: number, +) => { + ctx.fillStyle = color; + ctx.fillRect(x, y, w, h); +}; + +/** + * The bed and the banks — the solid ground the river lies over. + * + * Silt and gravel. It is seen THROUGH the water in the channel and directly on + * the banks, where the vertex tint takes it green, so it has to read as ground + * in both places. + */ +export const bakeGround = () => { + return bake(64, 64, (ctx) => { + px(ctx, "#b8a06a", 0, 0, 64, 64); + for (let i = 0; i < 30; i++) { + px( + ctx, + i % 3 === 0 ? "#a68f5c" : "#c4ad78", + (i * 27 + 5) % 61, + (i * 19 + 11) % 59, + 3, + 2, + ); + } + for (let i = 0; i < 10; i++) { + px(ctx, "#8f7c50", (i * 23 + 9) % 60, (i * 31 + 3) % 60, 2, 2); + } + }); +}; + +/** + * The river surface: teal simplex noise with downstream current bands and broken white water, + * tiled along the gorge by the mesh UVs. + * + * V runs downstream, so every streak here is drawn vertically — a horizontal + * one reads as a standing wave across the river and kills the sense of flow. + * Contrast is deliberately overdone: the lighting multiplies it (~0.85) and + * the fog multiplies it again, and small differences vanish on a bright + * surface long before they reach the screen. + */ +export const makeWater = () => { + // A colour ramp rather than grey noise: the field maps straight to water + // tones, so no second pass is needed to tint it. Deep in the troughs, + // bright at the crests, with most of the range in the middle where the + // river actually sits. + const ramp = new Gradient("linear", [0, 0, 1, 0]); + // Alpha, not just colour: the bed shows through the troughs and closes up + // under the crests. That gradient of transparency is what separates water + // from a blue floor — a uniform alpha reads as tinted glass. Widening that + // spread rather than lowering every stop is what reads as depth, but there + // is a floor on it: the water plane's outer columns rise 60 units to tuck + // into the bank, and past about 0.6 at the trough that lip shows through + // the surface as a pale wedge in the near corners. + ramp.addColorStop(0, "rgba(16,80,98,0.64)"); + ramp.addColorStop(0.42, "rgba(34,120,142,0.72)"); + ramp.addColorStop(0.72, "rgba(80,180,200,0.82)"); + ramp.addColorStop(0.9, "rgba(186,240,250,0.92)"); + ramp.addColorStop(1, "rgba(255,255,255,1)"); + + return new NoiseTexture2d({ + // Small on purpose: the mesh UVs repeat it every `WATER_UV` world units, + // so pixels past a certain point buy nothing but bake time. + width: 96, + height: 96, + type: "simplex", + seed: 7, + // three octaves: a swell, a chop, and a fine sparkle on the crests. Two + // read as a smooth undulation that the eye takes for shading instead. + octaves: 3, + frequency: 0.055, + gain: 0.5, + // ESSENTIAL: the terrain UVs run to many multiples of 1, so a texture + // that does not tile cleanly puts a hard seam across the river every + // `WATER_UV` units. + seamless: true, + colorRamp: ramp, + // Static. The movement comes from the surface DRIFTING past the boat + // (see `RIVER_FLOW`), not from re-baking the field. + // + // Two dead ends are worth recording here. An `animated: true` texture + // re-bakes correctly — the pixels genuinely change — but the mesh path + // never picked the new copy up, so the river rendered frozen while + // every counter said it worked. And a `ShaderEffect` scrolling a static + // field, which is how the 2D aquarium example does its ripples, cannot + // host a mesh draw on the WebGPU backend at all: the engine says so + // itself in `webgpu_renderer.js`, and silently keeps the built-in + // shading, which renders the surface as its plain texture. + animated: false, + }); +}; + +/** + * The sun: a hot white core inside a warm halo. + * + * Drawn additively through the transparent pass, so the halo adds light to the + * sky rather than compositing a grey disc over it — an opaque mesh cannot do + * this at all, because the mesh path disables blending. + */ +export const bakeSun = () => { + return bake(64, 64, (ctx) => { + const g = ctx.createRadialGradient(32, 32, 2, 32, 32, 31); + // Warm, not white. The disc is drawn ADDITIVELY over a bright sky, which + // pushes every channel toward 255 — so a core that starts near-white + // arrives white and the sun reads as a hole in the sky rather than as a + // light in it. Holding blue back through the whole ramp is what keeps + // the yellow once the sky has been added to it. + g.addColorStop(0, "rgba(255,248,206,1)"); + g.addColorStop(0.3, "rgba(255,228,150,0.88)"); + g.addColorStop(0.6, "rgba(255,198,96,0.38)"); + g.addColorStop(1, "rgba(255,176,74,0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, 64, 64); + }); +}; + +/** one soft round puff — the trail and the snow spray are made of these */ +export const bakePuff = () => { + return bake(32, 32, (ctx) => { + const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 15); + g.addColorStop(0, "rgba(255,255,255,1)"); + g.addColorStop(0.55, "rgba(255,255,255,0.85)"); + g.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, 32, 32); + }); +}; diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index cd7b12cc7..979820912 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -48,6 +48,11 @@ const ExampleAfterBurner = lazy(() => default: m.ExampleAfterBurner, })), ); +const ExampleJungleRabbit = lazy(() => + import("./examples/jungleRabbit/ExampleJungleRabbit").then((m) => ({ + default: m.ExampleJungleRabbit, + })), +); const ExampleClipping = lazy(() => import("./examples/clipping/ExampleClipping").then((m) => ({ default: m.ExampleClipping, @@ -312,6 +317,14 @@ const examples: { description: "Behind-the-plane arcade shooter on Camera3d + 3D Mesh models — arrows / WASD to fly, space to shoot.", }, + { + component: , + label: "Jungle Rabbit", + path: "jungle-rabbit", + sourceDir: "jungleRabbit", + description: + "A low-poly river endless runner: Blender-modelled props through InstancedMesh, a rigged paddling boat, and a procedural valley.", + }, { component: , label: "Clipping", diff --git a/packages/examples/tsconfig.app.json b/packages/examples/tsconfig.app.json index 2452e5906..3c0f7e7f5 100644 --- a/packages/examples/tsconfig.app.json +++ b/packages/examples/tsconfig.app.json @@ -9,5 +9,8 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["./src"] + "include": ["./src"], + // Tiled writes its tilesets as `.tsx` — XML, not React. Left in, TS parses + // them as JSX and the type gate drowns in syntax errors on data files. + "exclude": ["src/**/assets/**/*.tsx"] }