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
263 changes: 239 additions & 24 deletions packages/examples/src/examples/lineOfSight/ExampleLineOfSight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
}
}

Expand Down Expand Up @@ -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
Expand All @@ -409,15 +601,38 @@ 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
// the sentry (reads `pos` + `angle` each frame) and to the
// 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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/examples/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ExampleMasking />,
Expand Down
10 changes: 10 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading