Let Mouse position and Is hovered see through any camera (#304)
game-maker / gate (push) Successful in 3m19s
game-maker / suite (push) Successful in 16m23s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 19:09:53 +02:00
1 parent 1d0fa7b947
commit 70f3608733
15 files changed
+286 -17

No files matched your search

+1 -1
View File
@@ -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
+5
View File
@@ -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.
+5 -3
View File
@@ -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)) } };
},
};
+10 -4
View File
@@ -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 } };
},
};
+23
View File
@@ -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 };
}
+1
View File
@@ -61,6 +61,7 @@ export class GameViewport extends WorldViewport<EditorState> {
this,
() => this.pointerWorld(),
() => this.currentView(),
() => this.mainCamera(),
);
}
+1
View File
@@ -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?.();
+33 -3
View File
@@ -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<string> = 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. */
+5
View File
@@ -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));
+14 -4
View File
@@ -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;
+30
View File
@@ -1318,6 +1318,16 @@ const CASES: Record<string, Case[]> = {
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<string, Case[]> = {
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: [
{
+2 -2
View File
@@ -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);
});
+1
View File
@@ -238,6 +238,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
wasKeyPressed: () => false,
wasKeyReleased: () => false,
pointer: { ...NO_POINTER },
pointerThrough: () => ({ x: 0, y: 0, inside: false }),
hitTest: () => false,
isTouching: () => false,
raycast: () => null,
+87
View File
@@ -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);
});
});
+68
View File
@@ -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…');