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 }, }; }