From 70f360873364b635765c76c78dfa105b4bacdbb6 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 19:09:53 +0200 Subject: [PATCH] Let Mouse position and Is hovered see through any camera (#304) An overlay camera from #302 can stand elsewhere and zoom differently. A HUD button only it shows lay somewhere else in the world than the mouse of the main camera, so Is hovered never found it. Both nodes now take a camera and see the mouse through that camera's picture. - New input `camera` on Mouse position and Is hovered. Left open, both read the main camera exactly as before. - `pointerThrough` in core/properties/camera is the rule as a pure function. It fits the camera's picture into the main one and centres it, the same way overlayView draws it. - The game view reports where the mouse lies on the main picture, as a part of its width and height. The world turns that into a camera's view, so a main camera on the move does not drag the mouse across the HUD while the mouse stands still. - `in view` is true only on the camera's own picture. A camera that is not drawn (switched off, a second plain one, not a camera) never has the mouse on it. - On click and Mouse button stay with the main camera, as agreed in the issue. Also fixes a unit test that 1d0fa7b broke. play-settings expected 8 to be no game view scale, but the steps go up to 20 since then. It now checks 20 as the top and 21 as no choice. Tests: - Unit: the rule lands on the same world point as overlayView plus screenToWorld at four screen points for two cameras. One mouse gives (100, 50) through the main camera and (1050, 25) through an overlay at (1000, 0) with zoom 2. - e2e 63c2: the real mouse 30 units right of the picture centre hovers the overlay's square through it, through the main camera not. - Counter-checks: Math.max instead of Math.min in the rule, and no check for "drawn", each fail one unit test. The view reporting the centre of the picture always fails 63c2 with x 2000 instead of 2015. - Gate: 231 files, 3526 tests (+6). e2e 63: 12 checks (+1), sum 1730. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/graph/NodeContext.ts | 5 ++ src/core/graph/nodes/data/IsHovered.ts | 8 +- src/core/graph/nodes/data/MousePosition.ts | 14 +++- src/core/properties/camera.ts | 23 ++++++ src/editor/viewport/GameViewport.ts | 1 + src/player/GameScreen.ts | 1 + src/runtime/World.ts | 36 ++++++++- src/runtime/systems/behaviorContext.ts | 5 ++ src/viewport/gamePointer.ts | 18 ++++- tests/core/node-catalog.test.ts | 30 ++++++++ tests/editor/play-settings.test.ts | 4 +- tests/helpers.ts | 1 + tests/viewport/camera-pointer.test.ts | 87 ++++++++++++++++++++++ tools/e2e/63-render-layers.mjs | 68 +++++++++++++++++ 15 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 tests/viewport/camera-pointer.test.ts diff --git a/README.md b/README.md index 0098f3a7..f8909882 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1729 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1730 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 14fe898a..73032566 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -162,6 +162,11 @@ export interface NodeContext { wasKeyPressed(key: string): boolean; wasKeyReleased(key: string): boolean; pointer: PointerState; + /** + * The mouse as `camera` sees it (#304): world units through that camera, and whether the mouse is + * on its picture. The main camera gives `pointer`; a camera not drawn is never under the mouse. + */ + pointerThrough(camera: EntityHandle): { x: number; y: number; inside: boolean }; /** * Whether a world point lies on `target`: on its colliders when it has any, otherwise on its own * footprint or that of anything parented below it. diff --git a/src/core/graph/nodes/data/IsHovered.ts b/src/core/graph/nodes/data/IsHovered.ts index 7642788b..767dd894 100644 --- a/src/core/graph/nodes/data/IsHovered.ts +++ b/src/core/graph/nodes/data/IsHovered.ts @@ -6,13 +6,15 @@ export const IsHovered: NodeDef = { type: 'IsHovered', label: 'Is hovered', category: 'data', - description: "Whether the mouse is over an object (its colliders, else its own or its children's footprint).", - inputs: [obj('object')], + description: + "Whether the mouse is over an object (its colliders, else its own or its children's footprint) – as the main camera sees it, or the camera given.", + inputs: [obj('object'), obj('camera')], outputs: [bool('result')], params: [], run: (ctx, inputs) => { const target = asEntity(inputs.object); - const { x, y, inside } = ctx.pointer; + const camera = asEntity(inputs.camera); + const { x, y, inside } = camera ? ctx.pointerThrough(camera) : ctx.pointer; return { outputs: { result: Boolean(target && inside && ctx.hitTest(target, x, y)) } }; }, }; diff --git a/src/core/graph/nodes/data/MousePosition.ts b/src/core/graph/nodes/data/MousePosition.ts index fd4187f6..49ef0f93 100644 --- a/src/core/graph/nodes/data/MousePosition.ts +++ b/src/core/graph/nodes/data/MousePosition.ts @@ -1,13 +1,19 @@ import type { NodeDef } from '../../NodeRegistry'; -import { bool, num } from '../shared'; +import { asEntity } from '../../values'; +import { bool, num, obj } from '../shared'; export const MousePosition: NodeDef = { type: 'MousePosition', label: 'Mouse position', category: 'data', - description: 'Where the mouse is in the Game tab, in world units.', - inputs: [], + description: + 'Where the mouse is in the Game tab, in world units – through the main camera, or through the camera given.', + inputs: [obj('camera')], outputs: [num('x'), num('y'), bool('inside', 'in view')], params: [], - run: (ctx) => ({ outputs: { x: ctx.pointer.x, y: ctx.pointer.y, inside: ctx.pointer.inside } }), + run: (ctx, inputs) => { + const camera = asEntity(inputs.camera); + const { x, y, inside } = camera ? ctx.pointerThrough(camera) : ctx.pointer; + return { outputs: { x, y, inside } }; + }, }; diff --git a/src/core/properties/camera.ts b/src/core/properties/camera.ts index 1f0468c4..1fadc77a 100644 --- a/src/core/properties/camera.ts +++ b/src/core/properties/camera.ts @@ -25,6 +25,29 @@ export function cameraSize(props: PropertyBag): { w: number; h: number } { return { w: Number(props.width ?? DEFAULTS.width), h: Number(props.height ?? DEFAULTS.height) }; } +/** + * Where the mouse is in the world as `camera` sees it, with that camera's picture fitted into the + * main camera's and centred, the way #302 lays an overlay on. `onPicture` is the mouse on the main + * picture, from its centre in parts of its width and height; `onMain` whether it is on it at all. + * `inside` is then whether the mouse is on this camera's picture. + */ +export function pointerThrough( + onPicture: { x: number; y: number }, + onMain: boolean, + mainProps: PropertyBag, + camera: { x: number; y: number; props: PropertyBag }, +): { x: number; y: number; inside: boolean } { + const main = cameraFrame(mainProps); + const frame = cameraFrame(camera.props); + // Units of the main frame per unit of this camera's. + const fit = Math.min(main.w / frame.w, main.h / frame.h); + if (!(fit > 0) || !Number.isFinite(fit)) return { x: camera.x, y: camera.y, inside: false }; + const dx = onPicture.x * main.w; + const dy = onPicture.y * main.h; + const inside = onMain && Math.abs(dx) <= (frame.w * fit) / 2 && Math.abs(dy) <= (frame.h * fit) / 2; + return { x: camera.x + dx / fit, y: camera.y + dy / fit, inside }; +} + export function rectAround(cx: number, cy: number, w: number, h: number): WorldRect { return { left: cx - w / 2, top: cy - h / 2, right: cx + w / 2, bottom: cy + h / 2 }; } diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index 9b735e92..3e159059 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -61,6 +61,7 @@ export class GameViewport extends WorldViewport { this, () => this.pointerWorld(), () => this.currentView(), + () => this.mainCamera(), ); } diff --git a/src/player/GameScreen.ts b/src/player/GameScreen.ts index 72699157..c2c2e977 100644 --- a/src/player/GameScreen.ts +++ b/src/player/GameScreen.ts @@ -43,6 +43,7 @@ export class GameScreen extends WorldViewport { this, () => this.running, () => this.currentView(), + () => this.mainCamera(), ); this.on('pointerdown', (e) => { if (e.button === 0 && !this.running && this.startable) this.onStart?.(); diff --git a/src/runtime/World.ts b/src/runtime/World.ts index bccbb7fa..2b6df87d 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,4 +1,10 @@ -import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph'; +import { + ENTITY_HANDLE, + isHandleEnabled, + type PointerState, + worldMatrixOf, + worldPositionOf, +} from '../core/graph'; import { newId } from '../core/ids'; import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset'; import { sameAtRest, withoutObjects } from '../core/graph/liveObjects'; @@ -17,6 +23,7 @@ import { apply, IDENTITY } from '../core/model/matrix'; import { hasTransformProps } from '../core/properties/PropertySchema'; import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform'; import { footprintContains } from '../core/properties/bounds'; +import { pointerThrough } from '../core/properties/camera'; import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import type { Entity } from './Entity'; @@ -129,6 +136,8 @@ export class World { released: false, wheel: 0, }; + /** The mouse on the main camera's picture, from its centre in parts of its width and height (#304). */ + private pointerOnPicture: Vec2 = { x: 0, y: 0 }; /** Objects the editor is dragging (ids): the physics puts them where the drag does, at rest. */ held: ReadonlySet = new Set(); /** Messages logged by behaviors (Log node), newest last. */ @@ -484,11 +493,32 @@ export class World { this.pointer.wheel = 0; } - /** Game view: where the mouse is (world units) and whether it is over the view at all. */ - movePointer(x: number, y: number, inside: boolean): void { + /** + * Game view: where the mouse is (world units), whether it is over the view at all, and where it + * lies on the main camera's picture (see `pointerOnPicture`). + */ + movePointer(x: number, y: number, inside: boolean, onPicture: Vec2 = { x: 0, y: 0 }): void { this.pointer.x = x; this.pointer.y = y; this.pointer.inside = inside; + this.pointerOnPicture = onPicture; + } + + /** + * Where the mouse is in the world as `camera` sees it (#304). The main camera answers `pointer`. + * Kept on the picture rather than in the world, so a main camera on the move does not drag the + * mouse across an overlay. Only a camera that is drawn can have the mouse on its picture. + */ + pointerThrough(camera: Entity): { x: number; y: number; inside: boolean } { + const { x, y, inside } = this.pointer; + const main = this.activeCamera(); + if (camera === main) return { x, y, inside }; + if (!main || camera.type !== 'camera') return { x, y, inside: false }; + const seen = pointerThrough(this.pointerOnPicture, inside, main.props, { + ...worldPositionOf(camera), + props: camera.props, + }); + return { ...seen, inside: seen.inside && this.overlayCameras().includes(camera) }; } /** Game view: the wheel turned by `notches`; what turns within one tick adds up. */ diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 7b19166c..660696df 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -76,6 +76,11 @@ export function behaviorContext( wasKeyPressed: (key) => run.input.justPressed.has(keyName(key)), wasKeyReleased: (key) => run.input.justReleased.has(keyName(key)), pointer: world.pointer, + pointerThrough: (camera) => { + const found = resolve(camera); + const { x, y } = world.pointer; + return found ? world.pointerThrough(found) : { x, y, inside: false }; + }, hitTest: (target, x, y) => { const found = resolve(target); return Boolean(found && world.hitTest(found, x, y)); diff --git a/src/viewport/gamePointer.ts b/src/viewport/gamePointer.ts index 511aaea0..d0a6ca08 100644 --- a/src/viewport/gamePointer.ts +++ b/src/viewport/gamePointer.ts @@ -1,20 +1,30 @@ import type { Container, FederatedMouseEvent, FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js'; +import { cameraFrame } from '../core/properties/camera'; import type { World } from '../runtime/World'; +import type { ActiveCamera } from './activeCamera'; import { isInside, screenToWorld, type View, wheelNotches } from './view'; /** * Hands the mouse over `target` to a running world: position, left button and wheel. Shared by the * editor's game view and a released game (#266). Outside the camera frame counts as "not in view": - * that region is letterbox, not game. + * that region is letterbox, not game. `camera` is the main camera `view` shows. */ -export function routePointer(target: Container, world: () => World | null, view: () => View | null): void { +export function routePointer( + target: Container, + world: () => World | null, + view: () => View | null, + camera: () => ActiveCamera | null, +): void { const report = (e: FederatedMouseEvent): void => { const w = world(); const v = view(); - if (!w || !v) return; + const c = camera(); + if (!w || !v || !c) return; const local = target.toLocal(e.global); const point = screenToWorld(v, local.x, local.y); - w.movePointer(point.x, point.y, isInside(v, local.x, local.y)); + const frame = cameraFrame(c.props); + const onPicture = { x: (point.x - v.x) / frame.w, y: (point.y - v.y) / frame.h }; + w.movePointer(point.x, point.y, isInside(v, local.x, local.y), onPicture); }; target.on('pointerdown', (e: FederatedPointerEvent) => { if (e.button !== 0) return; diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index b6e3e729..d586dbfd 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1318,6 +1318,16 @@ const CASES: Record = { context: pointer({ x: 5, y: 5, inside: false }), outputs: { x: 5, y: 5, inside: false }, }, + { + name: 'through the camera given, where that camera sees the mouse', + inputs: { camera: fakeEntity('Ui camera') }, + context: { + ...pointer({ x: 100, y: 50, inside: true }), + pointerThrough: (camera) => + camera.id === 'Ui camera' ? { x: 1050, y: 25, inside: false } : { x: 0, y: 0, inside: true }, + }, + outputs: { x: 1050, y: 25, inside: false }, + }, ], MouseButton: [ { @@ -1355,6 +1365,26 @@ const CASES: Record = { context: { ...pointer({ inside: true }), hitTest: () => true }, outputs: { result: false }, }, + { + name: 'through the camera given, the test is where that camera sees the mouse', + inputs: { object: fakeEntity('Button'), camera: fakeEntity('Ui camera') }, + context: { + ...pointer({ x: 100, y: 50, inside: true }), + pointerThrough: () => ({ x: 1050, y: 25, inside: true }), + hitTest: (_, x, y) => x === 1050 && y === 25, + }, + outputs: { result: true }, + }, + { + name: 'off the picture of the camera given, nothing is hovered', + inputs: { object: fakeEntity('Button'), camera: fakeEntity('Ui camera') }, + context: { + ...pointer({ x: 1050, y: 25, inside: true }), + pointerThrough: () => ({ x: 1050, y: 25, inside: false }), + hitTest: () => true, + }, + outputs: { result: false }, + }, ], IsTouching: [ { diff --git a/tests/editor/play-settings.test.ts b/tests/editor/play-settings.test.ts index 06d164c4..e352385b 100644 --- a/tests/editor/play-settings.test.ts +++ b/tests/editor/play-settings.test.ts @@ -42,9 +42,9 @@ describe('play settings in the project', () => { expect(gameScaleOf({ gameScale: 'fit' })).toBe('fit'); expect(gameScaleOf({ gameScale: 0.25 })).toBe(0.25); expect(gameScaleOf({ gameScale: 3 })).toBe(3); - expect(gameScaleOf({ gameScale: 6 })).toBe(6); + expect(gameScaleOf({ gameScale: 20 })).toBe(20); expect(gameScaleOf({ gameScale: 2.5 })).toBe(1); - expect(gameScaleOf({ gameScale: 8 })).toBe(1); + expect(gameScaleOf({ gameScale: 21 })).toBe(1); expect(gameScaleOf({ gameScale: '2' })).toBe(1); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 29b518e3..ed45b59f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -238,6 +238,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { wasKeyPressed: () => false, wasKeyReleased: () => false, pointer: { ...NO_POINTER }, + pointerThrough: () => ({ x: 0, y: 0, inside: false }), hitTest: () => false, isTouching: () => false, raycast: () => null, diff --git a/tests/viewport/camera-pointer.test.ts b/tests/viewport/camera-pointer.test.ts new file mode 100644 index 00000000..3aad38b5 --- /dev/null +++ b/tests/viewport/camera-pointer.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { pointerThrough } from '../../src/core/properties/camera'; +import { OVERLAY_KEY } from '../../src/core/model/renderLayers'; +import { cameraView, isInside, overlayView, screenToWorld } from '../../src/viewport/view'; +import { makeSpriteScene, place, startWorld } from '../helpers'; + +const MAIN = { width: 640, height: 360, zoom: 1 }; + +describe('the mouse through a camera (#304)', () => { + it('lands where the overlay picture #302 draws shows that world point', () => { + const main = cameraView({ x: 0, y: 0, props: MAIN }, 1280, 720, 2); + const cameras = [ + { x: 1000, y: 0, props: { ...MAIN, zoom: 2 } }, + { x: 50, y: 7, props: { width: 320, height: 360, zoom: 2 } }, + ]; + for (const camera of cameras) { + const drawn = overlayView(camera, main, { w: 640, h: 360 }); + for (const [sx, sy] of [ + [640, 360], + [840, 460], + [100, 700], + [330, 10], + ] as const) { + const onMain = screenToWorld(main, sx, sy); + const seen = pointerThrough({ x: onMain.x / 640, y: onMain.y / 360 }, true, MAIN, camera); + const expected = screenToWorld(drawn, sx, sy); + expect(seen.x).toBeCloseTo(expected.x, 9); + expect(seen.y).toBeCloseTo(expected.y, 9); + expect(seen.inside).toBe(isInside(drawn, sx, sy)); + } + } + }); + + it('gives one mouse two world points through the main camera and a zoomed overlay', () => { + const f = makeSpriteScene(); + const cam = new CreateAssetCommand('camera', 'Cam'); + f.stack.execute(cam); + const set = (id: string, key: string, value: number | boolean) => + f.stack.execute(new SetOverrideCommand(f.sceneId, id, key, value)); + const mainId = place(f, cam.asset.id, { x: 0, y: 0 }); + const overId = place(f, cam.asset.id, { x: 1000, y: 0 }); + const plainId = place(f, cam.asset.id, { x: -1000, y: 0 }); + set(overId, OVERLAY_KEY, true); + set(overId, 'zoom', 2); + const { world } = startWorld(f); + const [main, over, plain] = [mainId, overId, plainId].map((id) => world.findByObjectId(id)!); + + world.movePointer(100, 50, true, { x: 100 / 640, y: 50 / 360 }); + expect(world.pointerThrough(main!)).toEqual({ x: 100, y: 50, inside: true }); + expect(world.pointerThrough(over!)).toEqual({ x: 1050, y: 25, inside: true }); + // A second plain camera is not drawn, so the mouse is never on its picture. + expect(world.pointerThrough(plain!).inside).toBe(false); + + // The main camera moving on under a mouse that stands still does not move it on the overlay. + main!.x = 500; + expect(world.pointerThrough(over!)).toEqual({ x: 1050, y: 25, inside: true }); + + world.movePointer(100, 50, false, { x: 100 / 640, y: 50 / 360 }); + expect(world.pointerThrough(over!).inside).toBe(false); + + world.movePointer(100, 50, true, { x: 100 / 640, y: 50 / 360 }); + set(overId, 'enabled', false); + world.syncFromModel(); + expect(world.pointerThrough(over!)).toMatchObject({ x: 1050, y: 25, inside: false }); + }); + + it('hovers a button only the overlay shows through that camera only', () => { + const f = makeSpriteScene('Button'); + const cam = new CreateAssetCommand('camera', 'Cam'); + f.stack.execute(cam); + place(f, cam.asset.id, { x: 0, y: 0 }); + const overId = place(f, cam.asset.id, { x: 1000, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, overId, OVERLAY_KEY, true)); + f.stack.execute(new SetOverrideCommand(f.sceneId, overId, 'zoom', 2)); + const buttonId = place(f, f.spriteId, { x: 1050, y: 25 }); + const { world } = startWorld(f); + const button = world.findByObjectId(buttonId)!; + const over = world.findByObjectId(overId)!; + + world.movePointer(100, 50, true, { x: 100 / 640, y: 50 / 360 }); + const { x, y } = world.pointerThrough(over); + expect(world.hitTest(button, x, y)).toBe(true); + expect(world.hitTest(button, world.pointer.x, world.pointer.y)).toBe(false); + }); +}); diff --git a/tools/e2e/63-render-layers.mjs b/tools/e2e/63-render-layers.mjs index 15856096..d13e657a 100644 --- a/tools/e2e/63-render-layers.mjs +++ b/tools/e2e/63-render-layers.mjs @@ -4,6 +4,7 @@ * a green sprite of the overlay with z -10 covers a red one of the main camera with z 1000, and * what a camera does not see stays away – a yellow `UI` sprite in front of the main camera, a blue * `Default` one in front of the overlay. Switched to no overlay, the second camera draws nothing. + * `Is hovered` and `Mouse position` find the mouse through the overlay camera too (#304). * Then the main camera wears a shader over its picture (#299), in the Game tab and, switched on in * the eye, in the scene view. * @@ -119,6 +120,73 @@ export async function renderLayers(t) { ); await stop(); + // 63c2. (#304) The mouse through the overlay camera, zoomed 2x: 30 units right of the centre of + // the picture it lies 15 units right of the overlay's centre, on the 40 wide square B. Through + // the main camera nothing is there. Logged each tick: hovered through it, without it, its x. + const node = (id, type, x, params = {}) => ({ id, type, x, y: 0, params }); + const edge = (id, from, fromPort, to, toPort) => ({ + id, + from: { node: from, port: fromPort }, + to: { node: to, port: toPort }, + }); + const hoverGraph = { + nodes: [ + node('rl63-tick', 'OnUpdate', 0), + node('rl63-b-find', 'ObjectByName', 0, { name: 'B 63' }), + node('rl63-cam-find', 'ObjectByName', 0, { name: 'Overlay 63' }), + node('rl63-through', 'IsHovered', 200), + node('rl63-plain', 'IsHovered', 200), + node('rl63-mouse', 'MousePosition', 200), + node('rl63-log1', 'Log', 400), + node('rl63-log2', 'Log', 600), + node('rl63-log3', 'Log', 800), + ], + edges: [ + edge('rl63-e1', 'rl63-tick', 'exec', 'rl63-log1', 'exec'), + edge('rl63-e2', 'rl63-log1', 'exec', 'rl63-log2', 'exec'), + edge('rl63-e3', 'rl63-log2', 'exec', 'rl63-log3', 'exec'), + edge('rl63-e4', 'rl63-b-find', 'object', 'rl63-through', 'object'), + edge('rl63-e5', 'rl63-cam-find', 'object', 'rl63-through', 'camera'), + edge('rl63-e6', 'rl63-b-find', 'object', 'rl63-plain', 'object'), + edge('rl63-e7', 'rl63-cam-find', 'object', 'rl63-mouse', 'camera'), + edge('rl63-e8', 'rl63-through', 'result', 'rl63-log1', 'message'), + edge('rl63-e9', 'rl63-plain', 'result', 'rl63-log2', 'message'), + edge('rl63-e10', 'rl63-mouse', 'x', 'rl63-log3', 'message'), + ], + }; + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + p.assets.push({ id: 'rl63-hover', type: 'behavior', name: 'Hover 63', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(hoverGraph)} }); + ${MAKE_OBJECT} + scene.objects.push(make('rl63-logic', 'rl63-hover', '', 0, 'Hover 63')); + const cam = scene.objects.find((o) => o.id === '${OVERLAY}'); + window.__rl63.overlay = structuredClone(cam.overrides); + cam.overrides = { ...cam.overrides, zoom: 2 }; + `); + await play(); + await sleep(300); + const game = await json('__gm.gameScale()'); + const centre = { x: game.frame.x + game.frame.w / 2, y: game.frame.y + game.frame.h / 2 }; + await t.mouse('mouseMoved', centre.x + 30 * game.scale, centre.y); + await sleep(300); + const ran = await json('__gm.world()'); + const hovered = ran?.log.slice(-3) ?? []; + const overlayX = ran?.entities.find((e) => e.objectId === OVERLAY)?.worldX; + await stop(); + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'rl63-logic'); + p.assets = p.assets.filter((a) => a.id !== 'rl63-hover'); + scene.objects.find((o) => o.id === '${OVERLAY}').overrides = window.__rl63.overlay; + `); + check( + 'Is hovered through the overlay camera finds what only it shows, through the main one not', + hovered[0] === 'true' && hovered[1] === 'false' && Math.abs(Number(hovered[2]) - (overlayX + 15)) < 1, + JSON.stringify({ hovered, overlayX }), + ); + // 63d. The project settings list the render layers, apart from the collision layers. await t.clickToolbar('menu'); await clickText('Project settings…');