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
1 change: 1 addition & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
- Application: a `maxfps` above 120 no longer stops the game. Updates ran every `60 / maxfps` frames rounded to a whole number, which came out as 0, so none ran
- Effects: changing the texture filter after an effect's texture was replaced with `setTexture`, or after the effect was destroyed, no longer raises a WebGL error. The deleted texture was still listed as bound, and the filter change bound it again
- Camera: the follow deadzone has its edges on whole pixels, so a camera following a target on a whole pixel comes to rest on one. Its size, a sixth of the view by default, was kept as a fraction, so with `subPixel` on a camera following its target right or down stopped between two pixels
- Camera: `offset` can shift the view for good, such as to look above the hero, and not only for a shake: a shake adds its jitter to the offset and gives it back when it ends, where it used to reset it to zero. Culling, the pointer (`localToWorld`, `worldToLocal`), tile layers and parallax `ImageLayer`s all count it, so a parallax background now shakes with the world, by its ratio
- Lighting: on the Canvas renderer, a light's hole in the ambient darkness is cut where the light is, under a scrolled or zoomed camera and one drawn at an offset such as a minimap. Under a scrolled camera the mask kept the darkness inside the light and cleared it around it, and the holes of a zoomed or offset camera were cut as if it were neither
- Lighting: the ambient darkness covers the whole view of a camera. Zoomed out, it covered only as much of the world as the camera is wide, and at a fractional position, such as a camera following its target with damping, it left the first column and row lit unless `subPixel` was set
- Audio: a sprite region declared looping (`[start, duration, true]`) loops when played with `audio.play(name, { sprite })`. It played only once unless `loop: true` was passed as well
Expand Down
20 changes: 19 additions & 1 deletion packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: melonjs-camera-and-drawing
description: "Use this skill for camera control and immediate-mode drawing in melonJS — following a target, viewport bounds, shake and fade, secondary cameras and what each one draws, and drawing shapes, lines and gradients inside a custom draw(). Covers world versus screen coordinate conversion, clipping and masking, baking with CanvasRenderTarget, and reading pixels back. Triggers on: Camera2d, viewport, follow, setBounds, minimap, second camera, split screen, cullMask, visibilityLayer, hide from a camera, shake, fadeIn, fadeOut, worldToLocal, localToWorld, colorMatrix, renderer.fill, renderer.stroke, Rect, Ellipse, Polygon, Line, Gradient, clipRect, mask, CanvasRenderTarget, NoiseTexture2d, lookAt, setBasis, toImageData, getImageData, toBlob, toDataURL, screenshot, read pixels."
description: "Use this skill for camera control and immediate-mode drawing in melonJS — following a target, viewport bounds, shake and fade, secondary cameras and what each one draws, and drawing shapes, lines and gradients inside a custom draw(). Covers world versus screen coordinate conversion, clipping and masking, baking with CanvasRenderTarget, and reading pixels back. Triggers on: Camera2d, viewport, follow, offset, look ahead, setBounds, minimap, second camera, split screen, cullMask, visibilityLayer, hide from a camera, shake, fadeIn, fadeOut, worldToLocal, localToWorld, colorMatrix, renderer.fill, renderer.stroke, Rect, Ellipse, Polygon, Line, Gradient, clipRect, mask, CanvasRenderTarget, NoiseTexture2d, lookAt, setBasis, toImageData, getImageData, toBlob, toDataURL, screenshot, read pixels."
license: MIT
---

Expand All @@ -24,6 +24,24 @@ frame-rate independent since 19.7. `AXIS.HORIZONTAL` / `AXIS.VERTICAL` /

Call it from the followed entity's `onActivateEvent`, using `this.parentApp`.

**To frame the target off centre, set `offset`; do not follow a helper
object.** It shifts the view without moving the camera, in world units, and is
applied after following and the bounds, so the view can show past them:

```js
app.viewport.follow(player, app.viewport.AXIS.BOTH, 0.1);
app.viewport.offset.set(0, -70); // look a little above the feet

// or ahead of where the player faces, eased (see math.damp below)
const ahead = facingLeft ? -80 : 80;
app.viewport.offset.x = math.damp(app.viewport.offset.x, ahead, 4, dt / 1000);
```

Culling, tile layers, parallax `ImageLayer`s, lights and the pointer
(`localToWorld`, `worldToLocal`) all count it. `shake()` adds its jitter on top
and gives the value back when it ends, so setting it before or during a shake
is fine. `reset()` clears it.

