diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index c90d4c936..a93502e12 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -20,6 +20,11 @@ - **SVG `A`/`H`/`V` commands opening a new sub-path drew from the wrong origin** — the arc (`A`), horizontal-line (`H`) and vertical-line (`V`) parsers used the previous sub-path's last point as the current pen position instead of the point set by the preceding `M`. A circular hole authored with arcs (e.g. a donut) therefore degenerated into a spiral with a radial slit. They now track the pen position correctly. Latent since these commands were added; surfaced by the new multi-sub-path holes support (#1253). - **2D sprites could silently vanish when using a large `pos.z` sort key** (regression since 19.7) — since the depth pipeline landed, a sprite's `depth`/`pos.z` is written into the vertex `z` and participates in clip-space. Under a 2D orthographic camera `pos.z` is only a painter's sort key (the world is CPU-sorted on it), so a large value — a `baseZ + pos.y` Y-sort, or `Container.autoDepth` on a big world — pushed the vertex past the camera far plane (`Camera2d.far`, 1e6) and the GPU clip-culled the sprite (it disappeared, or rendered incorrectly on some drivers). `Renderer.setDepth` now feeds depth into the vertex stream only under a perspective projection (`Camera3d`, which genuinely uses z for parallax/depth); orthographic cameras keep depth out of clip-space, so a sort key can never clip a sprite. `Camera3d` / mesh depth is unaffected. - **`Mesh.textureRepeat` leaked its wrap mode to every consumer of the same image** (#1503) — the setting was applied by mutating the per-image `TextureAtlas` shared by everything drawing that image, so two meshes (or a mesh and a sprite/pattern) pointing at one image with different wrap needs were last-writer-wins: the loser silently sampled with the wrong wrap. The wrap now lives on the mesh and is threaded to the batcher at draw time — sampler state per use, the same separation modern GPU APIs enforce — and the texture-unit cache's `(source, repeat)` keying gives each wrap its own GL texture, so per-mesh wraps coexist on one image. Also fixed alongside: unloading an image now frees the texture units of **all** its wrap-mode variants — previously only the unit matching the atlas's current `repeat` field was freed, pinning the rest until a full cache reset. +- **`new Color("darkgray")` threw, and several CSS color keywords were wrong or missing** — the named-color table's `darkgray`/`darkgrey` keys were pasted from a spec table *with* their footnote marker (`"darkgray[*]"`), so looking either name up missed the table and threw `invalid parameter`; `cyan`, `magenta` and `rebeccapurple` were missing entirely (same throw); and `silver`, `aliceblue` and `burlywood` carried single-digit channel typos (e.g. `silver` was `rgb(192,192,129)` instead of `rgb(192,192,192)`). The full 148-keyword table is now validated against the CSS spec by a test, so a paste error can never hide again. +- **`Matrix2d` 6-argument (canvas convention) form dropped translation and transposed rotation** — `new Matrix2d(a, b, c, d, e, f)` / `setTransform(a, b, c, d, e, f)` stored its components row-major into the column-major layout: `e`/`f` landed in the unused projective row (so `new Matrix2d(1, 0, 0, 1, x, y)` produced the identity with **no translation at all**) and any rotation came out inverted. The 9-argument form was unaffected. +- **`moveTowards()` never reached its target — and returned the target instead of `this`** (all four vector classes) — the arrival test compared the remaining distance against `step * step` (wrong units: snapped way too early for steps above 1, dithered around the target forever for fractional steps), and on "arrival" it returned the **caller's target vector** without moving `this` at all — so the vector never landed, and chaining onto the result silently mutated the target. It now lands exactly on the target and always returns `this`, per its own documentation; negative-step flee semantics are unchanged. +- **Every preloaded video was fetched with forced anonymous CORS** — the video parser unconditionally stamped the `crossorigin` attribute with the loader's `crossOrigin` setting, which defaults to `undefined` — and per the HTML spec an *invalid* value (the string `"undefined"`) maps to `anonymous`, unlike a *missing* attribute (no CORS). Cross-origin videos served without CORS headers failed to load outright when they would have played fine untainted. The attribute is now only set when `crossOrigin` is actually configured (the empty string remains a valid, honored value). +- **Video preloading hung forever on autoplay-restricted browsers (e.g. iOS Safari) unless `autoplay: false` was spelled out** — the parser waited for `canplay`, which those browsers never fire for a video that isn't allowed to play, and only an *explicit* `autoplay: false` opted into the reliable `loadedmetadata` path — so the common manifest shape (no `autoplay` key) stalled the whole preloader and the game never started. Preloading now completes on `loadedmetadata` unless `autoplay: true` explicitly asks to wait for `canplay`. ## [19.8.0] (melonJS 2) - _2026-06-26_ diff --git a/packages/melonjs/src/loader/parsers/video.js b/packages/melonjs/src/loader/parsers/video.js index 0fa1e2a77..162c57f99 100644 --- a/packages/melonjs/src/loader/parsers/video.js +++ b/packages/melonjs/src/loader/parsers/video.js @@ -60,7 +60,14 @@ export function preloadVideo(data, onload, onerror, settings) { videoElement.setAttribute("playsinline", "true"); videoElement.setAttribute("disablePictureInPicture", "true"); videoElement.setAttribute("controls", "false"); - videoElement.setAttribute("crossorigin", settings.crossOrigin); + // only stamp crossorigin when actually configured: per the HTML spec a + // MISSING attribute means no CORS, but an INVALID value (e.g. the string + // "undefined") maps to Anonymous — which force-enables CORS and breaks + // cross-origin sources served without CORS headers. Note "" is a valid + // value (anonymous), so guard on type, not truthiness. + if (typeof settings.crossOrigin === "string") { + videoElement.setAttribute("crossorigin", settings.crossOrigin); + } if (data.autoplay === true) { videoElement.setAttribute("autoplay", "true"); @@ -70,8 +77,14 @@ export function preloadVideo(data, onload, onerror, settings) { } if (typeof onload === "function") { - // some mobile browser (e.g. safari) won't emit the canplay event if autoplay is disabled - if (data.stream === true || data.autoplay === false) { + // some mobile browsers (e.g. safari) won't emit the canplay event + // unless the video actually plays — only wait for it when autoplay + // was explicitly requested; everywhere else (including the common + // case of autoplay simply omitted) loadedmetadata is the reliable + // "preloaded" signal, otherwise preload hangs forever on + // autoplay-restricted browsers. Streaming videos always use + // loadedmetadata — their preload="metadata" never buffers to canplay. + if (data.stream === true || data.autoplay !== true) { videoElement.onloadedmetadata = () => { if (typeof onload === "function") { onload(); diff --git a/packages/melonjs/src/math/color.ts b/packages/melonjs/src/math/color.ts index 95c4b81ed..4c0a4911b 100644 --- a/packages/melonjs/src/math/color.ts +++ b/packages/melonjs/src/math/color.ts @@ -43,7 +43,7 @@ const hex8Rx = const CSS_COLORS = [ // CSS1 ["black", [0, 0, 0]], - ["silver", [192, 192, 129]], + ["silver", [192, 192, 192]], ["gray", [128, 128, 128]], ["white", [255, 255, 255]], ["maroon", [128, 0, 0]], @@ -63,7 +63,7 @@ const CSS_COLORS = [ ["orange", [255, 165, 0]], // CSS3 - ["aliceblue", [240, 248, 245]], + ["aliceblue", [240, 248, 255]], ["antiquewhite", [250, 235, 215]], ["aquamarine", [127, 255, 212]], ["azure", [240, 255, 255]], @@ -72,7 +72,7 @@ const CSS_COLORS = [ ["blanchedalmond", [255, 235, 205]], ["blueviolet", [138, 43, 226]], ["brown", [165, 42, 42]], - ["burlywood", [222, 184, 35]], + ["burlywood", [222, 184, 135]], ["cadetblue", [95, 158, 160]], ["chartreuse", [127, 255, 0]], ["chocolate", [210, 105, 30]], @@ -80,12 +80,13 @@ const CSS_COLORS = [ ["cornflowerblue", [100, 149, 237]], ["cornsilk", [255, 248, 220]], ["crimson", [220, 20, 60]], + ["cyan", [0, 255, 255]], ["darkblue", [0, 0, 139]], ["darkcyan", [0, 139, 139]], ["darkgoldenrod", [184, 134, 11]], - ["darkgray[*]", [169, 169, 169]], + ["darkgray", [169, 169, 169]], ["darkgreen", [0, 100, 0]], - ["darkgrey[*]", [169, 169, 169]], + ["darkgrey", [169, 169, 169]], ["darkkhaki", [189, 183, 107]], ["darkmagenta", [139, 0, 139]], ["darkolivegreen", [85, 107, 47]], @@ -140,6 +141,7 @@ const CSS_COLORS = [ ["lightyellow", [255, 255, 224]], ["limegreen", [50, 205, 50]], ["linen", [250, 240, 230]], + ["magenta", [255, 0, 255]], ["mediumaquamarine", [102, 205, 170]], ["mediumblue", [0, 0, 205]], ["mediumorchid", [186, 85, 211]], @@ -191,6 +193,9 @@ const CSS_COLORS = [ ["wheat", [245, 222, 179]], ["whitesmoke", [245, 245, 245]], ["yellowgreen", [154, 205, 50]], + + // CSS4 + ["rebeccapurple", [102, 51, 153]], ] as const; type ColorName = (typeof CSS_COLORS)[number][0]; diff --git a/packages/melonjs/src/math/matrix2d.ts b/packages/melonjs/src/math/matrix2d.ts index 0291df51a..f48da780d 100644 --- a/packages/melonjs/src/math/matrix2d.ts +++ b/packages/melonjs/src/math/matrix2d.ts @@ -93,15 +93,18 @@ export class Matrix2d { this.val[7] = values[7]; // h - m12 this.val[8] = values[8]; // i - m22 } else if (values.length === 6) { - this.val[0] = values[0]; // a - this.val[1] = values[2]; // c - this.val[2] = values[4]; // e - this.val[3] = values[1]; // b - this.val[4] = values[3]; // d - this.val[5] = values[5]; // f - this.val[6] = 0; // g - this.val[7] = 0; // h - this.val[8] = 1; // i + // canvas-style (a, b, c, d, e, f): (a, b) is the first column + // (m00/m10), (c, d) the second (m01/m11), (e, f) the translation + // (m02/m12 — the tx/ty getters read val[6]/val[7]) + this.val[0] = values[0]; // a - m00 + this.val[1] = values[1]; // b - m10 + this.val[2] = 0; // m20 + this.val[3] = values[2]; // c - m01 + this.val[4] = values[3]; // d - m11 + this.val[5] = 0; // m21 + this.val[6] = values[4]; // e - m02 (tx) + this.val[7] = values[5]; // f - m12 (ty) + this.val[8] = 1; // m22 } return this; diff --git a/packages/melonjs/src/math/observableVector2d.ts b/packages/melonjs/src/math/observableVector2d.ts index 7f2432fc9..ae66a9ebf 100644 --- a/packages/melonjs/src/math/observableVector2d.ts +++ b/packages/melonjs/src/math/observableVector2d.ts @@ -440,14 +440,17 @@ export class ObservableVector2d { * @returns Reference to this object for method chaining */ moveTowards(target: Vector2d | ObservableVector2d, step: number) { - const angle = Math.atan2(target.y - this.y, target.x - this.x); - const distance = this.distance(target); - if (distance === 0 || (step >= 0 && distance <= step * step)) { - return target; + // within one step (and not fleeing): land exactly on the target — + // and always mutate/return `this` (via set(), so observers fire), + // never the caller's target + if (distance === 0 || (step >= 0 && distance <= step)) { + this.set(target.x, target.y); + return this; } + const angle = Math.atan2(target.y - this.y, target.x - this.x); this.set(this.x + Math.cos(angle) * step, this.y + Math.sin(angle) * step); return this; diff --git a/packages/melonjs/src/math/observableVector3d.ts b/packages/melonjs/src/math/observableVector3d.ts index 1ddbe027d..4db95e9d9 100644 --- a/packages/melonjs/src/math/observableVector3d.ts +++ b/packages/melonjs/src/math/observableVector3d.ts @@ -505,17 +505,20 @@ export class ObservableVector3d { * @returns Reference to this object for method chaining */ moveTowards(target: Vector2d | Vector3d | ObservableVector3d, step: number) { - const angle = Math.atan2(target.y - this.y, target.x - this.x); - - const dx = this.x - target.x; - const dy = this.y - target.y; + const dx = target.x - this.x; + const dy = target.y - this.y; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance === 0 || (step >= 0 && distance <= step * step)) { - return target; + // within one step (and not fleeing): land exactly on the target (x/y + // only, this is a 2D interpolation) — and always mutate/return `this` + // (via set(), so observers fire), never the caller's target + if (distance === 0 || (step >= 0 && distance <= step)) { + this.set(target.x, target.y, this.z); + return this; } + const angle = Math.atan2(dy, dx); this.set( this.x + Math.cos(angle) * step, this.y + Math.sin(angle) * step, diff --git a/packages/melonjs/src/math/vector2d.ts b/packages/melonjs/src/math/vector2d.ts index d7476751a..eca33c471 100644 --- a/packages/melonjs/src/math/vector2d.ts +++ b/packages/melonjs/src/math/vector2d.ts @@ -364,14 +364,17 @@ export class Vector2d { * @returns Reference to this object for method chaining */ moveTowards(target: Vector2d, step: number) { - const angle = Math.atan2(target.y - this.y, target.x - this.x); - const distance = this.distance(target); - if (distance === 0 || (step >= 0 && distance <= step * step)) { - return target; + // within one step (and not fleeing): land exactly on the target — + // and always mutate/return `this`, never the caller's target + if (distance === 0 || (step >= 0 && distance <= step)) { + this.x = target.x; + this.y = target.y; + return this; } + const angle = Math.atan2(target.y - this.y, target.x - this.x); this.x += Math.cos(angle) * step; this.y += Math.sin(angle) * step; diff --git a/packages/melonjs/src/math/vector3d.ts b/packages/melonjs/src/math/vector3d.ts index e7ec0965b..d276cc98d 100644 --- a/packages/melonjs/src/math/vector3d.ts +++ b/packages/melonjs/src/math/vector3d.ts @@ -415,17 +415,21 @@ export class Vector3d { * @returns Reference to this object for method chaining */ moveTowards(target: Vector2d | Vector3d, step: number) { - const angle = Math.atan2(target.y - this.y, target.x - this.x); - - const dx = this.x - target.x; - const dy = this.y - target.y; + const dx = target.x - this.x; + const dy = target.y - this.y; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance === 0 || (step >= 0 && distance <= step * step)) { - return target; + // within one step (and not fleeing): land exactly on the target (x/y + // only, this is a 2D interpolation) — and always mutate/return `this`, + // never the caller's target + if (distance === 0 || (step >= 0 && distance <= step)) { + this.x = target.x; + this.y = target.y; + return this; } + const angle = Math.atan2(dy, dx); this.x += Math.cos(angle) * step; this.y += Math.sin(angle) * step; diff --git a/packages/melonjs/tests/css-colors.spec.ts b/packages/melonjs/tests/css-colors.spec.ts new file mode 100644 index 000000000..a8be357ad --- /dev/null +++ b/packages/melonjs/tests/css-colors.spec.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import { Color } from "../src/index.js"; + +/** + * Validates the engine's CSS named-color table against the full CSS Color + * Module keyword list (147 extended keywords + rebeccapurple). + * + * Written failing-first against three table defects found in a code audit: + * - "darkgray" / "darkgrey" keys were pasted from a spec table WITH their + * footnote marker ("darkgray[*]"), so looking either name up missed the + * table and fell through parseRGB → parseHex → throw. + * - silver / aliceblue / burlywood carried single-digit typos in one channel. + * - cyan, magenta and rebeccapurple were missing entirely (same throw). + */ + +// [keyword, spec hex] — the canonical list; expected RGB is derived via +// parseHex so this spec never hand-transcribes channel values. +const CSS_COLOR_KEYWORDS: [string, string][] = [ + ["aliceblue", "#F0F8FF"], + ["antiquewhite", "#FAEBD7"], + ["aqua", "#00FFFF"], + ["aquamarine", "#7FFFD4"], + ["azure", "#F0FFFF"], + ["beige", "#F5F5DC"], + ["bisque", "#FFE4C4"], + ["black", "#000000"], + ["blanchedalmond", "#FFEBCD"], + ["blue", "#0000FF"], + ["blueviolet", "#8A2BE2"], + ["brown", "#A52A2A"], + ["burlywood", "#DEB887"], + ["cadetblue", "#5F9EA0"], + ["chartreuse", "#7FFF00"], + ["chocolate", "#D2691E"], + ["coral", "#FF7F50"], + ["cornflowerblue", "#6495ED"], + ["cornsilk", "#FFF8DC"], + ["crimson", "#DC143C"], + ["cyan", "#00FFFF"], + ["darkblue", "#00008B"], + ["darkcyan", "#008B8B"], + ["darkgoldenrod", "#B8860B"], + ["darkgray", "#A9A9A9"], + ["darkgreen", "#006400"], + ["darkgrey", "#A9A9A9"], + ["darkkhaki", "#BDB76B"], + ["darkmagenta", "#8B008B"], + ["darkolivegreen", "#556B2F"], + ["darkorange", "#FF8C00"], + ["darkorchid", "#9932CC"], + ["darkred", "#8B0000"], + ["darksalmon", "#E9967A"], + ["darkseagreen", "#8FBC8F"], + ["darkslateblue", "#483D8B"], + ["darkslategray", "#2F4F4F"], + ["darkslategrey", "#2F4F4F"], + ["darkturquoise", "#00CED1"], + ["darkviolet", "#9400D3"], + ["deeppink", "#FF1493"], + ["deepskyblue", "#00BFFF"], + ["dimgray", "#696969"], + ["dimgrey", "#696969"], + ["dodgerblue", "#1E90FF"], + ["firebrick", "#B22222"], + ["floralwhite", "#FFFAF0"], + ["forestgreen", "#228B22"], + ["fuchsia", "#FF00FF"], + ["gainsboro", "#DCDCDC"], + ["ghostwhite", "#F8F8FF"], + ["gold", "#FFD700"], + ["goldenrod", "#DAA520"], + ["gray", "#808080"], + ["green", "#008000"], + ["greenyellow", "#ADFF2F"], + ["grey", "#808080"], + ["honeydew", "#F0FFF0"], + ["hotpink", "#FF69B4"], + ["indianred", "#CD5C5C"], + ["indigo", "#4B0082"], + ["ivory", "#FFFFF0"], + ["khaki", "#F0E68C"], + ["lavender", "#E6E6FA"], + ["lavenderblush", "#FFF0F5"], + ["lawngreen", "#7CFC00"], + ["lemonchiffon", "#FFFACD"], + ["lightblue", "#ADD8E6"], + ["lightcoral", "#F08080"], + ["lightcyan", "#E0FFFF"], + ["lightgoldenrodyellow", "#FAFAD2"], + ["lightgray", "#D3D3D3"], + ["lightgreen", "#90EE90"], + ["lightgrey", "#D3D3D3"], + ["lightpink", "#FFB6C1"], + ["lightsalmon", "#FFA07A"], + ["lightseagreen", "#20B2AA"], + ["lightskyblue", "#87CEFA"], + ["lightslategray", "#778899"], + ["lightslategrey", "#778899"], + ["lightsteelblue", "#B0C4DE"], + ["lightyellow", "#FFFFE0"], + ["lime", "#00FF00"], + ["limegreen", "#32CD32"], + ["linen", "#FAF0E6"], + ["magenta", "#FF00FF"], + ["maroon", "#800000"], + ["mediumaquamarine", "#66CDAA"], + ["mediumblue", "#0000CD"], + ["mediumorchid", "#BA55D3"], + ["mediumpurple", "#9370DB"], + ["mediumseagreen", "#3CB371"], + ["mediumslateblue", "#7B68EE"], + ["mediumspringgreen", "#00FA9A"], + ["mediumturquoise", "#48D1CC"], + ["mediumvioletred", "#C71585"], + ["midnightblue", "#191970"], + ["mintcream", "#F5FFFA"], + ["mistyrose", "#FFE4E1"], + ["moccasin", "#FFE4B5"], + ["navajowhite", "#FFDEAD"], + ["navy", "#000080"], + ["oldlace", "#FDF5E6"], + ["olive", "#808000"], + ["olivedrab", "#6B8E23"], + ["orange", "#FFA500"], + ["orangered", "#FF4500"], + ["orchid", "#DA70D6"], + ["palegoldenrod", "#EEE8AA"], + ["palegreen", "#98FB98"], + ["paleturquoise", "#AFEEEE"], + ["palevioletred", "#DB7093"], + ["papayawhip", "#FFEFD5"], + ["peachpuff", "#FFDAB9"], + ["peru", "#CD853F"], + ["pink", "#FFC0CB"], + ["plum", "#DDA0DD"], + ["powderblue", "#B0E0E6"], + ["purple", "#800080"], + ["rebeccapurple", "#663399"], + ["red", "#FF0000"], + ["rosybrown", "#BC8F8F"], + ["royalblue", "#4169E1"], + ["saddlebrown", "#8B4513"], + ["salmon", "#FA8072"], + ["sandybrown", "#F4A460"], + ["seagreen", "#2E8B57"], + ["seashell", "#FFF5EE"], + ["sienna", "#A0522D"], + ["silver", "#C0C0C0"], + ["skyblue", "#87CEEB"], + ["slateblue", "#6A5ACD"], + ["slategray", "#708090"], + ["slategrey", "#708090"], + ["snow", "#FFFAFA"], + ["springgreen", "#00FF7F"], + ["steelblue", "#4682B4"], + ["tan", "#D2B48C"], + ["teal", "#008080"], + ["thistle", "#D8BFD8"], + ["tomato", "#FF6347"], + ["turquoise", "#40E0D0"], + ["violet", "#EE82EE"], + ["wheat", "#F5DEB3"], + ["white", "#FFFFFF"], + ["whitesmoke", "#F5F5F5"], + ["yellow", "#FFFF00"], + ["yellowgreen", "#9ACD32"], +]; + +describe("CSS named colors (full keyword table)", () => { + it("parses every CSS color keyword to its spec value", () => { + const wrong: string[] = []; + for (const [name, hex] of CSS_COLOR_KEYWORDS) { + const expected = new Color().parseHex(hex as `#${string}`); + let actual: Color; + try { + actual = new Color().parseCSS(name); + } catch { + wrong.push(`${name}: throws (missing from the table)`); + continue; + } + if ( + actual.r !== expected.r || + actual.g !== expected.g || + actual.b !== expected.b + ) { + wrong.push( + `${name}: got rgb(${actual.r},${actual.g},${actual.b}), expected rgb(${expected.r},${expected.g},${expected.b}) (${hex})`, + ); + } + } + expect(wrong).toEqual([]); + }); + + it('parses "darkgray" and "darkgrey" (keys carried a pasted "[*]" footnote marker)', () => { + for (const name of ["darkgray", "darkgrey"]) { + const c = new Color().parseCSS(name); + expect([c.r, c.g, c.b]).toEqual([169, 169, 169]); + } + }); + + it("silver / aliceblue / burlywood match the spec (single-digit channel typos)", () => { + const c1 = new Color().parseCSS("silver"); + expect([c1.r, c1.g, c1.b]).toEqual([192, 192, 192]); + const c2 = new Color().parseCSS("aliceblue"); + expect([c2.r, c2.g, c2.b]).toEqual([240, 248, 255]); + const c3 = new Color().parseCSS("burlywood"); + expect([c3.r, c3.g, c3.b]).toEqual([222, 184, 135]); + }); + + it('supports "cyan", "magenta" and "rebeccapurple"', () => { + const cyan = new Color().parseCSS("cyan"); + expect([cyan.r, cyan.g, cyan.b]).toEqual([0, 255, 255]); + const magenta = new Color().parseCSS("magenta"); + expect([magenta.r, magenta.g, magenta.b]).toEqual([255, 0, 255]); + const rebecca = new Color().parseCSS("rebeccapurple"); + expect([rebecca.r, rebecca.g, rebecca.b]).toEqual([102, 51, 153]); + }); +}); diff --git a/packages/melonjs/tests/matrix2d-settransform.spec.ts b/packages/melonjs/tests/matrix2d-settransform.spec.ts new file mode 100644 index 000000000..c377c3a8c --- /dev/null +++ b/packages/melonjs/tests/matrix2d-settransform.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { Matrix2d, Vector2d } from "../src/index.js"; + +/** + * Matrix2d stores column-major (val[0..2] = first column m00/m10/m20; + * translation at val[6]/val[7] — see the tx/ty getters and apply()). + * + * Written failing-first: the 6-argument canvas-convention form + * (a, b, c, d, e, f) stored its components ROW-major into that column-major + * layout — rotation came out transposed (inverted) and e/f landed in the + * unused projective row, so translation was silently dropped: + * new Matrix2d(1, 0, 0, 1, x, y) produced the identity with NO translation. + */ +describe("Matrix2d 6-argument (canvas convention) setTransform", () => { + it("keeps translation — e and f map to tx/ty", () => { + const m = new Matrix2d(1, 0, 0, 1, 10, 20); + expect(m.tx).toEqual(10); + expect(m.ty).toEqual(20); + }); + + it("applies rotation with canvas semantics, not transposed", () => { + // 90° CCW (a=cos=0, b=sin=1, c=-sin=-1, d=cos=0) + translate(5, 7): + // (1, 0) rotates to (0, 1), then translates to (5, 8) + const m = new Matrix2d().setTransform(0, 1, -1, 0, 5, 7); + const v = m.apply(new Vector2d(1, 0)); + expect(v.x).toBeCloseTo(5, 10); + expect(v.y).toBeCloseTo(8, 10); + }); + + it("matches the equivalent 9-argument column-major form", () => { + const six = new Matrix2d().setTransform(2, 0.5, -0.5, 3, 11, 13); + const nine = new Matrix2d().setTransform(2, 0.5, 0, -0.5, 3, 0, 11, 13, 1); + expect(Array.from(six.val)).toEqual(Array.from(nine.val)); + }); +}); diff --git a/packages/melonjs/tests/vector-movetowards.spec.ts b/packages/melonjs/tests/vector-movetowards.spec.ts new file mode 100644 index 000000000..ec9475975 --- /dev/null +++ b/packages/melonjs/tests/vector-movetowards.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + ObservableVector2d, + ObservableVector3d, + Vector2d, + Vector3d, +} from "../src/index.js"; + +/** + * moveTowards() contract, per its own JSDoc: interpolate on the x and y axis + * towards the target by at most `step` per call, returning "Reference to this + * object for method chaining"; negative steps push away from the target. + * + * Written failing-first against two bugs shared by all four vector classes: + * - the arrival test compared the remaining distance against `step * step` + * (wrong units — snapped way too early for step > 1, and never converged + * for fractional steps), and + * - on "arrival" it returned the TARGET vector without moving `this` at all, + * so the vector never actually reached the goal and chained calls mutated + * the caller's target object. + */ + +type VecLike = { + x: number; + y: number; + moveTowards(target: VecLike, step: number): VecLike; +}; + +const impls: [string, (x: number, y: number) => VecLike][] = [ + [ + "Vector2d", + (x, y) => { + return new Vector2d(x, y); + }, + ], + [ + "Vector3d", + (x, y) => { + return new Vector3d(x, y, 0); + }, + ], + [ + "ObservableVector2d", + (x, y) => { + return new ObservableVector2d(x, y); + }, + ], + [ + "ObservableVector3d", + (x, y) => { + return new ObservableVector3d(x, y, 0); + }, + ], +]; + +describe.each(impls)("%s.moveTowards", (_name, vec) => { + it("steps by exactly `step` toward the target", () => { + const v = vec(0, 0); + const target = vec(10, 0); + const returned = v.moveTowards(target, 3); + expect(returned).toBe(v); + expect(v.x).toBeCloseTo(3, 10); + expect(v.y).toBeCloseTo(0, 10); + }); + + it("lands exactly on the target when within `step`, returning `this` (not the target)", () => { + const v = vec(9, 0); + const target = vec(10, 0); + const returned = v.moveTowards(target, 5); + // pre-fix: returned `target` and left `v` untouched at (9, 0) + expect(returned).toBe(v); + expect(v.x).toBeCloseTo(10, 10); + expect(v.y).toBeCloseTo(0, 10); + // the caller's target must never be the mutation surface + expect(target.x).toBeCloseTo(10, 10); + }); + + it("converges with a fractional step (step*step threshold dithered forever)", () => { + const v = vec(0, 0); + const target = vec(1, 0); + // pre-fix: with step 0.4 the vector oscillated 0.2 either side of the + // target forever (threshold 0.16 never satisfied); 10 iterations is + // more than enough for correct code (3 steps) + for (let i = 0; i < 10 && v.x !== target.x; i++) { + v.moveTowards(target, 0.4); + } + expect(v.x).toBeCloseTo(1, 10); + expect(v.y).toBeCloseTo(0, 10); + }); + + it("a negative step pushes away from the target (documented flee semantics)", () => { + const v = vec(9, 0); + const returned = v.moveTowards(vec(10, 0), -2); + expect(returned).toBe(v); + expect(v.x).toBeCloseTo(7, 10); + }); +}); diff --git a/packages/melonjs/tests/video-parser.spec.js b/packages/melonjs/tests/video-parser.spec.js new file mode 100644 index 000000000..30dffc84c --- /dev/null +++ b/packages/melonjs/tests/video-parser.spec.js @@ -0,0 +1,98 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { videoList } from "../src/loader/cache.js"; +import { preloadVideo } from "../src/loader/parsers/video.js"; + +/** + * Video preloading wiring, written failing-first against two bugs: + * + * - crossorigin: the parser unconditionally called + * setAttribute("crossorigin", settings.crossOrigin) — with the loader + * default (undefined) that stamps the string "undefined", and per the HTML + * spec an INVALID enumerated value maps to Anonymous (missing maps to + * no-CORS). Every video was therefore fetched with forced anonymous CORS, + * breaking cross-origin sources served without CORS headers. + * + * - completion event: the parser's own comment notes Safari/mobile never + * fires "canplay" when autoplay is disabled, but the guard routed only an + * EXPLICIT `autoplay: false` to loadedmetadata. The common manifest shape + * (autoplay omitted) still waited on canplay → preload hung forever on + * those browsers and the game never started. + */ + +// self-contained inline source — no fixture file, no 404 fetch from the +// browser runner. Enough for the parser's MIME sniff + canPlayType gate +// (isDataUrl requires a non-empty base64 payload — this one is the WebM/EBML +// magic bytes); the payload never needs to fully decode because these tests +// assert WIRING, not playback — a no-op onerror swallows any media error. +const WEBM_DATA_URL = "data:video/webm;base64,GkXf"; +const noop = () => {}; + +describe("video parser wiring", () => { + afterAll(() => { + for (const name of Object.keys(videoList)) { + if (name.startsWith("vp-")) { + videoList[name].removeAttribute("src"); + delete videoList[name]; + } + } + }); + + it("does not set a crossorigin attribute when crossOrigin is not configured", () => { + preloadVideo( + { name: "vp-default", type: "video", src: WEBM_DATA_URL }, + undefined, + noop, + {}, + ); + // the string "undefined" would map to Anonymous (forced CORS) + expect(videoList["vp-default"].getAttribute("crossorigin")).toBe(null); + }); + + it("honors an explicitly configured crossOrigin, including the empty string", () => { + preloadVideo( + { name: "vp-cors", type: "video", src: WEBM_DATA_URL }, + undefined, + noop, + { crossOrigin: "anonymous" }, + ); + expect(videoList["vp-cors"].getAttribute("crossorigin")).toBe("anonymous"); + + // "" is a VALID value (maps to anonymous) and must not be dropped + preloadVideo( + { name: "vp-cors-empty", type: "video", src: WEBM_DATA_URL }, + undefined, + noop, + { crossOrigin: "" }, + ); + expect(videoList["vp-cors-empty"].getAttribute("crossorigin")).toBe(""); + }); + + it("completes on loadedmetadata when autoplay is omitted (Safari never fires canplay without autoplay)", () => { + preloadVideo( + { name: "vp-noauto", type: "video", src: WEBM_DATA_URL }, + () => {}, + noop, + {}, + ); + const el = videoList["vp-noauto"]; + expect(typeof el.onloadedmetadata).toBe("function"); + expect(el.oncanplay).toBe(null); + }); + + it("still waits for canplay when autoplay is explicitly requested", () => { + preloadVideo( + { + name: "vp-auto", + type: "video", + src: WEBM_DATA_URL, + autoplay: true, + }, + () => {}, + noop, + {}, + ); + const el = videoList["vp-auto"]; + expect(typeof el.oncanplay).toBe("function"); + expect(el.onloadedmetadata).toBe(null); + }); +});