diff --git a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx index 71f585401..d0ba7de6e 100644 --- a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx +++ b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx @@ -32,7 +32,6 @@ import { Light3d, loader, state, - Vector3d, video, } from "melonjs"; import { createExampleComponent } from "../utils"; @@ -161,15 +160,15 @@ const createGame = async () => { // A key light plus an ambient fill so the shadowed side stays // readable rather than black. app.world.addChild( - new Light3d(0, 0, 0, { + new Light3d({ type: "directional", - direction: new Vector3d(-0.35, -0.7, -0.6), + direction: [-0.35, 0.7, -0.6], color: new Color(255, 244, 226), intensity: 1.2, }), ); app.world.addChild( - new Light3d(0, 0, 0, { + new Light3d({ type: "ambient", color: new Color(128, 146, 178), intensity: 0.6, diff --git a/packages/examples/src/examples/afterBurner/sfx.ts b/packages/examples/src/examples/afterBurner/sfx.ts index 030066176..51f76faa8 100644 --- a/packages/examples/src/examples/afterBurner/sfx.ts +++ b/packages/examples/src/examples/afterBurner/sfx.ts @@ -119,26 +119,27 @@ export function playEnemyHit(pan = 0): void { filter: { type: "lowpass", frequency: 1600, Q: 0.5 }, filterSweep: 0.18, }); - // 5. secondary blast — real explosions double-tap - setTimeout(() => { - audio.noise({ - type: "white", - duration: 0.05, - gain: 0.4, - attack: 0.001, - pan, - filter: { type: "highpass", frequency: 1200, Q: 0.8 }, - }); - audio.tone({ - freq: 70, - duration: 0.32, - wave: "sine", - gain: 0.5, - attack: 0.002, - pitchSlide: 0.3, - pan, - }); - }, 80); + // 5. secondary blast — real explosions double-tap. `delay` puts it on + // the audio clock, so the 80ms gap holds through a frame spike. + audio.noise({ + type: "white", + duration: 0.05, + gain: 0.4, + attack: 0.001, + pan, + filter: { type: "highpass", frequency: 1200, Q: 0.8 }, + delay: 0.08, + }); + audio.tone({ + freq: 70, + duration: 0.32, + wave: "sine", + gain: 0.5, + attack: 0.002, + pitchSlide: 0.3, + pan, + delay: 0.08, + }); } /** diff --git a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx deleted file mode 100644 index a3b1dffc7..000000000 --- a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * melonJS — Camera3d (perspective) minimal example. - * - * Three monster sprites stacked along the camera's forward axis at - * z = 200 / 400 / 600. Under perspective, the front one renders - * largest, the back one smallest — proving: - * - per-sprite depth flows from `sprite.depth` to the GPU vertex - * stream (PR A) - * - the Camera3d's perspective matrix scales sprites by their z - * (PR B) - * - painter-algorithm z-sorting puts the front sprite on top of - * the ones behind it (visible occlusion order) - * - * On-screen controls rotate the camera (yaw / pitch) and zoom in/out. - * Drag the canvas to orbit. Demonstrates the simplest opt-in path — - * the Application-level `cameraClass: Camera3d` setting. - * - * 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, - type Camera3d, - Camera3d as Camera3dClass, - input, - loader, - type Pointer, - plugin, - Sprite, - state, - video, -} from "melonjs"; -import monsterImg from "../shaderEffects/assets/monster.png"; -import { createExampleComponent } from "../utils"; - -const createGame = async () => { - // Stash teardown work assembled inside the loader callback so the - // outer `createGame` can return a single cleanup function from the - // async preload completion. - let pointerCleanup: (() => void) | null = null; - let domCleanup: (() => void) | null = null; - // NOTE: an `unmounted` guard around the preload callback would in - // principle fix the "user navigates away before preload finishes" - // race that Copilot flagged on review. It can't be added cleanly - // today — `examples/utils.tsx` runs `currentTeardown()` on every - // React useEffect cleanup (including StrictMode's dev double-mount - // cycle), but the same-example remount branch only reattaches the - // canvas without re-invoking `createGameFn`. An `unmounted` flag - // flipped in teardown therefore stays `true` across the StrictMode - // remount, the preload callback bails for the rest of the session, - // and the example never renders. Picks up cleanly once the - // utils.tsx remount path is fixed (separate review thread). - - // opt in to Camera3d at the Application level — every stage in this - // app gets a Camera3d as its default camera (the loader screen pins - // to Camera2d via its own constructor regardless). - const app = new Application(1024, 768, { - parent: "screen", - renderer: video.AUTO, - scale: "auto", - cameraClass: Camera3dClass, - }); - await app.init(); - - app.world.backgroundColor.parseCSS("#0a0a14"); - plugin.register(DebugPanelPlugin, "debugPanel"); - - loader.preload([{ name: "monster", type: "image", src: monsterImg }], () => { - // loader.preload internally transitions to state.LOADING (the - // DefaultLoadingScreen). Transition back to the default game - // stage so its Camera3d becomes the active viewport. - state.change(state.DEFAULT, true); - - // three monsters along the camera's forward axis at increasing - // depth. Same x, same y — only z differs. Perspective scales - // each one inversely to z. - const depths = [200, 400, 600]; - for (const z of depths) { - const sprite = new Sprite(0, 0, { image: "monster" }); - sprite.scale(0.5); - app.world.addChild(sprite); - // set depth AFTER addChild — Container.autoDepth (default - // true) would otherwise overwrite our intended z - sprite.depth = z; - } - - // the app's default camera is now a Camera3d (via cameraClass). - const camera = app.viewport as Camera3d; - - // orbit state: yaw / pitch / distance. Driven by drag + buttons. - let yaw = 0; - let pitch = 0; - let distance = 700; - - const updateCameraPos = () => { - // orbit around the middle sprite (z = 400). When yaw/pitch - // are 0, the camera sits at z = 400 - distance (behind the - // middle sprite) and looks at it. - const target = 400; - camera.pos.set( - Math.sin(yaw) * Math.cos(pitch) * -distance, - Math.sin(pitch) * distance, - target - Math.cos(yaw) * Math.cos(pitch) * distance, - ); - camera.lookAt(0, 0, target); - }; - updateCameraPos(); - - // drag-to-orbit. Pointer events are registered against the - // camera region; we release them on teardown so a same-tab - // navigation back to the example index doesn't leave listeners - // attached to a no-longer-mounted camera. - let dragging = false; - let lastX = 0; - let lastY = 0; - input.registerPointerEvent("pointerdown", camera, (ev: Pointer) => { - dragging = true; - lastX = ev.gameX; - lastY = ev.gameY; - }); - input.registerPointerEvent("pointerup", camera, () => { - dragging = false; - }); - input.registerPointerEvent("pointermove", camera, (ev: Pointer) => { - if (!dragging) { - return; - } - yaw += (ev.gameX - lastX) * 0.005; - pitch = Math.max( - -Math.PI / 2 + 0.1, - Math.min(Math.PI / 2 - 0.1, pitch - (ev.gameY - lastY) * 0.005), - ); - lastX = ev.gameX; - lastY = ev.gameY; - updateCameraPos(); - }); - pointerCleanup = () => { - input.releasePointerEvent("pointerdown", camera); - input.releasePointerEvent("pointerup", camera); - input.releasePointerEvent("pointermove", camera); - }; - - // on-screen HTML control panel — yaw / pitch / zoom / reset. - // HTML buttons live above the canvas; `#screen > *` already - // has `pointer-events: auto` (PR A's CSS fix) so they're - // clickable. - const panel = document.createElement("div"); - panel.style.cssText = - "position:absolute;top:60px;left:16px;display:grid;" + - "grid-template-columns:repeat(3,40px);grid-template-rows:repeat(4,40px);" + - "gap:4px;z-index:1000;font-family:sans-serif;"; - const mkButton = (label: string, gridArea: string, handler: () => void) => { - const b = document.createElement("button"); - b.textContent = label; - b.style.cssText = - "background:#1a1a1a;color:#e0e0e0;border:1px solid #444;" + - "border-radius:4px;cursor:pointer;font-size:18px;" + - `grid-area:${gridArea};`; - b.addEventListener("click", handler); - panel.appendChild(b); - }; - const YAW_STEP = 0.15; - const PITCH_STEP = 0.1; - const ZOOM_STEP = 60; - mkButton("▲", "1 / 2 / 2 / 3", () => { - pitch = Math.min(Math.PI / 2 - 0.1, pitch + PITCH_STEP); - updateCameraPos(); - }); - mkButton("◀", "2 / 1 / 3 / 2", () => { - yaw -= YAW_STEP; - updateCameraPos(); - }); - mkButton("●", "2 / 2 / 3 / 3", () => { - yaw = 0; - pitch = 0; - distance = 700; - updateCameraPos(); - }); - mkButton("▶", "2 / 3 / 3 / 4", () => { - yaw += YAW_STEP; - updateCameraPos(); - }); - mkButton("▼", "3 / 2 / 4 / 3", () => { - pitch = Math.max(-Math.PI / 2 + 0.1, pitch - PITCH_STEP); - updateCameraPos(); - }); - mkButton("−", "4 / 1 / 5 / 2", () => { - distance = Math.min(1500, distance + ZOOM_STEP); - updateCameraPos(); - }); - mkButton("+", "4 / 3 / 5 / 4", () => { - distance = Math.max(150, distance - ZOOM_STEP); - updateCameraPos(); - }); - - const hint = document.createElement("div"); - hint.textContent = "Drag or use controls"; - hint.style.cssText = - "position:absolute;top:240px;left:16px;color:#888;" + - "font-family:sans-serif;font-size:12px;z-index:1000;"; - - const parent = app.renderer.getCanvas().parentElement; - if (parent) { - parent.style.position = "relative"; - parent.appendChild(panel); - parent.appendChild(hint); - } - domCleanup = () => { - panel.remove(); - hint.remove(); - }; - }); - - // Returned to `createExampleComponent` so a same-tab navigation - // back to the example index doesn't leave the HTML control panel / - // hint overlay sitting on top of the index page (the canvas parent - // `#screen` persists across mounts), and doesn't leave stale - // pointer listeners attached to a dead camera reference. - return () => { - if (pointerCleanup) pointerCleanup(); - if (domCleanup) domCleanup(); - }; -}; - -export const ExampleCamera3d = createExampleComponent(createGame); diff --git a/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx b/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx index 67f5e85db..5735a5cbd 100644 --- a/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx +++ b/packages/examples/src/examples/dragAndDrop/ExampleDragAndDrop.tsx @@ -10,6 +10,7 @@ import { DropTarget, game, Text, + timer, video, } from "melonjs"; import { createExampleComponent } from "../utils"; @@ -106,10 +107,16 @@ class DropTarget1 extends DropTarget { // indicate a succesful drop this.color = "green"; - // set the color back to red after a second - window.setTimeout(() => { - this.color = "red"; - }, 1000); + // set the color back to red after a second. `timer.setTimeout`, not the + // window one: the third argument makes it respect the engine's pause + // state, so a paused game does not quietly finish the countdown + timer.setTimeout( + () => { + this.color = "red"; + }, + 1000, + true, + ); } } diff --git a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx index daf18f2a6..15e179b9e 100644 --- a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx +++ b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx @@ -125,15 +125,15 @@ function buildScene(app: Application) { // One directional key light plus a dim ambient fill. The key is what the // specular highlight rides: a highlight needs a direction to reflect, so // an ambient-only scene would show none however glossy the material. - const key = new Light3d(0, 0, { + const key = new Light3d({ type: "directional", - direction: new Vector3d(-0.45, -0.75, 0.5), + direction: [-0.45, 0.75, 0.5], color: "#fff6e8", intensity: 1.15, }); key.name = "key"; world.addChild(key); - world.addChild(new Light3d(0, 0, { type: "ambient", color: "#3b4870" })); + world.addChild(new Light3d({ type: "ambient", color: "#3b4870" })); // a floor for the shadows to land on const F = 900; diff --git a/packages/examples/src/examples/plinko-planck/audio.ts b/packages/examples/src/examples/plinko-planck/audio.ts index 0a9622da0..c58f7890a 100644 --- a/packages/examples/src/examples/plinko-planck/audio.ts +++ b/packages/examples/src/examples/plinko-planck/audio.ts @@ -216,34 +216,35 @@ export const playWin = (score: number, pan = 0): void => { pitchSlide: 1.03, }); - // 4) Brass note 2 — fifth + octave climb at t=110ms. - setTimeout(() => { - audio.tone({ - freq: [fifth, octave], - duration: 0.18, - gain: 0.32, - pan, - wave: "triangle", - }); - }, 110); + // 4) Brass note 2 — fifth + octave climb at t=110ms. `delay` schedules + // on the audio clock, so the fanfare keeps time through a frame spike + // that would visibly slip a `setTimeout`. + audio.tone({ + freq: [fifth, octave], + duration: 0.18, + gain: 0.32, + pan, + wave: "triangle", + delay: 0.11, + }); // 5) Resolution chord at t=230ms — sustained octave + fifth + // 2-octave triangle chord. This is the headline "DAAAAH". - setTimeout(() => { - audio.tone({ - freq: [octave, octave * 1.5, octave * 2], - duration: 0.75, - gain: 0.38, - pan, - wave: "triangle", - }); - // 6) Bell sparkle — sine high-octave stack on top of the - // chord swell for celebratory shimmer. - audio.tone({ - freq: [octave * 2, octave * 3, octave * 4], - duration: 0.55, - gain: 0.16, - pan, - }); - }, 230); + audio.tone({ + freq: [octave, octave * 1.5, octave * 2], + duration: 0.75, + gain: 0.38, + pan, + wave: "triangle", + delay: 0.23, + }); + // 6) Bell sparkle — sine high-octave stack on top of the + // chord swell for celebratory shimmer. + audio.tone({ + freq: [octave * 2, octave * 3, octave * 4], + duration: 0.55, + gain: 0.16, + pan, + delay: 0.23, + }); }; diff --git a/packages/examples/src/examples/plinko-planck/entities/scoreFly.ts b/packages/examples/src/examples/plinko-planck/entities/scoreFly.ts index 8d60c2ec2..218fcfc8b 100644 --- a/packages/examples/src/examples/plinko-planck/entities/scoreFly.ts +++ b/packages/examples/src/examples/plinko-planck/entities/scoreFly.ts @@ -14,16 +14,15 @@ * with the spark burst spawned alongside it (see `sparkBurst.ts`), * each landing reads as a deliberate event with clear cause and effect. * - * Implemented as a Container (not a custom Renderable) because the - * engine's draw walk relies on container.draw translating to its own - * pos before iterating children — without that translation, a Text - * child renders at its baked-in metrics offset relative to the parent, - * which for our purposes is the world origin. With Container as the - * Tween target, animating `pos.x` / `pos.y` moves the text correctly. + * It is a `Text` itself. It used to be a `Container` wrapping one, because + * moving a `Text` did not move it — the blit origin was refreshed only when + * the string changed, so a label that travelled slid off its own canvas. The + * wrapper held the label still at its local origin and moved the container + * instead. That is fixed in the engine, so the label is the renderable now. */ import type { Container as ContainerType } from "melonjs"; -import { Container, Text, Tween } from "melonjs"; +import { Text, Tween } from "melonjs"; /** Total flight duration (ms). Tuned so the trip feels deliberate but not slow. */ const FLY_MS = 800; @@ -33,7 +32,7 @@ const FLY_MS = 800; */ const ARC_HEIGHT = 80; -export class ScoreFly extends Container { +export class ScoreFly extends Text { /** Tween-driven 0 → 1 lerp factor. */ private t = 0; private readonly startX: number; @@ -41,7 +40,6 @@ export class ScoreFly extends Container { private readonly targetX: number; private readonly targetY: number; private readonly value: number; - private readonly label: Text; private readonly onLand: (value: number) => void; constructor( @@ -54,8 +52,15 @@ export class ScoreFly extends Container { fontSize: number, onLand: (value: number) => void, ) { - super(startX, startY, 1, 1); - this.anchorPoint.set(0, 0); + super(startX, startY, { + font: "Courier New", + size: fontSize, + fillStyle: color, + textAlign: "center", + textBaseline: "middle", + bold: true, + text: `+${value}`, + }); this.alwaysUpdate = true; // Float so the fly renders in screen space (matches the HUD's // coordinate frame — the play area uses no camera scroll, so @@ -71,20 +76,6 @@ export class ScoreFly extends Container { this.targetY = targetY; this.value = value; this.onLand = onLand; - - // Text is centred at this container's local (0, 0). Container.draw - // translates to `this.pos` before drawing children, so the Text - // appears at (this.pos.x, this.pos.y) in world space. - this.label = new Text(0, 0, { - font: "Courier New", - size: fontSize, - fillStyle: color, - textAlign: "center", - textBaseline: "middle", - bold: true, - text: `+${value}`, - }); - this.addChild(this.label); } override onActivateEvent(): void { @@ -123,27 +114,29 @@ export class ScoreFly extends Container { oneMinusT * oneMinusT * this.startY + 2 * oneMinusT * t * midY + t * t * this.targetY; - // Use `pos.set(x, y)` (2-arg form defaults z=0). The engine's - // `World._sortReverseZ` reads `pos.z` and crashes on undefined; - // stacking order is driven by `this.depth` (set in the ctor), - // not by stuffing a depth into `pos.z`. - this.pos.set(x, y); + // Assign x and y rather than `pos.set(x, y)`: the 2-argument form + // defaults z to 0, and `depth` IS `pos.z` — so setting the position + // that way wiped the depth given in the constructor on the very first + // frame, and the fly sorted at 0 instead of on top. + this.pos.x = x; + this.pos.y = y; // Scale: pop up briefly (1 → 1.3 in the first 20% of flight), // then settle back to 1.0 by 60%. Sells "ejected from the slot". // `currentTransform.scale` MULTIPLIES the current matrix — reset - // to identity each frame before applying the new scale. + // to identity each frame before applying the new scale. `preDraw` + // pivots the transform around `pos`, so this scales in place. const popPhase = Math.min(1, t / 0.2); const settlePhase = Math.max(0, Math.min(1, (t - 0.2) / 0.4)); const scale = 1 + popPhase * 0.3 - settlePhase * 0.3; - this.label.currentTransform.identity(); - this.label.currentTransform.scale(scale, scale); + this.currentTransform.identity(); + this.currentTransform.scale(scale, scale); // Alpha: full opacity until 75%, fade out in the final 25% so // the fly dissolves into the counter rather than abruptly // vanishing. const alpha = t < 0.75 ? 1 : 1 - (t - 0.75) / 0.25; - this.label.setOpacity(alpha); + this.setOpacity(alpha); super.update(dt); return true; } diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 8aa1c2307..cd7b12cc7 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -48,11 +48,6 @@ const ExampleAfterBurner = lazy(() => default: m.ExampleAfterBurner, })), ); -const ExampleCamera3d = lazy(() => - import("./examples/camera3d/ExampleCamera3d").then((m) => ({ - default: m.ExampleCamera3d, - })), -); const ExampleClipping = lazy(() => import("./examples/clipping/ExampleClipping").then((m) => ({ default: m.ExampleClipping, @@ -317,14 +312,6 @@ const examples: { description: "Behind-the-plane arcade shooter on Camera3d + 3D Mesh models — arrows / WASD to fly, space to shoot.", }, - { - component: , - label: "Camera3d (perspective)", - path: "camera-3d", - sourceDir: "camera3d", - description: - "Perspective camera orbiting three sprite billboards spaced along the z axis. Drag to orbit; closer sprites render larger.", - }, { component: , label: "Clipping", diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index ae1d8c555..f4e942c71 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,21 +3,24 @@ ## [20.5.0] (melonJS 2) - _unreleased_ ### Added -- Mesh: a `ShaderEffect` can be hosted on a mesh — `addPostEffect` shades it on both backends. The body runs as a colour hook, so the mesh keeps its own placement, alpha cutout, lighting and fog and `apply()` transforms the result; `setUniform` and `setTime` reach it. `screen_uv`, `noise_uv`, `screen_texture`, a body's own samplers, WGSL `vColor` and `InstancedMesh` are unsupported and warn once ([#1658](https://github.com/melonjs/melonJS/issues/1658)) -- `GLTFModel` can be placed and moved: `pos`, `depth` and the transform helpers (`rotate`, `scale`) now drive the whole rig, as they do for any other renderable. Every root node used to hang from the identity, so a loaded character posed at the world origin and stayed there — the only way to frame it was to move the camera. The placement composes with the animated pose rather than replacing it, so a walk cycle plays wherever the model stands. It reaches the parts by being baked into each one's world position, so the container no longer translates the renderer for its children or folds its own `currentTransform` in: doing both would apply every move and every turn twice, drawing a model at x = -400 as though it were at -800 while its reported position stayed correct. A model left at the origin with no transform poses exactly as before -- Text: `fillStyle` takes a `Gradient` as well as a colour — the same object `Renderer#setColor` already accepts, built with `createLinearGradient`/`addColorStop` as on the canvas API. Coordinates are the label's own bake, and the ramp colours the fill only, since the stroke is a separate pass. A multi-line label restarts the ramp on every line, so it reads like one `Text` per line without the caller having to author the gradient over the block height; `gradientPerLine: false` spans a single ramp across the whole block instead, as a plain canvas does. Widening what the setting accepts does not widen what the property holds: `fillStyle` still reads back as the pooled `Color`, still gates the fill through its `alpha`, and a label given a plain colour takes the same path it always did - -- `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record`, and that index signature swallowed its own members — under `strict`, `save.add()` was `unknown` and **could not be called at all** (the documented `me.save.add({ score: 0, lives: 3 })` did not compile), while every key read back as `unknown`. It now has a real interface, and `add()` returns the namespace typed with the keys just registered, so `const store = save.add({ hiscore: 0 })` gives `store.hiscore` as a `number`. Chained calls accumulate. `add()` previously returned nothing, and dynamic access through `save.anything` is unchanged - -### Fixed -- Renderable: opacity now cascades. `alpha` is multiplied with the alpha already on the renderer rather than replacing it, so fading a `Container` fades everything inside it and the values compose — a child at 0.5 inside a parent at 0.5 draws at 0.25. It was assigned outright, so a child overwrote its parent's value and a half-faded container still drew a fully opaque subtree; `Container.setChildsProperty("alpha", …)` was the only way to fade a tree, and that assigns to the children rather than composing. The save/restore the cascade relies on was already in place — `RenderState` stacks the tint and every renderable is wrapped by `preDraw`/`postDraw` — so nothing leaks between siblings. **A nested renderable that was visibly opaque under a faded ancestor will now fade with it.** Fading a subtree is `container.alpha` — the renderer composes it as it walks the tree; alpha should not be assigned down a hierarchy by hand -- GLTFModel: a loaded model reported no bounds at all. A `Container` carries no dimensions of its own, so `getBounds()` handed back an empty box — and the broadphase files every item by it, so a model with a `Body` was sorted into a node unrelated to where it stood and silently collided with nothing. Its extent now comes from the glTF bounding box, measured once at load -- Mesh: `resize()`, `recalc()` and the `width`/`height` setters threw on every mesh. A `Mesh` repurposes two slots it inherits from `Polygon` — `normals` and `indices` — with typed arrays, and `Polygon#recalc` walked them expecting its own 2D collision geometry. The polygon's edge normals are now `edgeNormals`, and `recalc` leaves `indices` alone unless it owns it -- Text: glyph tops were shorn off on **Safari**. The cache texture was invalidated *before* the canvas was resized and repainted, so the renderer refreshed it against the dimensions the canvas had on the way in. WebGL re-specifies texture storage on every upload and healed it silently; a WebGPU texture is immutable-sized, so the copy kept the old height and the label sampled past its own content. The refresh now happens last, after the resize and the repaint -- Text: a glyph is no longer clipped along the top of its own bake. The offscreen canvas was sized from the nominal line box — `fontSize × lineHeight` — which says nothing about where the ink lands, so a display face whose glyphs rise above the em box, or any stroked label (half of `lineWidth` sits outside the outline, and no metric reports it), lost the overshoot. The bake is now padded by the ink's real extent, read from `actualBoundingBoxAscent`/`Descent`, and the blit shifts back by the same amount — so glyphs keep their exact screen position and the reported bounds are unchanged. A font that cannot report ink extents renders as before -- Renderer: `stroke()` and `fill()` threw `Invalid geometry` on a `Box3d` — the one body shape with no case in the dispatcher, which the debug panel's hitbox overlay hits on any body carrying one. It now draws the XY footprint `Box3d` already exposes through its own `getBounds()` -- Mesh: a mesh with `normalize: false` and an explicit `scale` now sizes itself from its geometry when no `width`/`height` is given, the way a `Sprite` sizes itself from its frame. It reported a zero-size box at its position while drawing at full size, misleading frustum culling, pointer picking and the broadphase alike. `meshScale` is unchanged -- Mesh: a `ShaderEffect` attached to a mesh drew the geometry unplaced and without the camera on WebGL, and was refused on WebGPU with a message naming the wrong reason. An effect is realized against the quad vertex contract, which declares neither `uModelMatrix` nor `uViewMatrix` ([#1658](https://github.com/melonjs/melonJS/issues/1658)) +- Mesh: a `ShaderEffect` can be hosted on a mesh — `addPostEffect` shades it on both backends as a colour hook, so the mesh keeps its own placement, alpha cutout, lighting and fog. `screen_uv`, `noise_uv`, `screen_texture`, a body's own samplers, WGSL `vColor` and `InstancedMesh` are unsupported and warn once ([#1658](https://github.com/melonjs/melonJS/issues/1658)) +- `GLTFModel` can be placed and moved: `pos`, `depth`, `rotate` and `scale` drive the whole rig as on any other renderable, composing with the animated pose rather than replacing it. Every root node used to hang from the identity, so a loaded model posed at the world origin and stayed there +- Text: `fillStyle` takes a `Gradient` as well as a colour, built with `createLinearGradient`/`addColorStop`. Coordinates are the label's own bake and the ramp colours the fill only, since the stroke is a separate pass. A multi-line label restarts the ramp on every line; `gradientPerLine: false` spans one ramp across the block instead +- `save`: registered keys are reachable from TypeScript without a cast. The namespace was typed `Record`, and that index signature swallowed its own members, so under `strict` `save.add()` was `unknown` and could not be called at all. `add()` now returns the namespace typed with the keys just registered, and chained calls accumulate +- `audio.tone()` and `audio.noise()` take a `delay` in seconds, scheduled on the audio clock, so a multi-part sound — a stinger's second note, an explosion's double-tap — sequences without a `setTimeout`. A timer fires on the main thread and a busy frame slips the note; this is scheduled up front and is sample-accurate. Omitting it is unchanged + +### Fixed +- Renderable: opacity now cascades — `alpha` multiplies with the alpha already on the renderer instead of replacing it, so a child at 0.5 inside a parent at 0.5 draws at 0.25. **A nested renderable that was visibly opaque under a faded ancestor will now fade with it** +- GLTFModel: a loaded model reported no bounds at all, so a model with a `Body` was filed in the broadphase away from where it stood and collided with nothing. Its extent now comes from the glTF bounding box, measured once at load +- Mesh: `resize()`, `recalc()` and the `width`/`height` setters threw on every mesh — `Polygon#recalc` walked the `normals` and `indices` slots a `Mesh` repurposes for typed arrays. The polygon's edge normals are now `edgeNormals` +- Text: glyph tops were shorn off on **Safari**. The cache texture was invalidated before the canvas was resized and repainted, and a WebGPU texture is immutable-sized, so the copy kept the old height and the label sampled past its own content. The refresh now happens last +- Text: a glyph is no longer clipped along the top of its own bake. The offscreen canvas was sized from the nominal line box, which says nothing about where the ink lands, so a display face or any stroked label lost the overshoot. The bake is padded by the ink's real extent and the blit shifts back by the same amount, so glyphs keep their exact screen position +- Text: moving a label now moves it. The blit origin is derived from `pos` but was refreshed only when the text changed, so repositioning a label without re-setting it slid the glyphs off their own canvas and drew at the old spot. Sub-pixel placement is unchanged +- Renderer: `stroke()` and `fill()` threw `Invalid geometry` on a `Box3d`, which the debug panel's hitbox overlay hits on any body carrying one. It now draws the XY footprint +- Mesh: a mesh with `normalize: false` and an explicit `scale` now sizes itself from its geometry when no `width`/`height` is given. It reported a zero-size box while drawing at full size, misleading frustum culling, pointer picking and the broadphase alike +- Mesh: a `ShaderEffect` attached to a mesh drew the geometry unplaced and without the camera on WebGL, and was refused on WebGPU with a message naming the wrong reason ([#1658](https://github.com/melonjs/melonJS/issues/1658)) +- `Sprite3d`: `fog: false` reaches the mesh — a sprite builds its own settings for `Mesh` and did not copy it, so a sun could not be kept out of the haze. `transparent` and `castGroundShadow` are documented on `Sprite3d` too +- Typings: documented options no longer fail to compile — the `Noise` settings `NoiseTexture2d` forwards, the `Mesh` settings `InstancedMesh` forwards, `setCurrentAnimation(name, { loop: true })`, non-object values for `Container#setChildsProperty`, an `HTMLCanvasElement` as `image`/`texture`, and `super.update(dt)` in a custom `Stage`. Settings shapes are exported as `MeshSettings`, `InstancedMeshSettings`, `NoiseTexture2dSettings` and `AnimationOptionsInput` ## [20.4.0] (melonJS 2) - _2026-09-09_ diff --git a/packages/melonjs/skills/melonjs-3d/SKILL.md b/packages/melonjs/skills/melonjs-3d/SKILL.md index e339cd68f..5e8afc16b 100644 --- a/packages/melonjs/skills/melonjs-3d/SKILL.md +++ b/packages/melonjs/skills/melonjs-3d/SKILL.md @@ -83,6 +83,10 @@ class GameStage extends Stage { `camera.setClipPlanes(near, far)`. - **`camera.pos.set(x, y)` is 2-argument and zeroes z.** Use `camera.depth` — the documented z accessor — or assign `pos.x`/`pos.y` individually. +- **`worldToLocal` does not project.** It is a 2D camera's offset subtraction. + To pin a label or marker to a point in the scene use + `camera.worldToScreen(x, y, z)`, which applies the projection and returns + `null` behind the camera. See `melonjs-camera-and-drawing`. ## Depth sorting @@ -476,6 +480,35 @@ world.addChild(new Light3d({ type: "directional", direction: [0.3, 1, 0.2] })); world.addChild(new Light3d({ type: "ambient", intensity: 0.3 })); ``` +Both of that call's traps fail **silently**, and they compound: + +- `new Light3d(0, 0, {…})` — JavaScript drops the extra arguments, so `options` + becomes the number `0` and every setting in your literal is discarded. The + light still appears, on pure defaults: a `type: "ambient"` written this way is + a second DIRECTIONAL light, and the scene looks plausible enough that nobody + checks. +- `direction: new Vector3d(x, y, z)` — `direction` and `position` are `[x, y, z]` + **arrays**, read by index. A `Vector3d` has no `[0]`, so the light's direction + becomes `NaN` and it contributes nothing. (`color` is the odd one out: it does + take a `Color`, a CSS string or an `[r, g, b]` array.) +- **`direction` is where the light GOES, and this is a Y-down space** — so a sun + overhead travels *downward* and its Y is **positive**. Get the sign wrong and + the scene is lit from underneath: faces that should be in shade are bright, + the ground glows and the sky-facing surfaces go dark. + + ```js + direction: [-0.35, 0.8, 0.45] // sun overhead, late afternoon + direction: [-0.35, -0.8, 0.45] // lit from below — almost never what you want + ``` + + `position` follows the same convention: a lamp above the floor has a + **smaller** y than the floor. + +They hide behind each other: TypeScript stops checking an object literal once +the argument count is already wrong, so fixing the call reveals the `Vector3d`, +and fixing that finally lets the sign show. A scene can go from "looks fine" to +"entirely black" to "lit from below" across three apparently-correct edits. + Use **both halves**: with a key light but no ambient, the shadow side of a mesh goes black. With no `Light3d` in the world at all, a `lit: true` mesh falls back to a white ambient and renders fullbright — indistinguishable from unlit, which @@ -599,6 +632,7 @@ To branch rather than fail, read `app.renderer.supportsDepthBuffer` after | symptom | cause | |---|---| +| scene lit from underneath | `direction` Y sign — this is Y-down, so a sun overhead is **+Y** | | a `lit` mesh renders fullbright | it had no normals — supply them, or let the engine generate them | | a gradient across one mesh is impossible | `tint` is per object — use `vertexColors` / `setVertexColor` | | a mesh stays solid as you fade it out | meshes render opaque; only `alpha` 0 (hidden) and 1 differ | diff --git a/packages/melonjs/skills/melonjs-audio/SKILL.md b/packages/melonjs/skills/melonjs-audio/SKILL.md index f7ffcd484..2abca19fb 100644 --- a/packages/melonjs/skills/melonjs-audio/SKILL.md +++ b/packages/melonjs/skills/melonjs-audio/SKILL.md @@ -141,6 +141,23 @@ audio.noise({ duration: 0.5, filter: { type: "lowpass", frequency: 800 } }); `"white"`), `attack`, `pan`, `filter` and `filterSweep`. `gain` defaults to `0.1` on both. +### Sequencing: `delay`, not `setTimeout` + +Both take `delay` — seconds before the sound starts, scheduled on the **audio +clock**. That is what builds a multi-part sound: a stinger's second note, an +explosion's double-tap. + +```js +audio.tone({ freq: 392, duration: 0.12 }); +audio.tone({ freq: 587, duration: 0.18, delay: 0.11 }); +audio.tone({ freq: [784, 1176], duration: 0.75, delay: 0.23 }); +``` + +Do not reach for `setTimeout` here. A timer fires on the main thread, so a busy +frame slips the note late and the sequence audibly loses time — the fault is +easy to blame on the sound design rather than the scheduling. `delay` is +sample-accurate because the whole envelope is scheduled up front. + Both are **silent no-ops** when there is no WebAudio context — they bail on `getAudioContext() === null` rather than throwing. Neither needs the clip to be loaded; neither goes through the file-playback path at all. diff --git a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md index 1574f9136..f7b09d447 100644 --- a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md +++ b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md @@ -78,6 +78,30 @@ whichever of world/screen matches the region — screen for a `floating` region, world otherwise. Use the screen pair for anything that *moves* the camera, or the drag feeds back on itself. +### In 3D: `Camera3d#worldToScreen` + +`worldToLocal` is a 2D camera's offset subtraction — it knows nothing about a +perspective projection. To pin a label, damage number or marker to a point in a +3D scene, project it: + +```js +const p = camera.worldToScreen(x, y, z); // view + projection, divide included +if (p !== null) { // null = at or BEHIND the camera + label.pos.set(p.x, p.y); +} +``` + +It returns canvas pixels with the origin top-left and **y down**, which is the +engine's own 2D draw space — so the result feeds a `floating` renderable or the +immediate-mode API directly, with no flip. + +**Always check for `null`.** A point at or behind the camera (clip `w <= 0`) +has no honest screen position, and projecting it anyway yields a mirrored +coordinate that puts your label on the wrong side of the screen. Skip it. + +Do not hand-roll this with `Matrix3d` — the perspective divide and the behind- +camera case are exactly what gets it wrong. + ## Secondary cameras Cameras are first-class — a minimap is a second camera with `autoResize = false` diff --git a/packages/melonjs/skills/melonjs-renderables/SKILL.md b/packages/melonjs/skills/melonjs-renderables/SKILL.md index 51bd53c4e..24ffc6922 100644 --- a/packages/melonjs/skills/melonjs-renderables/SKILL.md +++ b/packages/melonjs/skills/melonjs-renderables/SKILL.md @@ -184,6 +184,37 @@ v.set(10, 20, v.z); // keep it `pos` is an `ObservableVector3d`, whose `set(x = 0, y = 0, z = 0)` does the same — so `this.pos.set(x, y)` wipes the object's depth. +## 7. A settings key the class never reads is silently ignored + +A settings object is a plain literal: nothing rejects a key, so a name that +belongs somewhere else does nothing at all and says nothing about it. + +```js +// WRONG — only the RENDERER reads a `blendMode` setting, never a renderable +const glow = new Sprite3d(x, y, { image: "sun", blendMode: "additive" }); + +// right: it is a property +glow.blendMode = "additive"; +``` + +The same shape catches `transparent` on a `GLTFModel` (its parts carry it — +`model.setChildsProperty("transparent", true, true)`) and any `Mesh` setting +handed to a class that forwards only a curated subset. If a visual option +appears to do nothing, check whether the class actually reads it before +tuning the value. + +## 8. `isRenderable` does not hide anything + +It gates `updateBounds`, not drawing. Setting it `false` leaves the object on +screen and merely stops its bounds tracking, which is worse than doing nothing. + +```js +sprite.isRenderable = false; // WRONG — still drawn, bounds now stale +sprite.alpha = 0; // hides it +``` + +Use `alpha` for a blink, or remove the child for a long absence. + ## Update and draw ```js @@ -236,6 +267,8 @@ Hand-rolled equivalents miss the batching and the multi-backend support. | custom draw offset by half the size | centred `anchorPoint` default not zeroed | | spatial query never finds an object | `isKinematic` left `true` — not in the broadphase | | `z` unexpectedly 0 after a `set` | `Vector3d.set(x, y)` defaults `z` to 0 | +| a settings option appears to do nothing | the class never reads that key — see section 7 | +| `isRenderable = false` did not hide the object | it gates bounds, not drawing — use `alpha` | ## Related skills diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index f6ad9f5bd..87225033b 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -54,6 +54,48 @@ an outline keeps its own colour without any luminance trickery — and it works Canvas2D, which a post effect does not. `fillStyle.alpha` still gates the fill, and the property still reads back as a `Color`. +## BitmapText: `size` is a RATIO, not pixels + +The trap when moving over from `Text`: + +```js +new Text(x, y, { font: "Arial", size: 24, text: "SCORE" }); // 24 pixels +new BitmapText(x, y, { font: "arial", size: 24, text: "SCORE" }); // 24 TIMES +``` + +`size` scales the font's authored size, so `1` is native and `2` is double — +`resize(scale)` and `set(textAlign, scale)` take the same ratio. Whole numbers +keep pixel art crisp; fractional ones resample the page image. + +It has **no stroke** — there is no `strokeStyle` or `lineWidth` here, which is +part of why it stays sharp. Colour comes from the tint instead: + +```js +const score = new BitmapText(8, 8, { + font: "arial", text: "1000", fillStyle: "#ffd700", +}); +score.fillStyle = "#ff4040"; // recolour at any time +score.fillStyle = new Color(255, 255, 255); // UNTINTED, not "white text" +``` + +`fillStyle` is `Renderable#tint` under another name: white is the *absence* of a +tint and every other colour tints away from it, so author the page in white to +keep every colour available to you. + +Load the descriptor as `binary` and its page as `image` under the **same name**. +Both BMFont flavours — text (`.fnt`) and XML — are auto-detected, so an `.xml` +descriptor loads as-is: + +```js +loader.preload([ + { name: "arial", type: "binary", src: "data/font/arial.fnt" }, + { name: "arial", type: "image", src: "data/font/arial.png" }, +]); +``` + +Reach for it over `Text` when the text is mostly static, has to stay crisp at +integer scales, or wants recolouring without a re-bake. + ## The HUD pattern A HUD is a `floating` container at a high z, built once and re-added: diff --git a/packages/melonjs/src/audio/procedural.ts b/packages/melonjs/src/audio/procedural.ts index 55c1ea256..9df778190 100644 --- a/packages/melonjs/src/audio/procedural.ts +++ b/packages/melonjs/src/audio/procedural.ts @@ -120,7 +120,7 @@ function _connectToOutput( * wants to show a "no audio" badge or fall back to a different * feedback channel. * @param opts - the {@link ToneOptions} (frequency, duration, - * envelope, pan, slide). See the interface for per-field defaults. + * envelope, pan, slide, delay). See the interface for per-field defaults. * @example * // simple UI click * me.audio.tone({ freq: 1200, duration: 0.08, pitchSlide: 0.5 }); @@ -128,6 +128,11 @@ function _connectToOutput( * me.audio.tone({ freq: [880, 1320], duration: 0.4, gain: 0.18, pan: 0.5 }); * // descending "thud" — square wave with a wide pitch drop * me.audio.tone({ freq: 200, duration: 0.15, wave: "square", pitchSlide: 0.25 }); + * // a three-part fanfare, sequenced on the AUDIO clock — `delay` keeps the + * // notes in time whatever the frame rate is doing, which `setTimeout` cannot + * me.audio.tone({ freq: 392, duration: 0.12 }); + * me.audio.tone({ freq: 587, duration: 0.18, delay: 0.11 }); + * me.audio.tone({ freq: [784, 1176], duration: 0.75, delay: 0.23 }); * @category Audio */ export function tone(opts: ToneOptions): void { @@ -142,6 +147,7 @@ export function tone(opts: ToneOptions): void { attack = 0.005, pan = 0, pitchSlide = 1, + delay = 0, } = opts; const freqs = Array.isArray(freq) ? freq : [freq]; @@ -153,7 +159,9 @@ export function tone(opts: ToneOptions): void { _resumeIfSuspended(ctx); const dur = Math.max(0.001, duration); - const t0 = ctx.currentTime; + // Everything below hangs off `t0`, so a delay is one addition here + // rather than a timer around the whole call. + const t0 = ctx.currentTime + Math.max(0, delay); const t1 = t0 + dur; const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain); const panner = _connectToOutput(ctx, env, pan, t0); @@ -256,7 +264,7 @@ function fillNoiseBuffer( * explicitly disabled) this is a silent no-op: {@link getAudioContext} * returns `null` and nothing is scheduled. * @param opts - the {@link NoiseOptions} (duration, spectral colour, - * envelope, pan, optional filter + sweep). See the interface for + * envelope, pan, delay, optional filter + sweep). See the interface for * per-field defaults. * @example * // Explosion: brown rumble closing into a thud @@ -308,12 +316,15 @@ export function noise(opts: NoiseOptions): void { pan = 0, filter, filterSweep = 1, + delay = 0, } = opts; _resumeIfSuspended(ctx); const dur = Math.max(0.001, duration); - const t0 = ctx.currentTime; + // Everything below hangs off `t0`, so a delay is one addition here + // rather than a timer around the whole call. + const t0 = ctx.currentTime + Math.max(0, delay); const t1 = t0 + dur; const env = _buildGainEnvelope(ctx, t0, t1, attack, dur, gain); diff --git a/packages/melonjs/src/audio/types.ts b/packages/melonjs/src/audio/types.ts index bd6e2f32b..3ccbdb77b 100644 --- a/packages/melonjs/src/audio/types.ts +++ b/packages/melonjs/src/audio/types.ts @@ -213,6 +213,17 @@ export interface NoiseOptions { * Has no effect when `filter` is unset. */ filterSweep?: number; + /** + * Seconds to wait before the sound starts, scheduled on the audio + * clock rather than a timer. `0` (the default) plays immediately. + * + * This is what sequences a multi-part sound — a stinger's second + * note, an explosion's double-tap — and it is sample-accurate, which + * `setTimeout` is not: a timer fires on the main thread, so a busy + * frame slips the note audibly. Nothing is allocated until the sound + * actually starts. + */ + delay?: number; } /** @@ -250,6 +261,17 @@ export interface ToneOptions { * value < 1) or rising stings (value > 1). */ pitchSlide?: number; + /** + * Seconds to wait before the sound starts, scheduled on the audio + * clock rather than a timer. `0` (the default) plays immediately. + * + * This is what sequences a multi-part sound — a stinger's second + * note, an explosion's double-tap — and it is sample-accurate, which + * `setTimeout` is not: a timer fires on the main thread, so a busy + * frame slips the note audibly. Nothing is allocated until the sound + * actually starts. + */ + delay?: number; } /** diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index f753c8877..0d0419866 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -166,10 +166,23 @@ export { default as BuiltinAdapter } from "./physics/builtin/builtin-adapter.ts" export { collision } from "./physics/collision.js"; export * as plugin from "./plugin/plugin.ts"; export { getPool } from "./pool.ts"; +export type { + AnimationOptions, + AnimationOptionsInput, +} from "./renderable/animation.ts"; +export type { + InstancedMeshOwnSettings, + InstancedMeshSettings, +} from "./renderable/instanced_mesh.js"; +export type { MeshSettings } from "./renderable/mesh.js"; export * as device from "./system/device.js"; export * as event from "./system/event.ts"; export * as utils from "./utils/utils.ts"; export * from "./version.ts"; +export type { + NoiseTexture2dBakeSettings, + NoiseTexture2dSettings, +} from "./video/texture/noise_texture2d.js"; export * as video from "./video/video.js"; // export all class definition export { diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 0bb943194..3ceec0137 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -15,7 +15,7 @@ import { linearToSrgb8 } from "./srgb.js"; /** * additional import for TypeScript - * @import { AnimationOptions } from "../../renderable/animation.ts"; + * @import { AnimationOptionsInput } from "../../renderable/animation.ts"; */ // column-major identity, the root's parent transform when the model sits @@ -92,7 +92,7 @@ const _localScratch = new Array(16); */ export default class GLTFModel extends Container { /** - * @param {object} data - the parsed glTF descriptor (`{ graph, animations, bounds, ... }`) + * @param {import("../../loader/loader.js").GLTFData} data - the parsed glTF descriptor, as returned by {@link loader.getGLTF} * @param {object} [options] * @param {number} [options.scale=1] - pixels per glTF unit (uniform scene scale) * @param {boolean} [options.rightHanded=true] - glTF Y-up → engine Y-down via a rotation (no mirror) @@ -404,7 +404,7 @@ export default class GLTFModel extends Container { * another clip when this one ends, a `function` legacy completion callback * (return `false` to hold the final pose), or an options object. * @param {string} name - animation clip id (see {@link GLTFModel#getAnimationNames}) - * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior + * @param {AnimationOptionsInput} [options] - loop / chain / completion behavior * @param {boolean} [preserveTime=false] - keep the current playback time instead of restarting at 0 * @returns {GLTFModel} this, for chaining * @example @@ -478,7 +478,7 @@ export default class GLTFModel extends Container { * (and start) it, or with no argument to resume after {@link GLTFModel#pause}. * Always clears the paused state. * @param {string} [name] - clip id to play; omit to just resume - * @param {string|Function|AnimationOptions} [options] - loop / chain / completion behavior (see {@link GLTFModel#setCurrentAnimation}) + * @param {AnimationOptionsInput} [options] - loop / chain / completion behavior (see {@link GLTFModel#setCurrentAnimation}) * @returns {GLTFModel} this, for chaining * @example * model.play("walk"); // switch to + play "walk" diff --git a/packages/melonjs/src/lighting/light3d.ts b/packages/melonjs/src/lighting/light3d.ts index 5793fe683..96e484bad 100644 --- a/packages/melonjs/src/lighting/light3d.ts +++ b/packages/melonjs/src/lighting/light3d.ts @@ -16,11 +16,22 @@ export interface Light3dOptions { */ type?: "directional" | "ambient" | "point" | "spot"; /** - * world-space direction the light travels along (directional lights, - * and the cone axis of spot lights). + * World-space direction the light TRAVELS ALONG — not the direction it + * comes from (directional lights, and the cone axis of spot lights). + * + * Render space is **Y-down**, so a sun overhead shining onto the scene + * travels *downward* and its Y is **positive**: `[-0.35, 0.8, 0.45]` is a + * late-afternoon sun. A negative Y lights everything from underneath, + * which reads instantly as wrong and is the usual mistake here. + * + * An `[x, y, z]` array, read by index — a `Vector3d` has no `[0]`, so + * passing one yields `NaN` and the light contributes nothing at all. */ direction?: [number, number, number]; - /** world-space position (point and spot lights). */ + /** + * World-space position (point and spot lights), as an `[x, y, z]` array. + * Y-down again: a lamp above the floor has a **smaller** y than the floor. + */ position?: [number, number, number]; /** * light color — a {@link Color}, a CSS color string, or an `[r, g, b]` diff --git a/packages/melonjs/src/renderable/animation.ts b/packages/melonjs/src/renderable/animation.ts index 00060ad8e..6aeb0b46c 100644 --- a/packages/melonjs/src/renderable/animation.ts +++ b/packages/melonjs/src/renderable/animation.ts @@ -23,6 +23,30 @@ export interface AnimationOptions { legacyFn?: boolean; } +/** + * What a caller may PASS as the second argument of `setCurrentAnimation`. + * + * The loose counterpart of {@link AnimationOptions}, which is what those + * choices normalize TO. Every field here is optional, because the defaults are + * the point: `{ loop: true }` has to be a complete thing to say. Typing an + * argument as the normalized shape instead makes `loop` and `speed` mandatory + * and turns the shortest useful call into a type error. + * @category Animation + */ +export type AnimationOptionsInput = + | string + | (() => unknown) + | { + /** called when the animation completes a cycle */ + onComplete?: () => unknown; + /** name of an animation to switch to when this one finishes */ + next?: string; + /** loop forever (default) or play once and hold the last frame */ + loop?: boolean; + /** playback rate multiplier (1 = authored speed) */ + speed?: number; + }; + /** * Normalize the polymorphic second argument of `setCurrentAnimation` into a * uniform {@link AnimationOptions}. Accepts: @@ -36,14 +60,7 @@ export interface AnimationOptions { */ export function parseAnimationOptions( arg?: - | string - | (() => unknown) - | { - onComplete?: () => unknown; - next?: string; - loop?: boolean; - speed?: number; - } + | AnimationOptionsInput // `null` is passed by the internal animation-chain call // (`setCurrentAnimation(next, null, true)`), so it must be accepted. | null, diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index 01e866a3c..44234282a 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -959,7 +959,8 @@ export default class Container extends Renderable { * {@link Renderable#alpha} on the container instead: opacity cascades at * draw time and leaves every child's own value alone. * @param {string} prop - property name - * @param {object} value - property value + * @param {*} value - property value; whatever the named property holds, so a + * flag is a boolean and a colour is a string, not only an object * @param {boolean} [recursive=false] - recursively apply the value to child containers if true * @see Renderable#alpha */ diff --git a/packages/melonjs/src/renderable/instanced_mesh.js b/packages/melonjs/src/renderable/instanced_mesh.js index 6a95bc094..af69beeb6 100644 --- a/packages/melonjs/src/renderable/instanced_mesh.js +++ b/packages/melonjs/src/renderable/instanced_mesh.js @@ -101,14 +101,25 @@ const _dirtySpan = [0, 0]; * forest.visibleInstanceCount = 1200; * app.world.addChild(forest, 10); */ +/** + * Everything {@link InstancedMesh} takes: the instancing settings, plus every + * {@link MeshSettings} — they are handed to {@link Mesh} whole, so the type + * says so by intersection rather than by copying the list. + * @typedef {object} InstancedMeshOwnSettings + * @property {number} [instanceCount=0] - number of instances to pre-allocate. Instances start at the group origin (identity transform) until placed; `addInstance` grows past this. + * @property {boolean} [instanceColors=false] - give each instance its own colour (16 bytes per instance), multiplied into the mesh tint + * @property {boolean} [instanceData=false] - give each instance an opaque `vec4` (16 bytes per instance). Read as emissive by the built-in lit shading, or as anything at all by a custom mesh shader. + */ + +/** + * @typedef {InstancedMeshOwnSettings & import("./mesh.js").MeshSettings} InstancedMeshSettings + */ + export default class InstancedMesh extends Mesh { /** * @param {number} x - the x coordinate of the group origin * @param {number} y - the y coordinate of the group origin - * @param {object} settings - every {@link Mesh} setting, plus those below - * @param {number} [settings.instanceCount=0] - number of instances to pre-allocate. Instances start at the group origin (identity transform) until placed; `addInstance` grows past this. - * @param {boolean} [settings.instanceColors=false] - give each instance its own colour (16 bytes per instance), multiplied into the mesh tint - * @param {boolean} [settings.instanceData=false] - give each instance an opaque `vec4` (16 bytes per instance). Read as emissive by the built-in lit shading, or as anything at all by a custom mesh shader. + * @param {InstancedMeshSettings} settings - every {@link Mesh} setting, plus the instancing ones */ constructor(x, y, settings) { super(x, y, settings); diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 8a580fe9b..796257ada 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -303,6 +303,42 @@ function buildTextureGroups( * vertex bake is the single anchoring mechanism. * @category Game Objects */ +/** + * Everything {@link Mesh} takes. + * + * Split out of the constructor's own `@param` list so a subclass that + * forwards these wholesale — {@link InstancedMesh} does — can say so in its + * type, instead of restating 27 entries that would then drift apart. + * @typedef {object} MeshSettings + * @property {string} [model] - name of a preloaded OBJ model (via loader.preload with type "obj"). Vertex normals come with it — authored `vn` when the file has them, generated from face geometry when it does not — so an OBJ model can be `lit`. + * @property {Float32Array|number[]} [vertices] - vertex positions as x,y,z triplets (alternative to `model`) + * @property {Float32Array|number[]} [uvs] - texture coordinates as u,v pairs (alternative to `model`) + * @property {Uint16Array|number[]} [indices] - triangle vertex indices (alternative to `model`) + * @property {HTMLImageElement|HTMLCanvasElement|TextureAtlas|string} [texture] - the texture to apply (image name, HTMLImageElement, or TextureAtlas). If omitted and settings.material is provided, the texture is resolved from the MTL material's map_Kd. Passing this pins ONE binding over the whole model, which on a multi-material model suppresses the per-material texture split — see {@link Mesh#textureGroups}. + * @property {string} [material] - name of a preloaded MTL material (via loader.preload with type "mtl"). When provided, the diffuse texture (map_Kd), tint color (Kd), and opacity (d) are automatically applied. On a multi-material model each material's own `map_Kd` is bound for its own slice of the geometry (#1573) and each `Kd` is baked per-vertex, so one `Mesh` renders the whole model. + * @property {number} width - display width in pixels. With normalization on (the default) the model is scaled to fit this size; with `normalize: false` this is the uniform pixels-per-unit scale applied to the raw geometry. With `normalize: false` and an explicit `scale`, an omitted `width`/`height` is derived from the GEOMETRY's own extent, the way a Sprite takes its size from its frame — a mesh that reports no extent misleads frustum culling, pointer picking and the physics broadphase alike. + * @property {number} [height] - display height in pixels (normalized models only; ignored when `normalize: false`) + * @property {boolean} [cullBackFaces=true] - enable backface culling + * @property {boolean} [normalize=true] - fit the source geometry into a `[-0.5, 0.5]` unit cube before scaling, so `width`/`height` behave like a Sprite. Set `false` to keep the geometry's real-world coordinates — required when several meshes share one coordinate space (e.g. nodes of an imported glTF scene) so their relative scale and layout are preserved. + * @property {number} [scale] - world-space scale (pixels per source unit) for the Camera3d path; defaults to `width`. Set this when `width`/`height` describe the renderable's world bounds (frustum culling) rather than the geometry scale — see {@link Mesh#meshScale}. + * @property {boolean} [rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}. + * @property {string} [textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) this mesh samples its texture with (per-mesh — it does not modify the shared texture, so other meshes/sprites using the same image are unaffected). Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2. + * @property {string} [textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. On the mesh path, linear filtering also samples a generated mip chain with trilinear minification and 4× anisotropy (distant geometry stops shimmering) — `"nearest"` opts out, keeping crisp pixel-art models on hard level-0 sampling. GPU backends only (ignored by the Canvas renderer). + * @property {number} [alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). + * @property {number[]|Float32Array} [emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). + * @property {boolean} [lit=false] - shade this mesh with the scene's {@link Light3d} lights (the lit mesh pipeline) instead of rendering fullbright. Set automatically by the glTF importer when the scene carries a directional, point or spot light. With `lit` on and no lights present the batcher uploads a white ambient, so the result is indistinguishable from unlit. + * @property {Uint32Array|Color[]|number[]} [vertexColors] - per-vertex colour, one entry per vertex, multiplied into {@link Mesh#tint}. Either packed RGBA8 (`Uint32Array`, the form the batchers read — no conversion) or one {@link Color} per vertex. Omit for plain white. Lets a single mesh carry a gradient — fading a terrain toward the sky with distance, darkening a crease — which a per-object `tint` cannot express. An explicit value wins over the colours a multi-material OBJ bakes from its MTL. + * @property {number[]|Float32Array} [normals] - per-vertex normals for the lit path. An explicit value wins over the ones an OBJ or glTF source supplies; omit it and they are taken from the model, or generated from the geometry when the mesh is `lit`. Generated normals average per vertex where faces share vertices (smooth shading) and equal the face normal where they do not (flat shading) — the geometry decides, not a flag. + * @property {number[]|Float32Array} [specular] - specular color `[r, g, b]` (0..1) for the lit path. Set by the OBJ loader from MTL `Ks`, and derived from glTF metallic/roughness. + * @property {number} [shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface. + * @property {string|TextureAtlas|HTMLImageElement} [alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`). + * @property {boolean} [castGroundShadow] - give this mesh a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a `Camera3d`. + * @property {boolean} [transparent] - draw in the transparent pass (blended, back-to-front, no depth write). Omit and a mesh goes transparent whenever its draw alpha is fractional; `true` for soft-alpha textures; `false` to stay opaque however faded + * @property {boolean} [fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does + * @property {number} [shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. + * @property {number} [shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. + */ + /** * The width and height a mesh should report, filling in from the GEOMETRY what * the caller did not declare — the way a Sprite takes its size from its frame. @@ -374,34 +410,7 @@ export default class Mesh extends Renderable { /** * @param {number} x - the x screen position of the mesh object * @param {number} y - the y screen position of the mesh object - * @param {object} settings - Configuration parameters for the Mesh object - * @param {string} [settings.model] - name of a preloaded OBJ model (via loader.preload with type "obj"). Vertex normals come with it — authored `vn` when the file has them, generated from face geometry when it does not — so an OBJ model can be `lit`. - * @param {Float32Array|number[]} [settings.vertices] - vertex positions as x,y,z triplets (alternative to settings.model) - * @param {Float32Array|number[]} [settings.uvs] - texture coordinates as u,v pairs (alternative to settings.model) - * @param {Uint16Array|number[]} [settings.indices] - triangle vertex indices (alternative to settings.model) - * @param {HTMLImageElement|TextureAtlas|string} [settings.texture] - the texture to apply (image name, HTMLImageElement, or TextureAtlas). If omitted and settings.material is provided, the texture is resolved from the MTL material's map_Kd. Passing this pins ONE binding over the whole model, which on a multi-material model suppresses the per-material texture split — see {@link Mesh#textureGroups}. - * @param {string} [settings.material] - name of a preloaded MTL material (via loader.preload with type "mtl"). When provided, the diffuse texture (map_Kd), tint color (Kd), and opacity (d) are automatically applied. On a multi-material model each material's own `map_Kd` is bound for its own slice of the geometry (#1573) and each `Kd` is baked per-vertex, so one `Mesh` renders the whole model. - * @param {number} settings.width - display width in pixels. With normalization on (the default) the model is scaled to fit this size; with `normalize: false` this is the uniform pixels-per-unit scale applied to the raw geometry. With `normalize: false` and an explicit `scale`, an omitted `width`/`height` is derived from the GEOMETRY's own extent, the way a Sprite takes its size from its frame — a mesh that reports no extent misleads frustum culling, pointer picking and the physics broadphase alike. - * @param {number} [settings.height] - display height in pixels (normalized models only; ignored when `normalize: false`) - * @param {boolean} [settings.cullBackFaces=true] - enable backface culling - * @param {boolean} [settings.normalize=true] - fit the source geometry into a `[-0.5, 0.5]` unit cube before scaling, so `width`/`height` behave like a Sprite. Set `false` to keep the geometry's real-world coordinates — required when several meshes share one coordinate space (e.g. nodes of an imported glTF scene) so their relative scale and layout are preserved. - * @param {number} [settings.scale] - world-space scale (pixels per source unit) for the Camera3d path; defaults to `width`. Set this when `width`/`height` describe the renderable's world bounds (frustum culling) rather than the geometry scale — see {@link Mesh#meshScale}. - * @param {boolean} [settings.rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}. - * @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) this mesh samples its texture with (per-mesh — it does not modify the shared texture, so other meshes/sprites using the same image are unaffected). Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2. - * @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. On the mesh path, linear filtering also samples a generated mip chain with trilinear minification and 4× anisotropy (distant geometry stops shimmering) — `"nearest"` opts out, keeping crisp pixel-art models on hard level-0 sampling. GPU backends only (ignored by the Canvas renderer). - * @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). - * @param {number[]|Float32Array} [settings.emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). GPU mesh path only (WebGL and WebGPU; the Canvas renderer ignores it). - * @param {boolean} [settings.lit=false] - shade this mesh with the scene's {@link Light3d} lights (the lit mesh pipeline) instead of rendering fullbright. Set automatically by the glTF importer when the scene carries a directional, point or spot light. With `lit` on and no lights present the batcher uploads a white ambient, so the result is indistinguishable from unlit. - * @param {Uint32Array|Color[]|number[]} [settings.vertexColors] - per-vertex colour, one entry per vertex, multiplied into {@link Mesh#tint}. Either packed RGBA8 (`Uint32Array`, the form the batchers read — no conversion) or one {@link Color} per vertex. Omit for plain white. Lets a single mesh carry a gradient — fading a terrain toward the sky with distance, darkening a crease — which a per-object `tint` cannot express. An explicit value wins over the colours a multi-material OBJ bakes from its MTL. - * @param {number[]|Float32Array} [settings.normals] - per-vertex normals for the lit path. An explicit value wins over the ones an OBJ or glTF source supplies; omit it and they are taken from the model, or generated from the geometry when the mesh is `lit`. Generated normals average per vertex where faces share vertices (smooth shading) and equal the face normal where they do not (flat shading) — the geometry decides, not a flag. - * @param {number[]|Float32Array} [settings.specular] - specular color `[r, g, b]` (0..1) for the lit path. Set by the OBJ loader from MTL `Ks`, and derived from glTF metallic/roughness. - * @param {number} [settings.shininess=0] - specular exponent for the lit path (MTL `Ns`). `0` for a fully diffuse surface. - * @param {string|TextureAtlas|HTMLImageElement} [settings.alphaMap] - per-texel opacity map, sampled in addition to the diffuse texture (MTL `map_d`). - * @param {boolean} [settings.castGroundShadow] - give this mesh a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a `Camera3d`. - * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write). Omit and a mesh goes transparent whenever its draw alpha is fractional; `true` for soft-alpha textures; `false` to stay opaque however faded - * @param {boolean} [settings.fog] - set `false` to exempt this mesh from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does - * @param {number} [settings.shadowGroundY] - world Y of the floor the shadow lands on. Omit and the blob sits at the object's own base at full strength; set it and the blob shrinks and fades as the object rises. Render space is Y-down, so the floor is a **greater** Y than the object above it. - * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the object, before any height fade. + * @param {MeshSettings} settings - Configuration parameters for the Mesh object * @example * // create from OBJ + MTL (texture auto-resolved from material) * let mesh = new me.Mesh(0, 0, { diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index 901efc730..b5849fdc8 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -148,7 +148,7 @@ export default class Sprite3d extends Mesh { * @param {number} x - world x position * @param {number} y - world y position * @param {object} settings - configuration - * @param {HTMLImageElement|Texture2d|string} [settings.image] - the sprite texture (image name, image, or a {@link Texture2d} asset such as a {@link TextureAtlas}). Alias: `settings.texture`. + * @param {HTMLImageElement|HTMLCanvasElement|Texture2d|string} [settings.image] - the sprite texture (image name, image, or a {@link Texture2d} asset such as a {@link TextureAtlas}). Alias: `settings.texture`. * @param {number} [settings.width=settings.framewidth] - quad width in world units (pixels) * @param {number} [settings.height=settings.width] - quad height in world units * @param {number} [settings.framewidth] - width of a single frame within a spritesheet (enables frame animation) @@ -162,6 +162,11 @@ export default class Sprite3d extends Mesh { * @param {boolean} [settings.flipY=false] - mirror the sprite vertically (see {@link Sprite3d#flipY}) * @param {boolean} [settings.lit=false] - shade through the lit mesh batcher (see {@link Mesh}) * @param {number[]|Float32Array} [settings.emissive] - emissive color (see {@link Mesh}) + * @param {number} [settings.shadowGroundY] - world Y of the floor the blob shadow lands on. Omit and it falls back to the sprite's own base — which for a billboard moves with the camera, so a scene that knows where its floor is should say so. + * @param {number} [settings.shadowOpacity=0.45] - opacity of the shadow directly beneath the sprite, before any height fade + * @param {boolean} [settings.castGroundShadow] - give this sprite a blob ground shadow, overriding the application's `castGroundShadow` setting in both directions. Omit to inherit. Needs a GPU backend and a {@link Camera3d}. + * @param {boolean} [settings.fog] - set `false` to exempt this sprite from the camera's distance fog ({@link Camera3d#setFog}); omit to fog whenever the camera does. A sun or a moon wants this — everything else at that distance dissolves into the haze, and so would it. + * @param {boolean} [settings.transparent] - draw in the transparent pass (blended, back-to-front, no depth write) instead of the opaque one. Omit and the sprite goes transparent whenever its draw alpha is fractional; `true` for a soft-alpha sprite such as an additive glow; `false` to stay opaque however faded. * @param {number} [settings.alphaCutoff=0.5] - alpha cutout threshold (see {@link Mesh}). The mesh pass is opaque (no alpha blending), so this defaults to `0.5` to discard a sprite's transparent background (clean cutout silhouette, correct depth, no sorting). Set `0` for a fully-opaque quad, or tune the threshold. */ constructor(x, y, settings) { @@ -272,6 +277,7 @@ export default class Sprite3d extends Mesh { // flatten it to an explicit false, silently opting every sprite out // of a scene-wide default castGroundShadow: settings.castGroundShadow, + fog: settings.fog, // raw for the same reason as above: `undefined` means "decide from // the draw's alpha", and coercing it would pin every sprite opaque transparent: settings.transparent, diff --git a/packages/melonjs/src/renderable/text/bitmaptext.js b/packages/melonjs/src/renderable/text/bitmaptext.js index ec969b313..31dc72a36 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -23,9 +23,8 @@ export default class BitmapText extends Renderable { * @param {object} settings - the text configuration * @param {string|Image} settings.font - a font name to identify the corresponding source image * @param {string} [settings.fontData=settings.font] - the bitmap font data corresponding name, or the bitmap font data itself (AngelCode BMFont, `.fnt` text or `.xml`) - * @param {number} [settings.size] - size a scaling ratio - * @param {Color|string} [settings.fillStyle] - a CSS color value used to tint the bitmapText (@see BitmapText.tint) - * @param {number} [settings.lineWidth=1] - line width, in pixels, when drawing stroke + * @param {number} [settings.size=1.0] - a scaling RATIO applied to the font's authored size, not a pixel size: `2` draws it at double. ({@link Text} takes pixels here; this one does not.) + * @param {Color|string} [settings.fillStyle] - a CSS color value used to tint the glyphs, see {@link Renderable#tint} * @param {string} [settings.textAlign="left"] - horizontal text alignment * @param {string} [settings.textBaseline="top"] - the text baseline * @param {number} [settings.lineHeight=1.0] - line spacing height @@ -170,7 +169,7 @@ export default class BitmapText extends Renderable { /** * change the font settings * @param {string} textAlign - ("left", "center", "right") - * @param {number} [scale] + * @param {number} [scale] - a scaling ratio, applied through {@link BitmapText#resize} when given * @returns {BitmapText} this object for chaining */ set(textAlign, scale) { @@ -320,10 +319,27 @@ export default class BitmapText extends Renderable { } /** - * defines the color used to tint the bitmap text + * defines the color used to tint the bitmap text. + * + * This is {@link Renderable#tint} under another name, so the same rule + * applies: white — `(255, 255, 255)` — is the absence of a tint, and any + * other colour tints away from there. A page image authored in white + * therefore keeps every colour available to it. * @public * @type {Color} * @see Renderable#tint + * @example + * // tint at construction... + * const score = new BitmapText(8, 8, { + * font: "arial", + * text: "1000", + * fillStyle: "#ffd700", // gold + * }); + * app.world.addChild(score); + * + * // ...or at any point after it, from a CSS string or a Color + * score.fillStyle = "#ff4040"; // flash red on damage + * score.fillStyle = new Color(255, 255, 255); // back to untinted */ get fillStyle() { return this.tint; @@ -339,8 +355,14 @@ export default class BitmapText extends Renderable { /** * change the font display size - * @param {number} scale - ratio + * @param {number} scale - a ratio against the font's authored size, NOT a + * pixel size: `1` is the page image at its native scale, `2` is double * @returns {BitmapText} this object for chaining + * @example + * // a bitmap font is pixel art — whole-number ratios stay crisp, and + * // fractional ones resample the page image + * title.resize(3); // three times its authored size + * title.set("center", 2); // align and rescale in one call */ resize(scale) { this.fontScale.set(scale, scale); @@ -358,6 +380,10 @@ export default class BitmapText extends Renderable { * measure the given text size in pixels * @param {string} [text] * @returns {TextMetrics} a TextMetrics object with two properties: `width` and `height`, defining the output dimensions + * @example + * // size a panel around a label, at the label's CURRENT scale + * const size = label.measureText(); + * panel.resize(size.width + 16, size.height + 16); */ measureText(text = this._text) { return this.metrics.measureText(text); diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index cdf153aa9..aa5e0cc8d 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -69,6 +69,8 @@ export default class Text extends Renderable { * @param {number} [settings.lineHeight=1.0] - line spacing height * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.0, y:0.0}] - anchor point to draw the text at. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. * @param {number} [settings.wordWrapWidth] - the maximum length in CSS pixels of a line before it wraps + * @param {boolean} [settings.bold=false] - render the face bold, as {@link Text#bold} does + * @param {boolean} [settings.italic=false] - render the face italic, as {@link Text#italic} does * @param {(string|string[])} [settings.text=""] - a string, or an array of strings * @example * // a styled, word-wrapped, multi-line label using a generic system font @@ -87,6 +89,23 @@ export default class Text extends Renderable { * label.setOpacity(0.8); // per-object transparency * app.world.addChild(label); * @example + * // a gradient fill: `fillStyle` takes a Gradient as well as a colour. + * // Its coordinates are the label's OWN bake, so (0, 0) is the top-left of + * // the render box and the ramp below runs down exactly one line. + * const ramp = app.renderer.createLinearGradient(0, 0, 0, 32); + * ramp.addColorStop(0, "#fffdf0"); + * ramp.addColorStop(1, "#ffa71d"); + * + * app.world.addChild(new Text(8, 8, { + * font: "sans-serif", + * size: 32, + * fillStyle: ramp, // ramps the glyphs... + * strokeStyle: "#000000", // ...while the outline keeps its own colour + * lineWidth: 1, + * text: "GAME\nOVER", // every line restarts the ramp by default + * // gradientPerLine: false, // ...or span ONE ramp across both lines + * })); + * @example * // a web font (loaded via the fontface loader) is used by its family name * loader.preload( * [{ name: "kenpixel", type: "fontface", src: "data/font/kenvector.woff2" }], @@ -453,7 +472,7 @@ export default class Text extends Renderable { this.canvasTexture.context, this._text, this.pos.x - this.metrics.x, - this.pos.y - this.metrics.y + this.metrics.inkPadTop, + this.pos.y - this.metrics.y, ); // Invalidate LAST, so the renderer re-uploads the canvas at the size @@ -495,9 +514,18 @@ export default class Text extends Renderable { /** * the ratio of visible characters (0.0 to 1.0). - * Setting this automatically updates {@link visibleCharacters}. + * Setting this automatically updates {@link Text#visibleCharacters}. + * + * This is the one to tween: it is independent of how many characters the + * label holds, so a reveal takes the same time whatever the string is. * @public * @type {number} + * @default 1.0 + * @see Text#visibleCharacters + * @example + * // reveal over two seconds, regardless of length + * label.visibleRatio = 0; + * new Tween(label).to({ visibleRatio: 1.0 }, { duration: 2000 }).start(); */ get visibleRatio() { if (this._visibleCharacters === -1) { @@ -576,6 +604,14 @@ export default class Text extends Renderable { * measure the given text size in pixels * @param {string} [text] - the text to be measured * @returns {TextMetrics} a TextMetrics object defining the dimensions of the given piece of text + * @example + * // size a panel around a label + * const size = label.measureText(); + * panel.resize(size.width + 16, size.height + 16); + * + * // or measure a string the label does not currently hold, to reserve + * // room for the widest state a counter will reach + * const widest = label.measureText("00:00").width; */ measureText(text = this._text) { return this.metrics.measureText(text, this.canvasTexture.context); @@ -586,6 +622,18 @@ export default class Text extends Renderable { * @param {CanvasRenderer|WebGLRenderer} renderer - Reference to the destination renderer instance */ draw(renderer) { + // Re-anchor the box to where the label is NOW. + // + // `metrics.x/y` is derived from `pos`, but the rest of `measureText` is + // not, so it used to refresh only when the STRING changed. Move a label + // without re-setting its text and the bake offset (`pos - metrics`) grew + // by the whole distance moved while the canvas stayed the size it was — + // the glyphs slid off their own canvas and were clipped, and the blit + // still went to where the label used to be. Refreshing the origin is + // cheap: it reads `pos` and the already-measured width, and measures no + // glyphs. + this.metrics.updateOrigin(); + // re-render the canvas texture when dirty (e.g. visibleCharacters changed) if (this.isDirty) { this.canvasTexture.clear(); @@ -593,7 +641,7 @@ export default class Text extends Renderable { this.canvasTexture.context, this._text, this.pos.x - this.metrics.x, - this.pos.y - this.metrics.y + this.metrics.inkPadTop, + this.pos.y - this.metrics.y, ); // after the repaint, not before it — see `setText` this.canvasTexture.invalidate(renderer); @@ -620,65 +668,115 @@ export default class Text extends Renderable { * @internal */ _drawFont(context, text, x, y) { - setContextStyle(context, this); - - let remaining = this.visibleCharacters; - - // A gradient is re-anchored to EACH LINE, so every line of a multi-line - // label carries the same ramp — what you would get from one `Text` per - // line, which is how a HUD is usually built. + // The bake canvas carries `inkPadTop` of headroom above the layout box. + // Translating INTO that headroom — rather than adding it to every draw + // position — keeps bake space equal to METRICS space, which is the + // space a caller authored their gradient coordinates in. Add the pad to + // the positions instead and the glyphs slide down the ramp while the + // ramp stays put, so the fill changes colour with the padding, and the + // padding differs per browser. The translate is a whole number of + // pixels, so glyph rasterization is untouched. + context.save(); + context.translate(0, this.metrics.inkPadTop); + + const fill = setContextStyle(context, this); + + const fills = this.fillStyle.alpha > 0; + const strokes = this.lineWidth > 0 && this.strokeStyle.alpha > 0; + + // A gradient fill is painted THROUGH the glyphs rather than handed to + // `fillText`. // - // The canvas would otherwise spread one ramp across the whole block: - // a `CanvasGradient` lives in the current transform's space, so lines - // drawn further down sample further along it, and every line after the - // first comes out flat unless the caller happens to have authored the - // ramp over the exact block height. That is silent and easy to get - // wrong. Translating per line instead keeps the gradient with the text. + // WebKit rasterizes gradient-filled text by drawing the glyphs into a + // mask it sizes from the FONT's layout box, then filling that mask — + // so ink rising above the declared ascent, which display faces do + // routinely, never enters the mask and comes out fully transparent. + // The stroke is a flat colour and takes a different path, which leaves + // an outlined but hollow glyph. Filling flat and re-colouring through + // `source-in` puts the glyphs through the same rasterizer every browser + // uses for ordinary text, so all of them agree. // - // The trade is that a ramp spanning a whole two-line title is no longer - // expressible; compose that from one `Text` per line. - const perLine = - this.gradientPerLine === true && - this.fillGradient !== undefined && - text.length > 1; - const firstY = y; - - for (let i = 0; i < text.length; i++) { - let string = text[i].trimEnd(); - - // limit visible characters if needed - if (remaining !== -1) { - if (remaining <= 0) { - break; + // Measured on a plain canvas with no engine involved, 34px display + // face: with a FLAT fill both engines paint 7px above the top alignment + // point; with a GRADIENT, WebKit stops at 0 — precisely the ascent it + // reports for the face. Everything above that is absent, not faint. + // The mask path is `drawTextUnchecked` in WebCore's + // `CanvasRenderingContext2DBase`, under `USE(CG)`. + // + // Done unconditionally, not behind a browser check: one rasterization + // path is worth more than saving a `fillRect` on the engines that would + // have been fine either way. + // + // The trade is that fill and stroke now interleave per BLOCK rather + // than per line, observable only where lines overlap. + // + // DO NOT collapse this back into a single `fillText` with the gradient + // as `fillStyle`. The second pass reads as a redundant `fillRect` and + // is not one — it is the only reason the glyph tops survive on Safari. + const masked = fills && this.fillGradient !== undefined; + + /** + * Walk the visible lines, handing each to `paint`. + * @param {(line: string, x: number, y: number) => void} paint - per line + */ + const eachLine = (paint) => { + let remaining = this.visibleCharacters; + let lineY = y; + + for (let i = 0; i < text.length; i++) { + let string = text[i].trimEnd(); + + // limit visible characters if needed + if (remaining !== -1) { + if (remaining <= 0) { + break; + } + string = string.substring(0, remaining); + remaining -= string.length; } - string = string.substring(0, remaining); - remaining -= string.length; - } - // Shift the whole space down to this line rather than the draw - // position, so the gradient travels with it and each line is - // painted from the ramp's start. - if (perLine) { - context.save(); - context.translate(0, y - firstY); + paint(string, x, lineY); + // add leading space + lineY += this.metrics.lineHeight(); } - const lineY = perLine ? firstY : y; + }; - // draw the string - if (this.fillStyle.alpha > 0) { - context.fillText(string, x, lineY); - } - // stroke the text - if (this.lineWidth > 0 && this.strokeStyle.alpha > 0) { - context.strokeText(string, x, lineY); + if (fills) { + if (masked === true) { + // any opaque colour: only the alpha it leaves behind survives + context.fillStyle = "#000000"; } + eachLine((line, lineX, lineY) => { + context.fillText(line, lineX, lineY); + }); + } - if (perLine) { - context.restore(); - } - // add leading space - y += this.metrics.lineHeight(); + if (masked === true) { + // Re-colour the silhouette under the SAME transform the glyphs were + // drawn with, so the gradient travels with them. The rect only has + // to cover the canvas — a gradient's colours come from its own + // coordinates, not from the rect it is painted into. + const canvas = context.canvas; + context.globalCompositeOperation = "source-in"; + context.fillStyle = fill; + context.fillRect( + -1, + -this.metrics.inkPadTop - 1, + canvas.width + 2, + canvas.height + 2, + ); + // back before the stroke: the outline is a flat colour and must not + // be masked by the fill it sits on + context.globalCompositeOperation = "source-over"; + } + + if (strokes) { + eachLine((line, lineX, lineY) => { + context.strokeText(line, lineX, lineY); + }); } + + context.restore(); return this.metrics; } diff --git a/packages/melonjs/src/renderable/text/textmetrics.js b/packages/melonjs/src/renderable/text/textmetrics.js index 63fec6fd2..f199f81e7 100644 --- a/packages/melonjs/src/renderable/text/textmetrics.js +++ b/packages/melonjs/src/renderable/text/textmetrics.js @@ -2,6 +2,28 @@ import { Bounds } from "../../physics/bounds.ts"; import Text from "./text.js"; import setContextStyle from "./textstyle.js"; +/** + * One pixel of slack on top of the reported ink extent. + * + * `actualBoundingBox*` describes the OUTLINE, and the rasterizer paints past + * it: antialiasing and pixel snapping both put ink outside what the metrics + * promise. Measured against two engines, the painted ink reached a full pixel + * beyond the reported bound — enough to shear the top row off a stroke. Over- + * padding costs nothing but invisible canvas headroom. + * @ignore + * @internal + */ +const INK_SLACK = 1; + +/** + * The share of the em to pad by when the ink extent cannot be measured at all. + * Covers the overshoot of the display faces measured here without depending on + * a metric the browser may not report. + * @ignore + * @internal + */ +const INK_GUESS = 0.25; + /** * a Text Metrics object that contains helper for text manipulation */ @@ -144,9 +166,10 @@ class TextMetrics extends Bounds { // screen position and only the clipped pixels are recovered. // // `actualBoundingBox*` is measured from the alignment point, so the - // values already account for whichever `textBaseline` is in force. A - // browser or font that cannot report them leaves the padding at zero, - // which is exactly today's behaviour. + // values already account for whichever `textBaseline` is in force. + // They describe the outline, though, not the pixels — see `INK_SLACK` — + // and where they are unavailable the padding falls back to `INK_GUESS` + // rather than to nothing. this.inkPadTop = 0; this.inkPadBottom = 0; if (!isBitmapText && strings.length > 0 && typeof context !== "undefined") { @@ -162,21 +185,53 @@ class TextMetrics extends Bounds { : context.measureText(strings[strings.length - 1].trimEnd()); const above = first.actualBoundingBoxAscent; const below = last.actualBoundingBoxDescent; - if (Number.isFinite(above) && Number.isFinite(below)) { - this.inkPadTop = Math.max(0, Math.ceil(above + stroke)); - // the last line starts one line box short of the bottom, so only - // what reaches past that needs room - this.inkPadBottom = Math.max( - 0, - Math.ceil(this.inkPadTop + below + stroke - this.lineHeight()), - ); - } + // A face that cannot report its ink falls back to a share of the em + // rather than to nothing: a guess that covers most faces clips less + // than a zero that covers none. + const measured = Number.isFinite(above) && Number.isFinite(below); + const top = measured ? above : style.fontSize * INK_GUESS; + const bottom = measured ? below : style.fontSize * INK_GUESS; + + this.inkPadTop = Math.max(0, Math.ceil(top + stroke) + INK_SLACK); + // the last line starts one line box short of the bottom, so only + // what reaches past that needs room + this.inkPadBottom = Math.max( + 0, + Math.ceil(this.inkPadTop + bottom + stroke - this.lineHeight()) + + INK_SLACK, + ); } this.width = Math.ceil(this.width); this.height = Math.ceil(this.height); - // compute the bounding box position + this.updateOrigin(); + + if (typeof context !== "undefined") { + // restore the context + context.restore(); + } + + return this; + } + + /** + * Recompute the box's position from the ancestor's CURRENT `pos`. + * + * Split out of `measureText` because it depends on `pos` while everything + * else there depends only on the string and the font. A label that moves + * needs this refreshed — and nothing else — so the draw path can call it + * per frame without re-measuring a single glyph. + * + * The `Math.floor` is what puts the canvas on a whole pixel. The remainder + * it drops is not lost: `Text` bakes its glyphs at `pos - metrics`, so the + * sub-pixel part lands in the rasterization, where the font rasterizer can + * antialias it, instead of resampling the whole texture. + * @returns {TextMetrics} this instance for chaining + * @ignore + * @internal + */ + updateOrigin() { this.x = Math.floor( this.ancestor.textAlign === "right" ? this.ancestor.pos.x - this.width @@ -191,12 +246,6 @@ class TextMetrics extends Bounds { ? this.ancestor.pos.y - this.lineHeight() / 2 : this.ancestor.pos.y - this.lineHeight(), ); - - if (typeof context !== "undefined") { - // restore the context - context.restore(); - } - return this; } diff --git a/packages/melonjs/src/renderable/text/textstyle.js b/packages/melonjs/src/renderable/text/textstyle.js index 1e8f8ca97..667ce177e 100644 --- a/packages/melonjs/src/renderable/text/textstyle.js +++ b/packages/melonjs/src/renderable/text/textstyle.js @@ -1,18 +1,86 @@ +/** + * How far from vertical a linear gradient may lean and still be treated as a + * per-line ramp. A gradient running mostly across the label is one the caller + * meant to span it, not one to repeat. + * @ignore + * @internal + */ +const VERTICAL_TOLERANCE = 0.1; + +/** + * The fill style a bake should use: a flat colour, the gradient as authored, + * or — for a multi-line label under `gradientPerLine` — one gradient spanning + * the whole block with the caller's stops REPEATED once per line. + * + * Repeating the stops rather than re-anchoring the gradient per line keeps this + * to a single `fillStyle` and leaves the canvas transform alone. `fillText` is + * already where the browsers disagree most about rasterization; moving the + * transform underneath it during text drawing is not a place to add variables. + * @param {CanvasRenderingContext2D} context - the bake's context + * @param {object} style - the Text being drawn + * @returns {string|CanvasGradient} a value for `context.fillStyle` + * @ignore + * @internal + */ +function resolveFill(context, style) { + const gradient = style.fillGradient; + + if (gradient === undefined) { + return style.fillStyle.toRGBA(); + } + + const lines = style._text?.length ?? 1; + if ( + style.gradientPerLine !== true || + lines <= 1 || + gradient.type !== "linear" + ) { + return gradient.toCanvasGradient(context); + } + + const [x0, y0, x1, y1] = gradient.coords; + const span = y1 - y0; + // only a (near) vertical ramp repeats: a horizontal one has nothing to say + // about lines, and a diagonal one was authored for the whole block + if (span === 0 || Math.abs(x1 - x0) > Math.abs(span) * VERTICAL_TOLERANCE) { + return gradient.toCanvasGradient(context); + } + + const lineHeight = style.metrics.lineHeight(); + const blockHeight = lines * lineHeight; + if (blockHeight <= 0) { + return gradient.toCanvasGradient(context); + } + + const repeated = context.createLinearGradient(x0, 0, x1, blockHeight); + for (let line = 0; line < lines; line++) { + for (const stop of gradient.colorStops) { + const y = line * lineHeight + y0 + stop.offset * span; + repeated.addColorStop( + Math.min(1, Math.max(0, y / blockHeight)), + stop.color, + ); + } + } + return repeated; +} + /** * apply the current text style to the given context + * @param {CanvasRenderingContext2D} context - the bake's context + * @param {object} style - the Text being drawn + * @returns {string|CanvasGradient} the resolved fill, so a caller that repaints + * the glyphs through it does not have to resolve it a second time * @ignore * @internal */ export default function setContextStyle(context, style) { + const fill = resolveFill(context, style); context.font = style.font; - // a Gradient fills the glyphs in place of the flat colour — the same choice - // `Renderer#setColor` makes, resolved here against the bake's own context - context.fillStyle = - style.fillGradient !== undefined - ? style.fillGradient.toCanvasGradient(context) - : style.fillStyle.toRGBA(); + context.fillStyle = fill; context.strokeStyle = style.strokeStyle.toRGBA(); context.lineWidth = style.lineWidth; context.textAlign = style.textAlign; context.textBaseline = style.textBaseline; + return fill; } diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts index a986a9b4c..ed3f71cfd 100644 --- a/packages/melonjs/src/state/stage.ts +++ b/packages/melonjs/src/state/stage.ts @@ -294,9 +294,8 @@ export default class Stage { } /** - * update function - * @ignore - * @internal + * Update the stage. Override it to run your own per-frame logic, and call + * `super.update(dt)` so the world and cameras still advance. * @param dt - time since the last update in milliseconds. * @returns true if the stage needs to be redrawn */ @@ -322,8 +321,9 @@ export default class Stage { * Lights are rendered as part of the world tree (they're now first-class * Renderables) and the ambient overlay pass runs inside each Camera's * post-effect FBO bracket via {@link Stage#drawLighting}. - * @ignore - * @internal + * + * Override it to draw under or over the world, calling `super.draw(...)` + * where the world itself belongs in that order. * @param renderer - the renderer object to draw with * @param world - the world object to draw */ diff --git a/packages/melonjs/src/video/texture/noise_texture2d.js b/packages/melonjs/src/video/texture/noise_texture2d.js index f98ef7a5e..78d4ac4a7 100644 --- a/packages/melonjs/src/video/texture/noise_texture2d.js +++ b/packages/melonjs/src/video/texture/noise_texture2d.js @@ -12,6 +12,39 @@ const lerp = (a, b, t) => { return a + (b - a) * t; }; +/** + * What a {@link NoiseTexture2d} bakes WITH — its own settings, as opposed to + * the field's. + * @typedef {object} NoiseTexture2dBakeSettings + * @property {number} [width=256] - baked texture width in pixels + * @property {number} [height=256] - baked texture height in pixels + * @property {Noise} [noise] - an existing {@link Noise} to bake; when omitted + * one is built from the forwarded field settings + * @property {boolean} [seamless=false] - tile cleanly in both axes + * @property {number} [seamlessBlendSkirt=0.1] - edge blend band width as a + * fraction (0..1) of the smaller dimension, when `seamless` + * @property {boolean} [invert=false] - invert the noise value (`1 - v`) + * @property {boolean} [asNormalMap=false] - encode as a normal map + * @property {number} [bumpStrength=1] - normal steepness when `asNormalMap` + * @property {import("../gradient.js").Gradient} [colorRamp] - map the noise + * value to a color + * @property {boolean} [animated=false] - sample in 3D (`getNoise3d`) using an + * internal `time` as the third axis, advanced by {@link NoiseTexture2d#update} + * @property {number} [speed=1] - animation speed in noise z-units per second + * (only used while `animated`) + */ + +/** + * Everything the constructor takes. + * + * The settings object is handed to {@link Noise} WHOLE when no `noise` instance + * is given, so every field setting — `type`, `seed`, `frequency`/`scale`, + * `octaves`, `gain`/`persistence`, `lacunarity`, `fractalType`, the domain-warp + * settings — belongs here too. Spelling them out a second time is what would + * drift; the intersection cannot. + * @typedef {NoiseTexture2dBakeSettings & import("../../math/noise.ts").NoiseSettings} NoiseTexture2dSettings + */ + /** * A {@link Texture2d} that bakes a {@link Noise} field into a drawable canvas — * usable directly as a sprite image, a normal map, an image layer, or a custom @@ -54,25 +87,8 @@ const lerp = (a, b, t) => { */ class NoiseTexture2d extends Texture2d { /** - * @param {object} [settings] - configuration; any {@link Noise} setting - * (`type`, `seed`, `frequency`/`scale`, `octaves`, `gain`/`persistence`, - * `lacunarity`, `fractalType`, domain-warp settings) is forwarded when no - * `noise` instance is given. - * @param {number} [settings.width=256] - baked texture width in pixels - * @param {number} [settings.height=256] - baked texture height in pixels - * @param {Noise} [settings.noise] - an existing {@link Noise} to bake; when - * omitted one is built from the forwarded settings - * @param {boolean} [settings.seamless=false] - tile cleanly in both axes - * @param {number} [settings.seamlessBlendSkirt=0.1] - edge blend band width - * as a fraction (0..1) of the smaller dimension, when `seamless` - * @param {boolean} [settings.invert=false] - invert the noise value (`1 - v`) - * @param {boolean} [settings.asNormalMap=false] - encode as a normal map - * @param {number} [settings.bumpStrength=1] - normal steepness when `asNormalMap` - * @param {Gradient} [settings.colorRamp] - map the noise value to a color - * @param {boolean} [settings.animated=false] - sample in 3D (`getNoise3d`) - * using an internal `time` as the third axis, advanced by {@link NoiseTexture2d#update} - * @param {number} [settings.speed=1] - animation speed in noise z-units per - * second (only used while `animated`) + * @param {NoiseTexture2dSettings} [settings] - bake settings, plus any + * {@link NoiseSettings} for the field itself */ constructor(settings = {}) { super(); diff --git a/packages/melonjs/tests/audio.spec.js b/packages/melonjs/tests/audio.spec.js index 74c705a9b..e2f3dedf9 100644 --- a/packages/melonjs/tests/audio.spec.js +++ b/packages/melonjs/tests/audio.spec.js @@ -197,6 +197,76 @@ describe("audio", () => { }).not.toThrow(); }); + it("delay schedules the start on the audio clock, not a timer", () => { + const ctx = audio.getAudioContext(); + if (ctx === null) { + // documented contract with no WebAudio: a silent no-op + expect(() => { + return audio.tone({ freq: 440, duration: 0.05, delay: 0.25 }); + }).not.toThrow(); + return; + } + + // capture what time each oscillator is actually told to start + const starts = []; + const create = ctx.createOscillator.bind(ctx); + ctx.createOscillator = () => { + const osc = create(); + const start = osc.start.bind(osc); + osc.start = (when) => { + starts.push(when); + return start(when); + }; + return osc; + }; + + try { + const before = ctx.currentTime; + audio.tone({ freq: 440, duration: 0.05 }); + audio.tone({ freq: 440, duration: 0.05, delay: 0.25 }); + expect(starts).toHaveLength(2); + // BACKWARD COMPATIBILITY: omitting `delay` still starts now. + // The option is additive — a call that does not mention it + // schedules exactly where it always did. + expect(starts[0] - before).toBeGreaterThanOrEqual(0); + expect(starts[0] - before).toBeLessThan(0.05); + // and the gap is the delay, measured on the context's own + // clock — the point of the option over `setTimeout` + expect(starts[1] - starts[0]).toBeCloseTo(0.25, 2); + } finally { + Reflect.deleteProperty(ctx, "createOscillator"); + } + }); + + it("a negative delay does not schedule in the past", () => { + const ctx = audio.getAudioContext(); + if (ctx === null) { + expect(() => { + return audio.noise({ duration: 0.05, delay: -5 }); + }).not.toThrow(); + return; + } + const starts = []; + const create = ctx.createBufferSource.bind(ctx); + ctx.createBufferSource = () => { + const src = create(); + const start = src.start.bind(src); + src.start = (when) => { + starts.push(when); + return start(when); + }; + return src; + }; + try { + const before = ctx.currentTime; + audio.noise({ duration: 0.05, delay: -5 }); + expect(starts).toHaveLength(1); + expect(starts[0]).toBeGreaterThanOrEqual(before); + } finally { + Reflect.deleteProperty(ctx, "createBufferSource"); + } + }); + it("tone clamps pan to [-1, 1]", () => { // Out-of-range pan should be clamped internally, no throw. expect(() => { diff --git a/packages/melonjs/tests/text-pixel-snapping.spec.js b/packages/melonjs/tests/text-pixel-snapping.spec.js new file mode 100644 index 000000000..27a88d099 --- /dev/null +++ b/packages/melonjs/tests/text-pixel-snapping.spec.js @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Text } from "../src/index.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; + +/** + * The two halves of a `Text`'s position, and the rule that keeps them apart. + * + * A label is a canvas blitted at `metrics.x/y` with its glyphs baked at + * `pos - metrics` INSIDE that canvas. `metrics` is floored, so the canvas + * always lands on a whole pixel — a texture blitted at a fractional coordinate + * resamples and goes soft — and the fraction the floor drops is handed to the + * bake instead, where the font rasterizer antialiases it properly. The two must + * always sum back to `pos`. + * + * `metrics.x/y` reads `pos`, but nothing else in `measureText` does, so it used + * to be refreshed only when the STRING changed. A label that moved kept the + * origin it was born with: the bake offset grew by the whole distance travelled + * while the canvas stayed its original size, so the glyphs slid off their own + * canvas and were clipped. `TextMetrics#updateOrigin` is the split-out part the + * draw path now refreshes every frame. + */ +describe("Text — pixel snapping and a label that moves", () => { + // borrow the session's shared renderer: every test here needs Text to be + // constructible, and the draw-path test needs something to draw into + let renderer; + + beforeAll(async () => { + renderer = await getWebGLRenderer(); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const makeText = (x, y, settings = {}) => { + return new Text(x, y, { + font: "Arial", + size: 16, + fillStyle: "#ffffff", + text: "Hello", + ...settings, + }); + }; + + /** where the glyphs sit inside their canvas */ + const bakeOffset = (t) => { + return { x: t.pos.x - t.metrics.x, y: t.pos.y - t.metrics.y }; + }; + + it("lands the canvas on a whole pixel, whatever the position", (ctx) => { + requireWebGL(ctx, renderer); + for (const [x, y] of [ + [0, 0], + [10.5, 20.25], + [0.999, 0.001], + [-3.75, -8.5], + ]) { + const t = makeText(x, y); + t.metrics.updateOrigin(); + expect(Number.isInteger(t.metrics.x), `x at ${x}`).toBe(true); + expect(Number.isInteger(t.metrics.y), `y at ${y}`).toBe(true); + } + }); + + it("hands the dropped sub-pixel to the bake, not to the blit", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10.25, 20.75); + t.metrics.updateOrigin(); + + // default align/baseline is left/top, so the glyph origin is the box + // origin and the whole remainder is the fraction the floor dropped + const offset = bakeOffset(t); + expect(offset.x).toBeCloseTo(0.25, 10); + expect(offset.y).toBeCloseTo(0.75, 10); + + // and the two halves still sum back to where the caller put the label + expect(t.metrics.x + offset.x).toBeCloseTo(10.25, 10); + expect(t.metrics.y + offset.y).toBeCloseTo(20.75, 10); + }); + + it("keeps the glyph phase identical across a whole-pixel move", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10.25, 20.75); + t.metrics.updateOrigin(); + const before = bakeOffset(t); + const originX = t.metrics.x; + + t.pos.x += 40; + t.pos.y += 7; + t.metrics.updateOrigin(); + + // the canvas moved by exactly the distance travelled... + expect(t.metrics.x).toBe(originX + 40); + // ...and the glyphs did not move inside it at all, so the rasterized + // pixels are reusable rather than merely close + const after = bakeOffset(t); + expect(after.x).toBeCloseTo(before.x, 10); + expect(after.y).toBeCloseTo(before.y, 10); + }); + + it("absorbs a sub-pixel move into the phase, not the canvas", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10, 20); + t.metrics.updateOrigin(); + const originX = t.metrics.x; + + t.pos.x += 0.4; + t.metrics.updateOrigin(); + + // the canvas stays put — a 0.4px blit would resample the whole texture + expect(t.metrics.x).toBe(originX); + // the shift is carried by the bake instead + expect(bakeOffset(t).x).toBeCloseTo(0.4, 10); + }); + + it("follows a moved label instead of keeping its birth origin", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10, 10); + const born = t.metrics.x; + + // move it a long way WITHOUT re-setting the text — the case that used + // to slide the glyphs off their own canvas + t.pos.x = 400; + t.metrics.updateOrigin(); + + expect(t.metrics.x).toBe(born + 390); + // the bake offset must stay sub-pixel: it is what used to grow by the + // whole 390px and push the glyphs past the canvas edge + expect(Math.abs(bakeOffset(t).x)).toBeLessThan(1); + }); + + it("refreshes the origin from the DRAW path, not only from setText", (ctx) => { + requireWebGL(ctx, renderer); + const t = makeText(10, 10); + const born = t.metrics.x; + + // move it and draw, without touching the text. Every other test here + // calls `updateOrigin` itself, so this is the one that fails if the + // draw path stops asking for it — which is the whole fix. + t.pos.x = 120; + t.draw(renderer); + + expect(t.metrics.x).toBe(born + 110); + expect(Math.abs(t.pos.x - t.metrics.x)).toBeLessThan(1); + }); + + it("snaps under every textAlign", (ctx) => { + requireWebGL(ctx, renderer); + for (const textAlign of ["left", "center", "right"]) { + const t = makeText(30.5, 10, { textAlign }); + t.metrics.updateOrigin(); + + expect(Number.isInteger(t.metrics.x), textAlign).toBe(true); + + // the glyph origin differs per alignment, but the canvas is always + // within a pixel below the point the alignment asks for + const anchored = + textAlign === "right" + ? 30.5 - t.metrics.width + : textAlign === "center" + ? 30.5 - t.metrics.width / 2 + : 30.5; + expect(t.metrics.x, textAlign).toBe(Math.floor(anchored)); + } + }); + + it("snaps under every textBaseline", (ctx) => { + requireWebGL(ctx, renderer); + for (const textBaseline of [ + "top", + "hanging", + "middle", + "alphabetic", + "ideographic", + "bottom", + ]) { + const t = makeText(10, 30.5, { textBaseline }); + t.metrics.updateOrigin(); + + expect(Number.isInteger(t.metrics.y), textBaseline).toBe(true); + + const line = t.metrics.lineHeight(); + const anchored = + textBaseline === "top" || textBaseline === "hanging" + ? 30.5 + : textBaseline === "middle" + ? 30.5 - line / 2 + : 30.5 - line; + expect(t.metrics.y, textBaseline).toBe(Math.floor(anchored)); + } + }); +}); diff --git a/packages/melonjs/tests/text_bucket.spec.js b/packages/melonjs/tests/text_bucket.spec.js index 1083f7179..e31bd13bc 100644 --- a/packages/melonjs/tests/text_bucket.spec.js +++ b/packages/melonjs/tests/text_bucket.spec.js @@ -13,6 +13,11 @@ import { * only happens across bucket boundaries, and the canvas never shrinks * (the pre-existing grow-only rule). Replaces the power-of-two rounding, * whose waste was multiplicative instead of ≤31px per axis. + * + * Height is bucketed from the layout box PLUS the ink padding, not from the + * layout box alone — the padding is canvas the glyphs genuinely occupy, so + * measuring waste against `metrics.height` would call it waste and would go + * red the moment the padding crossed a bucket boundary. */ describe("Text — 32px canvas buckets", () => { // borrow the session's single shared renderer — specs must never boot @@ -43,6 +48,12 @@ describe("Text — 32px canvas buckets", () => { return Math.ceil(n / 32) * 32; }; + // everything the canvas has to hold: the layout box and the ink that + // escapes it top and bottom + const baked = (t) => { + return t.metrics.height + t.metrics.inkPadTop + t.metrics.inkPadBottom; + }; + it("the canvas lands exactly on the metric's 32px bucket (no power-of-two jumps)", (ctx) => { requireWebGL(ctx, renderer); const t = makeText("Hello World"); @@ -50,10 +61,10 @@ describe("Text — 32px canvas buckets", () => { expect(c.width % 32).toBe(0); expect(c.height % 32).toBe(0); expect(c.width).toBe(bucket(t.metrics.width)); - expect(c.height).toBe(bucket(t.metrics.height)); + expect(c.height).toBe(bucket(baked(t))); // waste is bounded additively — the whole point of the change expect(c.width - t.metrics.width).toBeLessThan(32); - expect(c.height - t.metrics.height).toBeLessThan(32); + expect(c.height - baked(t)).toBeLessThan(32); }); it("property sweep: every string's canvas is bucket-exact and minimal", (ctx) => { @@ -80,7 +91,10 @@ describe("Text — 32px canvas buckets", () => { expect(c.height % 32, str).toBe(0); expect(c.width, str).toBeGreaterThanOrEqual(Math.ceil(t.metrics.width)); expect(c.width - t.metrics.width, str).toBeLessThan(32); - expect(c.height - t.metrics.height, str).toBeLessThan(32); + // the canvas must FIT the padded box — one pixel short of this is + // the top row of a stroke sheared off + expect(c.height, str).toBeGreaterThanOrEqual(Math.ceil(baked(t))); + expect(c.height - baked(t), str).toBeLessThan(32); } });