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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d0fa7b947
commit
70f3608733
15 files changed
+286
-17
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)) } };
|
||||
},
|
||||
};
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export class GameViewport extends WorldViewport<EditorState> {
|
||||
this,
|
||||
() => this.pointerWorld(),
|
||||
() => this.currentView(),
|
||||
() => this.mainCamera(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user