diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f688c0ac8..6afcb7908 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -14,11 +14,13 @@ - UI: `hoverScale` and `pressScale` on every `UIBaseElement`: a bounce under the pointer or the focus, and while held, easing by itself and scaling only the drawing, not the hit area or the layout - UI: `ProgressBar`'s `onChange` also gets the bar - UI: the `UISkin`, `UILabel`, `Paint` and settings and skin types of every widget are exported; `Container#clipContains(x, y)` tells whether a container shows its children at a point +- Text: `lineJoin` and `miterLimit` settings, the shape of the outline's corners: `"round"` (the default), `"bevel"`, or `"miter"` for pointed corners as a plain canvas draws them, with `miterLimit` capping how far a point may reach ### Changed - UI: `UITextButton` no longer writes the size and alignment it worked out back into the settings object, so one object can make several buttons - UI: `UITextButton#label` is the button's text, a `BitmapText` or, with a CSS font, a `Text` - UI: `UISpriteElement` is now a `UIBaseElement` holding a `Sprite` (`sprite`), instead of a `Sprite` with its own copy of the pointer handling, so it has everything the other widgets have: `enabled` (drawn at half opacity when off), `cancelPress()` / `onCancel()`, `hoverScale` / `pressScale`, dragging and keyboard / gamepad focus. `(x, y)` is still where the image's anchor point goes, so existing layouts do not move, and `setRegion`, `addAnimation`, `setCurrentAnimation` and `isCurrentAnimation` are kept on the element. As a sprite does, it follows its image's size, a region or animation frame of another size included, with the image's anchor point staying in place. Other `Sprite` members are now on `element.sprite`; `pos` is the element's top left corner; setting `anchorPoint` after construction no longer moves the image (pass it in the settings); and a second sprite drawn in a `draw()` override becomes a child added with `addChild` +- Text: an outlined `Text` (`lineWidth` > 0) is drawn with round corners by default, as game text outlines usually are, instead of the canvas's pointed ones, which grew long spikes from sharp glyphs on a thick outline and were cut off at the top of the text. Set `lineJoin: "miter"` to keep pointed corners; they are now drawn whole - UI: a press stops at a UI element by default: `UIBaseElement`'s `onClick()` and `onRelease()` now return `false`, as `UISpriteElement`'s already did, so a click on a button, checkbox or widget of your own no longer also reaches the game behind it. A subclass that relied on presses passing through returns `true` from them. Hover is unchanged, and a non-blocking `UIPanel` still lets presses on its background through ### Deprecated @@ -37,6 +39,7 @@ - Color: `parseHex()` keeps all 8 bits of alpha instead of rounding to one decimal. Low alpha values no longer become fully transparent, and 4- or 8-digit hex colors round-trip through `toHex8()` without changing alpha (thanks @mmporong) - Math: `isPowerOfTwo()` rejects zero, negative and fractional values instead of accepting them after 32-bit coercion, and handles powers and non-powers beyond the 32-bit range (thanks @mmporong) - Input: a child cropped out of view by a container with `clipping`, or scrolled out of a `UIPanel`, no longer gets pointer events where it is not drawn +- Text: an outline is no longer cut at the edges of the text. A pointed corner can reach several times half the line width past a glyph, and the tips of letters such as "A", "V" or "M" were cut flat at the top; and nothing was left to the sides, so the outline (or an italic's ink) on the left of the first glyph and past the end of the line was cut too. The text is baked with room for the outline on all four sides, for whichever corners are set; its position and bounds are unchanged - Geometry: `Line#contains()` checks the segment endpoints as well as collinearity; a point on the line beyond either endpoint was reported as contained (thanks @mmporong) - UI: a `UITextButton` moved after it was built draws its background where it is, not where it was first put - UI: a blocking `UIPanel` stays opaque to hover for as long as the pointer is on it, not only on the frame the pointer enters diff --git a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md index e7aec4b66..9f6bf5fd1 100644 --- a/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md +++ b/packages/melonjs/skills/melonjs-ui-and-text/SKILL.md @@ -29,6 +29,36 @@ If you have a label carrying a leading `\n` to buy headroom, that workaround is now dead weight — drop it, and take the line height back off the position you shifted it by. +The outline's corners are set by `lineJoin`, the same property a canvas +context has, and `miterLimit`: + +| setting | corners | use it for | +|---|---|---| +| `lineJoin: "round"` (default) | rounded: the outline is the same width all around | almost everything: HUDs, buttons, titles | +| `lineJoin: "bevel"` | cut flat | a harder edge with no spikes | +| `lineJoin: "miter"` | pointed, as a plain canvas strokes text | a sharp, angular look | +| `miterLimit: n` (default 10) | with `"miter"` only: a corner pointing further than `n` half line widths is cut flat instead | taming the spikes `"miter"` grows on "A", "V", "M" | + +At the canvas default of 10, a thick `"miter"` outline grows spikes several +times its width from sharp glyphs; 2 to 4 keeps the corners pointed and the +spikes short: + +```js +const outline = { + font: "sans-serif", size: 48, + fillStyle: "#ffffff", strokeStyle: "#000000", lineWidth: 6, +}; +new Text(x, y, { ...outline, text: "ROUND" }); // the default +new Text(x, y, { ...outline, lineJoin: "bevel", text: "BEVEL" }); +new Text(x, y, { ...outline, lineJoin: "miter", text: "MITER" }); // long spikes +new Text(x, y, { ...outline, lineJoin: "miter", miterLimit: 3, text: "MITER 3" }); +``` + +Whichever you pick, the bake leaves room for it on all four sides, so no corner +is cut and the label's position and bounds do not move; pointed corners just +cost a bigger texture. Changed on a live label, they show from the next +`setText()`. + ## Gradient text `fillStyle` takes a `Gradient` as well as a colour — the same object @@ -142,7 +172,7 @@ label from one class to the other: | white | white glyphs | the ABSENCE of a tint | | gradient | yes, via `fillGradient` | no: a tint is one colour over a page image | | recolour it later | `label.fillStyle.parseCSS("#ff4040")` — the field is a `Color` with no setter, and ASSIGNING a string replaces it, after which the glyphs stop filling entirely and nothing is logged | `label.fillStyle = "#ff4040"` — a real setter that parses the string | -| stroke | `strokeStyle` + `lineWidth` | none | +| stroke | `strokeStyle` + `lineWidth`, corners with `lineJoin` / `miterLimit` | none | | `size` | pixels | a RATIO of the authored size | ### Per-glyph wave, shake and colour: `glyphEffect` diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js index afe341c89..dc3392a57 100644 --- a/packages/melonjs/src/renderable/text/text.js +++ b/packages/melonjs/src/renderable/text/text.js @@ -51,7 +51,8 @@ const genericFontFamilies = new Set([ * a Text object: draws a string using a system or web font. * * The text is rasterised to a cached texture, so it draws as fast as a sprite - * while staying fully styleable — `fillStyle`, `strokeStyle` + `lineWidth`, and + * while staying fully styleable — `fillStyle`, `strokeStyle` + `lineWidth` + * (with the outline's corners set by `lineJoin` / `miterLimit`), and * per-object opacity — with multi-line text (embedded `\n`), optional * `wordWrapWidth` word-wrapping, and chainable {@link Text#bold} / * {@link Text#italic}. `font` accepts any CSS family, including the generic @@ -73,6 +74,8 @@ export default class Text extends Renderable { * @param {boolean} [settings.gradientPerLine=true] - restart a gradient fill on every line; `false` spans one ramp across the whole block, as a plain canvas does * @param {Color|string} [settings.strokeStyle="#000000"] - a CSS color value used for the glyph outline (drawn when `lineWidth` > 0) * @param {number} [settings.lineWidth=0] - outline width in pixels (0 = no stroke) + * @param {"round"|"bevel"|"miter"} [settings.lineJoin="round"] - the shape of the outline's corners: "round", "bevel" or "miter" (pointed, as a plain canvas draws them) + * @param {number} [settings.miterLimit=10] - with a "miter" join, how far a corner may point, in half line widths, before it is cut flat * @param {string} [settings.textAlign="left"] - horizontal text alignment ("left", "center", "right") * @param {string} [settings.textBaseline="top"] - the text baseline ("top", "hanging", "middle", "alphabetic", "ideographic", "bottom") * @param {number} [settings.lineHeight=1.0] - line spacing height @@ -115,6 +118,34 @@ export default class Text extends Renderable { * // gradientPerLine: false, // ...or span ONE ramp across both lines * })); * @example + * // outlined text and its corners: the outline is centred on the glyph + * // edge, drawn on top of the fill, and its corners are set by `lineJoin` + * const outline = { + * font: "sans-serif", + * size: 48, + * fillStyle: "#ffffff", + * strokeStyle: "#1d3557", + * lineWidth: 6, + * }; + * + * // "round" (the default): the outline is the same width all around + * app.world.addChild(new Text(10, 10, { ...outline, text: "ROUND" })); + * + * // "bevel": corners cut flat + * app.world.addChild(new Text(10, 70, { ...outline, lineJoin: "bevel", text: "BEVEL" })); + * + * // "miter": pointed corners, as a plain canvas strokes text. At the + * // canvas's default limit of 10 a sharp glyph ("A", "V", "M") grows a + * // long spike... + * app.world.addChild(new Text(10, 130, { ...outline, lineJoin: "miter", text: "MITER" })); + * + * // ...which `miterLimit` caps: a corner that would point further than + * // this many half line widths is cut flat instead + * app.world.addChild(new Text(10, 190, { ...outline, lineJoin: "miter", miterLimit: 3, text: "MITER 3" })); + * + * // whichever is set, the whole outline is drawn: the label's texture + * // grows to hold it, while its position and bounds stay the same + * @example * // a web font (loaded via the fontface loader) is used by its family name * loader.preload( * [{ name: "kenpixel", type: "fontface", src: "data/font/kenvector.woff2" }], @@ -150,6 +181,57 @@ export default class Text extends Renderable { */ this.lineWidth = 0; + /** + * The shape of the outline's corners, where the stroke turns on a + * glyph: "round", "bevel" (cut flat) or "miter" (pointed). The + * canvas's own default is "miter", which on a thick outline grows + * long spikes from sharp glyphs such as "A", "V" or "M"; "round" + * keeps the outline the same width all around, as most game text + * outlines are. + * + * The text is baked with room for whichever is set, so no corner is + * cut: "miter" takes more, up to {@link Text#miterLimit} half line + * widths around the text. + * + * Like the other style properties, a change shows from the next + * {@link Text#setText}. + * @type {"round"|"bevel"|"miter"} + * @default "round" + * @example + * // sharp, pointed corners, as a plain canvas strokes them + * const title = new Text(0, 0, { + * font: "sans-serif", + * size: 48, + * fillStyle: "#ffffff", + * strokeStyle: "#000000", + * lineWidth: 6, + * lineJoin: "miter", + * miterLimit: 3, // and no spike past 3 half widths + * text: "VICTORY", + * }); + */ + this.lineJoin = "round"; + + /** + * With a "miter" {@link Text#lineJoin}: how far a corner of the + * outline may point, in half line widths, before it is cut flat + * instead. The canvas's default, 10, lets sharp glyphs grow long + * spikes; 2 to 4 keeps most corners pointed and the spikes short. + * Has no effect with "round" or "bevel" joins. A value the canvas + * would refuse (zero, negative, infinite or NaN) draws with 10. + * + * Like the other style properties, a change shows from the next + * {@link Text#setText}. + * @type {number} + * @default 10 + * @example + * // pointed corners with short spikes + * label.lineJoin = "miter"; + * label.miterLimit = 2; + * label.setText("VICTORY"); // re-bakes with the new corners + */ + this.miterLimit = 10; + /** * Set the default text alignment (or justification),
* possible values are "left", "right", and "center".
@@ -314,6 +396,9 @@ export default class Text extends Renderable { } this.lineWidth = settings.lineWidth || 0; + // set every time, for a recycled label not to keep the last one's + this.lineJoin = settings.lineJoin ?? "round"; + this.miterLimit = settings.miterLimit ?? 10; this.textAlign = settings.textAlign || "left"; this.textBaseline = settings.textBaseline || "top"; this.lineHeight = settings.lineHeight || 1.0; @@ -505,7 +590,16 @@ export default class Text extends Renderable { // see `TextMetrics#inkPadTop`. // At least one bucket: a blank string measures 0 wide, and a canvas with // a zero side cannot be uploaded (WebGPU throws). - const width = Math.max(1, Math.ceil(this.metrics.width / 32)) * 32; + const width = + Math.max( + 1, + Math.ceil( + (this.metrics.width + + this.metrics.inkPadLeft + + this.metrics.inkPadRight) / + 32, + ), + ) * 32; const height = Math.max( 1, @@ -707,10 +801,11 @@ export default class Text extends Renderable { this.canvasTexture.invalidate(renderer); } - // adjust x,y position based on the bounding box. The blit rises by the - // same padding the glyphs were drawn down by, so they land in exactly - // the pixels they always did and only the clipped ink is recovered. - const x = this.metrics.x; + // adjust x,y position based on the bounding box. The blit moves back + // by the same padding the glyphs were drawn in by, so they land in + // exactly the pixels they always did and only the clipped ink is + // recovered. + const x = this.metrics.x - this.metrics.inkPadLeft; const y = this.metrics.y - this.metrics.inkPadTop; // draw the text (on whole pixels unless `subPixel` is set, where it @@ -723,7 +818,8 @@ export default class Text extends Renderable { * @internal */ _drawFont(context, text, x, y) { - // The bake canvas carries `inkPadTop` of headroom above the layout box. + // The bake canvas carries `inkPadTop` of headroom above the layout box, + // and `inkPadLeft` of room to its left. // Translating INTO that headroom — rather than adding it to every draw // position — keeps bake space equal to METRICS space, which is the // space a caller authored their gradient coordinates in. Add the pad to @@ -732,7 +828,7 @@ export default class Text extends Renderable { // padding differs per browser. The translate is a whole number of // pixels, so glyph rasterization is untouched. context.save(); - context.translate(0, this.metrics.inkPadTop); + context.translate(this.metrics.inkPadLeft, this.metrics.inkPadTop); const fill = setContextStyle(context, this); @@ -815,7 +911,7 @@ export default class Text extends Renderable { context.globalCompositeOperation = "source-in"; context.fillStyle = fill; context.fillRect( - -1, + -this.metrics.inkPadLeft - 1, -this.metrics.inkPadTop - 1, canvas.width + 2, canvas.height + 2, diff --git a/packages/melonjs/src/renderable/text/textmetrics.js b/packages/melonjs/src/renderable/text/textmetrics.js index a5619caa9..23a524c65 100644 --- a/packages/melonjs/src/renderable/text/textmetrics.js +++ b/packages/melonjs/src/renderable/text/textmetrics.js @@ -1,6 +1,6 @@ import { Bounds } from "../../physics/bounds.ts"; import Text from "./text.js"; -import setContextStyle from "./textstyle.js"; +import setContextStyle, { strokeReach } from "./textstyle.js"; /** * additional import for TypeScript @@ -175,14 +175,21 @@ class TextMetrics extends Bounds { // They describe the outline, though, not the pixels — see `INK_SLACK` — // and where they are unavailable the padding falls back to `INK_GUESS` // rather than to nothing. + // + // The same goes across: `width` is the advance of the longest line, + // and a glyph's ink can start before its line's alignment point (an + // "A" whose foot leans out, an italic) or end past its advance, the + // stroke adding its reach on both sides. `inkPadLeft` and + // `inkPadRight` are that overshoot, kept out of `width` the same way. this.inkPadTop = 0; this.inkPadBottom = 0; + this.inkPadLeft = 0; + this.inkPadRight = 0; if (!isBitmapText && strings.length > 0 && typeof context !== "undefined") { const style = this.ancestor; - const stroke = - style.lineWidth > 0 && style.strokeStyle.alpha > 0 - ? style.lineWidth / 2 - : 0; + // half the line width past the outline, or for a "miter" join as + // far as its corners can point + const stroke = strokeReach(style); const first = context.measureText(strings[0].trimEnd()); const last = strings.length === 1 @@ -205,6 +212,37 @@ class TextMetrics extends Bounds { Math.ceil(this.inkPadTop + bottom + stroke - this.lineHeight()) + INK_SLACK, ); + + // across, line by line: each sits at its own alignment point in + // the box, and its ink box is measured from that point + const align = style.textAlign; + const anchor = + align === "right" + ? this.width + : align === "center" + ? this.width / 2 + : 0; + let left = 0; + let right = 0; + for (let i = 0; i < strings.length; i++) { + const line = + i === 0 + ? first + : i === strings.length - 1 + ? last + : context.measureText(strings[i].trimEnd()); + const inkLeft = line.actualBoundingBoxLeft; + const inkRight = line.actualBoundingBoxRight; + if (Number.isFinite(inkLeft) && Number.isFinite(inkRight)) { + left = Math.max(left, inkLeft - anchor); + right = Math.max(right, anchor + inkRight - this.width); + } + } + this.inkPadLeft = Math.max(0, Math.ceil(left + stroke) + INK_SLACK); + // plus a pixel: the box sits on a whole pixel and the glyphs are + // baked at what that leaves over (see `updateOrigin`), so they can + // land up to a pixel right of where they were measured + this.inkPadRight = Math.max(0, Math.ceil(right + stroke + 1) + INK_SLACK); } this.width = Math.ceil(this.width); diff --git a/packages/melonjs/src/renderable/text/textstyle.js b/packages/melonjs/src/renderable/text/textstyle.js index 667ce177e..685cda62c 100644 --- a/packages/melonjs/src/renderable/text/textstyle.js +++ b/packages/melonjs/src/renderable/text/textstyle.js @@ -65,6 +65,52 @@ function resolveFill(context, style) { return repeated; } +/** + * The canvas default for `miterLimit`, kept when a Text is given a value the + * canvas would refuse (zero, negative, infinite or NaN). + * @ignore + * @internal + */ +const DEFAULT_MITER_LIMIT = 10; + +/** + * The miter limit a Text's outline is drawn with. + * @param {object} style - the Text being drawn + * @returns {number} its `miterLimit`, or the canvas default for one the + * canvas would refuse + * @ignore + * @internal + */ +function miterLimitOf(style) { + const limit = style.miterLimit; + return Number.isFinite(limit) && limit > 0 ? limit : DEFAULT_MITER_LIMIT; +} + +/** + * How far a Text's outline can reach past the glyph's own outline, in pixels. + * + * The stroke is centred on the outline, so it reaches half `lineWidth` past it + * on curves and edges, and at corners too with "round" or "bevel" joins. A + * "miter" join extends a corner to a point instead, and the canvas lets that + * point reach up to `miterLimit` half widths past the corner before it cuts it + * flat: on a sharp glyph like an "A" or an "M", several times further. + * @param {object} style - the Text being drawn + * @returns {number} the reach in pixels, 0 when there is no visible outline + * @ignore + * @internal + */ +export function strokeReach(style) { + if (!(style.lineWidth > 0) || !(style.strokeStyle?.alpha > 0)) { + return 0; + } + const half = style.lineWidth / 2; + if (style.lineJoin !== "miter") { + return half; + } + // below 1 every corner is past the limit and is cut flat: half a width + return half * Math.max(1, miterLimitOf(style)); +} + /** * apply the current text style to the given context * @param {CanvasRenderingContext2D} context - the bake's context @@ -80,6 +126,8 @@ export default function setContextStyle(context, style) { context.fillStyle = fill; context.strokeStyle = style.strokeStyle.toRGBA(); context.lineWidth = style.lineWidth; + context.lineJoin = style.lineJoin ?? "round"; + context.miterLimit = miterLimitOf(style); context.textAlign = style.textAlign; context.textBaseline = style.textBaseline; return fill; diff --git a/packages/melonjs/tests/text-outline-join.spec.js b/packages/melonjs/tests/text-outline-join.spec.js new file mode 100644 index 000000000..36d3a8b55 --- /dev/null +++ b/packages/melonjs/tests/text-outline-join.spec.js @@ -0,0 +1,338 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { Application, boot, getPool, Text, video } from "../src/index.js"; + +/** + * A Text's outline is drawn whole, whatever its corners. + * + * A canvas strokes text with pointed ("miter") corners by default, and lets a + * point reach up to `miterLimit` (10) half line widths past its corner: on a + * sharp glyph such as an "A" or an "M", several times the half width the bake + * left room for, so the tips were cut flat. The bake left no room at all on + * the sides, so the outline of a leading "A" lost its left edge, whatever the + * corners. + * + * Outlines now have round corners by default, as in most game engines, and + * `lineJoin` / `miterLimit` choose others; the bake leaves room for whichever + * is set, on all four sides. + */ + +/** + * The first and last rows and columns of a Text's bake that hold any ink. + * @param {Text} text - a baked Text + * @returns {{top: number, bottom: number, left: number, right: number, width: number, height: number}} + */ +const inkBox = (text) => { + const { canvas, context } = text.canvasTexture; + const { width, height } = canvas; + const data = context.getImageData(0, 0, width, height).data; + let top = -1; + let bottom = -1; + let left = width; + let right = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (data[(y * width + x) * 4 + 3] !== 0) { + if (top === -1) { + top = y; + } + bottom = y; + left = Math.min(left, x); + right = Math.max(right, x); + } + } + } + return { top, bottom, left, right, width, height }; +}; + +/** a thickly outlined label of sharp glyphs */ +const outlined = (extra = {}) => { + return new Text(0, 0, { + font: "sans-serif", + size: 72, + fillStyle: "#ffffff", + strokeStyle: "#000000", + lineWidth: 8, + text: "AVWM", + ...extra, + }); +}; + +/** no ink on any edge of the bake, where a cut would leave it */ +const expectUncut = (text) => { + const ink = inkBox(text); + expect(ink.top).toBeGreaterThan(0); + expect(ink.left).toBeGreaterThan(0); + expect(ink.bottom).toBeLessThan(ink.height - 1); + expect(ink.right).toBeLessThan(ink.width - 1); +}; + +describe("Text outline corners: lineJoin and miterLimit", () => { + let app; + + beforeAll(async () => { + boot(); + app = new Application(320, 240, { + parent: "screen", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + describe("settings", () => { + it("default to round corners, and the canvas's miter limit", () => { + const text = outlined(); + expect(text.lineJoin).toBe("round"); + expect(text.miterLimit).toBe(10); + }); + + it("take lineJoin and miterLimit from the settings", () => { + const text = outlined({ lineJoin: "miter", miterLimit: 3 }); + expect(text.lineJoin).toBe("miter"); + expect(text.miterLimit).toBe(3); + }); + + it("stroke the glyphs with them", () => { + const seen = []; + const spy = vi + .spyOn(CanvasRenderingContext2D.prototype, "strokeText") + .mockImplementation(function () { + seen.push([this.lineJoin, this.miterLimit]); + }); + try { + outlined(); + outlined({ lineJoin: "bevel" }); + outlined({ lineJoin: "miter", miterLimit: 3 }); + } finally { + spy.mockRestore(); + } + expect(seen).toEqual([ + ["round", 10], + ["bevel", 10], + ["miter", 3], + ]); + }); + + it("a miter limit the canvas would refuse strokes with the default", () => { + const seen = []; + const spy = vi + .spyOn(CanvasRenderingContext2D.prototype, "strokeText") + .mockImplementation(function () { + seen.push(this.miterLimit); + }); + try { + for (const miterLimit of [0, -2, Number.NaN, Infinity]) { + outlined({ lineJoin: "miter", miterLimit }); + } + } finally { + spy.mockRestore(); + } + expect(seen).toEqual([10, 10, 10, 10]); + }); + }); + + describe("changing the corners later", () => { + it("shows from the next setText, with room for the new corners", () => { + const text = outlined(); + const before = text.canvasTexture.height; + text.lineJoin = "miter"; + text.setText("AVWM"); + expect(text.canvasTexture.height).toBeGreaterThan(before); + expectUncut(text); + + // and back: the canvas only grows, but the outline is round again + const seen = []; + const spy = vi + .spyOn(CanvasRenderingContext2D.prototype, "strokeText") + .mockImplementation(function () { + seen.push(this.lineJoin); + }); + try { + text.lineJoin = "round"; + text.setText("AVWM"); + } finally { + spy.mockRestore(); + } + expect(seen).toEqual(["round"]); + }); + + it("a lower miterLimit takes less room", () => { + const long = outlined({ lineJoin: "miter" }); + const short = outlined({ lineJoin: "miter", miterLimit: 2 }); + expect(short.metrics.inkPadTop).toBeLessThan(long.metrics.inkPadTop); + expect(short.metrics.inkPadLeft).toBeLessThan(long.metrics.inkPadLeft); + expectUncut(short); + }); + + it("round and bevel corners take the same room, whatever the miterLimit", () => { + const round = outlined({ miterLimit: 50 }); + const bevel = outlined({ lineJoin: "bevel", miterLimit: 50 }); + const plain = outlined(); + for (const text of [round, bevel]) { + expect(text.metrics.inkPadTop).toBe(plain.metrics.inkPadTop); + expect(text.metrics.inkPadBottom).toBe(plain.metrics.inkPadBottom); + expect(text.metrics.inkPadLeft).toBe(plain.metrics.inkPadLeft); + expect(text.metrics.inkPadRight).toBe(plain.metrics.inkPadRight); + } + }); + + it("an outline left invisible takes no room for its corners", () => { + const plain = outlined({ lineWidth: 0 }); + const hidden = outlined({ lineJoin: "miter", strokeStyle: "#00000000" }); + expect(hidden.metrics.inkPadTop).toBe(plain.metrics.inkPadTop); + expect(hidden.metrics.inkPadLeft).toBe(plain.metrics.inkPadLeft); + }); + }); + + describe("a recycled label", () => { + it("does not keep the corners of the one before", () => { + const pool = getPool("text"); + const first = pool.get(0, 0, { + font: "sans-serif", + size: 24, + lineWidth: 4, + lineJoin: "miter", + miterLimit: 2, + text: "A", + }); + pool.release(first); + const second = pool.get(0, 0, { + font: "sans-serif", + size: 24, + lineWidth: 4, + text: "A", + }); + try { + expect(second).toBe(first); + expect(second.lineJoin).toBe("round"); + expect(second.miterLimit).toBe(10); + } finally { + pool.release(second); + } + }); + }); + + describe("the outline is baked whole", () => { + it.each([ + ["round (the default)", {}], + ["bevel", { lineJoin: "bevel" }], + ["miter, at the canvas's limit of 10", { lineJoin: "miter" }], + ["miter, limited to 3", { lineJoin: "miter", miterLimit: 3 }], + [ + "miter, with a limit the canvas refuses", + { lineJoin: "miter", miterLimit: 0 }, + ], + ])("with %s corners", (_name, extra) => { + expectUncut(outlined(extra)); + }); + + it.each([ + ["left", 0], + ["center", 160], + ["right", 320], + ])("on every line of a %s aligned block", (textAlign, x) => { + const text = new Text(x, 0, { + font: "sans-serif", + size: 48, + fillStyle: "#ffffff", + strokeStyle: "#000000", + lineWidth: 8, + lineJoin: "miter", + textAlign, + text: "AVA\nWIDE MAW\nM", + }); + expectUncut(text); + }); + + it("with no outline, the glyphs themselves", () => { + expectUncut(outlined({ lineWidth: 0 })); + }); + }); + + describe("the label's layout is unchanged", () => { + it("reports the same bounds whatever the corners", () => { + const round = outlined(); + const miter = outlined({ lineJoin: "miter" }); + const plain = outlined({ lineWidth: 0 }); + for (const text of [miter, plain]) { + expect(text.metrics.x).toBe(round.metrics.x); + expect(text.metrics.y).toBe(round.metrics.y); + expect(text.metrics.width).toBe(round.metrics.width); + expect(text.metrics.height).toBe(round.metrics.height); + } + }); + + it.each([ + ["round", {}], + ["miter", { lineJoin: "miter" }], + ])( + "draws the %s outline on the screen pixels a plain canvas does", + (_name, extra) => { + // what a canvas draws for the same text, straight at the label's + // position: the bake's room must not move a pixel of it + const settings = { + font: "sans-serif", + size: 40, + fillStyle: "#ffffff", + strokeStyle: "#ff0000", + lineWidth: 6, + text: "AV", + ...extra, + }; + const at = { x: 60, y: 50 }; + const text = new Text(at.x, at.y, settings); + + const renderer = app.renderer; + const screen = renderer.getContext(); + const canvas = renderer.getCanvas(); + renderer.clearColor("#00000000", true); + renderer.save(); + renderer.resetTransform(); + text.draw(renderer); + renderer.restore(); + const drawn = screen.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ).data; + + const reference = document.createElement("canvas"); + reference.width = canvas.width; + reference.height = canvas.height; + const ctx = reference.getContext("2d"); + ctx.font = text.font; + ctx.textBaseline = "top"; + ctx.lineWidth = settings.lineWidth; + ctx.lineJoin = settings.lineJoin ?? "round"; + ctx.fillStyle = settings.fillStyle; + ctx.strokeStyle = settings.strokeStyle; + ctx.fillText(settings.text, at.x, at.y); + ctx.strokeText(settings.text, at.x, at.y); + const expected = ctx.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ).data; + + // the same pixels inked, give or take antialiasing on the edges + let differ = 0; + let inked = 0; + for (let i = 3; i < drawn.length; i += 4) { + if (expected[i] > 128) { + inked++; + } + if (drawn[i] > 128 !== expected[i] > 128) { + differ++; + } + } + expect(inked).toBeGreaterThan(100); + expect(differ).toBe(0); + }, + ); + }); +}); diff --git a/packages/melonjs/tests/text_bucket.spec.js b/packages/melonjs/tests/text_bucket.spec.js index c876a13a2..999c3cdcb 100644 --- a/packages/melonjs/tests/text_bucket.spec.js +++ b/packages/melonjs/tests/text_bucket.spec.js @@ -14,10 +14,10 @@ import { * (the pre-existing grow-only rule). Replaces the power-of-two rounding, * whose waste was multiplicative instead of ≤31px per axis. * - * Height is bucketed from the layout box PLUS the ink padding, not from the - * layout box alone — the padding is canvas the glyphs genuinely occupy, so - * measuring waste against `metrics.height` would call it waste and would go - * red the moment the padding crossed a bucket boundary. + * Both sides are bucketed from the layout box PLUS the ink padding, not from + * the layout box alone — the padding is canvas the glyphs genuinely occupy, so + * measuring waste against `metrics.width` / `metrics.height` would call it + * waste and would go red the moment the padding crossed a bucket boundary. */ describe("Text — 32px canvas buckets", () => { // borrow the session's single shared renderer — specs must never boot @@ -49,10 +49,14 @@ describe("Text — 32px canvas buckets", () => { }; // everything the canvas has to hold: the layout box and the ink that - // escapes it top and bottom + // escapes it top and bottom... const baked = (t) => { return t.metrics.height + t.metrics.inkPadTop + t.metrics.inkPadBottom; }; + // ...and left and right + const bakedWidth = (t) => { + return t.metrics.width + t.metrics.inkPadLeft + t.metrics.inkPadRight; + }; it("the canvas lands exactly on the metric's 32px bucket (no power-of-two jumps)", (ctx) => { requireWebGL(ctx, renderer); @@ -60,10 +64,10 @@ describe("Text — 32px canvas buckets", () => { const c = t.canvasTexture; expect(c.width % 32).toBe(0); expect(c.height % 32).toBe(0); - expect(c.width).toBe(bucket(t.metrics.width)); + expect(c.width).toBe(bucket(bakedWidth(t))); expect(c.height).toBe(bucket(baked(t))); // waste is bounded additively — the whole point of the change - expect(c.width - t.metrics.width).toBeLessThan(32); + expect(c.width - bakedWidth(t)).toBeLessThan(32); expect(c.height - baked(t)).toBeLessThan(32); }); @@ -89,8 +93,8 @@ describe("Text — 32px canvas buckets", () => { const c = t.canvasTexture; expect(c.width % 32, str).toBe(0); expect(c.height % 32, str).toBe(0); - expect(c.width, str).toBeGreaterThanOrEqual(Math.ceil(t.metrics.width)); - expect(c.width - t.metrics.width, str).toBeLessThan(32); + expect(c.width, str).toBeGreaterThanOrEqual(Math.ceil(bakedWidth(t))); + expect(c.width - bakedWidth(t), str).toBeLessThan(32); // the canvas must FIT the padded box — one pixel short of this is // the top row of a stroke sheared off expect(c.height, str).toBeGreaterThanOrEqual(Math.ceil(baked(t))); @@ -145,8 +149,8 @@ describe("Text — 32px canvas buckets", () => { const grown = t.canvasTexture.width; expect(grown).toBeGreaterThan(first); expect(grown % 32).toBe(0); - expect(grown).toBe(bucket(t.metrics.width)); - expect(grown - t.metrics.width).toBeLessThan(32); + expect(grown).toBe(bucket(bakedWidth(t))); + expect(grown - bakedWidth(t)).toBeLessThan(32); }); it("the canvas NEVER shrinks (grow-only hysteresis preserved)", (ctx) => { @@ -189,7 +193,7 @@ describe("Text — 32px canvas buckets", () => { const t = makeText("BIG", 180); const c = t.canvasTexture; expect(c.width % 32).toBe(0); - expect(c.width - t.metrics.width).toBeLessThan(32); + expect(c.width - bakedWidth(t)).toBeLessThan(32); expect(c.height - t.metrics.height).toBeLessThan(32); // sanity: POT would have padded up to nearly 2× in the worst case; // the bucket keeps total waste under one row/column of 32