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([]); + }); + }); +});