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…');