Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ package-lock.json
# the generated API reference, not every directory named `docs`
/packages/melonjs/docs/
.turbo
backend-shots/
26 changes: 26 additions & 0 deletions packages/examples/LICENSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** (<https://www.kenney.nl>),
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:

<https://pixabay.com/users/vladkrotov/>

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:

<https://fontstruct.com/fontstructors/caveras>

⚠️ 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`.
1 change: 1 addition & 0 deletions packages/examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"backends": "node scripts/backends.mjs",
"test:types": "tsc"
},
"dependencies": {
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
154 changes: 154 additions & 0 deletions packages/examples/scripts/backends.mjs
Original file line number Diff line number Diff line change
@@ -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 `<route>.<target>.<stamp>.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 <route> [--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);
Original file line number Diff line number Diff line change
@@ -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);
149 changes: 149 additions & 0 deletions packages/examples/src/examples/jungleRabbit/GameOverStage.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildBackdrop>;
/** 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);
}
}
Loading
Loading