diff --git a/packages/examples/src/examples/lineOfSight/ExampleLineOfSight.tsx b/packages/examples/src/examples/lineOfSight/ExampleLineOfSight.tsx
index 42ec654c3d..de85658ee9 100644
--- a/packages/examples/src/examples/lineOfSight/ExampleLineOfSight.tsx
+++ b/packages/examples/src/examples/lineOfSight/ExampleLineOfSight.tsx
@@ -21,12 +21,19 @@
* arc. The hit points form the visible-area polygon (same building
* block, used 21× per frame), so obstacles correctly occlude
* everything behind them. Arrow keys move the sentry; drag the green
- * boxes to rearrange the obstacles.
+ * shapes to rearrange the obstacles.
+ *
+ * Square blocks are placed by their top left corner, round pillars by
+ * their centre: a ray finds each where it is drawn. Intruders walk the
+ * edges of the room and a corridor, and the sentry spots one in its cone when the
+ * first hit of a ray towards it (`hit.fraction` along the ray) lies
+ * further away than the intruder does.
*/
import {
Application,
Color,
ColorLayer,
+ Ellipse,
input,
type PhysicsAdapter,
type Pointer,
@@ -76,33 +83,45 @@ const CONE_REACH = 1200;
const CONE_FILL_COLOR = "#ff5e9a";
const CONE_FILL_ALPHA = 0.18;
+const PILLAR_RADIUS = 34;
+
+/** How fast an intruder walks its beat, in pixels per frame. */
+const INTRUDER_SPEED = 1.2;
+const INTRUDER_RADIUS = 7;
+const INTRUDER_COLOR = "#7fb4ff";
+const SPOTTED_COLOR = "#ff3355";
+
const OBSTACLE_LIT_COLOR = new Color(255, 60, 90);
const OBSTACLE_DEFAULT_COLOR = new Color(60, 220, 140);
const OBSTACLE_OUTLINE_WIDTH = 2;
/**
- * Static rectangular obstacle. Declarative `bodyDef` — the engine
- * forwards it to the active physics adapter (BuiltinAdapter here, but
- * the same code runs unchanged under Matter or Planck) on `addChild`,
- * which creates the actual `body` and wires it into the broadphase
- * so raycasts can find it. The user can drag obstacles around; the
- * body's bounds follow `pos`, so the next raycast picks up the new
- * position automatically.
+ * Something the sentry cannot see through. Declarative `bodyDef` — the
+ * engine forwards it to the active physics adapter (BuiltinAdapter here,
+ * but the same code runs unchanged under Matter or Planck) on `addChild`,
+ * which creates the actual `body` and wires it into the broadphase so
+ * raycasts can find it. The user can drag obstacles around; the body
+ * follows `pos`, so the next raycast picks up the new position
+ * automatically.
*/
-class Obstacle extends Renderable {
- private isLit = false;
+abstract class Obstacle extends Renderable {
+ protected isLit = false;
private isDragging = false;
- private readonly grabOffset: Vector2d;
-
- constructor(x: number, y: number, width: number, height: number) {
+ private readonly grabOffset = new Vector2d(0, 0);
+
+ constructor(
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ shape: Rect | Ellipse,
+ ) {
super(x, y, width, height);
- this.anchorPoint.set(0, 0);
this.isKinematic = false;
this.bodyDef = {
type: "static",
- shapes: [new Rect(0, 0, width, height)],
+ shapes: [shape],
};
- this.grabOffset = new Vector2d(0, 0);
}
override onActivateEvent(): void {
@@ -124,6 +143,10 @@ class Obstacle extends Renderable {
this.isLit = lit;
}
+ protected get color(): Color {
+ return this.isLit ? OBSTACLE_LIT_COLOR : OBSTACLE_DEFAULT_COLOR;
+ }
+
private onPointerDown = (event: Pointer): boolean => {
this.isDragging = true;
this.grabOffset.set(event.gameX, event.gameY).sub(this.pos);
@@ -139,19 +162,27 @@ class Obstacle extends Renderable {
if (!this.isDragging) {
return false;
}
- this.pos.set(
+ // setPosition keeps the depth, where pos.set() would reset it
+ this.setPosition(
event.gameX - this.grabOffset.x,
event.gameY - this.grabOffset.y,
);
return false;
};
+}
+
+/** A square block, placed by its top left corner. */
+class Block extends Obstacle {
+ constructor(x: number, y: number, size: number) {
+ super(x, y, size, size, new Rect(0, 0, size, size));
+ this.anchorPoint.set(0, 0);
+ }
override draw(renderer: Renderer): void {
- const color = this.isLit ? OBSTACLE_LIT_COLOR : OBSTACLE_DEFAULT_COLOR;
renderer.save();
renderer.translate(this.pos.x, this.pos.y);
renderer.setGlobalAlpha(0.5);
- renderer.setColor(color);
+ renderer.setColor(this.color);
renderer.fillRect(0, 0, this.width, this.height);
renderer.setGlobalAlpha(1.0);
renderer.lineWidth = OBSTACLE_OUTLINE_WIDTH;
@@ -165,6 +196,98 @@ class Obstacle extends Renderable {
}
}
+/**
+ * A round pillar, placed by its centre: the anchor most renderables have,
+ * and a `Sprite`'s default. Its body is a circle in the frame the pillar
+ * draws in, which starts half its size before `pos`, and that is where
+ * the rays find it.
+ */
+class Pillar extends Obstacle {
+ constructor(x: number, y: number, radius: number) {
+ const size = radius * 2;
+ super(x, y, size, size, new Ellipse(radius, radius, size, size));
+ this.anchorPoint.set(0.5, 0.5);
+ }
+
+ override draw(renderer: Renderer): void {
+ // in the anchored frame, so the centre is half the size from its corner
+ const radius = this.width / 2;
+ const cx = this.pos.x + radius;
+ const cy = this.pos.y + radius;
+ renderer.save();
+ renderer.setGlobalAlpha(0.5);
+ renderer.setColor(this.color);
+ renderer.fillEllipse(cx, cy, radius, radius);
+ renderer.setGlobalAlpha(1.0);
+ renderer.lineWidth = OBSTACLE_OUTLINE_WIDTH;
+ renderer.strokeEllipse(
+ cx,
+ cy,
+ radius - OBSTACLE_OUTLINE_WIDTH,
+ radius - OBSTACLE_OUTLINE_WIDTH,
+ );
+ renderer.restore();
+ }
+}
+
+/**
+ * Walks back and forth between two points. It has no body, so the rays of
+ * the cone pass through it: the sentry spots it with a ray of its own.
+ */
+class Intruder extends Renderable {
+ /** Set by `VisionCone.update` each frame. */
+ spotted = false;
+ private readonly from: Vector2d;
+ private readonly to: Vector2d;
+ // how far along its beat, 0 at `from` and 1 at `to`
+ private t: number;
+ private step: number;
+
+ constructor(from: Vector2d, to: Vector2d, start: number) {
+ super(from.x, from.y, INTRUDER_RADIUS * 2, INTRUDER_RADIUS * 2);
+ // pos is its centre
+ this.anchorPoint.set(0.5, 0.5);
+ this.alwaysUpdate = true;
+ this.from = from;
+ this.to = to;
+ this.t = start;
+ this.step = INTRUDER_SPEED / from.distance(to);
+ }
+
+ override update(_dt: number): boolean {
+ this.t += this.step;
+ if (this.t >= 1 || this.t <= 0) {
+ this.t = Math.min(Math.max(this.t, 0), 1);
+ this.step = -this.step;
+ }
+ this.setPosition(
+ this.from.x + (this.to.x - this.from.x) * this.t,
+ this.from.y + (this.to.y - this.from.y) * this.t,
+ );
+ return true;
+ }
+
+ override draw(renderer: Renderer): void {
+ const cx = this.pos.x + INTRUDER_RADIUS;
+ const cy = this.pos.y + INTRUDER_RADIUS;
+ renderer.save();
+ renderer.setColor(this.spotted ? SPOTTED_COLOR : INTRUDER_COLOR);
+ renderer.fillEllipse(cx, cy, INTRUDER_RADIUS, INTRUDER_RADIUS);
+ if (this.spotted) {
+ // an exclamation mark over its head
+ renderer.lineWidth = 3;
+ renderer.strokeLine(
+ cx,
+ cy - INTRUDER_RADIUS - 14,
+ cx,
+ cy - INTRUDER_RADIUS - 7,
+ );
+ renderer.fillRect(cx - 1.5, cy - INTRUDER_RADIUS - 5, 3, 3);
+ }
+ renderer.restore();
+ }
+}
+
/**
* Draggable origin of the vision cone. Owns its own facing angle that
* rotates continuously, plus the pointer-drag handlers so the user can
@@ -270,9 +393,19 @@ class VisionCone extends Renderable {
// Vector2d allocations per frame.
private readonly rayFrom = new Vector2d(0, 0);
private readonly rayTo = new Vector2d(0, 0);
-
- constructor(sentry: Sentry, adapter: PhysicsAdapter) {
+ private readonly intruders: Intruder[];
+ private readonly status: Text;
+ private spottedCount = -1;
+
+ constructor(
+ sentry: Sentry,
+ adapter: PhysicsAdapter,
+ intruders: Intruder[],
+ status: Text,
+ ) {
super(0, 0, 1, 1);
+ this.intruders = intruders;
+ this.status = status;
this.alwaysUpdate = true;
// The cone's polygon is drawn at arbitrary world positions far
// from `this.pos`, so the default in-viewport check (which uses
@@ -350,6 +483,39 @@ class VisionCone extends Renderable {
// Refresh polygon edges / bounds from the mutated points array.
this.conePolygon.setVertices(this.polyPoints as Polyline);
+ // Spot the intruders: one in the cone is seen when the first thing a
+ // ray towards it enters lies further away than it does. `fraction`
+ // is how far along the ray the hit is, 0 at `from` and 1 at `to`.
+ let spotted = 0;
+ for (const intruder of this.intruders) {
+ const dx = intruder.pos.x - originX;
+ const dy = intruder.pos.y - originY;
+ const distance = Math.hypot(dx, dy);
+ let off = Math.atan2(dy, dx) - baseAngle;
+ off = Math.atan2(Math.sin(off), Math.cos(off));
+ intruder.spotted = false;
+ if (distance <= CONE_REACH && Math.abs(off) <= halfSpread) {
+ this.rayFrom.set(originX, originY);
+ this.rayTo.set(
+ originX + (dx / distance) * CONE_REACH,
+ originY + (dy / distance) * CONE_REACH,
+ );
+ const hit = raycast(this.rayFrom, this.rayTo);
+ intruder.spotted = !hit || hit.fraction * CONE_REACH > distance;
+ }
+ if (intruder.spotted) {
+ spotted++;
+ }
+ }
+ if (spotted !== this.spottedCount) {
+ this.spottedCount = spotted;
+ this.status.setText(
+ spotted > 0
+ ? `INTRUDER SPOTTED (${spotted} / ${this.intruders.length})`
+ : "all quiet",
+ );
+ }
+
return true;
}
@@ -363,6 +529,22 @@ class VisionCone extends Renderable {
renderer.setColor(CONE_FILL_COLOR);
(renderer as DemoRenderer).fillPolygon(this.conePolygon);
renderer.restore();
+
+ // the sentry's line of sight to everyone it spots
+ renderer.save();
+ renderer.setColor(SPOTTED_COLOR);
+ renderer.lineWidth = 1.5;
+ for (const intruder of this.intruders) {
+ if (intruder.spotted) {
+ renderer.strokeLine(
+ this.sentry.centerX,
+ this.sentry.centerY,
+ intruder.pos.x,
+ intruder.pos.y,
+ );
+ }
+ }
+ renderer.restore();
}
}
@@ -391,10 +573,20 @@ class PlayScreen extends Stage {
fillStyle: "#a8b0e8",
textAlign: "center",
textBaseline: "top",
- text: "drag the green boxes to rearrange the obstacles",
+ text: "drag the green shapes to rearrange the obstacles",
}),
200,
);
+ // what the sentry sees, updated by the cone
+ const status = new Text(app.viewport.width / 2, 50, {
+ font: "bold Courier New",
+ size: 12,
+ fillStyle: SPOTTED_COLOR,
+ textAlign: "center",
+ textBaseline: "top",
+ text: "all quiet",
+ });
+ app.world.addChild(status, 200);
// Obstacle layout — six static rectangles with gaps the cone
// can sweep through. The top-middle box sits a bit higher than
@@ -409,7 +601,27 @@ class PlayScreen extends Stage {
[600, 410],
];
for (const [x, y] of positions) {
- app.world.addChild(new Obstacle(x, y, OBSTACLE_SIZE, OBSTACLE_SIZE), 1);
+ app.world.addChild(new Block(x, y, OBSTACLE_SIZE), 1);
+ }
+ // round pillars in the gaps, placed by their centre
+ const pillars: ReadonlyArray<[number, number]> = [
+ [250, 300],
+ [525, 300],
+ [680, 120],
+ ];
+ for (const [x, y] of pillars) {
+ app.world.addChild(new Pillar(x, y, PILLAR_RADIUS), 1);
+ }
+
+ // intruders walking the edges of the room and the corridor in the
+ // middle, out of step
+ const intruders = [
+ new Intruder(new Vector2d(25, 70), new Vector2d(25, 580), 0),
+ new Intruder(new Vector2d(775, 70), new Vector2d(775, 580), 0.5),
+ new Intruder(new Vector2d(468, 70), new Vector2d(468, 580), 0.25),
+ ];
+ for (const intruder of intruders) {
+ app.world.addChild(intruder, 8);
}
// Sentry at the viewport centre. The cone owns a reference to
@@ -417,7 +629,10 @@ class PlayScreen extends Stage {
// world's active physics adapter (where it dispatches the
// per-frame raycasts).
const sentry = new Sentry(app.viewport.width / 2, app.viewport.height / 2);
- app.world.addChild(new VisionCone(sentry, app.world.adapter), 5);
+ app.world.addChild(
+ new VisionCone(sentry, app.world.adapter, intruders, status),
+ 5,
+ );
app.world.addChild(sentry, 10);
}
}
diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx
index 09bb54f65e..0030b9c3e4 100644
--- a/packages/examples/src/main.tsx
+++ b/packages/examples/src/main.tsx
@@ -478,7 +478,7 @@ const examples: {
path: "line-of-sight",
sourceDir: "lineOfSight",
description:
- "Stealth-style vision cone driven by the portable adapter.raycast API.",
+ "Stealth-style vision cone and line-of-sight checks with the portable adapter.raycast API.",
},
{
component: ,
diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index e1aa1b7984..53584dd9a9 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -19,6 +19,16 @@
- Tiled: a rotated tile object turns about the point its tileset aligns, as in Tiled, which is its bottom left (bottom center on isometric maps) unless the tileset sets another. It turned about another point, so it was drawn and collided away from where Tiled shows it
- Tiled: a map with an animated tile object loads, and the object plays its animation. One from a tileset image threw "string parameters for addAnimation are not allowed for standard spritesheet based Texture", and one from an image collection threw a TypeError
- Tiled: in a tile layer, tiles from an image collection whose images are not all the same height each sit on the bottom of their cell, as in Tiled. Every tile was placed as if it were as tall as the tallest image of the collection
+- Tiled: a collision object whose shape reaches left of or above its position, like every rectangle on an isometric map, is found by collision checks, `queryAABB` and `raycast` wherever its shape is. Parts of it were missed, so a character could walk through walls; such an object's `pos` is now the top left corner of its shape
+- Tiled: a tile object on an isometric map collides over its image, as on other maps. Its default body was its image's box projected onto the map grid, a slanted shape hanging off the image
+- Physics: the builtin `raycast` measures a body from the frame its renderable draws in, as collisions have since 20.7. On a body whose anchor is not its top left corner, such as a `Sprite`'s default centre, a ray hit half the body's size away from where it collides, or missed it
+- Camera: a zoomed camera follows its target, stays within its bounds and centres on `focusOn` in world units. Zoomed out, it kept the target off centre and scrolled past its bounds; zoomed in, it stopped short of them
+- Camera: `localToWorld` and `worldToLocal` take the camera's zoom and its place on the screen (`screenX`, `screenY`) into account. A pointer on a zoomed camera, or on one drawn in a corner of the screen, hit the world somewhere else than it pointed
+- Skills: the lighting skill no longer says a sprite without a normal map is lit flatly. Only a normal-mapped sprite is shaded by a `Light2d`; any other sprite just sits under its glow and its cutout
+- Skills: the drawing skill never said that on a GPU backend a `CanvasRenderTarget` keeps its texture for as long as the renderer lives. It now says to create one once and reuse it, and to call `invalidate()` after painting it again
+- Skills: the tilemaps skill now explains that `sortOn = "y"` sorts objects and never tiles, so scenery a character walks in front of and behind belongs on the character's own object layer as tile objects. It also warns that Tiled objects sort by the top of their image, and that a character with the default collision mask collides with the default body of every class-less tile object
+- Text: a label whose text is empty or only spaces no longer blanks the whole frame on WebGPU. Its canvas was sized 0 pixels wide, which WebGPU refuses to upload
+- Typings: `TextureAtlas#getAnimationSettings()` is typed as the settings it returns, so they pass to a `Sprite` constructor from TypeScript without a cast. It returned `object`, and `Sprite3d` typed the named animations an aseprite atlas carries as an array
### Performance
- Rendering: drawing a sprite, a glyph or a pattern computes its UVs from the image size instead of looking them up, with no string built and nothing allocated or kept. On the platformer, text and Jungle Rabbit examples a lookup took 34 to 58 ns and now takes 6 to 10, in Chrome and WebKit: 1 to 2% of a frame's CPU time on the text example, and too little to measure on the platformer
diff --git a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md
index 8d69b1f657..a3bacf8de7 100644
--- a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md
+++ b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md
@@ -269,6 +269,14 @@ Pass `rt.canvas`, never `rt` — a `CanvasRenderTarget` is a wrapper, not a
drawable, and the engine's own bakes (the particle emitter's default texture)
hand `.canvas` on for exactly that reason.
+**Create it once and keep it.** On a GPU backend, once drawn, its texture stays
+on the GPU for as long as the renderer lives: a stage change does not release
+it, and no public call does. A target made for every level, every restart or
+every spawned object leaks one texture each time, so make it once (at module
+level, say) and clear it for reuse. If you paint into it again after it has
+been drawn, call `rt.invalidate(renderer)` before the next draw so the GPU copy
+is refreshed.
+
This is the sanctioned performance idiom before reaching for a custom shader.
**For a solid fill you do not need a canvas at all.** `Renderer.getWhitePixel()`
@@ -338,6 +346,8 @@ const data = await rt.toImageData(0, 0, w, h);
| baked target draws nothing / throws | passed the `CanvasRenderTarget`, not its `.canvas` |
| `getImageData()` throws on one machine but not another | that one is on the WebGPU renderer, which cannot read back synchronously — `await toImageData()` instead |
| frame rate drops with many static draws | bake into a `CanvasRenderTarget` instead |
+| GPU memory grows with every level or restart | a `CanvasRenderTarget` created each time: make one and reuse it |
+| a repainted `CanvasRenderTarget` still shows the old pixels | call `rt.invalidate(renderer)` after painting |
| a 3D camera cannot be given an arbitrary up | `Camera3d#lookAt(target, up)` or `setBasis(right, up, forward)`; see `melonjs-3d` |
## Related skills
diff --git a/packages/melonjs/skills/melonjs-lighting/SKILL.md b/packages/melonjs/skills/melonjs-lighting/SKILL.md
index 48e600b22a..9350231705 100644
--- a/packages/melonjs/skills/melonjs-lighting/SKILL.md
+++ b/packages/melonjs/skills/melonjs-lighting/SKILL.md
@@ -131,8 +131,9 @@ Unlit areas of a normal-mapped sprite render pure black unless you raise
`Stage.ambientLightingColor` (default black) — that is the base level added to
every lit pixel, and it is a different knob from `Stage.ambientLight`.
-Without a normal map a sprite is still lit, but flatly: brightness varies with
-distance from the light, not with surface direction.
+Without a normal map a sprite is not shaded at all: it takes the unlit path on
+every backend, and a light changes how it looks only through its additive glow
+and its cutout in the ambient darkness.
## What needs a GPU backend, and what does not
@@ -171,7 +172,7 @@ See the `melonjs-3d` skill.
| symptom | cause |
|---|---|
| lights added but the scene never darkens | no ambient colour set — `ambientLight` alpha is 0, so the pass returns early |
-| sprite lit flatly, ignoring light direction | no `normalMap` on the sprite, or the Canvas backend is active |
+| sprite not shaded by a light, only glowed over | no `normalMap` on the sprite, or the Canvas backend is active |
| normal-mapped sprite is pure black where unlit | `Stage.ambientLightingColor` left at black |
| the 33rd light shades nothing | `MAX_LIGHTS = 32` for the normal-map pipeline |
| `TypeError` assigning `normalMap` | not an image-like value (a video element is rejected outright) |
diff --git a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md
index a9031b7edf..3c68cc1507 100644
--- a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md
+++ b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md
@@ -187,6 +187,43 @@ app.world.sortOn = "y"; // after level.load
on anything else. `Vector2d`, `Vector3d`, their observable variants and `Polygon`
all carry `.toIso()` / `.to2d()` for converting between screen and map space.
+### What the y-sort can and cannot sort
+
+`sortOn = "y"` sorts **objects**, never tiles. A tile layer is a single
+renderable drawn in one piece, so a character ends up in front of all of it or
+behind all of it. Anything a character walks both in front of and behind
+(walls, trees, furniture) has to be on an **object layer, as tile objects**,
+and in the **same** object layer as the characters: each Tiled layer gets its
+own `z`, and the y-sort compares `z` before `y`. Keep the floor on a tile layer
+below them. This holds for any y-sorted map, isometric or a top-down
+orthogonal one.
+
+```
+floor tile layer the ground, drawn first
+dungeon object layer walls, props, chests, doors, the hero
+collision object layer the shapes (not drawn)
+```
+
+Tile objects with no class are built for you: each one is a `Sprite` with its
+default body, static, over its whole image box and typed `ENEMY_OBJECT`. A
+character keeping the default `collisionMask` (every type) collides with all of
+them, so when the scenery's real collision is drawn in the collision layer,
+mask the character to it:
+
+```js
+this.body.setCollisionMask(collision.types.WORLD_SHAPE);
+```
+
+Give the objects with behaviour a Tiled class and register it, as above.
+
+The `y` it sorts on is `pos.y`, and Tiled objects default to a `(0, 0)` anchor,
+so that is the **top** of each image. Sorting by the top only matches sorting by
+the feet when every image is the same height and stands on the bottom of its
+box, the usual layout of an isometric pack. A shorter image sorts in front of
+things it stands behind, so pad the art to one height. A character you create
+in code needs the same convention: `anchorPoint` `(0, 0)` and a frame as tall
+as the scenery's.
+
## Image layers
`ImageLayer` gives parallax backgrounds with a ratio per axis, set in Tiled or in
@@ -241,6 +278,9 @@ unanimated layer into the offscreen-bake path instead.
| `No inflate function set` at parse time | compressed layers without the tiled-inflate plugin |
| collision shapes are not solid | the object layer's name does not contain `"collision"`, or the objects are named |
| isometric objects overlap wrongly | `world.sortOn` not set to `"y"` |
+| a character is always in front of (or behind) every wall | the walls are on a tile layer, or on another object layer than the character: each layer has its own `z`, compared before `y` |
+| a small prop draws over a character standing in front of it | images of different heights: the y-sort uses `pos.y`, the top of a Tiled object's image |
+| a character is stopped short of walls and props placed as tile objects | it collides with their default bodies (static, `ENEMY_OBJECT`, the whole image box); mask it to `collision.types.WORLD_SHAPE` when the collision layer holds the real shapes |
| missing tiles or a blank map | the `.tsx` or tileset image was not preloaded |
| tilemap slow on some machines | layer fell back off the shader path — check the console warning for the reason |
diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts
index 595b5fd8bc..8f75fe837d 100644
--- a/packages/melonjs/src/camera/camera2d.ts
+++ b/packages/melonjs/src/camera/camera2d.ts
@@ -342,17 +342,23 @@ export default class Camera2d extends Renderable {
* @internal
*/
_followH(target: Vector2d | Vector3d): number {
+ // the deadzone is in screen pixels, and a zoomed camera sees
+ // `width / zoom` of the world: compare and clamp in world units
+ const zoom = this.zoom;
let targetX = this.pos.x;
- if (target.x - this.pos.x > this.deadzone.right) {
+ if ((target.x - this.pos.x) * zoom > this.deadzone.right) {
// clamp against the bounds' right EDGE — `bounds.width` is
// `right - left`, so using it as a max only worked for a
// zero-origin world and cut `left` px off the far side
targetX = Math.min(
- target.x - this.deadzone.right,
- this.bounds.right - this.width,
+ target.x - this.deadzone.right / zoom,
+ this.bounds.right - this.width / zoom,
+ );
+ } else if ((target.x - this.pos.x) * zoom < this.deadzone.pos.x) {
+ targetX = Math.max(
+ target.x - this.deadzone.pos.x / zoom,
+ this.bounds.left,
);
- } else if (target.x - this.pos.x < this.deadzone.pos.x) {
- targetX = Math.max(target.x - this.deadzone.pos.x, this.bounds.left);
}
return targetX;
}
@@ -362,14 +368,19 @@ export default class Camera2d extends Renderable {
* @internal
*/
_followV(target: Vector2d | Vector3d): number {
+ // in world units, as `_followH`
+ const zoom = this.zoom;
let targetY = this.pos.y;
- if (target.y - this.pos.y > this.deadzone.bottom) {
+ if ((target.y - this.pos.y) * zoom > this.deadzone.bottom) {
targetY = Math.min(
- target.y - this.deadzone.bottom,
- this.bounds.bottom - this.height,
+ target.y - this.deadzone.bottom / zoom,
+ this.bounds.bottom - this.height / zoom,
+ );
+ } else if ((target.y - this.pos.y) * zoom < this.deadzone.pos.y) {
+ targetY = Math.max(
+ target.y - this.deadzone.pos.y / zoom,
+ this.bounds.top,
);
- } else if (target.y - this.pos.y < this.deadzone.pos.y) {
- targetY = Math.max(target.y - this.deadzone.pos.y, this.bounds.top);
}
return targetY;
}
@@ -670,9 +681,18 @@ export default class Camera2d extends Renderable {
const _y = this.pos.y;
// clamp against the bounds' far edges — `bounds.width/height` are
- // extents (`right - left`), correct as a max only for a zero origin
- this.pos.x = clamp(x, this.bounds.left, this.bounds.right - this.width);
- this.pos.y = clamp(y, this.bounds.top, this.bounds.bottom - this.height);
+ // extents (`right - left`), correct as a max only for a zero origin.
+ // A zoomed camera sees `width / zoom` of the world
+ this.pos.x = clamp(
+ x,
+ this.bounds.left,
+ this.bounds.right - this.width / this.zoom,
+ );
+ this.pos.y = clamp(
+ y,
+ this.bounds.top,
+ this.bounds.bottom - this.height / this.zoom,
+ );
//publish the VIEWPORT_ONCHANGE event if necessary
if (_x !== this.pos.x || _y !== this.pos.y) {
@@ -925,8 +945,8 @@ export default class Camera2d extends Renderable {
focusOn(target: Renderable): void {
const bounds = target.getBounds();
this.moveTo(
- bounds.left + bounds.width / 2 - this.width / 2,
- bounds.top + bounds.height / 2 - this.height / 2,
+ bounds.left + bounds.width / 2 - this.width / this.zoom / 2,
+ bounds.top + bounds.height / 2 - this.height / this.zoom / 2,
);
}
@@ -963,7 +983,10 @@ export default class Camera2d extends Renderable {
}
/**
- * convert the given "local" (screen) coordinates into world coordinates
+ * convert the given "local" (screen) coordinates into world coordinates.
+ * Screen coordinates are in canvas pixels: on a zoomed camera, or one
+ * drawn in a corner of the screen (see {@link screenX}), they are
+ * measured from where the camera is drawn, and scaled by its zoom.
* @param x - the x coordinate of the local point to be converted
* @param y - the y coordinate of the local point to be converted
* @param [v] - an optional vector object where to set the converted value
@@ -971,7 +994,9 @@ export default class Camera2d extends Renderable {
*/
localToWorld(x: number, y: number, v?: Vector2d): Vector2d {
v = v || vector2dPool.get();
- v.set(x, y).add(this.pos).sub(game.world.pos);
+ v.set((x - this.screenX) / this.zoom, (y - this.screenY) / this.zoom)
+ .add(this.pos)
+ .sub(game.world.pos);
if (!this.currentTransform.isIdentity()) {
this.invCurrentTransform.apply(v);
}
@@ -979,7 +1004,8 @@ export default class Camera2d extends Renderable {
}
/**
- * convert the given world coordinates into "local" (screen) coordinates
+ * convert the given world coordinates into "local" (screen) coordinates,
+ * in canvas pixels: where this camera draws the given point
* @param x - the x world coordinate to be converted
* @param y - the y world coordinate to be converted
* @param [v] - an optional vector object where to set the converted value
@@ -991,7 +1017,11 @@ export default class Camera2d extends Renderable {
if (!this.currentTransform.isIdentity()) {
this.currentTransform.apply(v);
}
- return v.sub(this.pos).add(game.world.pos);
+ v.sub(this.pos).add(game.world.pos);
+ return v.set(
+ v.x * this.zoom + this.screenX,
+ v.y * this.zoom + this.screenY,
+ );
}
/**
diff --git a/packages/melonjs/src/level/tiled/TMXObject.js b/packages/melonjs/src/level/tiled/TMXObject.js
index 50ffe8d523..7cb490140a 100644
--- a/packages/melonjs/src/level/tiled/TMXObject.js
+++ b/packages/melonjs/src/level/tiled/TMXObject.js
@@ -325,8 +325,9 @@ export default class TMXObject {
break;
}
- // Apply isometric projection
- if (this.orientation === "isometric") {
+ // Apply isometric projection, except to a tile object: Tiled draws
+ // it upright, so its box is already in screen space
+ if (this.orientation === "isometric" && this.gid === null) {
for (let i = 0; i < shapes.length; i++) {
if (typeof shapes[i].toIso === "function") {
shapes[i].toIso();
diff --git a/packages/melonjs/src/level/tiled/factories/shape.js b/packages/melonjs/src/level/tiled/factories/shape.js
index a4e59ac473..4f4c35d796 100644
--- a/packages/melonjs/src/level/tiled/factories/shape.js
+++ b/packages/melonjs/src/level/tiled/factories/shape.js
@@ -10,12 +10,6 @@ import { getDefaultShape } from "../TMXObjectFactory.js";
* @internal
*/
export function createShapeObject(settings) {
- const obj = new Renderable(
- settings.x,
- settings.y,
- settings.width,
- settings.height,
- );
const shape = getDefaultShape(settings);
if (!shape || (Array.isArray(shape) && shape.length === 0)) {
// Bail out loudly instead of poisoning `bodyDef.shapes` with
@@ -25,6 +19,32 @@ export function createShapeObject(settings) {
`melonJS: TMX object (id=${settings.id}, name=${settings.name ?? ""}, type=${settings.type ?? ""}) has no usable collision shape. Check the object's geometry in Tiled.`,
);
}
+ const shapes = Array.isArray(shape) ? shape : [shape];
+
+ // Size from the union of all shapes' bounds — the body isn't built
+ // until adapter auto-registration, so we can't read body.getBounds().
+ const bounds = boundsPool.get();
+ bounds.addShapes(shapes, true);
+ const { left, top, width, height } = bounds;
+ boundsPool.release(bounds);
+
+ // The object's bounds start at its pos, and the broadphase files it by
+ // them, but the shapes can start anywhere: every rectangle on an
+ // isometric map reaches left of its origin once projected, and so does
+ // a polygon drawn up or left from its first point. Start the object
+ // where the shapes start, and move the shapes back by as much, so they
+ // stay where they are. A point has no extent, and keeps its position.
+ let x = settings.x;
+ let y = settings.y;
+ if ((width > 0 || height > 0) && (left !== 0 || top !== 0)) {
+ x += left;
+ y += top;
+ for (const s of shapes) {
+ s.translate(-left, -top);
+ }
+ }
+
+ const obj = new Renderable(x, y, width, height);
obj.anchorPoint.set(0, 0);
obj.name = settings.name;
obj.type = settings.type;
@@ -33,14 +53,8 @@ export function createShapeObject(settings) {
// declarative body — adapter-portable, auto-registered on addChild
obj.bodyDef = {
type: "static",
- shapes: Array.isArray(shape) ? shape : [shape],
+ shapes,
};
- // Size from the union of all shapes' bounds — the body isn't built
- // until adapter auto-registration, so we can't read body.getBounds().
- const bounds = boundsPool.get();
- bounds.addShapes(obj.bodyDef.shapes, true);
- obj.resize(bounds.width, bounds.height);
- boundsPool.release(bounds);
obj.pos.z = settings.z;
return obj;
}
diff --git a/packages/melonjs/src/physics/builtin/raycast.ts b/packages/melonjs/src/physics/builtin/raycast.ts
index dbe875d844..839a65322c 100644
--- a/packages/melonjs/src/physics/builtin/raycast.ts
+++ b/packages/melonjs/src/physics/builtin/raycast.ts
@@ -6,6 +6,7 @@ import type Renderable from "../../renderable/renderable.js";
import type { RaycastHit } from "../adapter.ts";
import type World from "../world.js";
import type Body from "./body.js";
+import { anchorOffsetX, anchorOffsetY } from "./sat.js";
/**
* Shared internal raycast walker for the built-in physics path. Used by
@@ -45,8 +46,9 @@ function _computeShapeAbsPos(
outPos: Vector2d,
): void {
// Compute the absolute world position of a shape attached to a body
- // attached to a renderable. Mirrors what SAT does:
- // absPos = renderable.pos + ancestor.absPos + shape.pos
+ // attached to a renderable. Mirrors what SAT does, measuring the shape
+ // from the frame the renderable draws in:
+ // absPos = renderable.pos - size * anchorPoint + ancestor.absPos + shape.pos
const ancestor = renderable.ancestor as
| { getAbsolutePosition?(): { x: number; y: number } }
| undefined;
@@ -56,8 +58,8 @@ function _computeShapeAbsPos(
: null;
const ax = ancestorAbs ? ancestorAbs.x : 0;
const ay = ancestorAbs ? ancestorAbs.y : 0;
- outPos.x = renderable.pos.x + ax + shape.pos.x;
- outPos.y = renderable.pos.y + ay + shape.pos.y;
+ outPos.x = renderable.pos.x - anchorOffsetX(renderable) + ax + shape.pos.x;
+ outPos.y = renderable.pos.y - anchorOffsetY(renderable) + ay + shape.pos.y;
}
const _shapeAbs = new Vector2d(0, 0);
diff --git a/packages/melonjs/src/physics/builtin/sat.js b/packages/melonjs/src/physics/builtin/sat.js
index 4b5fee04f5..6beb5cb9ca 100644
--- a/packages/melonjs/src/physics/builtin/sat.js
+++ b/packages/melonjs/src/physics/builtin/sat.js
@@ -244,7 +244,7 @@ function vornoiRegion(line, point) {
* @returns {number} the x offset to subtract
* @ignore
*/
-function anchorOffsetX(r) {
+export function anchorOffsetX(r) {
return r.applyAnchorTransform !== false && Number.isFinite(r.width)
? r.width * r.anchorPoint.x
: 0;
@@ -256,7 +256,7 @@ function anchorOffsetX(r) {
* @returns {number} the y offset to subtract
* @ignore
*/
-function anchorOffsetY(r) {
+export function anchorOffsetY(r) {
return r.applyAnchorTransform !== false && Number.isFinite(r.height)
? r.height * r.anchorPoint.y
: 0;
diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js
index d19d2ca851..e89d80f3d5 100644
--- a/packages/melonjs/src/renderable/sprite3d.js
+++ b/packages/melonjs/src/renderable/sprite3d.js
@@ -158,7 +158,7 @@ export default class Sprite3d extends Mesh {
* @param {number} [settings.framewidth] - width of a single frame within a spritesheet (enables frame animation)
* @param {number} [settings.frameheight] - height of a single frame within a spritesheet
* @param {string} [settings.region] - region name when using a texture atlas (see {@link TextureAtlas})
- * @param {object[]} [settings.anims] - predefined animations (same shape as {@link Sprite})
+ * @param {Object|object[]} [settings.anims] - predefined animations (same shape as {@link Sprite}), such as the ones an aseprite atlas defines, keyed by name
* @param {number} [settings.z=0] - 3D depth (world z); also settable later via `.depth`
* @param {boolean|string} [settings.billboard=false] - billboard mode: `false` (fixed orientation), `true` / `"cylindrical"` (faces the camera but stays upright — the 2.5D default), or `"spherical"` (faces the camera on all axes). Only applies under a `Camera3d`.
* @param {string|Vector2d|{x:number,y:number}} [settings.anchorPoint={x:0.5,y:0.5}] - anchor of the quad relative to `pos` — same key, normalized 0..1 convention (`x`: 0 left→1 right, `y`: 0 top→1 bottom) and centered default as the 2D {@link Sprite}. Also accepts the named presets `"center"`, `"top"`, `"bottom"`, `"left"`, `"right"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`. Baked into the quad's local vertices (composes with billboarding, flips, and trimmed/rotated atlas frames) — never applied as a renderer transform on either camera path. Mutable at runtime via `this.anchorPoint.set(...)` (re-bakes the quad and re-derives the cull bounds). Invalid values throw.
diff --git a/packages/melonjs/src/renderable/text/text.js b/packages/melonjs/src/renderable/text/text.js
index 4251ca105b..27bfd2fe08 100644
--- a/packages/melonjs/src/renderable/text/text.js
+++ b/packages/melonjs/src/renderable/text/text.js
@@ -503,13 +503,18 @@ export default class Text extends Renderable {
// The canvas is the layout box PLUS whatever the ink escapes it by. The
// padding never reaches `metrics`, so the reported bounds are unmoved —
// see `TextMetrics#inkPadTop`.
- const width = Math.ceil(this.metrics.width / 32) * 32;
+ // 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 height =
- Math.ceil(
- (this.metrics.height +
- this.metrics.inkPadTop +
- this.metrics.inkPadBottom) /
- 32,
+ Math.max(
+ 1,
+ Math.ceil(
+ (this.metrics.height +
+ this.metrics.inkPadTop +
+ this.metrics.inkPadBottom) /
+ 32,
+ ),
) * 32;
const renderer = this.parentApp?.renderer ?? game.renderer;
diff --git a/packages/melonjs/src/video/texture/atlas.js b/packages/melonjs/src/video/texture/atlas.js
index 96b48f2ab0..8749f45e8b 100644
--- a/packages/melonjs/src/video/texture/atlas.js
+++ b/packages/melonjs/src/video/texture/atlas.js
@@ -773,7 +773,8 @@ export class TextureAtlas extends Texture2d {
* @param {string[]|number[]} [names] - list of names for each sprite
* (when manually creating a Texture out of a spritesheet, only numeric values are authorized).
* If not specified, all defined names/entries in the atlas will be added.
- * @returns {object} A settings object suitable for passing to the {@link Sprite} constructor
+ * @returns {{image: TextureAtlas, framewidth: number, frameheight: number, margin: number, spacing: number, atlas: object[], anims: Object|undefined, atlasIndices: Record}}
+ * A settings object suitable for passing to the {@link Sprite} constructor
* @example
* // extend Sprite directly with texture atlas animation frames
* class MyPlayer extends me.Sprite {
diff --git a/packages/melonjs/tests/camera.spec.js b/packages/melonjs/tests/camera.spec.js
index 43c24d45df..3c1a3d03c1 100644
--- a/packages/melonjs/tests/camera.spec.js
+++ b/packages/melonjs/tests/camera.spec.js
@@ -616,6 +616,196 @@ describe("Camera2d", () => {
});
});
+ // A zoomed camera sees `width / zoom` of the world. Following, clamping to
+ // the bounds and focusing measured in its size on screen instead, so a
+ // zoomed-out camera kept its target off centre and scrolled past its
+ // bounds, and a zoomed-in one stopped short of them.
+ describe("zoom: follow, bounds and focusOn", () => {
+ const zoomed = (zoom, size = 2000) => {
+ const cam = new Camera2d(0, 0, 100, 100);
+ cam.reset(0, 0);
+ cam.setBounds(0, 0, size, size);
+ cam.zoom = zoom;
+ return cam;
+ };
+ // where a point sits in the camera's view, as a fraction of it
+ const placeInView = (cam, p) => {
+ const view = cam.worldView;
+ return [(p.x - view.left) / view.width, (p.y - view.top) / view.height];
+ };
+
+ it("keeps a followed target where an unzoomed camera keeps it", () => {
+ const ref = zoomed(1);
+ const refTarget = new Vector2d(1000, 1000);
+ ref.follow(refTarget, ref.AXIS.BOTH);
+ const [rx, ry] = placeInView(ref, refTarget);
+ for (const zoom of [0.5, 2]) {
+ const cam = zoomed(zoom);
+ const target = new Vector2d(1000, 1000);
+ cam.follow(target, cam.AXIS.BOTH);
+ const [x, y] = placeInView(cam, target);
+ expect(x, `zoom ${zoom}`).toBeCloseTo(rx, 3);
+ expect(y, `zoom ${zoom}`).toBeCloseTo(ry, 3);
+ }
+ });
+
+ it("lets the target move inside the deadzone as measured on screen", () => {
+ // a 40 px deadzone on a 100 px camera spans 30..70 px of the screen,
+ // which at zoom 0.5 is 60..140 world units from the camera's left
+ const cam = zoomed(0.5);
+ cam.setDeadzone(40, 40);
+ cam.moveTo(0, 0);
+ const target = new Vector2d(100, 50);
+ cam.follow(target, cam.AXIS.HORIZONTAL);
+ // 50 px on screen: inside, so the camera stays put
+ expect(cam.pos.x).toEqual(0);
+
+ // 75 px on screen: past the right edge, so the camera catches up
+ // until the target is back on it
+ target.x = 150;
+ cam.updateTarget();
+ expect(cam.pos.x).toBeCloseTo(150 - 70 / 0.5, 5);
+ });
+
+ it("settles a smooth follow where an unzoomed camera keeps the target", () => {
+ const ref = zoomed(1);
+ const refTarget = new Vector2d(1000, 1000);
+ ref.follow(refTarget, ref.AXIS.BOTH);
+ const [rx, ry] = placeInView(ref, refTarget);
+ for (const zoom of [0.5, 2]) {
+ const cam = zoomed(zoom);
+ const target = new Vector2d(1000, 1000);
+ cam.follow(target, cam.AXIS.BOTH, 0.2);
+ for (let i = 0; i < 600; i++) {
+ cam.update(16);
+ }
+ const [x, y] = placeInView(cam, target);
+ expect(x, `zoom ${zoom}`).toBeCloseTo(rx, 3);
+ expect(y, `zoom ${zoom}`).toBeCloseTo(ry, 3);
+ }
+ });
+
+ it("keeps the target in its deadzone when the zoom changes while following", () => {
+ const cam = zoomed(1);
+ const target = new Vector2d(1000, 1000);
+ cam.follow(target, cam.AXIS.BOTH);
+ for (const zoom of [0.5, 2, 1]) {
+ cam.zoom = zoom;
+ cam.updateTarget();
+ const [x] = placeInView(cam, target);
+ expect(x, `zoom ${zoom}`).toBeGreaterThanOrEqual(
+ cam.deadzone.left / cam.width - 1e-9,
+ );
+ expect(x, `zoom ${zoom}`).toBeLessThanOrEqual(
+ cam.deadzone.right / cam.width + 1e-9,
+ );
+ }
+ });
+
+ it("keeps its view inside the bounds when zoomed out", () => {
+ const cam = zoomed(0.5, 1000);
+ cam.moveTo(5000, 5000);
+ expect(cam.worldView.right).toBeCloseTo(1000, 5);
+ expect(cam.worldView.bottom).toBeCloseTo(1000, 5);
+
+ const target = new Vector2d(990, 990);
+ cam.moveTo(0, 0);
+ cam.follow(target, cam.AXIS.BOTH);
+ expect(cam.worldView.right).toBeLessThanOrEqual(1000);
+ expect(cam.worldView.bottom).toBeLessThanOrEqual(1000);
+ });
+
+ it("reaches the edge of its bounds when zoomed in", () => {
+ const cam = zoomed(2, 1000);
+ cam.moveTo(5000, 5000);
+ expect(cam.worldView.right).toBeCloseTo(1000, 5);
+ expect(cam.worldView.bottom).toBeCloseTo(1000, 5);
+ });
+
+ it("centres on a renderable with focusOn when zoomed", () => {
+ for (const zoom of [0.5, 2]) {
+ const cam = zoomed(zoom);
+ const target = new Renderable(1000, 1000, 10, 10);
+ target.anchorPoint.set(0, 0);
+ cam.focusOn(target);
+ expect(cam.worldView.centerX, `zoom ${zoom}`).toBeCloseTo(1005, 5);
+ expect(cam.worldView.centerY, `zoom ${zoom}`).toBeCloseTo(1005, 5);
+ }
+ });
+ });
+
+ // A camera draws world point W at `(W - pos) * zoom + (screenX, screenY)`
+ // on the canvas. The conversions only undid `pos` (and `currentTransform`),
+ // so on a zoomed camera, or one drawn in a corner of the screen, a point
+ // on screen mapped to the wrong place in the world and back.
+ describe("zoom and screen offset: localToWorld / worldToLocal", () => {
+ const camera = (zoom, screenX = 0, screenY = 0) => {
+ const cam = new Camera2d(0, 0, 200, 100);
+ cam.reset(0, 0);
+ cam.setBounds(-5000, -5000, 10000, 10000);
+ cam.zoom = zoom;
+ cam.screenX = screenX;
+ cam.screenY = screenY;
+ cam.moveTo(100, 50);
+ return cam;
+ };
+
+ it("maps a point on a zoomed camera to the world it shows there", () => {
+ const cam = camera(0.5);
+ const world = cam.localToWorld(80, 40, new Vector2d());
+ expect(world.x).toBeCloseTo(100 + 80 / 0.5, 5);
+ expect(world.y).toBeCloseTo(50 + 40 / 0.5, 5);
+
+ const local = cam.worldToLocal(world.x, world.y, new Vector2d());
+ expect(local.x).toBeCloseTo(80, 5);
+ expect(local.y).toBeCloseTo(40, 5);
+ });
+
+ it("measures from where a secondary camera is drawn on the screen", () => {
+ const cam = camera(0.25, 600, 20);
+ // 50 px into the camera's own rectangle
+ const world = cam.localToWorld(650, 70, new Vector2d());
+ expect(world.x).toBeCloseTo(100 + 50 / 0.25, 5);
+ expect(world.y).toBeCloseTo(50 + 50 / 0.25, 5);
+
+ const local = cam.worldToLocal(world.x, world.y, new Vector2d());
+ expect(local.x).toBeCloseTo(650, 5);
+ expect(local.y).toBeCloseTo(70, 5);
+ });
+
+ it("maps the corners of the camera's rectangle to the corners of its worldView", () => {
+ for (const [zoom, sx, sy] of [
+ [1, 0, 0],
+ [0.5, 0, 0],
+ [2, 0, 0],
+ [0.25, 600, 20],
+ ]) {
+ const cam = camera(zoom, sx, sy);
+ const view = cam.worldView;
+ const topLeft = cam.localToWorld(sx, sy, new Vector2d());
+ const bottomRight = cam.localToWorld(
+ sx + cam.width,
+ sy + cam.height,
+ new Vector2d(),
+ );
+ const label = `zoom ${zoom} at ${sx},${sy}`;
+ expect(topLeft.x, label).toBeCloseTo(view.left, 5);
+ expect(topLeft.y, label).toBeCloseTo(view.top, 5);
+ expect(bottomRight.x, label).toBeCloseTo(view.right, 5);
+ expect(bottomRight.y, label).toBeCloseTo(view.bottom, 5);
+ }
+ });
+
+ it("round-trips with a zoom, a screen offset and a roll together", () => {
+ const cam = camera(0.5, 300, 40);
+ cam.roll = Math.PI / 7;
+ const world = cam.localToWorld(420, 90, new Vector2d());
+ const back = cam.worldToLocal(world.x, world.y, new Vector2d());
+ expect(back.x).toBeCloseTo(420, 4);
+ expect(back.y).toBeCloseTo(90, 4);
+ });
+ });
+
describe("isDefault", () => {
it("should be true for a default camera", () => {
const { camera } = setup();
diff --git a/packages/melonjs/tests/pointer-world-offset.spec.js b/packages/melonjs/tests/pointer-world-offset.spec.js
index 7d0c576005..7ddbcd3de5 100644
--- a/packages/melonjs/tests/pointer-world-offset.spec.js
+++ b/packages/melonjs/tests/pointer-world-offset.spec.js
@@ -273,6 +273,27 @@ describe("pointer events against a shifted world", () => {
).toBeUndefined();
});
+ // ---- a zoomed camera ---------------------------------------------------
+
+ it("hits through a zoomed camera, where the region is drawn", () => {
+ // zoomed out to half size, the 40x40 box at world (200, 200) is drawn
+ // 20x20 at canvas (100, 100)
+ const region = place(box(200, 200));
+ app.viewport.zoom = 0.5;
+ try {
+ const pointer = clickAt(region, 110, 110);
+ expect(pointer, "missed where the box is drawn").toBeDefined();
+ expect(pointer.gameWorldX).toBeCloseTo(220);
+ expect(pointer.gameWorldY).toBeCloseTo(220);
+ expect(
+ clickAt(region, 210, 210),
+ "hit where the box would be unzoomed",
+ ).toBeUndefined();
+ } finally {
+ app.viewport.zoom = 1;
+ }
+ });
+
// ---- the unshifted path must be untouched ------------------------------
it("leaves an unshifted world behaving exactly as before", () => {
diff --git a/packages/melonjs/tests/raycast.spec.js b/packages/melonjs/tests/raycast.spec.js
index 45597ce1e7..f252a91fd7 100644
--- a/packages/melonjs/tests/raycast.spec.js
+++ b/packages/melonjs/tests/raycast.spec.js
@@ -11,6 +11,7 @@ import {
Polygon,
Rect,
Renderable,
+ Sprite,
Vector2d,
video,
World,
@@ -318,6 +319,152 @@ describe("Raycast", () => {
});
});
+ // Since 20.7 the narrowphase measures a body from the frame its renderable
+ // DRAWS in, which `anchorPoint` shifts by `-size * anchorPoint`. The 2D
+ // raycast kept measuring from raw `pos`, so on a centre-anchored body (a
+ // `Sprite`'s default) a ray hit half the body away from where it collides,
+ // and missed outright through part of it. Every case above is corner
+ // anchored, where the two agree.
+ describe("anchored bodies", () => {
+ // 50x50 at (100, 100), centre anchored: it draws from (75, 75)
+ const makeCentred = () => {
+ const r = new Renderable(100, 100, 50, 50);
+ r.anchorPoint.set(0.5, 0.5);
+ r.isKinematic = false;
+ r.body = new Body(r, [new Rect(0, 0, 50, 50)]);
+ world.addChild(r);
+ return r;
+ };
+
+ it("hits a centre-anchored box at the top of its drawn frame", () => {
+ const target = makeCentred();
+ rebuildBroadphase(world);
+
+ const hits = raycastQuery(world, 110, 0, 110, 300);
+ expect(hits.length).toEqual(1);
+ expect(hits[0].renderable).toBe(target);
+ expect(hits[0].point.y).toBeCloseTo(75, 3);
+ expect(hits[0].normal.y).toBeCloseTo(-1, 3);
+ // where the engine culls, draws and collides with it
+ expect(target.getBounds().top).toBeCloseTo(75, 3);
+ });
+
+ it("hits it through the part left of its pos, and not beside its drawn frame", () => {
+ makeCentred();
+ rebuildBroadphase(world);
+
+ expect(raycastQuery(world, 80, 0, 80, 300).length).toEqual(1);
+ expect(raycastQuery(world, 140, 0, 140, 300).length).toEqual(0);
+ });
+
+ it("hits a centre-anchored ellipse at the top of its drawn frame", () => {
+ // a 40 wide circle centred in a 40x40 renderable at (200, 200)
+ const r = new Renderable(200, 200, 40, 40);
+ r.anchorPoint.set(0.5, 0.5);
+ r.isKinematic = false;
+ r.body = new Body(r, [new Ellipse(20, 20, 40, 40)]);
+ world.addChild(r);
+ rebuildBroadphase(world);
+
+ const hits = raycastQuery(world, 200, 0, 200, 400);
+ expect(hits.length).toEqual(1);
+ expect(hits[0].point.y).toBeCloseTo(180, 1);
+ });
+
+ it("measures from pos when the renderable opts out of the anchor", () => {
+ const target = makeCentred();
+ target.applyAnchorTransform = false;
+ rebuildBroadphase(world);
+
+ const hits = raycastQuery(world, 110, 0, 110, 300);
+ expect(hits.length).toEqual(1);
+ expect(hits[0].point.y).toBeCloseTo(100, 3);
+ });
+
+ it("hits the drawn frame whatever the anchor", () => {
+ // 50x50 at (100, 100): the frame starts `size * anchor` before pos
+ for (const [ax, ay] of [
+ [0, 0],
+ [0.5, 0.5],
+ [1, 1],
+ [0, 1],
+ [1, 0],
+ ]) {
+ world = new World(0, 0, 800, 600, adapter);
+ const r = new Renderable(100, 100, 50, 50);
+ r.anchorPoint.set(ax, ay);
+ r.isKinematic = false;
+ r.body = new Body(r, [new Rect(0, 0, 50, 50)]);
+ world.addChild(r);
+ rebuildBroadphase(world);
+ const left = 100 - 50 * ax;
+ const top = 100 - 50 * ay;
+
+ const down = raycastQuery(world, left + 25, 0, left + 25, 400);
+ expect(down.length, `anchor ${ax},${ay}`).toEqual(1);
+ expect(down[0].point.y, `anchor ${ax},${ay}`).toBeCloseTo(top, 3);
+
+ const across = raycastQuery(world, 0, top + 25, 400, top + 25);
+ expect(across.length, `anchor ${ax},${ay}`).toEqual(1);
+ expect(across[0].point.x, `anchor ${ax},${ay}`).toBeCloseTo(left, 3);
+ }
+ });
+
+ it("hits a Sprite with its default anchor where it is drawn", () => {
+ const image = document.createElement("canvas");
+ image.width = image.height = 50;
+ const sprite = new Sprite(100, 100, { image });
+ sprite.isKinematic = false;
+ sprite.body = new Body(sprite, [new Rect(0, 0, 50, 50)]);
+ world.addChild(sprite);
+ rebuildBroadphase(world);
+
+ const hits = raycastQuery(world, 110, 0, 110, 300);
+ expect(hits.length).toEqual(1);
+ expect(hits[0].renderable).toBe(sprite);
+ expect(hits[0].point.y).toBeCloseTo(sprite.getBounds().top, 3);
+ expect(hits[0].point.y).toBeCloseTo(75, 3);
+ });
+
+ it("adds the anchor to the offset of the container it is in", () => {
+ const room = new Container(300, 200, 400, 400);
+ world.addChild(room);
+ const r = new Renderable(100, 100, 50, 50);
+ r.anchorPoint.set(0.5, 0.5);
+ r.isKinematic = false;
+ r.body = new Body(r, [new Rect(0, 0, 50, 50)]);
+ room.addChild(r);
+ rebuildBroadphase(world);
+
+ // at (400, 300) in the world, drawn from (375, 275)
+ const hits = raycastQuery(world, 410, 0, 410, 600);
+ expect(hits.length).toEqual(1);
+ expect(hits[0].renderable).toBe(r);
+ expect(hits[0].point.y).toBeCloseTo(275, 3);
+ });
+
+ it("does the same through the legacy Detector.rayCast", () => {
+ const target = makeCentred();
+ rebuildBroadphase(world);
+
+ // through the part left of pos, then beside the drawn frame
+ expect(world.detector.rayCast(lineBetween(80, 0, 80, 300))).toEqual([
+ target,
+ ]);
+ expect(world.detector.rayCast(lineBetween(140, 0, 140, 300))).toEqual([]);
+ });
+
+ it("does the same through BuiltinAdapter.raycast", () => {
+ const target = makeCentred();
+ rebuildBroadphase(world);
+
+ const hit = adapter.raycast(new Vector2d(80, 0), new Vector2d(80, 300));
+ expect(hit).not.toBeNull();
+ expect(hit.renderable).toBe(target);
+ expect(hit.point.y).toBeCloseTo(75, 3);
+ });
+ });
+
describe("adversarial / edge cases", () => {
it("handles a zero-length ray (from == to) without crashing", () => {
makeRectBody(world, 50, 50, 20, 20);
diff --git a/packages/melonjs/tests/text_bucket.spec.js b/packages/melonjs/tests/text_bucket.spec.js
index e31bd13bc9..c876a13a29 100644
--- a/packages/melonjs/tests/text_bucket.spec.js
+++ b/packages/melonjs/tests/text_bucket.spec.js
@@ -98,6 +98,21 @@ describe("Text — 32px canvas buckets", () => {
}
});
+ it("an empty or blank label still gets a canvas with no zero side", (ctx) => {
+ requireWebGL(ctx, renderer);
+ // a blank string measures 0 wide, and WebGPU throws on a 0-sized canvas
+ for (const str of ["", " ", " ", "\n"]) {
+ const t = makeText(str);
+ expect(t.canvasTexture.width, JSON.stringify(str)).toBeGreaterThan(0);
+ expect(t.canvasTexture.height, JSON.stringify(str)).toBeGreaterThan(0);
+ }
+ // blanking a label keeps its canvas
+ const t = makeText("Chests 3 / 8");
+ const w = t.canvasTexture.width;
+ t.setText(" ");
+ expect(t.canvasTexture.width).toBe(w);
+ });
+
it("a ticking counter stays in the SAME bucket: same dimensions, same canvas element", (ctx) => {
requireWebGL(ctx, renderer);
const t = makeText("Score: 10");
diff --git a/packages/melonjs/tests/tmx-shape-factory.spec.js b/packages/melonjs/tests/tmx-shape-factory.spec.js
index 40d54e0d12..49d037d037 100644
--- a/packages/melonjs/tests/tmx-shape-factory.spec.js
+++ b/packages/melonjs/tests/tmx-shape-factory.spec.js
@@ -3,7 +3,15 @@
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
-import { Application, boot, video } from "../src/index.js";
+import {
+ Application,
+ boot,
+ Point,
+ Polygon,
+ Rect,
+ Vector2d,
+ video,
+} from "../src/index.js";
import { createShapeObject } from "../src/level/tiled/factories/shape.js";
import { getDefaultShape } from "../src/level/tiled/TMXObjectFactory.js";
@@ -101,4 +109,111 @@ describe("createShapeObject — TMX shape factory", () => {
expect(obj.bodyDef.shapes[0].contains(4, 28)).toBe(true);
});
});
+
+ // The broadphase files an object by getBounds(), which starts at pos.
+ // A shape reaching left of or above the object's origin (every
+ // rectangle on an isometric map, once projected) was left out of it, so
+ // the object was filed where it is not, and walked through.
+ describe("bounds cover the shapes", () => {
+ // a 20 x 256 wall from an isometric collision layer, after toIso()
+ function isoWall() {
+ return new Polygon(0, 0, [
+ new Vector2d(0, 0),
+ new Vector2d(20, 10),
+ new Vector2d(-236, 138),
+ new Vector2d(-256, 128),
+ ]);
+ }
+ function wallObject(x = 300, y = 100) {
+ return createShapeObject({
+ id: 9,
+ name: "",
+ type: "",
+ x,
+ y,
+ width: 20,
+ height: 256,
+ shapes: [isoWall()],
+ });
+ }
+ // where each vertex is in the world
+ function vertices(obj) {
+ const shape = obj.bodyDef.shapes[0];
+ return shape.points.map((p) => {
+ return [obj.pos.x + shape.pos.x + p.x, obj.pos.y + shape.pos.y + p.y];
+ });
+ }
+
+ it("covers a shape that reaches left of the origin", () => {
+ const b = wallObject().getBounds();
+ expect([b.left, b.top, b.right, b.bottom]).toEqual([44, 100, 320, 238]);
+ });
+
+ it("leaves the shape where it was in the world", () => {
+ expect(vertices(wallObject())).toEqual([
+ [300, 100],
+ [320, 110],
+ [64, 238],
+ [44, 228],
+ ]);
+ });
+
+ it("is found by queryAABB where the shape is", () => {
+ const wall = wallObject();
+ app.world.addChild(wall);
+ try {
+ app.world.update(16);
+ // on the wall's lower end, far left of its origin
+ const found = app.world.adapter.queryAABB(new Rect(55, 225, 10, 10));
+ expect(found).toContain(wall);
+ } finally {
+ app.world.removeChildNow(wall);
+ }
+ });
+
+ it("is hit by a raycast where the shape is", () => {
+ const wall = wallObject();
+ app.world.addChild(wall);
+ try {
+ app.world.update(16);
+ // a vertical ray across the wall's lower end
+ const hit = app.world.adapter.raycast(
+ new Vector2d(60, 150),
+ new Vector2d(60, 300),
+ );
+ expect(hit?.renderable).toBe(wall);
+ } finally {
+ app.world.removeChildNow(wall);
+ }
+ });
+
+ it("keeps the position of a shape that starts at the origin", () => {
+ const obj = createShapeObject({
+ id: 10,
+ name: "",
+ type: "",
+ x: 32,
+ y: 64,
+ width: 16,
+ height: 8,
+ });
+ expect([obj.pos.x, obj.pos.y]).toEqual([32, 64]);
+ const b = obj.getBounds();
+ expect([b.left, b.top, b.right, b.bottom]).toEqual([32, 64, 48, 72]);
+ });
+
+ it("keeps the position of a point object", () => {
+ const obj = createShapeObject({
+ id: 11,
+ name: "spawn",
+ type: "",
+ x: 40,
+ y: 50,
+ width: 0,
+ height: 0,
+ shapes: [new Point(40, 50)],
+ });
+ expect([obj.pos.x, obj.pos.y]).toEqual([40, 50]);
+ });
+ });
});
diff --git a/packages/melonjs/tests/tmxtilemap.spec.js b/packages/melonjs/tests/tmxtilemap.spec.js
index d3a871e92d..af80fa16e4 100644
--- a/packages/melonjs/tests/tmxtilemap.spec.js
+++ b/packages/melonjs/tests/tmxtilemap.spec.js
@@ -754,6 +754,46 @@ describe("TMXTileMap", () => {
expect(obj.anchorPoint.y).toEqual(0);
}
});
+
+ it("should cover the projected shape of a rectangle on an isometric map", () => {
+ // projected, a rectangle reaches left of its origin; the bounds
+ // box (what the broadphase files it by) must still cover it
+ const map = new TMXTileMap("test", {
+ ...minimalMap,
+ orientation: "isometric",
+ width: 4,
+ height: 4,
+ tilewidth: 64,
+ tileheight: 32,
+ layers: [
+ {
+ type: "objectgroup",
+ name: "collision",
+ opacity: 1,
+ visible: true,
+ objects: [
+ { id: 1, name: "", type: "", x: 32, y: 0, width: 8, height: 96 },
+ ],
+ },
+ ],
+ });
+ const [obj] = map.getObjects(true);
+ const shape = obj.bodyDef.shapes[0];
+ const xs = shape.points.map((p) => {
+ return obj.pos.x + shape.pos.x + p.x;
+ });
+ const ys = shape.points.map((p) => {
+ return obj.pos.y + shape.pos.y + p.y;
+ });
+ const b = obj.getBounds();
+ expect(Math.min(...xs)).toBeLessThan(obj.pos.x + 8);
+ expect([b.left, b.top, b.right, b.bottom]).toEqual([
+ Math.min(...xs),
+ Math.min(...ys),
+ Math.max(...xs),
+ Math.max(...ys),
+ ]);
+ });
});
// ---------------------------------------------------------------
@@ -1308,6 +1348,28 @@ describe("TMXTileMap", () => {
expect(objects[0].bodyDef.type).toEqual("static");
});
+ it("should not project the body of a tile object on an isometric map", () => {
+ // Tiled draws a tile object upright, so the default body covers
+ // the image box rather than a projected diamond
+ const map = new TMXTileMap("test", {
+ ...tileObjectMap,
+ orientation: "isometric",
+ tilewidth: 64,
+ tileheight: 32,
+ });
+ const shape = map.getObjects(true)[0].bodyDef.shapes[0];
+ expect(
+ shape.points.map((p) => {
+ return [p.x, p.y];
+ }),
+ ).toEqual([
+ [0, 0],
+ [32, 0],
+ [32, 32],
+ [0, 32],
+ ]);
+ });
+
it("should position tile objects correctly", () => {
const map = new TMXTileMap("test", tileObjectMap);
const objects = map.getObjects(true);