Skip to content

Commit 18e959a

Browse files
authored
Merge pull request #1354 from melonjs/feature/bezier-curves
Add bezier/quadratic curve support, fix Path2D bugs, extract utilities
2 parents ee6f2b4 + 2cacdc4 commit 18e959a

File tree

14 files changed

+1204
-242
lines changed

14 files changed

+1204
-242
lines changed

packages/examples/src/examples/graphics/ExampleGraphics.tsx

Lines changed: 86 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {
2+
Application,
23
type CanvasRenderer,
34
Color,
45
Ellipse,
5-
game,
66
Matrix2d,
77
Polygon,
88
Renderable,
@@ -14,18 +14,10 @@ import {
1414
import { createExampleComponent } from "../utils";
1515

1616
const createGame = () => {
17-
// Initialize the video.
18-
if (
19-
!video.init(1024, 768, {
20-
parent: "screen",
21-
renderer: video.AUTO,
22-
preferWebGL1: false,
23-
blendMode: "normal",
24-
})
25-
) {
26-
alert("Your browser does not support HTML5 canvas.");
27-
return;
28-
}
17+
const _app = new Application(1024, 768, {
18+
parent: "screen",
19+
renderer: video.AUTO,
20+
});
2921

3022
class Graphics extends Renderable {
3123
starMask: Polygon;
@@ -34,6 +26,8 @@ const createGame = () => {
3426
stripe1: Polygon;
3527
stripe2: Polygon;
3628
stripe3: Polygon;
29+
stripe4: Polygon;
30+
stripe5: Polygon;
3731
roundRect1: RoundRect;
3832
roundRect2: RoundRect;
3933
rrect1Tween: Tween;
@@ -45,7 +39,7 @@ const createGame = () => {
4539
transformMatrix: Matrix2d;
4640
// constructor
4741
constructor() {
48-
super(0, 0, game.viewport.width, game.viewport.height);
42+
super(0, 0, 1024, 768);
4943

5044
this.starMask = new Polygon(300, 70, [
5145
// draw a star
@@ -73,28 +67,42 @@ const createGame = () => {
7367
this.starMask.scale(4.0);
7468
this.polymask.scale(4.0);
7569

76-
this.circleMask = new Ellipse(630 + 50, 520 + 50, 200, 200);
70+
this.circleMask = new Ellipse(630 + 50, 560 + 50, 200, 200);
7771

72+
// Commodore rainbow stripes
73+
const bw = 20;
7874
this.stripe1 = new Polygon(0, 0, [
79-
{ x: 0, y: 40 },
75+
{ x: 0, y: bw },
8076
{ x: 0, y: 0 },
81-
{ x: 40, y: 0 },
77+
{ x: bw, y: 0 },
8278
]);
8379
this.stripe2 = new Polygon(0, 0, [
84-
{ x: 0, y: 40 },
85-
{ x: 40, y: 0 },
86-
{ x: 60, y: 0 },
87-
{ x: 0, y: 60 },
80+
{ x: 0, y: bw },
81+
{ x: bw, y: 0 },
82+
{ x: bw * 2, y: 0 },
83+
{ x: 0, y: bw * 2 },
8884
]);
8985
this.stripe3 = new Polygon(0, 0, [
90-
{ x: 0, y: 60 },
91-
{ x: 60, y: 0 },
92-
{ x: 80, y: 0 },
93-
{ x: 0, y: 80 },
86+
{ x: 0, y: bw * 2 },
87+
{ x: bw * 2, y: 0 },
88+
{ x: bw * 3, y: 0 },
89+
{ x: 0, y: bw * 3 },
90+
]);
91+
this.stripe4 = new Polygon(0, 0, [
92+
{ x: 0, y: bw * 3 },
93+
{ x: bw * 3, y: 0 },
94+
{ x: bw * 4, y: 0 },
95+
{ x: 0, y: bw * 4 },
96+
]);
97+
this.stripe5 = new Polygon(0, 0, [
98+
{ x: 0, y: bw * 4 },
99+
{ x: bw * 4, y: 0 },
100+
{ x: bw * 5, y: 0 },
101+
{ x: 0, y: bw * 5 },
94102
]);
95103

96-
this.roundRect1 = new RoundRect(100, 470, 400, 180, 4);
97-
this.roundRect2 = new RoundRect(105, 475, 390, 170, 4);
104+
this.roundRect1 = new RoundRect(100, 530, 400, 180, 4);
105+
this.roundRect2 = new RoundRect(105, 535, 390, 170, 4);
98106

99107
this.rrect1Tween = new Tween(this.roundRect1).to(
100108
{ radius: 100 },
@@ -118,21 +126,26 @@ const createGame = () => {
118126
);
119127

120128
// rotating + transformed ellipse
121-
this.filledEllipse = new Ellipse(860, 410, 200, 100);
129+
this.filledEllipse = new Ellipse(860, 460, 200, 100);
122130
this.transformMatrix = new Matrix2d();
123131
this.ellipseTime = 0;
124132
this.arcAngle = 0;
125133

126134
// a temporary color object
127135
this.color = new Color();
128136

137+
// pre-allocate dash patterns to avoid per-frame array creation
138+
this.dashZigzag = [10, 6];
139+
this.dashCurve = [8, 4];
140+
this.noDash = [] as number[];
141+
129142
this.anchorPoint.set(0, 0);
130143
}
131144

132145
override update(dt: number) {
133146
this.ellipseTime += dt;
134147
// reset and apply rotation + oscillating scale transform
135-
this.filledEllipse.setShape(860, 410, 200, 100);
148+
this.filledEllipse.setShape(860, 460, 200, 100);
136149
this.filledEllipse.rotate(this.ellipseTime / 1000);
137150
this.transformMatrix.identity();
138151
this.transformMatrix.scale(
@@ -152,16 +165,17 @@ const createGame = () => {
152165

153166
renderer.lineWidth = 3;
154167

155-
// draw 3 stripes
156-
this.color.parseHex("#55aa00");
157-
renderer.setColor(this.color);
168+
// draw Commodore rainbow stripes (red, orange, yellow, green, blue)
169+
renderer.setColor("#e02020");
158170
renderer.fill(this.stripe1);
159-
renderer.setColor("#ffcc00");
160-
// lerp from the the starting color and the current renderer one
161-
renderer.setColor(this.color.lerp(renderer.getColor(), 0.5));
171+
renderer.setColor("#e07020");
162172
renderer.fill(this.stripe2);
163-
renderer.setColor("#ffcc00");
173+
renderer.setColor("#e0c020");
164174
renderer.fill(this.stripe3);
175+
renderer.setColor("#40a040");
176+
renderer.fill(this.stripe4);
177+
renderer.setColor("#40a0e0");
178+
renderer.fill(this.stripe5);
165179

166180
renderer.setColor("#ffcc00");
167181
renderer.setGlobalAlpha(0.375);
@@ -170,19 +184,29 @@ const createGame = () => {
170184
renderer.fill(this.polymask.getBounds());
171185
renderer.clearMask();
172186

187+
// star bounding box with solid color
173188
renderer.setColor("#55aa00");
174-
renderer.fill(this.starMask);
175189
renderer.setGlobalAlpha(0.5);
176-
renderer.stroke(this.starMask);
177190
renderer.fill(this.starMask.getBounds());
178191
renderer.setGlobalAlpha(1.0);
179192
renderer.stroke(this.starMask.getBounds());
180193

194+
renderer.setColor("#88cc44");
195+
renderer.setGlobalAlpha(0.5);
196+
renderer.fill(this.starMask.getBounds());
197+
renderer.setGlobalAlpha(1.0);
198+
renderer.stroke(this.starMask.getBounds());
199+
200+
renderer.setColor("#88cc44");
201+
renderer.fill(this.starMask);
202+
renderer.setColor("#55aa00");
203+
renderer.stroke(this.starMask);
204+
181205
renderer.setGlobalAlpha(0.5);
182206

183207
renderer.setColor("#e15d55");
184208
renderer.save();
185-
renderer.translate(740, 200);
209+
renderer.translate(740, 260);
186210
renderer.rotate(this.arcAngle);
187211
renderer.strokeArc(0, 0, 110, Math.PI, 0);
188212
renderer.fillArc(0, 0, 110, 0, Math.PI);
@@ -191,9 +215,9 @@ const createGame = () => {
191215
renderer.setColor("#00aa88");
192216
renderer.translate(25, 0);
193217
renderer.setMask(this.circleMask);
194-
renderer.fillRect(580, 470, 200, 200);
195-
renderer.strokeEllipse(630 + 50, 520 + 50, 70, 70);
196-
renderer.strokeRect(600, 490, 160, 160);
218+
renderer.fillRect(580, 510, 200, 200);
219+
renderer.strokeEllipse(630 + 50, 560 + 50, 70, 70);
220+
renderer.strokeRect(600, 530, 160, 160);
197221
renderer.clearMask();
198222

199223
renderer.beginPath();
@@ -208,15 +232,32 @@ const createGame = () => {
208232
renderer.stroke();
209233

210234
// dashed zigzag line
211-
renderer.setLineDash([10, 6]);
235+
renderer.setLineDash(this.dashZigzag);
212236
renderer.beginPath();
213237
renderer.moveTo(540, 50);
214238
renderer.lineTo(640, 75);
215239
renderer.lineTo(740, 50);
216240
renderer.lineTo(840, 75);
217241
renderer.lineTo(940, 50);
218242
renderer.stroke();
219-
renderer.setLineDash([]);
243+
renderer.setLineDash(this.noDash);
244+
245+
// animated cubic bezier curve
246+
const wave = Math.sin(this.ellipseTime / 500) * 50;
247+
renderer.beginPath();
248+
renderer.setColor("#10b981");
249+
renderer.moveTo(540, 100);
250+
renderer.bezierCurveTo(640, 30 - wave, 840, 170 + wave, 940, 100);
251+
renderer.stroke();
252+
253+
// animated dashed quadratic bezier curve (inverted)
254+
renderer.setLineDash(this.dashCurve);
255+
renderer.beginPath();
256+
renderer.setColor("#f59e0b");
257+
renderer.moveTo(540, 100);
258+
renderer.quadraticCurveTo(740, 130 - wave, 940, 100);
259+
renderer.stroke();
260+
renderer.setLineDash(this.noDash);
220261

221262
renderer.setColor("#ff69b4");
222263
renderer.fill(this.roundRect1);
@@ -233,6 +274,6 @@ const createGame = () => {
233274
}
234275
}
235276

236-
game.world.addChild(new Graphics());
277+
_app.world.addChild(new Graphics());
237278
};
238279
export const ExampleGraphics = createExampleComponent(createGame);

packages/melonjs/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## [18.3.0] (melonJS 2)
44

55
### Added
6+
- Renderer: `bezierCurveTo()`, `quadraticCurveTo()`, and `arcTo()` path methods — draw cubic and quadratic Bezier curves, matching the Canvas 2D API. Canvas renderer uses native context methods, WebGL renderer tessellates via Path2D.
67
- 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()`.
78
- 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()`.
89
- 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
@@ -26,6 +27,8 @@
2627
- EventEmitter: `event.on()` and `event.once()` no longer create `.bind()` closures when a context is provided
2728

2829
### Fixed
30+
- 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.
31+
- 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.
2932
- 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.
3033
- Text/Light2d: fix invalid `pool.push` on CanvasRenderTarget instances that were never pool-registered (would throw on destroy)
3134
- CanvasRenderTarget: `destroy(renderer)` now properly cleans up WebGL GPU textures and cache entries (previously leaked in Light2d)

packages/melonjs/src/geometries/path2d.js

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -552,11 +552,16 @@ class Path2D {
552552
quadraticCurveTo(cpX, cpY, x, y) {
553553
const points = this.points;
554554
const startPoint = this.startPoint;
555-
const lastPoint =
556-
points.length === 0 ? startPoint : points[points.length - 1];
555+
// capture coordinates (not reference) since lineTo mutates startPoint
556+
const lx = points.length === 0 ? startPoint.x : points[points.length - 1].x;
557+
const ly = points.length === 0 ? startPoint.y : points[points.length - 1].y;
557558
const endPoint = pointPool.get().set(x, y);
558559
const controlPoint = pointPool.get().set(cpX, cpY);
559-
const resolution = this.arcResolution;
560+
// estimate curve length via control polygon
561+
const polyLen =
562+
Math.sqrt((cpX - lx) ** 2 + (cpY - ly) ** 2) +
563+
Math.sqrt((x - cpX) ** 2 + (y - cpY) ** 2);
564+
const resolution = Math.max(4, Math.ceil(polyLen / this.arcResolution));
560565

561566
const t = 1 / resolution;
562567
for (let i = 1; i <= resolution; i++) {
@@ -565,8 +570,8 @@ class Path2D {
565570
const omt2 = omt * omt;
566571
const ti2 = ti * ti;
567572
this.lineTo(
568-
lastPoint.x * omt2 + controlPoint.x * 2 * omt * ti + endPoint.x * ti2,
569-
lastPoint.y * omt2 + controlPoint.y * 2 * omt * ti + endPoint.y * ti2,
573+
lx * omt2 + controlPoint.x * 2 * omt * ti + endPoint.x * ti2,
574+
ly * omt2 + controlPoint.y * 2 * omt * ti + endPoint.y * ti2,
570575
);
571576
}
572577
pointPool.release(endPoint);
@@ -586,12 +591,18 @@ class Path2D {
586591
bezierCurveTo(cp1X, cp1Y, cp2X, cp2Y, x, y) {
587592
const points = this.points;
588593
const startPoint = this.startPoint;
589-
const lastPoint =
590-
points.length === 0 ? startPoint : points[points.length - 1];
594+
// capture coordinates (not reference) since lineTo mutates startPoint
595+
const lx = points.length === 0 ? startPoint.x : points[points.length - 1].x;
596+
const ly = points.length === 0 ? startPoint.y : points[points.length - 1].y;
591597
const endPoint = pointPool.get().set(x, y);
592598
const controlPoint1 = pointPool.get().set(cp1X, cp1Y);
593599
const controlPoint2 = pointPool.get().set(cp2X, cp2Y);
594-
const resolution = this.arcResolution;
600+
// estimate curve length via control polygon
601+
const polyLen =
602+
Math.sqrt((cp1X - lx) ** 2 + (cp1Y - ly) ** 2) +
603+
Math.sqrt((cp2X - cp1X) ** 2 + (cp2Y - cp1Y) ** 2) +
604+
Math.sqrt((x - cp2X) ** 2 + (y - cp2Y) ** 2);
605+
const resolution = Math.max(4, Math.ceil(polyLen / this.arcResolution));
595606

596607
const t = 1 / resolution;
597608
for (let i = 1; i <= resolution; i++) {
@@ -602,11 +613,11 @@ class Path2D {
602613
const ti2 = ti * ti;
603614
const ti3 = ti2 * ti;
604615
this.lineTo(
605-
lastPoint.x * omt3 +
616+
lx * omt3 +
606617
controlPoint1.x * 3 * omt2 * ti +
607618
controlPoint2.x * 3 * omt * ti2 +
608619
endPoint.x * ti3,
609-
lastPoint.y * omt3 +
620+
ly * omt3 +
610621
controlPoint1.y * 3 * omt2 * ti +
611622
controlPoint2.y * 3 * omt * ti2 +
612623
endPoint.y * ti3,

packages/melonjs/src/video/canvas/canvas_renderer.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,42 @@ export default class CanvasRenderer extends Renderer {
356356
this.getContext().lineTo(x, y);
357357
}
358358

359+
/**
360+
* Adds a quadratic Bezier curve to the current sub-path.
361+
* @param {number} cpx - The x-axis coordinate of the control point.
362+
* @param {number} cpy - The y-axis coordinate of the control point.
363+
* @param {number} x - The x-axis coordinate of the end point.
364+
* @param {number} y - The y-axis coordinate of the end point.
365+
*/
366+
quadraticCurveTo(cpx, cpy, x, y) {
367+
this.getContext().quadraticCurveTo(cpx, cpy, x, y);
368+
}
369+
370+
/**
371+
* Adds a cubic Bezier curve to the current sub-path.
372+
* @param {number} cp1x - The x-axis coordinate of the first control point.
373+
* @param {number} cp1y - The y-axis coordinate of the first control point.
374+
* @param {number} cp2x - The x-axis coordinate of the second control point.
375+
* @param {number} cp2y - The y-axis coordinate of the second control point.
376+
* @param {number} x - The x-axis coordinate of the end point.
377+
* @param {number} y - The y-axis coordinate of the end point.
378+
*/
379+
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) {
380+
this.getContext().bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
381+
}
382+
383+
/**
384+
* Adds a circular arc to the current sub-path, using the given control points and radius.
385+
* @param {number} x1 - The x-axis coordinate of the first control point.
386+
* @param {number} y1 - The y-axis coordinate of the first control point.
387+
* @param {number} x2 - The x-axis coordinate of the second control point.
388+
* @param {number} y2 - The y-axis coordinate of the second control point.
389+
* @param {number} radius - The arc's radius. Must be non-negative.
390+
*/
391+
arcTo(x1, y1, x2, y2, radius) {
392+
this.getContext().arcTo(x1, y1, x2, y2, radius);
393+
}
394+
359395
/**
360396
* creates a rectangular path whose starting point is at (x, y) and whose size is specified by width and height.
361397
* @param {number} x - The x axis of the coordinate for the rectangle starting point.

0 commit comments

Comments
 (0)