Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 9 additions & 44 deletions packages/examples/src/examples/text/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
type Application,
BitmapText,
ColorLayer,
Container,
NineSliceSprite,
Renderable,
type Renderer,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion packages/melonjs/skills/melonjs-ui-and-text/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
---

Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions packages/melonjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
140 changes: 139 additions & 1 deletion packages/melonjs/src/renderable/text/bitmaptext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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".
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions packages/melonjs/src/renderable/text/glypheffect.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading