diff --git a/packages/examples/src/examples/text/text.ts b/packages/examples/src/examples/text/text.ts index 73204cc6a8..487616ff2f 100644 --- a/packages/examples/src/examples/text/text.ts +++ b/packages/examples/src/examples/text/text.ts @@ -19,7 +19,6 @@ import { type Application, BitmapText, ColorLayer, - Container, NineSliceSprite, Renderable, type Renderer, @@ -93,18 +92,8 @@ class ContinueArrow extends Renderable { } } -/** - * RPG-style "wavy text": a bitmap-font string whose glyphs bob on a sine wave. - * Each character is its own BitmapText (BitmapText draws a string in one batch, - * with no per-glyph hook) so it can be offset independently every frame. - */ -class WavyText extends Container { - private glyphs: BitmapText[] = []; - private elapsed = 0; - private amplitude: number; - private speed: number; - private phaseStep: number; - +/** RPG-style wavy text, kept in one batched BitmapText renderable. */ +class WavyText extends BitmapText { constructor( x: number, y: number, @@ -114,38 +103,14 @@ class WavyText extends Container { speed = 0.009, phaseStep = 0.7, ) { - super(x, y); - this.amplitude = amplitude; - this.speed = speed; - this.phaseStep = phaseStep; - this.alwaysUpdate = true; - - // lay glyphs out left-to-right by measured advance - const measurer = new BitmapText(0, 0, settings); - let cx = 0; - for (const ch of text) { - const glyph = new BitmapText(cx, 0, { ...settings, text: ch }); - this.glyphs.push(glyph); - this.addChild(glyph); - measurer.setText(ch === " " ? "M" : ch); // spaces measure to 0 otherwise - cx += measurer.measureText().width; - } - - // Fixed, non-empty bounds so the camera never culls the group. Deriving - // bounds from the (continuously moving) child glyphs is what let MELONA - // occasionally vanish; a stable box covering the text + wave is robust. - this.width = cx; - this.height = (Number(settings.size) || 1) * 16 + this.amplitude * 2; - } - - override update(dt: number) { - this.elapsed += dt; - this.glyphs.forEach((glyph, i) => { - glyph.pos.y = - Math.sin(this.elapsed * this.speed + i * this.phaseStep) * - this.amplitude; + super(x, y, { + ...settings, + text, + glyphEffect: (out, ctx) => { + out.offsetY = + Math.sin(ctx.time * speed + ctx.index * phaseStep) * amplitude; + }, }); - return super.update(dt); } } diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 569dbf5074..d84636bcac 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -3,6 +3,7 @@ ## [20.9.0] (melonJS 2) - _unreleased_ ### Added +- **`BitmapText#glyphEffect`**: offset and tint individual glyphs in one renderable for wave, shake and colour effects, with reused callback objects and the existing GPU batch. Layout and typewriter reveal keep their normal behaviour; the text example uses the hook for its wavy speaker name ([#1522](https://github.com/melonjs/melonJS/issues/1522), thanks @snowyukitty) - **`RenderTarget#toImageData()`**: read back a render target's pixels as a promise. The portable readback, and the one backend-agnostic code should use: `toBlob()`, `toDataURL()` and `toImageBitmap()` are all built on it - **`Texture2d#isAtlas`**: whether a texture carries named regions addressable with `getRegion()`. `false` on every texture but a `TextureAtlas`, so a game holding a `Texture2d` of unknown kind can ask without a type test - **Spatial audio placed in world coordinates**: `audio.play(name, { follow: renderable })` tracks a sound to a renderable every frame, `{ at: { x, y } }` pins one to a fixed world point, and `{ stopWithTarget: true }` ends it when that renderable is destroyed. The numbers are world pixels with y measured down, the same ones already in `pos`, so a game converts nothing by hand. `audio.unfollow(id)` detaches a sound and leaves it playing where it is diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index 88700e7f94..12faf72d74 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-ui-and-text -description: "Use this skill for HUDs, buttons, menus, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, ProgressBar, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, Tween, easing, animate a label, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating." +description: "Use this skill for HUDs, buttons, menus, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, ProgressBar, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, Tween, easing, animate a label, wavy text, per-glyph effect, glyphEffect, shake text, rainbow text, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating." license: MIT --- @@ -125,6 +125,49 @@ label from one class to the other: | stroke | `strokeStyle` + `lineWidth` | none | | `size` | pixels | a RATIO of the authored size | +### Per-glyph wave, shake and colour: `glyphEffect` + +Animating individual letters does **not** mean one renderable per character. +`BitmapText#glyphEffect` offsets and tints each glyph inside the batch the +label already draws, so an animated 100-character line stays one draw call and +costs no extra objects: + +```js +label.glyphEffect = (out, ctx) => { + out.offsetY = Math.sin(ctx.time * 0.008 + ctx.index * 0.6) * 6; // wave + out.tint.setColor(255, 128, 128); // per glyph +}; +label.glyphEffect = null; // back to the plain path +``` + +`out` and `ctx` are **reused**: read `ctx` during the call and write `out`, but +never keep either. `out.offsetX` / `offsetY` start at zero and `out.tint` at +opaque white on every glyph, so an effect that only touches some glyphs leaves +the rest alone. `ctx` carries `index` (across lines, line breaks excluded), +`char`, `code`, `x`, `y` and `time`. + +Four things that catch people: + +- **`time` advances in `update`, not in `draw`.** A label drawn through two + cameras animates once, not twice. It is the milliseconds ACCUMULATED across + updates while an effect was set, and nothing resets it, so swapping the + callback continues the same clock rather than starting a new one. +- **Effects move pixels, not layout.** Metrics, wrapping, alignment, + `visibleCharacters` and the bounds all ignore the offsets, so the pen advance + and kerning are exactly as without one. The flip side is that **offsets do + not extend the culling bounds**: a big wave near the screen edge can clip, so + keep the measured text in view. +- **Canvas pays per distinct colour.** WebGL and WebGPU carry the tint in the + per-vertex colour, so batching survives. The Canvas renderer instead caches a + tinted copy of the whole font page per colour, and that cache is unbounded + for the renderer's life, so a colour driven by `ctx.time` allocates a + page-sized canvas every frame there. On Canvas, keep the palette finite or + animate only the offsets. +- **`tint` is multiplicative**, like `fillStyle` above: white leaves the glyph + alone, and `out.tint` multiplies whatever the label's own `fillStyle` is. + +`Text` has no equivalent, since it rasterises the whole string into one texture. + ## The HUD pattern A HUD is a `floating` container at a high z, built once and re-added: diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 0cd9444c3b..cc5c638c57 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -338,6 +338,11 @@ DOMContentLoaded(() => { } }); +export type { + GlyphEffect, + GlyphEffectContext, + GlyphEffectOutput, +} from "./renderable/text/glypheffect.ts"; export type { EasingFunction } from "./tweens/easing.ts"; export type { InterpolationFunction } from "./tweens/interpolation.ts"; export type { Topology } from "./video/gpu/topology.ts"; diff --git a/packages/melonjs/src/renderable/text/bitmaptext.js b/packages/melonjs/src/renderable/text/bitmaptext.js index a10d1b0728..0087d4e9b3 100644 --- a/packages/melonjs/src/renderable/text/bitmaptext.js +++ b/packages/melonjs/src/renderable/text/bitmaptext.js @@ -13,6 +13,7 @@ import TextMetrics from "./textmetrics.js"; * @import WebGLRenderer from "../../video/webgl/webgl_renderer.js"; * @import {Bounds} from "../../physics/bounds.ts"; * @import Renderer from "../../video/renderer.js"; + * @import {GlyphEffect, GlyphEffectContext, GlyphEffectOutput} from "./glypheffect.ts"; */ /** * a bitmap font object. @@ -38,6 +39,7 @@ export default class BitmapText extends Renderable { * @param {number} [settings.lineHeight=1.0] - line spacing height * @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.0, y:0.0}] - anchor point to draw the text at. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. * @param {number} [settings.wordWrapWidth] - the maximum length in CSS pixel for a single segment of text + * @param {GlyphEffect|null} [settings.glyphEffect=null] - a per-glyph offset and tint callback * @param {(string|string[])} [settings.text] - a string, or an array of strings * @example * // Load the BMFont descriptor as a "binary" asset and its page as an "image". @@ -170,8 +172,20 @@ export default class BitmapText extends Renderable { this.resize(settings.size); } + /** @private @type {GlyphEffect|null} */ + this._glyphEffect = null; + /** @private */ + this._glyphEffectTime = 0; + /** + * Lazily allocated; plain bitmap text needs no effect scratch objects. + * @private + * @type {{out: GlyphEffectOutput, context: GlyphEffectContext, tint: Color}|undefined} + */ + this._glyphEffectState = undefined; + // set the text this.setText(settings.text); + this.glyphEffect = settings.glyphEffect; } /** @@ -220,6 +234,63 @@ export default class BitmapText extends Renderable { return this; } + /** + * A per-glyph offset and multiplicative tint callback, or `null` to disable. + * The output and context are reused: do not retain them. Offsets start at + * zero and tint at opaque white on each call. Time advances through update, + * so drawing through multiple cameras does not advance the animation. + * Effects change drawing only: metrics, wrapping and bounds stay unchanged. + * Keep the measured text in view: effects do not extend the culling bounds. + * WebGL and WebGPU retain batching: the tint rides the per-vertex colour, + * so a whole animated line is still one draw call. Canvas realizes each + * DISTINCT tint as a cached, tinted copy of the entire font page, and that + * cache is unbounded for the life of the renderer, so a colour driven by + * `ctx.time` costs a font-page canvas per frame there. On Canvas, keep the + * palette finite or vary only the offsets. + * + * While an effect is set the renderable reports itself as changed every + * frame, since whether the callback reads `ctx.time` cannot be known. + * @type {GlyphEffect|null} + * @example + * text.glyphEffect = (out, ctx) => { + * out.offsetY = Math.sin(ctx.time * 0.008 + ctx.index * 0.6) * 6; + * out.tint.setColor(255, 128, 128); + * }; + */ + get glyphEffect() { + return this._glyphEffect; + } + + set glyphEffect(effect) { + // Normalized, as the constructor's `settings.glyphEffect || null` + // already was. `undefined` is how an unset option arrives and how a + // caller spells "turn it off", and storing it left the draw path's + // `!== null` test true with nothing callable behind it: a + // `TypeError: effect is not a function` out of `draw()`, i.e. from + // inside the frame loop. + const next = effect || null; + if (this._glyphEffect !== next) { + this._glyphEffect = next; + if (next !== null) { + this._glyphEffectState ??= { + out: { offsetX: 0, offsetY: 0, tint: new Color(255, 255, 255) }, + context: { index: 0, char: "", code: 0, time: 0, x: 0, y: 0 }, + tint: new Color(255, 255, 255), + }; + } + this.isDirty = true; + } + } + + /** @inheritdoc */ + update(dt) { + if (this._glyphEffect !== null) { + this._glyphEffectTime += dt; + return true; + } + return super.update(dt); + } + /** * the number of characters to display (use -1 to show all). * Useful for typewriter effects combined with Tween. @@ -469,7 +540,17 @@ export default class BitmapText extends Renderable { const scaleY = this.fontScale.y; // draw it - if (glyphWidth !== 0 && glyphHeight !== 0) { + if (this._glyphEffect !== null) { + this._drawGlyphEffect( + renderer, + glyph, + charCount, + ch, + string.charAt(c), + x + glyph.xoffset * scaleX, + y + glyph.yoffset * scaleY, + ); + } else if (glyphWidth !== 0 && glyphHeight !== 0) { // some browser throw an exception when drawing a 0 width or height image renderer.drawImage( this.fontImage, @@ -500,12 +581,69 @@ export default class BitmapText extends Renderable { } } + /** + * Draw a glyph without changing the pen advance or renderer state. + * @private + * @param {Renderer} renderer + * @param {import("./glyph.ts").default} glyph + * @param {number} index + * @param {number} code + * @param {string} char + * @param {number} x + * @param {number} y + */ + _drawGlyphEffect(renderer, glyph, index, code, char, x, y) { + const { out, context, tint } = this._glyphEffectState; + out.offsetX = out.offsetY = 0; + out.tint.setFloat(1, 1, 1, 1); + context.index = index; + context.char = char; + context.code = code; + context.time = this._glyphEffectTime; + context.x = x; + context.y = y; + tint.copy(renderer.currentTint); + const alpha = renderer.getGlobalAlpha(); + try { + const effect = this._glyphEffect; + effect(out, context); + const base = tint.toArray(); + const color = out.tint.toArray(); + renderer.currentTint.setFloat( + base[0] * color[0], + base[1] * color[1], + base[2] * color[2], + base[3], + ); + // Global alpha is shared by Canvas and both GPU backends. + renderer.setGlobalAlpha(alpha * color[3]); + if (glyph.width !== 0 && glyph.height !== 0) { + renderer.drawImage( + this.fontImage, + glyph.x, + glyph.y, + glyph.width, + glyph.height, + x + out.offsetX, + y + out.offsetY, + glyph.width * this.fontScale.x, + glyph.height * this.fontScale.y, + ); + } + } finally { + renderer.currentTint.copy(tint); + renderer.setGlobalAlpha(alpha); + } + } + /** * Destroy function * @ignore * @internal */ destroy() { + this._glyphEffect = null; + this._glyphEffectState = undefined; vector2dPool.release(this.fontScale); this.fontScale = undefined; bitmapTextDataPool.release(this.fontData); diff --git a/packages/melonjs/src/renderable/text/glypheffect.ts b/packages/melonjs/src/renderable/text/glypheffect.ts new file mode 100644 index 0000000000..400ea67242 --- /dev/null +++ b/packages/melonjs/src/renderable/text/glypheffect.ts @@ -0,0 +1,32 @@ +import type { Color } from "../../math/color.ts"; + +/** Mutable per-glyph output, reset before each call. Do not retain it. */ +export interface GlyphEffectOutput { + /** Horizontal offset in drawing pixels, after font scaling. */ + offsetX: number; + /** Vertical offset in drawing pixels, after font scaling. */ + offsetY: number; + /** Multiplicative tint; reset to opaque white before each call. */ + readonly tint: Color; +} + +/** Reused per-glyph context. Read its values during the callback only. */ +export interface GlyphEffectContext { + /** UTF-16 character index across drawn lines, excluding line breaks. */ + index: number; + /** The UTF-16 character represented by this glyph. */ + char: string; + /** The BMFont character code. */ + code: number; + /** Elapsed update time in milliseconds while the effect is enabled. */ + time: number; + /** Unmodified glyph destination coordinates, after alignment and scaling. */ + x: number; + y: number; +} + +/** Modify the reused output to offset or tint a BitmapText glyph. */ +export type GlyphEffect = ( + out: GlyphEffectOutput, + context: GlyphEffectContext, +) => void; diff --git a/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js b/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js new file mode 100644 index 0000000000..906134540e --- /dev/null +++ b/packages/melonjs/tests/bitmaptext-glyph-effect.spec.js @@ -0,0 +1,345 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Application, + BitmapText, + boot, + video, + WebGLRenderer, +} from "../src/index.js"; + +const SIZE = 64; +const data = `info face="effect-test" size=4 padding=0,0,0,0 spacing=0,0 +common lineHeight=6 base=4 scaleW=4 scaleH=4 pages=1 packed=0 +page id=0 file="effect-test.png" +chars count=4 +char id=32 x=0 y=0 width=0 height=0 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +char id=65 x=0 y=0 width=4 height=4 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +char id=66 x=0 y=0 width=4 height=4 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +char id=67 x=0 y=0 width=4 height=4 xoffset=0 yoffset=0 xadvance=6 page=0 chnl=15 +kernings count=1 +kerning first=65 second=66 amount=-1`; + +for (const backend of [video.CANVAS, video.WEBGL]) { + describe(`BitmapText glyph effects (${backend === video.CANVAS ? "Canvas" : "WebGL"})`, () => { + let app; + let renderer; + let image; + const texts = []; + beforeAll(async () => { + boot(); + app = new Application(SIZE, SIZE, { + parent: "screen", + renderer: backend, + antiAlias: false, + failIfMajorPerformanceCaveat: false, + transparent: true, + backgroundColor: "rgba(0, 0, 0, 0)", + }); + await app.init(); + renderer = app.renderer; + if (backend === video.WEBGL) { + expect(renderer).toBeInstanceOf(WebGLRenderer); + } + image = document.createElement("canvas"); + image.width = image.height = 4; + const ctx = image.getContext("2d"); + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, 4, 4); + }); + afterEach(() => { + vi.restoreAllMocks(); + // Direct draw probes also queue GPU quads; drain before the next scene. + renderer.flush(); + for (const text of texts.splice(0)) { + if (text.ancestor) { + app.world.removeChildNow(text); + } else { + text.destroy(); + } + } + renderer.clearTint(); + renderer.setGlobalAlpha(1); + }); + afterAll(() => { + return app.destroy(); + }); + const makeText = (settings = {}) => { + const text = new BitmapText(8, 8, { + font: image, + fontData: data, + text: "ABC", + ...settings, + }); + texts.push(text); + return text; + }; + const pixel = (x, y) => { + if (backend === video.CANVAS) { + return Array.from(renderer.getContext().getImageData(x, y, 1, 1).data); + } + const out = new Uint8Array(4); + const gl = renderer.gl; + gl.readPixels(x, SIZE - 1 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out); + // WebGL framebuffer RGB is premultiplied; Canvas readback is straight. + return [out[0], out[1], out[2]] + .map((v) => { + return out[3] ? Math.round((v * 255) / out[3]) : 0; + }) + .concat(out[3]); + }; + + it("keeps the ordinary path and layout identical with an identity effect", () => { + const text = makeText({ + text: "AB C\nABC", + size: 2, + textAlign: "center", + textBaseline: "middle", + wordWrapWidth: 18, + }); + const draw = vi.spyOn(renderer, "drawImage"); + text.draw(renderer); + const plain = draw.mock.calls.map((call) => { + return call.slice(); + }); + const bounds = { + x: text.getBounds().x, + y: text.getBounds().y, + width: text.getBounds().width, + height: text.getBounds().height, + }; + draw.mockClear(); + text.glyphEffect = () => {}; + text.draw(renderer); + expect(draw.mock.calls).toEqual(plain); + expect({ + x: text.getBounds().x, + y: text.getBounds().y, + width: text.getBounds().width, + height: text.getBounds().height, + }).toEqual(bounds); + text.glyphEffect = null; + draw.mockClear(); + text.draw(renderer); + expect(draw.mock.calls).toEqual(plain); + }); + + it("reuses and resets output/context, preserving spaces, reveal and pen advance", () => { + const text = makeText({ text: "A BC\nABC" }); + const contexts = [], + outputs = [], + snapshots = []; + text.glyphEffect = (out, ctx) => { + contexts.push(ctx); + outputs.push(out); + snapshots.push({ + ...ctx, + offsetX: out.offsetX, + offsetY: out.offsetY, + tint: Array.from(out.tint.toArray()), + }); + if (ctx.index === 0) { + out.offsetX = 5; + out.offsetY = 3; + out.tint.setColor(255, 0, 0, 0.5); + } + }; + text.visibleCharacters = 5; + text.update(20); + const draw = vi.spyOn(renderer, "drawImage"); + text.draw(renderer); + expect( + snapshots.map((ctx) => { + return ctx.char; + }), + ).toEqual(["A", " ", "B", "C", "A"]); + expect( + snapshots.map((ctx) => { + return ctx.index; + }), + ).toEqual([0, 1, 2, 3, 4]); + expect( + snapshots.every((ctx) => { + return ( + ctx.time === 20 && + ctx.offsetX === 0 && + ctx.offsetY === 0 && + ctx.tint.every((v) => { + return v === 1; + }) + ); + }), + ).toBe(true); + expect( + contexts.every((ctx) => { + return ctx === contexts[0]; + }), + ).toBe(true); + expect( + outputs.every((out) => { + return out === outputs[0]; + }), + ).toBe(true); + expect( + draw.mock.calls.map((call) => { + return call.slice(5, 7); + }), + ).toEqual([ + [13, 11], + [20, 8], + [26, 8], + [8, 12], + ]); + text.setText("CBA"); + snapshots.length = 0; + text.draw(renderer); + expect( + snapshots.map((ctx) => { + return ctx.char; + }), + ).toEqual(["C", "B", "A"]); + }); + + it("skips missing and hidden glyphs without renumbering later characters", () => { + const seen = []; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const text = makeText({ + text: "A?B", + glyphEffect: (_out, ctx) => { + return seen.push([ctx.index, ctx.code]); + }, + }); + text.draw(renderer); + expect(seen).toEqual([ + [0, 65], + [2, 66], + ]); + expect(warn).toHaveBeenCalledTimes(1); + seen.length = 0; + text.visibleCharacters = 0; + text.draw(renderer); + expect(seen).toEqual([]); + }); + + it("advances time on updates, dirties the scene without changing layout, and redraws after removal", () => { + const text = makeText(); + text.isDirty = false; + expect(text.update(16)).toBe(false); + const times = []; + text.glyphEffect = (_out, ctx) => { + return times.push(ctx.time); + }; + text.isDirty = false; + expect(text.update(16)).toBe(true); + expect(text.isDirty).toBe(false); + text.draw(renderer); + text.draw(renderer); + expect( + times.every((time) => { + return time === 16; + }), + ).toBe(true); + text.glyphEffect = null; + expect(text.update(16)).toBe(true); + text.isDirty = false; + expect(text.update(16)).toBe(false); + }); + + it("restores renderer tint and alpha when the callback throws or reveal stops", () => { + const text = makeText(); + renderer.currentTint.setFloat(0.5, 0.25, 1, 0.75); + renderer.setGlobalAlpha(0.5); + const original = Array.from(renderer.currentTint.toArray()); + text.glyphEffect = (out) => { + out.tint.setColor(255, 0, 0, 0.25); + }; + text.visibleCharacters = 1; + text.draw(renderer); + expect(Array.from(renderer.currentTint.toArray())).toEqual(original); + expect(renderer.getGlobalAlpha()).toBe(0.5); + text.glyphEffect = () => { + throw new Error("effect failed"); + }; + expect(() => { + return text.draw(renderer); + }).toThrow("effect failed"); + expect(Array.from(renderer.currentTint.toArray())).toEqual(original); + expect(renderer.getGlobalAlpha()).toBe(0.5); + }); + + it("REGRESSION: a falsy assignment disables the effect, it does not throw", () => { + // `undefined` is how an unset option arrives and how a caller + // spells "turn it off". The constructor normalized it with + // `|| null`; the setter stored it, which left the draw path's + // `!== null` test true with nothing callable behind it, and the + // `TypeError` came out of `draw()`, from inside the frame loop. + const text = makeText(); + text.glyphEffect = (out) => { + out.offsetY = 3; + }; + expect(typeof text.glyphEffect).toEqual("function"); + + text.glyphEffect = undefined; + // reads back as the documented `GlyphEffect|null`, not `undefined` + expect(text.glyphEffect).toBe(null); + expect(() => { + text.draw(renderer); + }).not.toThrow(); + + // and the ordinary path really is back: no per-glyph callback runs + const draw = vi.spyOn(renderer, "drawImage"); + text.draw(renderer); + expect(draw).toHaveBeenCalledTimes(3); + }); + + it("renders offset/tinted pixels through the world, keeps the next glyph untouched and batches GPU colours", () => { + const text = makeText({ + fillStyle: "#80ffff", + glyphEffect: (out, ctx) => { + if (ctx.index === 0) { + out.offsetY = 8; + out.tint.setColor(255, 0, 0, 0.5); + } + if (ctx.index === 1) { + out.tint.setColor(0, 255, 0); + } + }, + }); + text.setOpacity(0.5); + app.world.addChild(text); + renderer.backgroundColor.setFloat(0, 0, 0, 0); + renderer.clearRect(0, 0, SIZE, SIZE); + const draw = + backend === video.WEBGL + ? vi.spyOn(renderer.gl, "drawElements") + : undefined; + app.world.update(16); + app.repaint(); + app.draw(); + const red = pixel(9, 17), + green = pixel(15, 9), + third = pixel(20, 9); + expect(red[0]).toBeGreaterThan(100); + expect(red[1]).toBe(0); + expect(red[2]).toBe(0); + expect(red[3]).toBeGreaterThanOrEqual(62); + expect(red[3]).toBeLessThanOrEqual(65); + expect(green[1]).toBe(255); + expect(green[0]).toBe(0); + expect(green[3]).toBeGreaterThanOrEqual(126); + expect(green[3]).toBeLessThanOrEqual(129); + expect(third[0]).toBeGreaterThan(120); + expect(third[1]).toBe(255); + if (draw) { + expect(draw).toHaveBeenCalledTimes(1); + } + }); + }); +} diff --git a/packages/melonjs/tests/glyph-effect.spec.ts b/packages/melonjs/tests/glyph-effect.spec.ts new file mode 100644 index 0000000000..f2bc76e8c4 --- /dev/null +++ b/packages/melonjs/tests/glyph-effect.spec.ts @@ -0,0 +1,14 @@ +import { expectTypeOf, it } from "vitest"; +import type { + BitmapText, + GlyphEffect, + GlyphEffectContext, + GlyphEffectOutput, +} from "../src/index.js"; + +it("exports the callback types used by the public BitmapText API", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf().parameter(1).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +});