Skip to content

Commit 6db3d9b

Browse files
obiotclaude
andauthored
Fix BitmapText bounding box alignment and baseline positioning (#1355)
* Migrate text example to Application, remove standalone draw - ExampleText: use new Application() + Stage pattern - TextScreen: all text objects added as world children - BaselineOverlay: custom renderable for baseline reference lines - Remove deprecated video.init/device.onReady/game.world usage - Deferred #1345 multiline baseline tests (needs TextMetrics refactor) Note: bounding boxes for text with non-default textAlign/textBaseline are a pre-existing issue (#1345) — not a regression from this change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix BitmapText bounding box alignment and baseline positioning - Width: use max(xadvance, xoffset + width) for last glyph visual extent - Height: use actual glyph extents (maxBottom - minTop) instead of capHeight - Baseline: use real glyph metrics for middle/bottom/alphabetic/ideographic shifts in both draw and updateBounds, so text is correctly centered/aligned - Y offset: bounding box starts at first visible pixel (glyphMinTop) - Optimize: precompute glyphMinTop/glyphMaxBottom in BitmapTextData.parse() - Optimize: cache measureText results, only recompute in setText/resize - Update text example: increase multiline font size, adjust layout - Add comprehensive Text and BitmapText bounds test coverage (36 new tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix typo in xolo12.fnt test data: y=4a0 → y=40 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix multiline BitmapText baseline shift to use total text height Move baseline y-shift out of the per-line loop and apply it once based on the full text block height. Previously, the per-line shift caused multiline text with bottom/middle baselines to accumulate offsets incorrectly. - bottom/alphabetic/ideographic: shift by glyphYOffset + totalHeight - middle: shift by glyphYOffset + totalHeight/2 - updateBounds uses the same formula for consistent bounds Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 18e959a commit 6db3d9b

File tree

10 files changed

+1197
-208
lines changed

10 files changed

+1197
-208
lines changed

packages/examples/src/examples/text/ExampleText.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { DebugPanelPlugin } from "@melonjs/debug-plugin";
2-
import { game, loader, plugin, video } from "melonjs";
2+
import { Application, loader, plugin, state, video } from "melonjs";
33
import { createExampleComponent } from "../utils.tsx";
4-
import { TextTest } from "./text.ts";
4+
import { TextScreen } from "./text.ts";
55

66
const base = `${import.meta.env.BASE_URL}assets/text/`;
77

88
const createGame = () => {
9-
video.init(640, 480, {
9+
const _app = new Application(640, 480, {
1010
parent: "screen",
1111
scale: "auto",
1212
renderer: video.AUTO,
13-
preferWebGL1: false,
1413
});
1514

1615
// register the debug plugin
1716
plugin.register(DebugPanelPlugin, "debugPanel");
1817

19-
// set all ressources to be loaded
18+
// set all resources to be loaded
2019
loader.preload(
2120
[
2221
{ name: "xolo12", type: "image", src: `${base}xolo12.png` },
@@ -25,8 +24,8 @@ const createGame = () => {
2524
{ name: "arialfancy", type: "binary", src: `${base}arialfancy.fnt` },
2625
],
2726
() => {
28-
game.world.reset();
29-
game.world.addChild(new TextTest(), 1);
27+
state.set(state.PLAY, new TextScreen());
28+
state.change(state.PLAY);
3029
},
3130
);
3231
};
Lines changed: 143 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,82 @@
11
import {
2+
type Application,
23
BitmapText,
3-
type Color,
4-
getPool,
4+
ColorLayer,
55
Renderable,
6+
Stage,
67
Text,
7-
Tween,
8-
video,
98
} from "melonjs";
109

11-
export class TextTest extends Renderable {
12-
color: Color;
13-
14-
constructor() {
15-
super(0, 0, 640, 480);
16-
10+
/**
11+
* Overlay that draws the two red baseline reference lines.
12+
*/
13+
class BaselineOverlay extends Renderable {
14+
constructor(w: number, h: number) {
15+
super(0, 0, w, h);
1716
this.anchorPoint.set(0, 0);
17+
this.alwaysUpdate = true;
18+
}
1819

19-
// a default white color object
20-
this.color = getPool("color").get(255, 255, 255);
21-
22-
// define a tween to cycle the font color
23-
this.tween = new Tween(this.color)
24-
.to(
25-
{
26-
g: 0,
27-
b: 0,
28-
},
29-
2000,
30-
)
31-
.repeat(Number.POSITIVE_INFINITY)
32-
.yoyo(true)
33-
.start();
34-
35-
// text font
36-
this.font = new Text(0, 0, {
37-
font: "Arial",
38-
size: 8,
39-
fillStyle: "white",
40-
});
20+
override update() {
21+
return true;
22+
}
4123

42-
// bitmap font
43-
this.bFont = new BitmapText(0, 0, { font: "xolo12", size: 4.0 });
44-
this.fancyBFont = new BitmapText(0, 0, { font: "arialfancy" });
24+
override draw(renderer) {
25+
renderer.setColor("red");
26+
renderer.lineWidth = 3;
27+
renderer.strokeLine(0, 200.5, this.width, 200.5);
28+
renderer.strokeLine(0, 375.5, this.width, 375.5);
29+
renderer.lineWidth = 1;
4530
}
31+
}
4632

47-
// draw function
48-
draw(renderer) {
49-
let text = "";
50-
let xPos = 0;
51-
let yPos = 0;
33+
/**
34+
* Text example Stage.
35+
*/
36+
export class TextScreen extends Stage {
37+
onResetEvent(app: Application) {
38+
const w = app.viewport.width;
5239

53-
// black background
54-
renderer.clearColor("#202020");
40+
app.world.addChild(new ColorLayer("background", "#202020"), 0);
41+
app.world.addChild(new BaselineOverlay(w, app.viewport.height), 2);
5542

56-
// font size test
57-
this.font.textAlign = "left";
58-
this.font.strokeStyle.parseCSS("red");
59-
this.font.lineWidth = 1;
60-
this.font.setOpacity(0.5);
43+
// ---- Font size test (left side) ----
44+
let yPos = 0;
6145
for (let i = 8; i < 56; i += 8) {
62-
this.font.setFont("Arial", i);
63-
renderer.setTint(this.color);
64-
this.font.draw(renderer, `Arial Text ${i}px !`, 5, yPos);
65-
yPos += this.font.getBounds().height;
46+
const t = new Text(5, yPos, {
47+
font: "Arial",
48+
size: i,
49+
fillStyle: "white",
50+
strokeStyle: "red",
51+
lineWidth: 1,
52+
textBaseline: "top",
53+
textAlign: "left",
54+
text: `Arial Text ${i}px !`,
55+
});
56+
t.setOpacity(0.5);
57+
app.world.addChild(t, 1);
58+
yPos += t.getBounds().height;
6659
}
67-
this.font.lineWidth = 0;
6860

69-
// bFont size test
61+
// ---- BitmapText size test (right side) ----
7062
yPos = 0;
71-
this.bFont.textAlign = "right";
72-
this.bFont.fillStyle.parseCSS("indigo");
7363
for (let i = 1; i < 5; i++) {
74-
//this.bFont.setOpacity (0.1 * i);
75-
this.bFont.resize(i * 0.75);
76-
this.bFont.fillStyle.lighten(0.25);
77-
// call preDraw and postDraw for the tint to work
78-
// as the font is not added to the game world
79-
this.bFont.preDraw(renderer);
80-
this.bFont.draw(renderer, "BITMAP TEST", video.renderer.width, yPos);
81-
this.bFont.postDraw(renderer);
82-
yPos += this.bFont.getBounds().height * 1.5;
64+
const b = new BitmapText(w, yPos, {
65+
font: "xolo12",
66+
size: i * 0.75,
67+
textAlign: "right",
68+
textBaseline: "top",
69+
text: "BITMAP TEST",
70+
});
71+
b.fillStyle.parseCSS("indigo");
72+
for (let j = 0; j < i; j++) {
73+
b.fillStyle.lighten(0.25);
74+
}
75+
app.world.addChild(b, 1);
76+
yPos += b.getBounds().height * 1.5;
8377
}
8478

85-
this.font.setOpacity(1);
86-
this.bFont.setOpacity(1);
87-
88-
// font baseline test
89-
this.font.setFont("Arial", 16);
90-
let baseline = 200;
91-
92-
// Draw the baseline
93-
video.renderer.setColor("red");
94-
video.renderer.lineWidth = 3;
95-
video.renderer.strokeLine(
96-
0,
97-
baseline + 0.5,
98-
video.renderer.width,
99-
baseline + 0.5,
100-
);
101-
79+
// ---- Font baseline test (y=200) ----
10280
const baselines = [
10381
"bottom",
10482
"ideographic",
@@ -107,87 +85,97 @@ export class TextTest extends Renderable {
10785
"hanging",
10886
"top",
10987
];
88+
let xPos = 0;
11089

111-
// font baseline test
112-
video.renderer.setColor("white");
113-
for (let i = 0; i < baselines.length; i++) {
114-
text = baselines[i];
115-
this.font.textBaseline = baselines[i];
116-
this.font.lineWidth = 0;
117-
this.font.draw(renderer, text, xPos, baseline);
118-
xPos += this.font.measureText(renderer, `${text}@@@`).width;
90+
// we need a temp Text to measure widths for spacing
91+
const tmpFont = new Text(0, 0, {
92+
font: "Arial",
93+
size: 16,
94+
fillStyle: "white",
95+
});
96+
for (const bl of baselines) {
97+
const t = new Text(xPos, 200, {
98+
font: "Arial",
99+
size: 16,
100+
fillStyle: "white",
101+
textBaseline: bl,
102+
textAlign: "left",
103+
text: bl,
104+
});
105+
app.world.addChild(t, 3);
106+
// measure with extra chars for spacing (matching original)
107+
tmpFont.textBaseline = bl;
108+
tmpFont.setText(`${bl}@@@`);
109+
xPos += tmpFont.measureText().width;
119110
}
120111

121-
// restore default baseline
122-
this.font.textBaseline = "top";
123-
124-
// ---- multiline testing -----
125-
126-
// font text
127-
text =
128-
"this is a multiline font\n test with melonjs and it\nworks even with a\n specific lineHeight value!";
129-
this.font.textAlign = "center";
130-
this.font.lineHeight = 1.1;
131-
this.font.lineWidth = 0;
132-
this.font.draw(renderer, text, 90, 210);
133-
134-
text =
135-
"this is another web font \nwith right alignment\nand it still works!";
136-
this.font.textAlign = "right";
137-
this.font.lineHeight = 1.1;
138-
this.font.draw(renderer, text, 200, 300);
139-
140-
// bitmapfonts
141-
// bFont test
142-
this.fancyBFont.textAlign = "right";
143-
this.fancyBFont.wordWrapWidth = 430;
144-
text =
145-
"ANOTHER FANCY MULTILINE BITMAP TEXT USING WORD WRAP AND IT STILL WORKS";
146-
this.fancyBFont.lineHeight = 1.2;
147-
this.fancyBFont.resize(1.5);
148-
this.fancyBFont.draw(renderer, text, 620, 230);
149-
this.fancyBFont.lineHeight = 1.0;
150-
this.fancyBFont.wordWrapWidth = -1;
151-
152-
this.bFont.textAlign = "center";
153-
text = "THIS IS A MULTILINE\n BITMAP TEXT WITH MELONJS\nAND IT WORKS";
154-
this.bFont.resize(2.5);
155-
this.bFont.draw(renderer, text, video.renderer.width / 2, 400);
112+
// ---- Multiline text (center aligned) ----
113+
app.world.addChild(
114+
new Text(90, 210, {
115+
font: "Arial",
116+
size: 14,
117+
fillStyle: "white",
118+
textAlign: "center",
119+
textBaseline: "top",
120+
lineHeight: 1.1,
121+
text: "this is a multiline font\n test with melonjs and it\nworks even with a\n specific lineHeight value!",
122+
}),
123+
1,
124+
);
156125

157-
// baseline test with bitmap font
158-
xPos = 0;
159-
this.fancyBFont.textAlign = "left";
160-
baseline = 375;
126+
// ---- Multiline text (right aligned) ----
127+
app.world.addChild(
128+
new Text(165, 290, {
129+
font: "Arial",
130+
size: 14,
131+
fillStyle: "white",
132+
textAlign: "right",
133+
textBaseline: "top",
134+
lineHeight: 1.1,
135+
text: "this is another web font \nwith right alignment\nand it still works!",
136+
}),
137+
1,
138+
);
161139

162-
// Draw the baseline
163-
video.renderer.setColor("red");
164-
video.renderer.strokeLine(
165-
0,
166-
baseline + 0.5,
167-
video.renderer.width,
168-
baseline + 0.5,
140+
// ---- Fancy BitmapText multiline with word wrap ----
141+
const fancy = new BitmapText(620, 230, {
142+
font: "arialfancy",
143+
textAlign: "right",
144+
textBaseline: "top",
145+
size: 1.5,
146+
});
147+
fancy.lineHeight = 1.2;
148+
fancy.wordWrapWidth = 430;
149+
fancy.setText(
150+
"ANOTHER FANCY MULTILINE BITMAP TEXT USING WORD WRAP AND IT STILL WORKS",
169151
);
152+
app.world.addChild(fancy, 1);
153+
154+
// ---- BitmapText multiline centered ----
155+
const bMulti = new BitmapText(w / 2, 400, {
156+
font: "xolo12",
157+
size: 2.5,
158+
textAlign: "center",
159+
textBaseline: "top",
160+
text: "THIS IS A MULTILINE\n BITMAP TEXT WITH MELONJS\nAND IT WORKS",
161+
});
162+
app.world.addChild(bMulti, 1);
170163

171-
// font baseline test
172-
video.renderer.setColor("white");
173-
this.fancyBFont.resize(1.275);
174-
for (let i = 0; i < baselines.length; i++) {
175-
text = baselines[i];
176-
this.fancyBFont.textBaseline = baselines[i];
177-
this.fancyBFont.draw(renderer, text, xPos, baseline);
178-
xPos += this.fancyBFont.measureText(`${text}@`).width;
164+
// ---- BitmapText baseline test (y=375) ----
165+
xPos = 0;
166+
const tmpBFont = new BitmapText(0, 0, { font: "arialfancy", size: 1.275 });
167+
for (const bl of baselines) {
168+
const b = new BitmapText(xPos, 375, {
169+
font: "arialfancy",
170+
size: 1.275,
171+
textBaseline: bl,
172+
textAlign: "left",
173+
text: bl,
174+
});
175+
app.world.addChild(b, 3);
176+
tmpBFont.textBaseline = bl;
177+
tmpBFont.setText(`${bl}@`);
178+
xPos += tmpBFont.measureText().width;
179179
}
180-
181-
// restore default alignement/baseline
182-
this.font.textAlign = "left";
183-
this.font.textBaseline = "top";
184-
this.bFont.textAlign = "left";
185-
this.bFont.textBaseline = "top";
186-
this.fancyBFont.textAlign = "left";
187-
this.fancyBFont.textBaseline = "top";
188-
}
189-
190-
destroy() {
191-
getPool("color").release(this.color);
192180
}
193181
}

packages/melonjs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
### Fixed
3030
- Path2D: fix `quadraticCurveTo()` and `bezierCurveTo()` using a reference to `startPoint` instead of capturing coordinates — `lineTo()` mutates `startPoint` on each call, causing the curve to deform as it was tessellated. Captured `lx`/`ly` values instead.
3131
- Path2D: fix `quadraticCurveTo()` and `bezierCurveTo()` segment count — was using `arcResolution` directly (2 segments), now computes adaptive segment count based on control polygon length for smooth curves.
32+
- Text: fix textBaseline y offset for multiline text — "bottom"/"middle" used single line height instead of total text height, causing misaligned bounding boxes
33+
- BitmapText: fix bounds offset direction for textAlign/textBaseline — bounds were shifted in the wrong direction for "right"/"center"/"bottom"/"middle"
3234
- Application: `Object.assign(defaultApplicationSettings, options)` mutated the shared defaults object in both `Application.init()` and `video.init()` — creating multiple Application instances would corrupt settings. Fixed with object spread.
3335
- Text/Light2d: fix invalid `pool.push` on CanvasRenderTarget instances that were never pool-registered (would throw on destroy)
3436
- CanvasRenderTarget: `destroy(renderer)` now properly cleans up WebGL GPU textures and cache entries (previously leaked in Light2d)

0 commit comments

Comments
 (0)