diff --git a/README.md b/README.md
index 9dd8e5346..abaa6527f 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ Why melonJS
melonJS is designed so you can **focus on making games, not on graphics plumbing**.
-- **Canvas2D-inspired rendering API** — If you've used the HTML5 Canvas, you already know melonJS. The rendering API (`save`, `restore`, `translate`, `rotate`, `setColor`, `fillRect`, ...) follows the same familiar patterns — no render graphs, no shader pipelines, no instruction sets to learn.
+- **[Canvas2D-inspired rendering API](https://github.com/melonjs/melonJS/wiki/Rendering-API)** — If you've used the HTML5 Canvas, you already know melonJS. The rendering API (`save`, `restore`, `translate`, `rotate`, `setColor`, `fillRect`, ...) follows the same familiar patterns — no render graphs, no shader pipelines, no instruction sets to learn.
- **True renderer abstraction** — Write your game once, run it on WebGL or Canvas2D with zero code changes. The engine handles all GPU complexity behind a unified API, with automatic fallback when WebGL is not available. Designed to support future backends (WebGPU) without touching game code.
diff --git a/packages/examples/src/examples/gradients/ExampleGradients.tsx b/packages/examples/src/examples/gradients/ExampleGradients.tsx
new file mode 100644
index 000000000..b58cf014f
--- /dev/null
+++ b/packages/examples/src/examples/gradients/ExampleGradients.tsx
@@ -0,0 +1,230 @@
+import {
+ Application as App,
+ type Application,
+ type CanvasRenderer,
+ type Gradient,
+ Polygon,
+ Renderable,
+ Stage,
+ state,
+ type WebGLRenderer,
+} from "melonjs";
+import { createExampleComponent } from "../utils";
+
+type Renderer = CanvasRenderer | WebGLRenderer;
+
+/**
+ * A renderable that showcases linear and radial gradients.
+ * Gradients are created once and reused across frames.
+ */
+class GradientShowcase extends Renderable {
+ private sky!: Gradient;
+ private ground!: Gradient;
+ private sun!: Gradient;
+ private glow!: Gradient;
+ private health!: Gradient;
+ private mana!: Gradient;
+ private btn!: Gradient;
+ private circleGrad!: Gradient;
+ private pillGrad!: Gradient;
+ private rainbow!: Gradient;
+ private vig!: Gradient;
+ private star!: Polygon;
+
+ constructor() {
+ super(0, 0, 1024, 768);
+ this.anchorPoint.set(0, 0);
+ }
+
+ onActivateEvent() {
+ const w = this.width;
+ const h = this.height;
+ const renderer = this.parentApp.renderer;
+
+ // sky
+ this.sky = renderer.createLinearGradient(0, 0, 0, h * 0.6);
+ this.sky.addColorStop(0, "#0B0B3B");
+ this.sky.addColorStop(0.4, "#1a1a6e");
+ this.sky.addColorStop(0.7, "#4a2080");
+ this.sky.addColorStop(1, "#FF6B35");
+
+ // ground
+ this.ground = renderer.createLinearGradient(0, h * 0.6, 0, h);
+ this.ground.addColorStop(0, "#2d5016");
+ this.ground.addColorStop(0.5, "#1a3a0a");
+ this.ground.addColorStop(1, "#0d1f05");
+
+ // sun
+ const sunX = w * 0.75;
+ const sunY = h * 0.35;
+ const sunR = 60;
+ this.sun = renderer.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR);
+ this.sun.addColorStop(0, "#FFFFFF");
+ this.sun.addColorStop(0.3, "#FFEE88");
+ this.sun.addColorStop(0.7, "#FFAA33");
+ this.sun.addColorStop(1, "rgba(255, 100, 0, 0)");
+
+ // sun glow
+ const glowR = 150;
+ this.glow = renderer.createRadialGradient(
+ sunX,
+ sunY,
+ sunR * 0.5,
+ sunX,
+ sunY,
+ glowR,
+ );
+ this.glow.addColorStop(0, "rgba(255, 200, 100, 0.3)");
+ this.glow.addColorStop(1, "rgba(255, 100, 0, 0)");
+
+ // health bar
+ const barX = 30;
+ const barW = 200;
+ this.health = renderer.createLinearGradient(barX, 0, barX + barW, 0);
+ this.health.addColorStop(0, "#00FF00");
+ this.health.addColorStop(0.5, "#FFFF00");
+ this.health.addColorStop(1, "#FF0000");
+
+ // mana bar
+ this.mana = renderer.createLinearGradient(barX, 0, barX + barW, 0);
+ this.mana.addColorStop(0, "#0044FF");
+ this.mana.addColorStop(0.5, "#00BBFF");
+ this.mana.addColorStop(1, "#00FFFF");
+
+ // metallic button
+ const btnY = 110;
+ const btnH = 40;
+ this.btn = renderer.createLinearGradient(0, btnY, 0, btnY + btnH);
+ this.btn.addColorStop(0, "#EEEEEE");
+ this.btn.addColorStop(0.5, "#AAAAAA");
+ this.btn.addColorStop(0.51, "#888888");
+ this.btn.addColorStop(1, "#CCCCCC");
+
+ // circle
+ this.circleGrad = renderer.createRadialGradient(65, 190, 0, 65, 190, 25);
+ this.circleGrad.addColorStop(0, "#FF6600");
+ this.circleGrad.addColorStop(1, "#CC0000");
+
+ // pill
+ this.pillGrad = renderer.createLinearGradient(110, 0, 270, 0);
+ this.pillGrad.addColorStop(0, "#00CC88");
+ this.pillGrad.addColorStop(1, "#0066FF");
+
+ // rainbow star
+ const outerR = 45;
+ const innerR = 20;
+ const points = 5;
+ const starVerts: { x: number; y: number }[] = [];
+ for (let i = 0; i < points * 2; i++) {
+ const angle = (i * Math.PI) / points - Math.PI / 2;
+ const r = i % 2 === 0 ? outerR : innerR;
+ starVerts.push({ x: Math.cos(angle) * r, y: Math.sin(angle) * r });
+ }
+ this.star = new Polygon(330, 190, starVerts);
+ this.rainbow = renderer.createRadialGradient(0, 0, 0, 0, 0, outerR);
+ this.rainbow.addColorStop(0, "#FF0000");
+ this.rainbow.addColorStop(0.2, "#FF8800");
+ this.rainbow.addColorStop(0.4, "#FFFF00");
+ this.rainbow.addColorStop(0.6, "#00FF00");
+ this.rainbow.addColorStop(0.8, "#0088FF");
+ this.rainbow.addColorStop(1, "#8800FF");
+
+ // vignette
+ const vigR = Math.max(w, h) * 0.7;
+ this.vig = renderer.createRadialGradient(
+ w / 2,
+ h / 2,
+ vigR * 0.3,
+ w / 2,
+ h / 2,
+ vigR,
+ );
+ this.vig.addColorStop(0, "rgba(0, 0, 0, 0)");
+ this.vig.addColorStop(1, "rgba(0, 0, 0, 0.5)");
+ }
+
+ draw(renderer: Renderer) {
+ const w = this.width;
+ const h = this.height;
+ const sunX = w * 0.75;
+ const sunY = h * 0.35;
+ const sunR = 60;
+ const glowR = 150;
+ const barX = 30;
+ const barY = 30;
+ const barW = 200;
+ const barH = 24;
+ const healthPct = 0.7;
+ const manaY = barY + barH + 10;
+
+ // sky
+ renderer.setColor(this.sky);
+ renderer.fillRect(0, 0, w, h * 0.6);
+
+ // ground
+ renderer.setColor(this.ground);
+ renderer.fillRect(0, h * 0.6, w, h * 0.4);
+
+ // sun
+ renderer.setColor(this.sun);
+ renderer.fillRect(sunX - sunR, sunY - sunR, sunR * 2, sunR * 2);
+
+ // sun glow
+ renderer.setColor(this.glow);
+ renderer.fillRect(sunX - glowR, sunY - glowR, glowR * 2, glowR * 2);
+
+ // health bar
+ renderer.setColor("#333333");
+ renderer.fillRect(barX - 2, barY - 2, barW + 4, barH + 4);
+ renderer.setColor(this.health);
+ renderer.fillRect(barX, barY, barW * healthPct, barH);
+
+ // mana bar
+ renderer.setColor("#333333");
+ renderer.fillRect(barX - 2, manaY - 2, barW + 4, barH + 4);
+ renderer.setColor(this.mana);
+ renderer.fillRect(barX, manaY, barW * 0.5, barH);
+
+ // metallic button
+ renderer.setColor(this.btn);
+ renderer.fillRect(30, 110, 160, 40);
+ renderer.setColor("#666666");
+ renderer.strokeRect(30, 110, 160, 40);
+
+ // gradient shapes
+ renderer.setColor(this.circleGrad);
+ renderer.fillEllipse(65, 190, 25, 25);
+
+ renderer.setColor(this.pillGrad);
+ renderer.fillRoundRect(110, 175, 160, 30, 15);
+
+ renderer.setColor(this.rainbow);
+ renderer.fill(this.star);
+
+ // vignette
+ renderer.setColor(this.vig);
+ renderer.fillRect(0, 0, w, h);
+
+ renderer.setColor("#FFFFFF");
+ }
+}
+
+class GradientScreen extends Stage {
+ override onResetEvent(app: Application) {
+ app.world.backgroundColor.parseCSS("#000000");
+ app.world.addChild(new GradientShowcase());
+ }
+}
+
+const createGame = () => {
+ const _app = new App(1024, 768, {
+ parent: "screen",
+ scale: "auto",
+ scaleMethod: "flex-width",
+ });
+
+ state.set(state.PLAY, new GradientScreen());
+ state.change(state.PLAY);
+};
+
+export const ExampleGradients = createExampleComponent(createGame);
diff --git a/packages/examples/src/examples/graphics/ExampleGraphics.tsx b/packages/examples/src/examples/graphics/ExampleGraphics.tsx
index 89eeaac4a..6a7803458 100644
--- a/packages/examples/src/examples/graphics/ExampleGraphics.tsx
+++ b/packages/examples/src/examples/graphics/ExampleGraphics.tsx
@@ -205,12 +205,18 @@ const createGame = () => {
renderer.lineTo(840, 55);
renderer.lineTo(940, 30);
+ renderer.stroke();
+
+ // dashed zigzag line
+ renderer.setLineDash([10, 6]);
+ renderer.beginPath();
renderer.moveTo(540, 50);
renderer.lineTo(640, 75);
renderer.lineTo(740, 50);
renderer.lineTo(840, 75);
renderer.lineTo(940, 50);
renderer.stroke();
+ renderer.setLineDash([]);
renderer.setColor("#ff69b4");
renderer.fill(this.roundRect1);
diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx
index bb247e1c6..c9bb1b57e 100644
--- a/packages/examples/src/main.tsx
+++ b/packages/examples/src/main.tsx
@@ -48,6 +48,11 @@ const ExampleDragAndDrop = lazy(() =>
default: m.ExampleDragAndDrop,
})),
);
+const ExampleGradients = lazy(() =>
+ import("./examples/gradients/ExampleGradients").then((m) => ({
+ default: m.ExampleGradients,
+ })),
+);
const ExampleGraphics = lazy(() =>
import("./examples/graphics/ExampleGraphics").then((m) => ({
default: m.ExampleGraphics,
@@ -187,6 +192,14 @@ const examples: {
description:
"Interactive drag-and-drop with pointer events, collision detection, and drop zones.",
},
+ {
+ component: ,
+ label: "Gradients",
+ path: "gradients",
+ sourceDir: "gradients",
+ description:
+ "Linear and radial gradients for sky backgrounds, health bars, UI buttons, and lighting effects.",
+ },
{
component: ,
label: "Graphics",
diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index 9e75a793f..36dfc0ef1 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -3,6 +3,8 @@
## [18.3.0] (melonJS 2)
### Added
+- Renderer: `setLineDash()` and `getLineDash()` methods — set dash patterns for stroke operations, matching the Canvas 2D API. Works on both Canvas and WebGL renderers. Dash state is saved/restored with `save()`/`restore()`.
+- Renderer: `createLinearGradient()` and `createRadialGradient()` methods — create gradient fills that can be passed to `setColor()`, matching the Canvas 2D API. Works on both Canvas and WebGL renderers with all fill methods (`fillRect`, `fillEllipse`, `fillArc`, `fillPolygon`, `fillRoundRect`). Gradient state is saved/restored with `save()`/`restore()`.
- Tiled: extensible object factory registry for `TMXTileMap.getObjects()` — object creation is now dispatched through a `Map`-based registry (like `loader.setParser`), with built-in factories for text, tile, and shape objects, plus class-based factories for Entity, Collectable, Trigger, Light2d, Sprite, NineSliceSprite, ImageLayer, and ColorLayer
- Tiled: new public `registerTiledObjectFactory(type, factory)` and `registerTiledObjectClass(name, Constructor)` APIs allowing plugins to register custom Tiled object handlers by class name without modifying engine code
- Tiled: `detectObjectType()` now checks `settings.class` and `settings.name` against the factory registry before falling through to structural detection, enabling class-based dispatch for custom types
diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts
index 28c0466d7..9679f47cf 100644
--- a/packages/melonjs/src/index.ts
+++ b/packages/melonjs/src/index.ts
@@ -51,6 +51,7 @@ import save from "./system/save.ts";
import timer from "./system/timer.ts";
import Tween from "./tweens/tween.ts";
import CanvasRenderer from "./video/canvas/canvas_renderer.js";
+import { Gradient } from "./video/gradient.js";
import Renderer from "./video/renderer.js";
import RenderState from "./video/renderstate.js";
import CanvasRenderTarget from "./video/rendertarget/canvasrendertarget.js";
@@ -119,6 +120,7 @@ export {
DropTarget,
Entity, // eslint-disable-line @typescript-eslint/no-deprecated
GLShader,
+ Gradient,
ImageLayer,
Light2d,
NineSliceSprite,
diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js
index 828443e12..e153fc818 100644
--- a/packages/melonjs/src/video/canvas/canvas_renderer.js
+++ b/packages/melonjs/src/video/canvas/canvas_renderer.js
@@ -6,6 +6,7 @@ import {
ONCONTEXT_RESTORED,
on,
} from "../../system/event.ts";
+import { Gradient } from "./../gradient.js";
import Renderer from "./../renderer.js";
import TextureCache from "./../texture/cache.js";
@@ -525,6 +526,23 @@ export default class CanvasRenderer extends Renderer {
this.strokeLine(startX, startY, endX, endY);
}
+ /**
+ * Set the line dash pattern.
+ * @param {number[]} segments - dash pattern
+ */
+ setLineDash(segments) {
+ super.setLineDash(segments);
+ this.getContext().setLineDash(this.renderState.lineDash);
+ }
+
+ /**
+ * Get the current line dash pattern.
+ * @returns {number[]} dash pattern
+ */
+ getLineDash() {
+ return this.getContext().getLineDash();
+ }
+
/**
* Stroke the given me.Polygon on the screen
* @param {Polygon} poly - the shape to draw
@@ -669,7 +687,10 @@ export default class CanvasRenderer extends Renderer {
this.setBlendMode(result.blendMode);
}
// re-sync from the native context (which is authoritative for Canvas)
- this.currentColor.copy(context.fillStyle);
+ // fillStyle may be a CanvasGradient/CanvasPattern — only sync if it's a color string
+ if (typeof context.fillStyle === "string") {
+ this.currentColor.copy(context.fillStyle);
+ }
this.currentColor.glArray[3] = context.globalAlpha;
// reset scissor cache so the next clipRect() won't skip
this.currentScissor[0] = 0;
@@ -724,16 +745,19 @@ export default class CanvasRenderer extends Renderer {
/**
* Set the current fill & stroke style color.
* By default, or upon reset, the value is set to #000000.
- * @param {Color|string} color - css color value
+ * @param {Color|string|Gradient} color - css color value or a Gradient object
*/
setColor(color) {
- const currentColor = this.currentColor;
const context = this.getContext();
- currentColor.copy(color);
- // globalAlpha is applied at rendering time by the canvas
-
- context.strokeStyle = context.fillStyle = currentColor.toRGBA();
+ if (color instanceof Gradient) {
+ this.renderState.currentGradient = color;
+ context.strokeStyle = context.fillStyle = color.toCanvasGradient(context);
+ } else {
+ this.renderState.currentGradient = null;
+ this.currentColor.copy(color);
+ context.strokeStyle = context.fillStyle = this.currentColor.toRGBA();
+ }
}
/**
diff --git a/packages/melonjs/src/video/gradient.js b/packages/melonjs/src/video/gradient.js
new file mode 100644
index 000000000..26880b0f4
--- /dev/null
+++ b/packages/melonjs/src/video/gradient.js
@@ -0,0 +1,205 @@
+import { nextPowerOfTwo } from "../math/math.ts";
+import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js";
+
+/**
+ * @import {Color} from "../math/color.ts";
+ */
+
+/**
+ * A Gradient object representing a linear or radial gradient fill.
+ * Created via {@link Renderer#createLinearGradient} or {@link Renderer#createRadialGradient}.
+ * Can be passed to {@link Renderer#setColor} as a fill style.
+ */
+export class Gradient {
+ /**
+ * @param {"linear"|"radial"} type - the gradient type
+ * @param {number[]} coords - gradient coordinates [x0, y0, x1, y1] for linear, [x0, y0, r0, x1, y1, r1] for radial
+ */
+ constructor(type, coords) {
+ /**
+ * gradient type
+ * @type {"linear"|"radial"}
+ */
+ this.type = type;
+
+ /**
+ * gradient coordinates
+ * @type {number[]}
+ * @ignore
+ */
+ this.coords = coords;
+
+ /**
+ * color stops
+ * @type {Array<{offset: number, color: string}>}
+ * @ignore
+ */
+ this.colorStops = [];
+
+ /**
+ * cached canvas gradient (for Canvas renderer)
+ * @type {CanvasGradient|undefined}
+ * @ignore
+ */
+ this._canvasGradient = undefined;
+
+ /**
+ * cached gradient render target (for WebGL renderer)
+ * @type {CanvasRenderTarget|undefined}
+ * @ignore
+ */
+ this._renderTarget = undefined;
+
+ /**
+ * whether the gradient needs to be regenerated
+ * @type {boolean}
+ * @ignore
+ */
+ this._dirty = true;
+ }
+
+ /**
+ * Add a color stop to the gradient.
+ * @param {number} offset - value between 0.0 and 1.0
+ * @param {Color|string} color - a CSS color string or Color object
+ * @returns {Gradient} this gradient for chaining
+ * @example
+ * gradient.addColorStop(0, "#FF0000");
+ * gradient.addColorStop(0.5, "green");
+ * gradient.addColorStop(1, "blue");
+ */
+ addColorStop(offset, color) {
+ if (offset < 0.0 || offset > 1.0) {
+ throw new Error("offset must be between 0.0 and 1.0");
+ }
+ this.colorStops.push({
+ offset,
+ color: typeof color === "string" ? color : color.toRGBA(),
+ });
+ this._dirty = true;
+ this._canvasGradient = undefined;
+ // keep _renderTarget alive — the _dirty flag will trigger re-rendering
+ // in toCanvasGradient() / toCanvas(), avoiding a GL texture leak
+ return this;
+ }
+
+ /**
+ * Get or create a native CanvasGradient for use with a 2D context.
+ * @param {CanvasRenderingContext2D} context - the 2D context to create the gradient on
+ * @returns {CanvasGradient}
+ * @ignore
+ */
+ toCanvasGradient(context) {
+ if (this._canvasGradient && !this._dirty) {
+ return this._canvasGradient;
+ }
+
+ const c = this.coords;
+ if (this.type === "linear") {
+ this._canvasGradient = context.createLinearGradient(
+ c[0],
+ c[1],
+ c[2],
+ c[3],
+ );
+ } else {
+ this._canvasGradient = context.createRadialGradient(
+ c[0],
+ c[1],
+ c[2],
+ c[3],
+ c[4],
+ c[5],
+ );
+ }
+
+ for (const stop of this.colorStops) {
+ this._canvasGradient.addColorStop(stop.offset, stop.color);
+ }
+
+ this._dirty = false;
+ return this._canvasGradient;
+ }
+
+ /**
+ * Render the gradient onto a canvas matching the given draw rect.
+ * Uses the original gradient coordinates so the result matches Canvas 2D behavior.
+ * @param {CanvasRenderer|WebGLRenderer} renderer - the active renderer (used to invalidate GPU texture)
+ * @param {number} x - draw rect x
+ * @param {number} y - draw rect y
+ * @param {number} width - draw rect width
+ * @param {number} height - draw rect height
+ * @returns {HTMLCanvasElement|OffscreenCanvas} the rendered gradient canvas
+ * @ignore
+ */
+ toCanvas(renderer, x, y, width, height) {
+ // use power-of-two dimensions for WebGL texture compatibility
+ const tw = nextPowerOfTwo(Math.max(1, Math.ceil(width)));
+ const th = nextPowerOfTwo(Math.max(1, Math.ceil(height)));
+
+ // return cached texture if nothing changed
+ if (
+ this._renderTarget &&
+ !this._dirty &&
+ this._lastX === x &&
+ this._lastY === y &&
+ this._renderTarget.width === tw &&
+ this._renderTarget.height === th
+ ) {
+ return this._renderTarget.canvas;
+ }
+
+ // reuse or create the render target
+ if (!this._renderTarget) {
+ this._renderTarget = new CanvasRenderTarget(tw, th);
+ } else if (
+ this._renderTarget.width !== tw ||
+ this._renderTarget.height !== th
+ ) {
+ // NOTE: resizing the canvas invalidates its GPU texture in the
+ // TextureCache, but the cache key (the canvas element itself)
+ // remains the same. The drawImage path must force a re-upload
+ // so the GPU texture matches the new content.
+ this._renderTarget.canvas.width = tw;
+ this._renderTarget.canvas.height = th;
+ }
+
+ const ctx = this._renderTarget.context;
+ ctx.clearRect(0, 0, tw, th);
+
+ // create gradient with coordinates offset to the draw rect origin
+ const c = this.coords;
+ let gradient;
+
+ if (this.type === "linear") {
+ gradient = ctx.createLinearGradient(
+ c[0] - x,
+ c[1] - y,
+ c[2] - x,
+ c[3] - y,
+ );
+ } else {
+ gradient = ctx.createRadialGradient(
+ c[0] - x,
+ c[1] - y,
+ c[2],
+ c[3] - x,
+ c[4] - y,
+ c[5],
+ );
+ }
+
+ for (const stop of this.colorStops) {
+ gradient.addColorStop(stop.offset, stop.color);
+ }
+
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, tw, th);
+
+ this._dirty = false;
+ this._lastX = x;
+ this._lastY = y;
+ this._renderTarget.invalidate(renderer);
+ return this._renderTarget.canvas;
+ }
+}
diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js
index 2b8e3cf2a..b860f3ca6 100644
--- a/packages/melonjs/src/video/renderer.js
+++ b/packages/melonjs/src/video/renderer.js
@@ -3,6 +3,7 @@ import { Color } from "./../math/color.ts";
import { Matrix3d } from "../math/matrix3d.ts";
import { Vector2d } from "../math/vector2d.ts";
import { CANVAS_ONRESIZE, emit } from "../system/event.ts";
+import { Gradient } from "./gradient.js";
import RenderState from "./renderstate.js";
import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js";
@@ -256,6 +257,16 @@ export default class Renderer {
this.currentBlendMode = mode;
}
+ /**
+ * Set the current fill & stroke style color.
+ * By default, or upon reset, the value is set to #000000.
+ * @param {Color|string|Gradient} color - css color value or a Gradient object
+ */
+ // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
+ setColor(color) {
+ // implemented by subclasses
+ }
+
/**
* get the current fill & stroke style color.
* @returns {Color} current global color
@@ -264,6 +275,56 @@ export default class Renderer {
return this.currentColor;
}
+ /**
+ * Create a linear gradient that can be used with {@link Renderer#setColor}.
+ * @param {number} x0 - x-axis coordinate of the start point
+ * @param {number} y0 - y-axis coordinate of the start point
+ * @param {number} x1 - x-axis coordinate of the end point
+ * @param {number} y1 - y-axis coordinate of the end point
+ * @returns {Gradient} a Gradient object
+ */
+ createLinearGradient(x0, y0, x1, y1) {
+ return new Gradient("linear", [x0, y0, x1, y1]);
+ }
+
+ /**
+ * Create a radial gradient that can be used with {@link Renderer#setColor}.
+ * @param {number} x0 - x-axis coordinate of the start circle
+ * @param {number} y0 - y-axis coordinate of the start circle
+ * @param {number} r0 - radius of the start circle
+ * @param {number} x1 - x-axis coordinate of the end circle
+ * @param {number} y1 - y-axis coordinate of the end circle
+ * @param {number} r1 - radius of the end circle
+ * @returns {Gradient} a Gradient object
+ */
+ createRadialGradient(x0, y0, r0, x1, y1, r1) {
+ return new Gradient("radial", [x0, y0, r0, x1, y1, r1]);
+ }
+
+ /**
+ * Set the line dash pattern for stroke operations.
+ * @param {number[]} segments - an array of numbers specifying distances to alternately draw a line and a gap. An empty array clears the dash pattern (solid lines).
+ * @example
+ * // draw a dashed line
+ * renderer.setLineDash([10, 5]);
+ * renderer.strokeLine(0, 0, 100, 0);
+ * // clear the dash pattern
+ * renderer.setLineDash([]);
+ */
+ setLineDash(segments) {
+ this.renderState.lineDash = segments.filter((v) => {
+ return Number.isFinite(v) && v >= 0;
+ });
+ }
+
+ /**
+ * Get the current line dash pattern.
+ * @returns {number[]} the current dash pattern
+ */
+ getLineDash() {
+ return this.renderState.lineDash;
+ }
+
/**
* return the current global alpha
* @returns {number}
diff --git a/packages/melonjs/src/video/renderstate.js b/packages/melonjs/src/video/renderstate.js
index e80a86b1a..1a6fc4885 100644
--- a/packages/melonjs/src/video/renderstate.js
+++ b/packages/melonjs/src/video/renderstate.js
@@ -1,6 +1,10 @@
import { Color } from "./../math/color.ts";
import { Matrix2d } from "../math/matrix2d.ts";
+/**
+ * @import {Gradient} from "./gradient.js";
+ */
+
/**
* Renderer-agnostic state container with a pre-allocated save/restore stack.
*
@@ -35,6 +39,18 @@ export default class RenderState {
*/
this.currentScissor = new Int32Array(4);
+ /**
+ * current gradient fill (null when using solid color)
+ * @type {Gradient|null}
+ */
+ this.currentGradient = null;
+
+ /**
+ * current line dash pattern (empty array = solid line)
+ * @type {number[]}
+ */
+ this.lineDash = [];
+
/**
* current blend mode
* @type {string}
@@ -74,9 +90,15 @@ export default class RenderState {
return new Int32Array(4);
});
+ /** @ignore */
+ this._lineDashStack = new Array(this._stackCapacity);
+
/** @ignore */
this._scissorActive = new Uint8Array(this._stackCapacity);
+ /** @ignore */
+ this._gradientStack = new Array(this._stackCapacity);
+
/** @ignore */
this._blendStack = new Array(this._stackCapacity);
}
@@ -95,6 +117,8 @@ export default class RenderState {
this._colorStack[depth].copy(this.currentColor);
this._tintStack[depth].copy(this.currentTint);
this._matrixStack[depth].copy(this.currentTransform);
+ this._gradientStack[depth] = this.currentGradient;
+ this._lineDashStack[depth] = this.lineDash;
this._blendStack[depth] = this.currentBlendMode;
if (scissorTestActive) {
@@ -123,7 +147,8 @@ export default class RenderState {
this.currentColor.copy(this._colorStack[depth]);
this.currentTint.copy(this._tintStack[depth]);
this.currentTransform.copy(this._matrixStack[depth]);
-
+ this.currentGradient = this._gradientStack[depth];
+ this.lineDash = this._lineDashStack[depth];
const scissorActive = !!this._scissorActive[depth];
if (scissorActive) {
this.currentScissor.set(this._scissorStack[depth]);
@@ -162,6 +187,8 @@ export default class RenderState {
this._tintStack.push(new Color());
this._matrixStack.push(new Matrix2d());
this._scissorStack.push(new Int32Array(4));
+ this._gradientStack.push(null);
+ this._lineDashStack.push([]);
this._blendStack.push(undefined);
}
const newScissorActive = new Uint8Array(newCap);
diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js
index cbc94211c..f6d5e6fd6 100644
--- a/packages/melonjs/src/video/webgl/webgl_renderer.js
+++ b/packages/melonjs/src/video/webgl/webgl_renderer.js
@@ -8,6 +8,7 @@ import {
ONCONTEXT_RESTORED,
on,
} from "../../system/event.ts";
+import { Gradient } from "../gradient.js";
import Renderer from "./../renderer.js";
import { createAtlas, TextureAtlas } from "./../texture/atlas.js";
import TextureCache from "./../texture/cache.js";
@@ -103,6 +104,9 @@ export default class WebGLRenderer extends Renderer {
// scratch array for fillPolygon to avoid mutating polygon points
this._polyVerts = [];
+ // current gradient state (null when using solid color)
+ this._currentGradient = null;
+
/**
* The current transformation matrix used for transformations on the overall scene
* (alias to renderState.currentTransform for backward compatibility)
@@ -570,7 +574,8 @@ export default class WebGLRenderer extends Renderer {
this.currentBatcher.useShader(shader);
}
- // force reuploading if the given image is a HTMLVideoElement
+ // force reuploading if the given image is a HTMLVideoElement or a
+ // force re-upload for video elements
const reupload = typeof image.videoWidth !== "undefined";
const texture = this.cache.get(image);
const uvs = texture.getUVs(sx, sy, sw, sh);
@@ -693,7 +698,27 @@ export default class WebGLRenderer extends Renderer {
this.path2D.triangulatePath(),
);
} else {
- this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points);
+ const dash = this.renderState.lineDash;
+ if (dash.length > 0) {
+ const pts = this.path2D.points;
+ const dashed = [];
+ for (let i = 0; i < pts.length - 1; i += 2) {
+ dashed.push(
+ ...this.#dashSegments(
+ pts[i].x,
+ pts[i].y,
+ pts[i + 1].x,
+ pts[i + 1].y,
+ dash,
+ ),
+ );
+ }
+ if (dashed.length > 0) {
+ this.currentBatcher.drawVertices(this.gl.LINES, dashed);
+ }
+ } else {
+ this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points);
+ }
}
} else {
// dispatches to strokeRect/strokePolygon/etc. which each call setBatcher
@@ -845,6 +870,8 @@ export default class WebGLRenderer extends Renderer {
this.gl.disable(this.gl.SCISSOR_TEST);
}
}
+ // sync gradient from renderState
+ this._currentGradient = this.renderState.currentGradient;
}
/**
@@ -936,12 +963,19 @@ export default class WebGLRenderer extends Renderer {
/**
* Set the current fill & stroke style color.
* By default, or upon reset, the value is set to #000000.
- * @param {Color|string} color - css color string.
+ * @param {Color|string|Gradient} color - css color string or a Gradient object.
*/
setColor(color) {
- const alpha = this.currentColor.alpha;
- this.currentColor.copy(color);
- this.currentColor.alpha *= alpha;
+ if (color instanceof Gradient) {
+ this.renderState.currentGradient = color;
+ this._currentGradient = color;
+ } else {
+ this.renderState.currentGradient = null;
+ this._currentGradient = null;
+ const alpha = this.currentColor.alpha;
+ this.currentColor.copy(color);
+ this.currentColor.alpha *= alpha;
+ }
}
/**
@@ -975,6 +1009,18 @@ export default class WebGLRenderer extends Renderer {
* @param {boolean} [antiClockwise=false] - draw arc anti-clockwise
*/
fillArc(x, y, radius, start, end, antiClockwise = false) {
+ if (this._currentGradient) {
+ this.#gradientMask(
+ () => {
+ this.fillArc(x, y, radius, start, end, antiClockwise);
+ },
+ x - radius,
+ y - radius,
+ radius * 2,
+ radius * 2,
+ );
+ return;
+ }
this.setBatcher("primitive");
let diff = Math.abs(end - start);
if (antiClockwise) {
@@ -1026,6 +1072,18 @@ export default class WebGLRenderer extends Renderer {
* @param {number} h - vertical radius of the ellipse
*/
fillEllipse(x, y, w, h) {
+ if (this._currentGradient) {
+ this.#gradientMask(
+ () => {
+ this.fillEllipse(x, y, w, h);
+ },
+ x - w,
+ y - h,
+ w * 2,
+ h * 2,
+ );
+ return;
+ }
this.setBatcher("primitive");
const segments = Math.max(
8,
@@ -1046,10 +1104,18 @@ export default class WebGLRenderer extends Renderer {
*/
strokeLine(startX, startY, endX, endY) {
this.setBatcher("primitive");
- this.path2D.beginPath();
- this.path2D.moveTo(startX, startY);
- this.path2D.lineTo(endX, endY);
- this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points);
+ const dash = this.renderState.lineDash;
+ if (dash.length > 0) {
+ const segments = this.#dashSegments(startX, startY, endX, endY, dash);
+ if (segments.length > 0) {
+ this.currentBatcher.drawVertices(this.gl.LINES, segments);
+ }
+ } else {
+ this.path2D.beginPath();
+ this.path2D.moveTo(startX, startY);
+ this.path2D.lineTo(endX, endY);
+ this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points);
+ }
}
/**
@@ -1087,7 +1153,27 @@ export default class WebGLRenderer extends Renderer {
this.path2D.lineTo(nextPoint.x, nextPoint.y);
}
this.path2D.closePath();
- this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points);
+ const dash = this.renderState.lineDash;
+ if (dash.length > 0) {
+ const pts = this.path2D.points;
+ const dashed = [];
+ for (let i = 0; i < pts.length - 1; i += 2) {
+ dashed.push(
+ ...this.#dashSegments(
+ pts[i].x,
+ pts[i].y,
+ pts[i + 1].x,
+ pts[i + 1].y,
+ dash,
+ ),
+ );
+ }
+ if (dashed.length > 0) {
+ this.currentBatcher.drawVertices(this.gl.LINES, dashed);
+ }
+ } else {
+ this.currentBatcher.drawVertices(this.gl.LINES, this.path2D.points);
+ }
// add round joins at vertices for thick lines
if (this.lineWidth > 1) {
const radius = this.lineWidth / 2;
@@ -1111,6 +1197,37 @@ export default class WebGLRenderer extends Renderer {
* @param {Polygon} poly - the shape to draw
*/
fillPolygon(poly) {
+ if (this._currentGradient) {
+ const bounds = poly.getBounds();
+ // translate to polygon's local space so gradient coords match
+ this.translate(poly.pos.x, poly.pos.y);
+ this.#gradientMask(
+ () => {
+ // draw polygon vertices directly (already translated)
+ this.setBatcher("primitive");
+ const indices = poly.getIndices();
+ const points = poly.points;
+ const verts = this._polyVerts;
+ const len = indices.length;
+ while (verts.length < len) {
+ verts.push({ x: 0, y: 0 });
+ }
+ for (let i = 0; i < len; i++) {
+ const src = points[indices[i]];
+ verts[i].x = src.x;
+ verts[i].y = src.y;
+ }
+ this.currentBatcher.drawVertices(this.gl.TRIANGLES, verts, len);
+ },
+ // use local bounds (subtract pos since getBounds includes it)
+ bounds.x - poly.pos.x,
+ bounds.y - poly.pos.y,
+ bounds.width,
+ bounds.height,
+ );
+ this.translate(-poly.pos.x, -poly.pos.y);
+ return;
+ }
this.setBatcher("primitive");
this.translate(poly.pos.x, poly.pos.y);
const indices = poly.getIndices();
@@ -1174,6 +1291,11 @@ export default class WebGLRenderer extends Renderer {
* @param {number} height - The rectangle's height.
*/
fillRect(x, y, width, height) {
+ if (this._currentGradient) {
+ const canvas = this._currentGradient.toCanvas(this, x, y, width, height);
+ this.drawImage(canvas, 0, 0, width, height, x, y, width, height);
+ return;
+ }
this.setBatcher("primitive");
// 2 triangles directly — avoids path2D + earcut overhead
const right = x + width;
@@ -1223,6 +1345,18 @@ export default class WebGLRenderer extends Renderer {
* @param {number} radius - The rounded corner's radius.
*/
fillRoundRect(x, y, width, height, radius) {
+ if (this._currentGradient) {
+ this.#gradientMask(
+ () => {
+ this.fillRoundRect(x, y, width, height, radius);
+ },
+ x,
+ y,
+ width,
+ height,
+ );
+ return;
+ }
this.setBatcher("primitive");
const r = Math.min(radius, width / 2, height / 2);
const verts = [];
@@ -1357,6 +1491,132 @@ export default class WebGLRenderer extends Renderer {
this.currentBatcher.drawVertices(this.gl.TRIANGLES, verts);
}
+ /**
+ * Split a line segment into dashed sub-segments.
+ * @param {number} x0 - start x
+ * @param {number} y0 - start y
+ * @param {number} x1 - end x
+ * @param {number} y1 - end y
+ * @param {number[]} pattern - dash pattern [on, off, on, off, ...]
+ * @returns {Array<{x: number, y: number}>} pairs of start/end points for visible segments
+ * @ignore
+ */
+ #dashSegments(x0, y0, x1, y1, pattern) {
+ const dx = x1 - x0;
+ const dy = y1 - y0;
+ const lineLen = Math.sqrt(dx * dx + dy * dy);
+ if (lineLen === 0 || pattern.length === 0) {
+ return [
+ { x: x0, y: y0 },
+ { x: x1, y: y1 },
+ ];
+ }
+
+ const nx = dx / lineLen;
+ const ny = dy / lineLen;
+ // bail out if pattern has no positive values (would loop forever)
+ if (
+ !pattern.some((v) => {
+ return v > 0;
+ })
+ ) {
+ return [
+ { x: x0, y: y0 },
+ { x: x1, y: y1 },
+ ];
+ }
+
+ const segments = [];
+ let dist = 0;
+ let patIdx = 0;
+ let drawing = true; // start with "on"
+
+ while (dist < lineLen) {
+ const dashLen = pattern[patIdx % pattern.length];
+ if (dashLen <= 0) {
+ patIdx++;
+ drawing = !drawing;
+ continue;
+ }
+ const segEnd = Math.min(dist + dashLen, lineLen);
+
+ if (drawing) {
+ segments.push(
+ { x: x0 + nx * dist, y: y0 + ny * dist },
+ { x: x0 + nx * segEnd, y: y0 + ny * segEnd },
+ );
+ }
+
+ dist = segEnd;
+ drawing = !drawing;
+ patIdx++;
+ }
+
+ return segments;
+ }
+
+ /**
+ * Draw a gradient-filled shape by masking with the shape and filling the bounding rect.
+ * Temporarily disables the gradient to prevent recursion in the fill methods.
+ * @param {Function} drawShape - draws the shape into the stencil buffer
+ * @param {number} x - bounding rect x
+ * @param {number} y - bounding rect y
+ * @param {number} w - bounding rect width
+ * @param {number} h - bounding rect height
+ * @ignore
+ */
+ #gradientMask(drawShape, x, y, w, h) {
+ const gl = this.gl;
+ const grad = this._currentGradient;
+ const hasMask = this.maskLevel > 0;
+ const stencilRef = hasMask ? this.maskLevel + 1 : 1;
+ this._currentGradient = null;
+
+ this.flush();
+
+ gl.enable(gl.STENCIL_TEST);
+ gl.colorMask(false, false, false, false);
+
+ if (hasMask) {
+ // nest within existing mask level
+ gl.stencilFunc(gl.EQUAL, this.maskLevel, 0xff);
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.INCR);
+ } else {
+ gl.clear(gl.STENCIL_BUFFER_BIT);
+ gl.stencilFunc(gl.ALWAYS, 1, 0xff);
+ gl.stencilOp(gl.REPLACE, gl.REPLACE, gl.REPLACE);
+ }
+
+ drawShape();
+ this.flush();
+
+ // use stencil to clip gradient
+ gl.colorMask(true, true, true, true);
+ gl.stencilFunc(gl.EQUAL, stencilRef, 0xff);
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
+
+ this._currentGradient = grad;
+ this.fillRect(x, y, w, h);
+ this.flush();
+
+ if (hasMask) {
+ // restore the parent mask level by decrementing stencil
+ this._currentGradient = null;
+ gl.colorMask(false, false, false, false);
+ gl.stencilFunc(gl.EQUAL, stencilRef, 0xff);
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.DECR);
+ drawShape();
+ this.flush();
+ gl.colorMask(true, true, true, true);
+ // restore parent mask stencil test
+ gl.stencilFunc(gl.NOTEQUAL, this.maskLevel + 1, 1);
+ gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
+ this._currentGradient = grad;
+ } else {
+ gl.disable(gl.STENCIL_TEST);
+ }
+ }
+
/**
* Generate triangle fan vertices for an elliptical arc.
* @param {number} cx - center x
diff --git a/packages/melonjs/tests/gradient.spec.js b/packages/melonjs/tests/gradient.spec.js
new file mode 100644
index 000000000..5865d7c82
--- /dev/null
+++ b/packages/melonjs/tests/gradient.spec.js
@@ -0,0 +1,369 @@
+import { beforeAll, describe, expect, it } from "vitest";
+import { Application, Gradient } from "../src/index.js";
+
+describe("Gradient", () => {
+ let app;
+
+ beforeAll(() => {
+ app = new Application(64, 64, {
+ parent: "screen",
+ scale: "auto",
+ });
+ });
+
+ describe("createLinearGradient", () => {
+ it("should return a Gradient instance", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 100, 0);
+ expect(gradient).toBeInstanceOf(Gradient);
+ expect(gradient.type).toEqual("linear");
+ });
+
+ it("should store gradient coordinates", () => {
+ const gradient = app.renderer.createLinearGradient(10, 20, 30, 40);
+ expect(gradient.coords).toEqual([10, 20, 30, 40]);
+ });
+
+ it("should support addColorStop chaining", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 100, 0);
+ const result = gradient
+ .addColorStop(0, "#FF0000")
+ .addColorStop(1, "#0000FF");
+ expect(result).toBe(gradient);
+ expect(gradient.colorStops.length).toEqual(2);
+ });
+
+ it("should store color stop as string", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 100, 0);
+ gradient.addColorStop(0, "#FF0000");
+ expect(gradient.colorStops[0].color).toEqual("#FF0000");
+ });
+ });
+
+ describe("createRadialGradient", () => {
+ it("should return a Gradient instance", () => {
+ const gradient = app.renderer.createRadialGradient(50, 50, 0, 50, 50, 50);
+ expect(gradient).toBeInstanceOf(Gradient);
+ expect(gradient.type).toEqual("radial");
+ });
+
+ it("should store gradient coordinates", () => {
+ const gradient = app.renderer.createRadialGradient(10, 20, 5, 30, 40, 50);
+ expect(gradient.coords).toEqual([10, 20, 5, 30, 40, 50]);
+ });
+ });
+
+ describe("setColor with gradient", () => {
+ it("should accept a Gradient in setColor without throwing", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ expect(() => {
+ app.renderer.setColor(gradient);
+ }).not.toThrow();
+ });
+
+ it("should accept a regular color after a gradient", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ app.renderer.setColor(gradient);
+ expect(() => {
+ app.renderer.setColor("#FF0000");
+ }).not.toThrow();
+ });
+ });
+
+ describe("fillRect with gradient", () => {
+ it("should not throw when filling a rect with a gradient", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "#FF0000");
+ gradient.addColorStop(1, "#0000FF");
+ app.renderer.setColor(gradient);
+ expect(() => {
+ app.renderer.fillRect(0, 0, 64, 64);
+ }).not.toThrow();
+ });
+ });
+
+ describe("mixing colors and gradients across fill calls", () => {
+ it("should fill with solid color after gradient", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ app.renderer.setColor(gradient);
+ app.renderer.fillRect(0, 0, 64, 32);
+ app.renderer.setColor("#00FF00");
+ expect(() => {
+ app.renderer.fillRect(0, 32, 64, 32);
+ }).not.toThrow();
+ expect(app.renderer.renderState.currentGradient).toBeNull();
+ });
+
+ it("should fill with gradient after solid color", () => {
+ app.renderer.setColor("#FF0000");
+ app.renderer.fillRect(0, 0, 64, 32);
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "green");
+ gradient.addColorStop(1, "blue");
+ app.renderer.setColor(gradient);
+ expect(() => {
+ app.renderer.fillRect(0, 32, 64, 32);
+ }).not.toThrow();
+ expect(app.renderer.renderState.currentGradient).toBe(gradient);
+ });
+
+ it("should alternate between colors and gradients", () => {
+ const g1 = app.renderer.createLinearGradient(0, 0, 64, 0);
+ g1.addColorStop(0, "red");
+ g1.addColorStop(1, "blue");
+ const g2 = app.renderer.createRadialGradient(32, 32, 0, 32, 32, 32);
+ g2.addColorStop(0, "white");
+ g2.addColorStop(1, "black");
+
+ expect(() => {
+ app.renderer.setColor(g1);
+ app.renderer.fillRect(0, 0, 64, 16);
+ app.renderer.setColor("#FF0000");
+ app.renderer.fillRect(0, 16, 64, 16);
+ app.renderer.setColor(g2);
+ app.renderer.fillRect(0, 32, 64, 16);
+ app.renderer.setColor("#0000FF");
+ app.renderer.fillRect(0, 48, 64, 16);
+ }).not.toThrow();
+ expect(app.renderer.renderState.currentGradient).toBeNull();
+ });
+
+ it("should handle save/restore when mixing colors and gradients for rendering", () => {
+ app.renderer.setColor("#FF0000");
+ app.renderer.save();
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "green");
+ gradient.addColorStop(1, "blue");
+ app.renderer.setColor(gradient);
+ app.renderer.fillRect(0, 0, 64, 32);
+ app.renderer.restore();
+ // after restore, should be back to solid color
+ expect(app.renderer.renderState.currentGradient).toBeNull();
+ expect(() => {
+ app.renderer.fillRect(0, 32, 64, 32);
+ }).not.toThrow();
+ });
+ });
+
+ describe("toCanvasGradient", () => {
+ it("should produce a CanvasGradient for linear type", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ const canvasGradient = gradient.toCanvasGradient(ctx);
+ expect(canvasGradient).toBeInstanceOf(CanvasGradient);
+ });
+
+ it("should produce a CanvasGradient for radial type", () => {
+ const gradient = new Gradient("radial", [50, 50, 0, 50, 50, 50]);
+ gradient.addColorStop(0, "white");
+ gradient.addColorStop(1, "black");
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ const canvasGradient = gradient.toCanvasGradient(ctx);
+ expect(canvasGradient).toBeInstanceOf(CanvasGradient);
+ });
+
+ it("should cache the CanvasGradient", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ const first = gradient.toCanvasGradient(ctx);
+ const second = gradient.toCanvasGradient(ctx);
+ expect(first).toBe(second);
+ });
+
+ it("should invalidate cache after addColorStop", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ gradient.addColorStop(0, "red");
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ const first = gradient.toCanvasGradient(ctx);
+ gradient.addColorStop(1, "blue");
+ const second = gradient.toCanvasGradient(ctx);
+ expect(first).not.toBe(second);
+ });
+ });
+
+ describe("save/restore with gradient", () => {
+ it("should track gradient in renderState when set", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+
+ app.renderer.setColor(gradient);
+ expect(app.renderer.renderState.currentGradient).toBe(gradient);
+ });
+
+ it("should clear gradient in renderState when color is set", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+
+ app.renderer.setColor(gradient);
+ app.renderer.setColor("#00FF00");
+ expect(app.renderer.renderState.currentGradient).toBeNull();
+ });
+
+ it("should restore gradient after save(gradient) / setColor(solid) / restore", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+
+ app.renderer.setColor(gradient);
+ app.renderer.save();
+ app.renderer.setColor("#00FF00");
+ expect(app.renderer.renderState.currentGradient).toBeNull();
+ app.renderer.restore();
+ expect(app.renderer.renderState.currentGradient).toBe(gradient);
+ });
+
+ it("should restore solid color after save(solid) / setColor(gradient) / restore", () => {
+ app.renderer.setColor("#FF0000");
+ app.renderer.save();
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "green");
+ gradient.addColorStop(1, "blue");
+ app.renderer.setColor(gradient);
+ expect(app.renderer.renderState.currentGradient).toBe(gradient);
+ app.renderer.restore();
+ expect(app.renderer.renderState.currentGradient).toBeNull();
+ });
+
+ it("should handle nested save/restore with mixed colors and gradients", () => {
+ const gradient1 = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient1.addColorStop(0, "red");
+ gradient1.addColorStop(1, "blue");
+ const gradient2 = app.renderer.createRadialGradient(
+ 32,
+ 32,
+ 0,
+ 32,
+ 32,
+ 32,
+ );
+ gradient2.addColorStop(0, "white");
+ gradient2.addColorStop(1, "black");
+
+ // level 0: solid color
+ app.renderer.setColor("#FF0000");
+ app.renderer.save();
+
+ // level 1: gradient1
+ app.renderer.setColor(gradient1);
+ app.renderer.save();
+
+ // level 2: gradient2
+ app.renderer.setColor(gradient2);
+ expect(app.renderer.renderState.currentGradient).toBe(gradient2);
+
+ // restore to level 1: gradient1
+ app.renderer.restore();
+ expect(app.renderer.renderState.currentGradient).toBe(gradient1);
+
+ // restore to level 0: solid color
+ app.renderer.restore();
+ expect(app.renderer.renderState.currentGradient).toBeNull();
+ });
+ });
+
+ describe("toCanvas (texture)", () => {
+ it("should produce a canvas element matching the draw rect", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ const canvas = gradient.toCanvas(app.renderer, 0, 0, 100, 50);
+ expect(canvas).toBeDefined();
+ // dimensions are next power of two
+ expect(canvas.width).toEqual(128);
+ expect(canvas.height).toEqual(64);
+ });
+
+ it("should cache the canvas for same dimensions", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ const first = gradient.toCanvas(app.renderer, 0, 0, 100, 50);
+ const second = gradient.toCanvas(app.renderer, 0, 0, 100, 50);
+ expect(first).toBe(second);
+ });
+
+ it("should invalidate cache when position changes", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ const first = gradient.toCanvas(app.renderer, 0, 0, 100, 50);
+ const second = gradient.toCanvas(app.renderer, 10, 10, 100, 50);
+ // same canvas object reused, but re-rendered
+ expect(first).toBe(second);
+ });
+ });
+
+ describe("addColorStop validation", () => {
+ it("should throw for offset less than 0", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ expect(() => {
+ gradient.addColorStop(-0.1, "red");
+ }).toThrow();
+ });
+
+ it("should throw for offset greater than 1", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ expect(() => {
+ gradient.addColorStop(1.1, "red");
+ }).toThrow();
+ });
+
+ it("should accept offset 0 and 1", () => {
+ const gradient = new Gradient("linear", [0, 0, 100, 0]);
+ expect(() => {
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ }).not.toThrow();
+ });
+ });
+
+ describe("gradient with no color stops", () => {
+ it("should not throw when creating gradient with no stops", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 100, 0);
+ expect(() => {
+ app.renderer.setColor(gradient);
+ }).not.toThrow();
+ });
+ });
+
+ describe("gradient reuse across multiple setColor calls", () => {
+ it("should allow the same gradient to be set multiple times", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0, "red");
+ gradient.addColorStop(1, "blue");
+ expect(() => {
+ app.renderer.setColor(gradient);
+ app.renderer.fillRect(0, 0, 32, 32);
+ app.renderer.setColor("#000000");
+ app.renderer.setColor(gradient);
+ app.renderer.fillRect(32, 0, 32, 32);
+ }).not.toThrow();
+ });
+ });
+
+ describe("gradient with single color stop", () => {
+ it("should not throw with a single color stop", () => {
+ const gradient = app.renderer.createLinearGradient(0, 0, 64, 0);
+ gradient.addColorStop(0.5, "red");
+ expect(() => {
+ app.renderer.setColor(gradient);
+ app.renderer.fillRect(0, 0, 64, 64);
+ }).not.toThrow();
+ });
+ });
+});
diff --git a/packages/melonjs/tests/linedash.spec.js b/packages/melonjs/tests/linedash.spec.js
new file mode 100644
index 000000000..7e4c1a1af
--- /dev/null
+++ b/packages/melonjs/tests/linedash.spec.js
@@ -0,0 +1,299 @@
+import { beforeAll, describe, expect, it } from "vitest";
+import { Application } from "../src/index.js";
+
+describe("LineDash", () => {
+ let app;
+
+ beforeAll(() => {
+ app = new Application(64, 64, {
+ parent: "screen",
+ scale: "auto",
+ });
+ });
+
+ describe("setLineDash / getLineDash", () => {
+ it("should default to an empty array (solid line)", () => {
+ expect(app.renderer.getLineDash()).toEqual([]);
+ });
+
+ it("should set and get a dash pattern", () => {
+ app.renderer.setLineDash([10, 5]);
+ expect(app.renderer.getLineDash()).toEqual([10, 5]);
+ });
+
+ it("should clear the dash pattern with an empty array", () => {
+ app.renderer.setLineDash([10, 5]);
+ app.renderer.setLineDash([]);
+ expect(app.renderer.getLineDash()).toEqual([]);
+ });
+
+ it("should accept a complex dash pattern", () => {
+ app.renderer.setLineDash([5, 3, 10, 3]);
+ expect(app.renderer.getLineDash()).toEqual([5, 3, 10, 3]);
+ app.renderer.setLineDash([]);
+ });
+ });
+
+ describe("strokeLine with dash", () => {
+ it("should not throw when drawing a dashed line", () => {
+ app.renderer.setLineDash([10, 5]);
+ expect(() => {
+ app.renderer.strokeLine(0, 0, 64, 64);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should not throw when drawing a solid line after dashed", () => {
+ app.renderer.setLineDash([10, 5]);
+ app.renderer.strokeLine(0, 0, 64, 0);
+ app.renderer.setLineDash([]);
+ expect(() => {
+ app.renderer.strokeLine(0, 32, 64, 32);
+ }).not.toThrow();
+ });
+ });
+
+ describe("path-based stroke with dash", () => {
+ it("should not throw when stroking a path with dash", () => {
+ app.renderer.setLineDash([8, 4]);
+ app.renderer.beginPath();
+ app.renderer.moveTo(0, 0);
+ app.renderer.lineTo(32, 32);
+ app.renderer.lineTo(64, 0);
+ expect(() => {
+ app.renderer.stroke();
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+ });
+
+ describe("setLineDash input validation", () => {
+ it("should clone the array (not store by reference)", () => {
+ const pattern = [10, 5];
+ app.renderer.setLineDash(pattern);
+ pattern[0] = 999;
+ expect(app.renderer.getLineDash()[0]).toEqual(10);
+ app.renderer.setLineDash([]);
+ });
+
+ it("should filter out negative values", () => {
+ app.renderer.setLineDash([10, -5, 8, -3]);
+ expect(app.renderer.getLineDash()).toEqual([10, 8]);
+ app.renderer.setLineDash([]);
+ });
+
+ it("should keep zero values", () => {
+ app.renderer.setLineDash([10, 0, 5, 0]);
+ expect(app.renderer.getLineDash()).toEqual([10, 0, 5, 0]);
+ app.renderer.setLineDash([]);
+ });
+
+ it("should handle all-negative values (becomes empty = solid)", () => {
+ app.renderer.setLineDash([-1, -2, -3]);
+ expect(app.renderer.getLineDash()).toEqual([]);
+ app.renderer.setLineDash([]);
+ });
+
+ it("should not throw with zero-length dash pattern on strokeLine", () => {
+ app.renderer.setLineDash([0, 0]);
+ expect(() => {
+ app.renderer.strokeLine(0, 0, 64, 64);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should not infinite loop with zero dash values on strokeLine", () => {
+ app.renderer.setLineDash([0, 5, 0, 10]);
+ expect(() => {
+ app.renderer.strokeLine(0, 0, 64, 0);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should not infinite loop with all-zero dash pattern", () => {
+ app.renderer.setLineDash([0, 0]);
+ expect(() => {
+ app.renderer.strokeLine(0, 0, 64, 0);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should not infinite loop with all-zero pattern on path stroke", () => {
+ app.renderer.setLineDash([0, 0, 0]);
+ app.renderer.beginPath();
+ app.renderer.moveTo(0, 0);
+ app.renderer.lineTo(64, 64);
+ expect(() => {
+ app.renderer.stroke();
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+ });
+
+ describe("setLineDash edge cases", () => {
+ it("should filter NaN values", () => {
+ app.renderer.setLineDash([10, NaN, 5]);
+ const dash = app.renderer.getLineDash();
+ expect(dash).not.toContain(NaN);
+ app.renderer.setLineDash([]);
+ });
+
+ it("should filter Infinity values", () => {
+ app.renderer.setLineDash([10, Infinity, 5]);
+ const dash = app.renderer.getLineDash();
+ expect(dash).not.toContain(Infinity);
+ app.renderer.setLineDash([]);
+ });
+
+ it("should handle a single-element pattern", () => {
+ app.renderer.setLineDash([10]);
+ expect(() => {
+ app.renderer.strokeLine(0, 0, 64, 0);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should handle fractional values", () => {
+ app.renderer.setLineDash([0.5, 0.1]);
+ expect(() => {
+ app.renderer.strokeLine(0, 0, 64, 0);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+ });
+
+ describe("dash on different stroke methods", () => {
+ it("should not throw with strokeRect", () => {
+ app.renderer.setLineDash([8, 4]);
+ expect(() => {
+ app.renderer.strokeRect(0, 0, 32, 32);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should not throw with strokeEllipse", () => {
+ app.renderer.setLineDash([8, 4]);
+ expect(() => {
+ app.renderer.strokeEllipse(32, 32, 16, 16);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should not throw with strokeArc", () => {
+ app.renderer.setLineDash([6, 3]);
+ expect(() => {
+ app.renderer.strokeArc(32, 32, 16, 0, Math.PI * 2);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should not throw on zero-length line", () => {
+ app.renderer.setLineDash([10, 5]);
+ expect(() => {
+ app.renderer.strokeLine(32, 32, 32, 32);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+ });
+
+ describe("save/restore with dash", () => {
+ it("should restore dash pattern after save/restore", () => {
+ app.renderer.setLineDash([10, 5]);
+ app.renderer.save();
+ app.renderer.setLineDash([3, 3]);
+ expect(app.renderer.getLineDash()).toEqual([3, 3]);
+ app.renderer.restore();
+ expect(app.renderer.getLineDash()).toEqual([10, 5]);
+ app.renderer.setLineDash([]);
+ });
+
+ it("should restore solid line after save(solid) / setLineDash / restore", () => {
+ app.renderer.setLineDash([]);
+ app.renderer.save();
+ app.renderer.setLineDash([10, 5]);
+ app.renderer.restore();
+ expect(app.renderer.getLineDash()).toEqual([]);
+ });
+
+ it("should draw solid line after restoring from dashed state", () => {
+ app.renderer.save();
+ app.renderer.setLineDash([10, 5]);
+ app.renderer.strokeLine(0, 0, 64, 0);
+ app.renderer.restore();
+ // after restore, dash is cleared — this should draw solid
+ expect(app.renderer.getLineDash()).toEqual([]);
+ expect(() => {
+ app.renderer.strokeLine(0, 32, 64, 32);
+ }).not.toThrow();
+ });
+
+ it("should draw dashed line after restoring to dashed state", () => {
+ app.renderer.setLineDash([10, 5]);
+ app.renderer.save();
+ app.renderer.setLineDash([]);
+ // draw solid
+ app.renderer.strokeLine(0, 0, 64, 0);
+ app.renderer.restore();
+ // after restore, dash is back
+ expect(app.renderer.getLineDash()).toEqual([10, 5]);
+ expect(() => {
+ app.renderer.strokeLine(0, 32, 64, 32);
+ }).not.toThrow();
+ app.renderer.setLineDash([]);
+ });
+
+ it("should alternate solid and dashed lines with save/restore", () => {
+ expect(() => {
+ // solid
+ app.renderer.strokeLine(0, 0, 64, 0);
+
+ app.renderer.save();
+ app.renderer.setLineDash([8, 4]);
+ // dashed
+ app.renderer.strokeLine(0, 10, 64, 10);
+
+ app.renderer.save();
+ app.renderer.setLineDash([]);
+ // solid again
+ app.renderer.strokeLine(0, 20, 64, 20);
+ app.renderer.restore();
+
+ // back to dashed [8, 4]
+ app.renderer.strokeLine(0, 30, 64, 30);
+ app.renderer.restore();
+
+ // back to solid
+ app.renderer.strokeLine(0, 40, 64, 40);
+ }).not.toThrow();
+ expect(app.renderer.getLineDash()).toEqual([]);
+ });
+
+ it("should mix dashed strokeLine with solid path stroke", () => {
+ app.renderer.setLineDash([10, 5]);
+ app.renderer.strokeLine(0, 0, 64, 0);
+ app.renderer.setLineDash([]);
+ app.renderer.beginPath();
+ app.renderer.moveTo(0, 16);
+ app.renderer.lineTo(64, 16);
+ expect(() => {
+ app.renderer.stroke();
+ }).not.toThrow();
+ });
+
+ it("save should not be affected by subsequent setLineDash calls", () => {
+ app.renderer.setLineDash([10, 5]);
+ app.renderer.save();
+ // setLineDash creates a new array, so saved reference stays intact
+ app.renderer.setLineDash([20, 10]);
+ app.renderer.save();
+ app.renderer.setLineDash([1, 1]);
+ // restore to [20, 10]
+ app.renderer.restore();
+ expect(app.renderer.getLineDash()).toEqual([20, 10]);
+ // restore to [10, 5]
+ app.renderer.restore();
+ expect(app.renderer.getLineDash()).toEqual([10, 5]);
+ app.renderer.setLineDash([]);
+ });
+ });
+});