From 2ae94bb7ac6e4d0a39a3ac3af100fbc786364109 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 26 Oct 2026 07:47:54 +0800 Subject: [PATCH] Camera: offset shifts the view for good, and a shake keeps it `Camera2d#offset` is now the one field for both a lasting look offset (above the hero, ahead of him) and the shake. The built-in shake used to write random values into it and zero it when done, wiping any offset the game had set. It now adds its jitter on top of the game's value and gives that value back exactly when it ends, also when the game changes the offset mid-shake, when the shake is removed or replaced early, or when two shakes run at once. Everything that works out what the camera shows now counts the offset: culling (`worldView`), the pointer (`localToWorld`, `worldToLocal`), tile layers (which picked their tiles from the camera position alone and left a strip undrawn) and parallax `ImageLayer`s. `reset()` clears it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx --- packages/melonjs/CHANGELOG.md | 1 + .../melonjs-camera-and-drawing/SKILL.md | 20 +- packages/melonjs/src/camera/camera2d.ts | 42 +- .../src/camera/effects/shake_effect.ts | 65 ++- packages/melonjs/src/level/tiled/TMXLayer.js | 12 + packages/melonjs/src/renderable/imagelayer.js | 14 +- packages/melonjs/tests/camera-offset.spec.js | 499 ++++++++++++++++++ packages/melonjs/tests/imagelayer.spec.js | 2 + 8 files changed, 632 insertions(+), 23 deletions(-) create mode 100644 packages/melonjs/tests/camera-offset.spec.js diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index fea495d6b..322208d36 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -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 diff --git a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md index 84e1ecd94..889cc9e83 100644 --- a/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md +++ b/packages/melonjs/skills/melonjs-camera-and-drawing/SKILL.md @@ -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 --- @@ -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 diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index d6902e0a4..1e003d4f4 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -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 */ @@ -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 @@ -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; } @@ -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; @@ -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()) { @@ -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, ); } diff --git a/packages/melonjs/src/camera/effects/shake_effect.ts b/packages/melonjs/src/camera/effects/shake_effect.ts index 2ef7b3a9e..80f57a5de 100644 --- a/packages/melonjs/src/camera/effects/shake_effect.ts +++ b/packages/melonjs/src/camera/effects/shake_effect.ts @@ -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(); + /** - * 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, { @@ -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 @@ -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) { @@ -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); } } diff --git a/packages/melonjs/src/level/tiled/TMXLayer.js b/packages/melonjs/src/level/tiled/TMXLayer.js index 6bda29fbf..0ee20ff0d 100644 --- a/packages/melonjs/src/level/tiled/TMXLayer.js +++ b/packages/melonjs/src/level/tiled/TMXLayer.js @@ -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) @@ -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); diff --git a/packages/melonjs/src/renderable/imagelayer.js b/packages/melonjs/src/renderable/imagelayer.js index dbac98069..fbf40722e 100644 --- a/packages/melonjs/src/renderable/imagelayer.js +++ b/packages/melonjs/src/renderable/imagelayer.js @@ -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) { @@ -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; diff --git a/packages/melonjs/tests/camera-offset.spec.js b/packages/melonjs/tests/camera-offset.spec.js new file mode 100644 index 000000000..4e3bbb3e2 --- /dev/null +++ b/packages/melonjs/tests/camera-offset.spec.js @@ -0,0 +1,499 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Application, + boot, + Camera2d, + ImageLayer, + Renderable, + ShakeEffect, + TMXTileMap, + Vector2d, + video, +} from "../src/index.js"; +import { imgList } from "../src/loader/cache.js"; +import CanvasRenderer from "../src/video/canvas/canvas_renderer.js"; + +/** + * `Camera2d#offset` shifts the view without moving the camera: a look offset + * the game sets (above the hero, ahead of him), and the built-in shake's + * jitter on top of it. Everything that works out what the camera shows has + * to count it, and a shake has to give the game's value back when it ends. + */ + +// a camera looking at (100, 50), free to move anywhere +const makeCamera = (w = 200, h = 100) => { + const camera = new Camera2d(0, 0, w, h); + camera.reset(0, 0); + camera.setBounds(-5000, -5000, 10000, 10000); + camera.moveTo(100, 50); + return camera; +}; + +// Math.random returning the given values in turn (then the last one): the +// shake jitter on an axis is (random - 0.5) * intensity +const randoms = (...values) => { + let i = 0; + return vi.spyOn(Math, "random").mockImplementation(() => { + return values[Math.min(i++, values.length - 1)]; + }); +}; + +describe("Camera2d offset", () => { + let app; + beforeAll(async () => { + boot(); + app = new Application(128, 128, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("the built-in shake keeps the game's offset", () => { + it("shakes around the offset, and gives it back when it ends", () => { + const camera = makeCamera(); + camera.offset.set(0, -70); + // x then y each frame: jitter (3, 3), then (4, -2) + randoms(0.8, 0.8, 0.9, 0.3); + camera.shake(10, 100); + camera.update(16); + expect(camera.offset.x).toBeCloseTo(3, 9); + expect(camera.offset.y).toBeCloseTo(-67, 9); + // the last jitter is replaced, not added to + camera.update(16); + expect(camera.offset.x).toBeCloseTo(4, 9); + expect(camera.offset.y).toBeCloseTo(-72, 9); + camera.update(200); + // exactly: not the value with a jitter taken back off it + expect(camera.offset.x).toBe(0); + expect(camera.offset.y).toBe(-70); + expect(camera.getCameraEffect(ShakeEffect)).toBeUndefined(); + }); + + it("gives the offset back when the shake is taken off early", () => { + const camera = makeCamera(); + camera.offset.set(25, -70); + randoms(0.8); + camera.shake(10, 500); + camera.update(16); + camera.removeCameraEffect(camera.getCameraEffect(ShakeEffect)); + expect(camera.offset.x).toBe(25); + expect(camera.offset.y).toBe(-70); + }); + + it("keeps a value the game sets during the shake", () => { + const camera = makeCamera(); + camera.offset.set(0, -70); + randoms(0.8); + camera.shake(10, 100); + camera.update(16); + // the hero turns around: look the other way + camera.offset.set(-60, -70); + camera.update(16); + expect(camera.offset.x).toBeCloseTo(-57, 9); + expect(camera.offset.y).toBeCloseTo(-67, 9); + camera.offset.set(40, 10); + camera.update(200); + expect(camera.offset.x).toBe(40); + expect(camera.offset.y).toBe(10); + }); + + it("keeps a value the game sets on the frame the shake ends", () => { + const camera = makeCamera(); + randoms(0.8); + camera.shake(10, 100); + camera.update(16); + camera.offset.set(40, 10); + camera.update(200); + expect(camera.offset.x).toBe(40); + expect(camera.offset.y).toBe(10); + }); + + it("shakes only the axis it is given", () => { + const camera = makeCamera(); + camera.offset.set(5, -70); + randoms(0.8); + camera.shake(10, 100, camera.AXIS.VERTICAL); + camera.update(16); + expect(camera.offset.x).toBe(5); + expect(camera.offset.y).toBeCloseTo(-67, 9); + }); + + it("leaves no jitter behind when a forced shake replaces one", () => { + const camera = makeCamera(); + camera.offset.set(0, -70); + randoms(0.8); + camera.shake(10, 500); + camera.update(16); + camera.shake(20, 100, camera.AXIS.BOTH, undefined, true); + // the first one's jitter is gone, the second's not there yet + expect(camera.offset.x).toBe(0); + expect(camera.offset.y).toBe(-70); + camera.update(16); + expect(camera.offset.x).toBeCloseTo(6, 9); + expect(camera.offset.y).toBeCloseTo(-64, 9); + camera.update(200); + expect(camera.offset.x).toBe(0); + expect(camera.offset.y).toBe(-70); + }); + + it("adds up two shakes running at once, and gives the offset back", () => { + const camera = makeCamera(); + camera.offset.set(0, -70); + const long = new ShakeEffect(camera, { intensity: 10, duration: 100 }); + const short = new ShakeEffect(camera, { intensity: 20, duration: 40 }); + camera.addCameraEffect(long); + camera.addCameraEffect(short); + // long (3, 3), short (6, 6); then long (4, 4), short (-4, -4) + randoms(0.8, 0.8, 0.8, 0.8, 0.9, 0.9, 0.3, 0.3); + camera.update(16); + expect(camera.offset.x).toBeCloseTo(9, 9); + expect(camera.offset.y).toBeCloseTo(-61, 9); + camera.update(16); + expect(camera.offset.x).toBeCloseTo(0, 9); + expect(camera.offset.y).toBeCloseTo(-70, 9); + // the short one ends: the long one's jitter alone + vi.restoreAllMocks(); + randoms(0.9); + camera.update(16); + expect(short.isComplete).toBe(true); + expect(camera.offset.x).toBeCloseTo(4, 9); + expect(camera.offset.y).toBeCloseTo(-66, 9); + camera.update(200); + expect(camera.offset.x).toBe(0); + expect(camera.offset.y).toBe(-70); + }); + + it("shakes the camera when updated by hand, without being added to it", () => { + const camera = makeCamera(); + camera.offset.set(0, -70); + randoms(0.8); + const shake = new ShakeEffect(camera, { intensity: 10, duration: 100 }); + shake.update(16); + expect(camera.offset.x).toBeCloseTo(3, 9); + expect(camera.offset.y).toBeCloseTo(-67, 9); + shake.update(200); + expect(camera.offset.x).toBe(0); + expect(camera.offset.y).toBe(-70); + }); + + it("is cleared by reset(), even in the middle of a shake", () => { + const camera = makeCamera(); + camera.offset.set(0, -70); + randoms(0.8); + camera.shake(10, 500); + camera.update(16); + camera.reset(); + expect(camera.offset.x).toBe(0); + expect(camera.offset.y).toBe(0); + }); + + it("shakes a persistent shake around the cleared offset after a reset()", () => { + const camera = makeCamera(); + camera.offset.set(0, -70); + const shake = new ShakeEffect(camera, { intensity: 10, duration: 100 }); + shake.isPersistent = true; + camera.addCameraEffect(shake); + randoms(0.8); + camera.update(16); + camera.reset(); + expect(camera.getCameraEffect(ShakeEffect)).toBe(shake); + camera.update(16); + expect(camera.offset.x).toBeCloseTo(3, 9); + expect(camera.offset.y).toBeCloseTo(3, 9); + camera.update(200); + expect(camera.offset.x).toBe(0); + expect(camera.offset.y).toBe(0); + }); + + it("lets a camera effect of its own set the offset, as before", () => { + const camera = makeCamera(); + class Sway { + constructor(cam) { + this.camera = cam; + this.isComplete = false; + this.isPersistent = false; + } + update() { + this.camera.offset.set(7, -3); + } + draw() {} + destroy() {} + } + camera.addCameraEffect(new Sway(camera)); + camera.update(16); + expect(camera.offset.x).toBe(7); + expect(camera.offset.y).toBe(-3); + }); + }); + + describe("what the camera shows counts the offset", () => { + it("worldView is shifted by it", () => { + const camera = makeCamera(200, 100); + camera.offset.set(-30, 40); + const view = camera.worldView; + expect(view.left).toBe(70); + expect(view.top).toBe(90); + expect(view.right).toBe(270); + expect(view.bottom).toBe(190); + }); + + it("culls against the view it shows, not the one the camera is at", () => { + const camera = makeCamera(200, 100); + camera.offset.set(0, -70); + // in view only because of the offset: above the camera's own rect + const above = new Renderable(150, -10, 10, 10); + // below the shifted view, inside the camera's own rect + const below = new Renderable(150, 130, 10, 10); + expect(camera.isVisible(above, false)).toBe(true); + expect(camera.isVisible(below, false)).toBe(false); + }); + + it("maps a point on screen to the world point drawn there", () => { + const camera = makeCamera(200, 100); + camera.offset.set(-30, 40); + const world = camera.localToWorld(10, 20, new Vector2d()); + expect(world.x).toBeCloseTo(100 - 30 + 10, 9); + expect(world.y).toBeCloseTo(50 + 40 + 20, 9); + const local = camera.worldToLocal(world.x, world.y, new Vector2d()); + expect(local.x).toBeCloseTo(10, 9); + expect(local.y).toBeCloseTo(20, 9); + }); + + it("maps the corners of the screen to the corners of worldView, zoomed, placed and rolled", () => { + const camera = makeCamera(200, 100); + camera.offset.set(-30, 40); + camera.zoom = 0.5; + camera.screenX = 300; + camera.screenY = 40; + const view = camera.worldView; + const topLeft = camera.localToWorld(300, 40, new Vector2d()); + const bottomRight = camera.localToWorld(500, 140, new Vector2d()); + expect(topLeft.x).toBeCloseTo(view.left, 9); + expect(topLeft.y).toBeCloseTo(view.top, 9); + expect(bottomRight.x).toBeCloseTo(view.right, 9); + expect(bottomRight.y).toBeCloseTo(view.bottom, 9); + + camera.roll = Math.PI / 7; + const world = camera.localToWorld(420, 90, new Vector2d()); + const back = camera.worldToLocal(world.x, world.y, new Vector2d()); + // the roll matrix is single precision + expect(back.x).toBeCloseTo(420, 4); + expect(back.y).toBeCloseTo(90, 4); + }); + }); + + describe("a tile layer draws every tile of the shifted view", () => { + const TILE = 32; + const COLS = 8; + const ROWS = 8; + + const makeLayer = () => { + const canvas = document.createElement("canvas"); + canvas.width = TILE * 2; + canvas.height = TILE; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#ff0000"; + ctx.fillRect(0, 0, TILE * 2, TILE); + imgList["camera-offset-tiles"] = canvas; + const map = new TMXTileMap("camera-offset", { + width: COLS, + height: ROWS, + tilewidth: TILE, + tileheight: TILE, + orientation: "orthogonal", + renderorder: "right-down", + infinite: false, + version: "1.10", + tiledversion: "1.12.0", + tilesets: [ + { + firstgid: 1, + name: "camera-offset-tiles", + tilewidth: TILE, + tileheight: TILE, + spacing: 0, + margin: 0, + tilecount: 2, + columns: 2, + image: "camera-offset-tiles.png", + }, + ], + layers: [ + { + type: "tilelayer", + name: "ground", + width: COLS, + height: ROWS, + data: new Array(COLS * ROWS).fill(1), + visible: true, + opacity: 1, + }, + ], + }); + return map.getLayers()[0]; + }; + + // a camera at (96, 96) looking 80 px up and left of it: the view is + // (16, 16) to (144, 144), well inside the map + const offsetCamera = () => { + const camera = new Camera2d(0, 0, 128, 128); + camera.reset(0, 0); + camera.setBounds(0, 0, COLS * TILE, ROWS * TILE); + camera.moveTo(96, 96); + camera.offset.set(-80, -80); + return camera; + }; + + // the layer drawn as the camera draws the world, on a canvas the size + // of the camera: how many pixels nothing was drawn on + const blankPixels = (layer, camera) => { + const renderer = new CanvasRenderer({ + width: camera.width, + height: camera.height, + transparent: true, + }); + try { + renderer.translate( + -(camera.pos.x + camera.offset.x), + -(camera.pos.y + camera.offset.y), + ); + layer.draw(renderer, camera); + const data = renderer + .getContext() + .getImageData(0, 0, camera.width, camera.height).data; + let blank = 0; + for (let i = 3; i < data.length; i += 4) { + if (data[i] === 0) { + blank++; + } + } + return blank; + } finally { + renderer.destroy(); + } + }; + + it("tile by tile", () => { + expect(blankPixels(makeLayer(), offsetCamera())).toBe(0); + }); + + it("from its pre-rendered canvas", () => { + const layer = makeLayer(); + layer.renderMode = "prerender"; + layer.onActivateEvent(); + expect(layer.canvasRenderer).toBeDefined(); + expect(blankPixels(layer, offsetCamera())).toBe(0); + }); + + it("hands the renderer the shifted view, and the camera itself when there is no offset", () => { + const layer = makeLayer(); + const camera = offsetCamera(); + const renderer = { + drawTileLayer: vi.fn(), + cache: {}, + }; + layer.draw(renderer, camera); + const view = renderer.drawTileLayer.mock.calls[0][1]; + expect(view).not.toBe(camera); + expect(view.pos.x).toBe(16); + expect(view.pos.y).toBe(16); + expect(view.width).toBe(128); + expect(view.height).toBe(128); + // the camera itself did not move + expect(camera.pos.x).toBe(96); + expect(camera.pos.y).toBe(96); + + camera.offset.setZero(); + layer.draw(renderer, camera); + expect(renderer.drawTileLayer.mock.calls[1][1]).toBe(camera); + }); + }); + + describe("a parallax image layer follows the shifted view", () => { + // the translate draw() gives the layer, on a camera at (100, 50) + const drawnAt = (ratio, offset) => { + const image = document.createElement("canvas"); + image.width = 64; + image.height = 64; + const layer = new ImageLayer(0, 0, { + image, + name: "parallax", + repeat: "no-repeat", + ratio, + }); + layer.width = 64; + layer.height = 64; + layer.anchorPoint.set(0, 0); + const camera = makeCamera(); + camera.offset.set(offset.x, offset.y); + const translate = vi.fn(); + layer.draw( + { + translate, + scale: vi.fn(), + setMask: vi.fn(), + drawPattern: vi.fn(), + }, + camera, + ); + return translate.mock.calls[0]; + }; + + it("moves with the world at a ratio of 1", () => { + // the world is drawn translated by -(pos + offset) + const [x, y] = drawnAt(1, { x: -30, y: 40 }); + expect(x).toBeCloseTo(-(100 - 30), 9); + expect(y).toBeCloseTo(-(50 + 40), 9); + }); + + it("moves by half as much at a ratio of 0.5", () => { + const [x, y] = drawnAt(0.5, { x: -30, y: 40 }); + expect(x).toBeCloseTo(-0.5 * (100 - 30), 9); + expect(y).toBeCloseTo(-0.5 * (50 + 40), 9); + }); + + it("keeps the position it holds between draws where it is drawn", () => { + const image = document.createElement("canvas"); + image.width = 64; + image.height = 64; + const layer = new ImageLayer(0, 0, { + image, + name: "parallax", + repeat: "no-repeat", + ratio: 0.5, + }); + layer.anchorPoint.set(0, 0); + const viewport = app.viewport; + app.world.addChild(layer); + try { + viewport.moveTo(0, 0); + viewport.offset.set(-30, 40); + // what the camera moving calls + layer.updateLayer(); + expect(layer.pos.x).toBeCloseTo(-0.5 * (viewport.pos.x - 30), 9); + expect(layer.pos.y).toBeCloseTo(-0.5 * (viewport.pos.y + 40), 9); + } finally { + viewport.offset.setZero(); + app.world.removeChildNow(layer); + } + }); + }); +}); diff --git a/packages/melonjs/tests/imagelayer.spec.js b/packages/melonjs/tests/imagelayer.spec.js index 4845760db..07e9f15bb 100644 --- a/packages/melonjs/tests/imagelayer.spec.js +++ b/packages/melonjs/tests/imagelayer.spec.js @@ -189,6 +189,7 @@ describe("ImageLayer", () => { width, height, pos: { x: 0, y: 0 }, + offset: { x: 0, y: 0 }, bounds: { width, height }, }; } @@ -382,6 +383,7 @@ describe("ImageLayer", () => { width, height, pos: { x: 0, y: 0 }, + offset: { x: 0, y: 0 }, bounds: { width, height }, }; }