**For anything that is NOT the camera, the same smoothing is `math.damp`.**
`follow()` is frame-rate independent for you; a value you smooth yourself is
not, and `lerp(current, target, alpha)` once per frame moves further on a slow
Expand Down
42 changes: 31 additions & 11 deletions packages/melonjs/src/camera/camera2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,21 @@ export default class Camera2d extends Renderable {
*/
invCurrentTransform: Matrix3d;

/** offset for shake effect */
/**
* Shifts the view without moving the camera, in world units: to look
* ahead of what the camera follows, or above it, or for a shake of
* your own. It is applied after following and the camera's bounds, so
* the view can show past them. Drawing, culling, the lights and the
* pointer (`localToWorld`, `worldToLocal`) all count it. The built-in
* {@link Camera2d#shake} adds its jitter to it and takes it back off
* when it ends, so a value set before or during a shake is kept.
* Cleared by {@link Camera2d#reset}.
* @default <0,0>
* @example
* // follow the hero, looking a little above his feet
* app.viewport.follow(hero, app.viewport.AXIS.BOTH, 0.1);
* app.viewport.offset.set(0, -70);
*/
offset: Vector2d;

/** target to follow */
Expand Down Expand Up @@ -278,7 +292,7 @@ export default class Camera2d extends Renderable {

this.invCurrentTransform = new Matrix3d();

// offset for shake effect
// shifts the view, see the field's doc
this.offset = new Vector2d();

// target to follow
Expand Down Expand Up @@ -507,15 +521,17 @@ export default class Camera2d extends Renderable {

/**
* The world-space bounds currently visible through this camera,
* taking into account position and zoom level.
* taking into account position, {@link Camera2d#offset} and zoom level.
* @returns the visible world area
*/
get worldView(): Bounds {
const x = this.pos.x + this.offset.x;
const y = this.pos.y + this.offset.y;
this._worldView.setMinMax(
this.pos.x,
this.pos.y,
this.pos.x + this.width / this.zoom,
this.pos.y + this.height / this.zoom,
x,
y,
x + this.width / this.zoom,
y + this.height / this.zoom,
);
return this._worldView;
}
Expand Down Expand Up @@ -548,8 +564,9 @@ export default class Camera2d extends Renderable {
// override sets its own depth after this has run.
this.setPosition(x, y);

// reset the target
// reset the target, and the view's offset from it
this.unfollow();
this.offset.setZero();

// damping default value
this.smoothFollow = true;
Expand Down Expand Up @@ -1047,7 +1064,10 @@ export default class Camera2d extends Renderable {
*/
localToWorld(x: number, y: number, v?: Vector2d): Vector2d {
v = v || vector2dPool.get();
v.set((x - this.screenX) / this.zoom, (y - this.screenY) / this.zoom)
v.set(
(x - this.screenX) / this.zoom + this.offset.x,
(y - this.screenY) / this.zoom + this.offset.y,
)
.add(this.pos)
.sub(game.world.pos);
if (!this.currentTransform.isIdentity()) {
Expand All @@ -1072,8 +1092,8 @@ export default class Camera2d extends Renderable {
}
v.sub(this.pos).add(game.world.pos);
return v.set(
v.x * this.zoom + this.screenX,
v.y * this.zoom + this.screenY,
(v.x - this.offset.x) * this.zoom + this.screenX,
(v.y - this.offset.y) * this.zoom + this.screenY,
);
}

Expand Down
65 changes: 60 additions & 5 deletions packages/melonjs/src/camera/effects/shake_effect.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { Vector2d } from "../../math/vector2d.ts";
import type Camera2d from "../camera2d.ts";
import CameraEffect from "./camera_effect.ts";

// For each shaken camera: the offset the game gave it (`base`), and the
// offset as its shakes last left it (`left`: the base plus the jitter of
// every shake running on it). An offset that no longer reads `left` was set
// since, by the game or a reset: it becomes the base, and the shakes go on
// around it. Shared by every shake of a camera, so two at once add up.
const shaken = new WeakMap<Camera2d, { base: Vector2d; left: Vector2d }>();

/**
* A camera effect that shakes the viewport by applying random offsets.
* A camera effect that shakes the viewport. Each frame it adds a random
* jitter to the camera's {@link Camera2d#offset}, and takes it back off when
* it ends, so an offset the game sets, before or during the shake, is kept.
* @category Camera
* @example
* camera.addCameraEffect(new ShakeEffect(camera, {
Expand Down Expand Up @@ -32,6 +42,13 @@ export default class ShakeEffect extends CameraEffect {
*/
onComplete: (() => void) | undefined;

/**
* the jitter this shake has added to the camera's offset
* @ignore
* @internal
*/
_jitter: Vector2d;

/**
* @param camera - the camera to shake
* @param options - shake parameters
Expand All @@ -54,13 +71,48 @@ export default class ShakeEffect extends CameraEffect {
this.duration = options.duration;
this.axis = options.axis ?? camera.AXIS.BOTH;
this.onComplete = options.onComplete;
this._jitter = new Vector2d();
}

/**
* set this shake's jitter, and the camera's offset to the game's own plus
* the jitter of every shake running on it
* @param x - the horizontal jitter
* @param y - the vertical jitter
* @ignore
* @internal
*/
_setJitter(x: number, y: number): void {
const camera = this.camera;
const offset = camera.offset;
let state = shaken.get(camera);
if (state === undefined) {
state = { base: offset.clone(), left: offset.clone() };
shaken.set(camera, state);
} else if (!offset.equals(state.left)) {
state.base.copy(offset);
}
this._jitter.set(x, y);
offset.copy(state.base);
let listed = false;
for (const effect of camera.cameraEffects) {
if (effect instanceof ShakeEffect) {
offset.add(effect._jitter);
listed ||= effect === this;
}
}
// a shake updated without being added to the camera
if (!listed) {
offset.add(this._jitter);
}
state.left.copy(offset);
}

override update(dt: number): void {
this.duration -= dt;
if (this.duration <= 0) {
this.duration = 0;
this.camera.offset.setZero();
this._setJitter(0, 0);
// once, on the transition into complete: see the same guard in
// `MaskEffect`, which a persistent effect makes load-bearing
if (!this.isComplete) {
Expand All @@ -71,16 +123,19 @@ export default class ShakeEffect extends CameraEffect {
}
} else {
const axis = this.camera.AXIS;
let x = 0;
let y = 0;
if (this.axis === axis.BOTH || this.axis === axis.HORIZONTAL) {
this.camera.offset.x = (Math.random() - 0.5) * this.intensity;
x = (Math.random() - 0.5) * this.intensity;
}
if (this.axis === axis.BOTH || this.axis === axis.VERTICAL) {
this.camera.offset.y = (Math.random() - 0.5) * this.intensity;
y = (Math.random() - 0.5) * this.intensity;
}
this._setJitter(x, y);
}
}

override destroy(): void {
this.camera.offset.setZero();
this._setJitter(0, 0);
}
}
12 changes: 12 additions & 0 deletions packages/melonjs/src/level/tiled/TMXLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const FLIP_AD_BIT = 1 << 2;
// ever loaded).
let _warnedNoGpuTileSupportOnce = false;

// the area an offset camera shows, handed to the tile renderers in its place
// (made on first use)
let viewRect;

/**
* extract a 3-bit flip mask from a raw 32-bit GID (Tiled's flip bits live in
* the upper 3 bits)
Expand Down Expand Up @@ -846,6 +850,14 @@ export default class TMXLayer extends Renderable {
this.canvasRenderer.renderTarget.invalidate(renderer);
renderer.cache.tinted?.delete(this.canvasRenderer.getCanvas());
}
// the camera's offset (a look ahead, a shake) shifts what it shows
// without moving it: the tiles to draw are the ones in that view
const offset = rect.offset;
if (offset !== undefined && (offset.x !== 0 || offset.y !== 0)) {
viewRect ??= new Rect(0, 0, 0, 0);
viewRect.pos.set(rect.pos.x + offset.x, rect.pos.y + offset.y);
rect = viewRect.setSize(rect.width, rect.height);
}
// dispatch to the active renderer — picks shader / preRender / perTile
// based on `this.renderMode` and the renderer's capabilities
renderer.drawTileLayer(this, rect);
Expand Down
14 changes: 8 additions & 6 deletions packages/melonjs/src/renderable/imagelayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,16 +222,17 @@ export default class ImageLayer extends Sprite {
* Automatic positioning
*
* See https://github.com/melonjs/melonJS/issues/741#issuecomment-138431532
* for a thorough description of how this works.
* for a thorough description of how this works. The camera looks
* where its position, shifted by its offset, puts it.
*/
const x =
ax * (rx - 1) * (bw - viewport.width) +
this.offset.x -
rx * viewport.pos.x;
rx * (viewport.pos.x + viewport.offset.x);
const y =
ay * (ry - 1) * (bh - viewport.height) +
this.offset.y -
ry * viewport.pos.y;
ry * (viewport.pos.y + viewport.offset.y);

// Repeat horizontally; start drawing from left boundary
if (this.repeatX) {
Expand Down Expand Up @@ -311,15 +312,16 @@ export default class ImageLayer extends Sprite {
x = this.pos.x + ax * (bw - width);
y = this.pos.y + ay * (bh - height);
} else {
// parallax — compute position from the current viewport passed to draw()
// parallax — compute position from the current viewport passed to
// draw(), where it looks: its position shifted by its offset
x =
ax * (rx - 1) * (bw - viewport.width) +
this.offset.x -
rx * viewport.pos.x;
rx * (viewport.pos.x + viewport.offset.x);
y =
ay * (ry - 1) * (bh - viewport.height) +
this.offset.y -
ry * viewport.pos.y;
ry * (viewport.pos.y + viewport.offset.y);

if (this.repeatX) {
x = x % width;
Expand Down
Loading
Loading