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>
139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
/**
|
|
* Which scene Play starts and the game view scale (#226): both live in the project now, not in the
|
|
* browser. The start-scene switch is an undo step like every other project setting; the scale is
|
|
* saved but never lands on the undo stack.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { SetGameLinkZoomCommand, SetPlayFromCommand } from '../../src/core/commands/projectCommands';
|
|
import { gameLinkZoomOf, gameScaleOf, playFromOf } from '../../src/core/model/playSettings';
|
|
import { createEmptyProject } from '../../src/core/model/Project';
|
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
|
import { EditorState } from '../../src/editor/state';
|
|
import { cameraView } from '../../src/viewport/view';
|
|
|
|
function loaded() {
|
|
const state = new EditorState();
|
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
|
return state;
|
|
}
|
|
|
|
describe('play settings in the project', () => {
|
|
it('start from the start scene at 100% in a new project and in one written before there were any', () => {
|
|
const fresh = createEmptyProject('p', 'P');
|
|
const old = deserializeProject({ id: 'p', name: 'P', assets: [] });
|
|
expect([fresh.playFrom, fresh.gameScale]).toEqual(['start', 1]);
|
|
expect([old.playFrom, old.gameScale]).toEqual(['start', 1]);
|
|
});
|
|
|
|
it('survive a save and a load', () => {
|
|
const project = {
|
|
...createEmptyProject('p', 'P'),
|
|
playFrom: 'active' as const,
|
|
gameScale: 'auto' as const,
|
|
};
|
|
const back = deserializeProject(serializeProject(project));
|
|
expect([back.playFrom, back.gameScale]).toEqual(['active', 'auto']);
|
|
});
|
|
|
|
it('read untrusted JSON as the defaults unless it is one of the choices', () => {
|
|
expect(playFromOf({ playFrom: 'active' })).toBe('active');
|
|
expect(playFromOf({ playFrom: 'elsewhere' })).toBe('start');
|
|
expect(playFromOf(null)).toBe('start');
|
|
expect(gameScaleOf({ gameScale: 'fit' })).toBe('fit');
|
|
expect(gameScaleOf({ gameScale: 0.25 })).toBe(0.25);
|
|
expect(gameScaleOf({ gameScale: 3 })).toBe(3);
|
|
expect(gameScaleOf({ gameScale: 20 })).toBe(20);
|
|
expect(gameScaleOf({ gameScale: 2.5 })).toBe(1);
|
|
expect(gameScaleOf({ gameScale: 21 })).toBe(1);
|
|
expect(gameScaleOf({ gameScale: '2' })).toBe(1);
|
|
});
|
|
|
|
it('make the start-scene switch one undo step', () => {
|
|
const state = loaded();
|
|
state.execute(new SetPlayFromCommand('active'));
|
|
expect(state.playFrom).toBe('active');
|
|
expect(state.saveStatus).toBe('dirty');
|
|
state.undo();
|
|
expect(state.playFrom).toBe('start');
|
|
});
|
|
|
|
it('save the game view scale without an undo step', () => {
|
|
const state = loaded();
|
|
state.setGameScale(4);
|
|
expect(state.project.gameScale).toBe(4);
|
|
expect(state.saveStatus).toBe('dirty');
|
|
expect(state.commands.canUndo).toBe(false);
|
|
});
|
|
|
|
it('keep a scale picked while playing after Stop', () => {
|
|
const state = loaded();
|
|
state.enterPlay();
|
|
state.setGameScale('fit');
|
|
expect(state.gameScale).toBe('fit');
|
|
state.exitPlay();
|
|
expect(state.gameScale).toBe('fit');
|
|
expect(state.saveStatus).toBe('dirty');
|
|
});
|
|
});
|
|
|
|
describe('the zoom of the game link (#279)', () => {
|
|
it('is Auto in a new project and in one written before there was a choice', () => {
|
|
expect(createEmptyProject('p', 'P').gameLinkZoom).toBe('auto');
|
|
expect(deserializeProject({ id: 'p', name: 'P', assets: [] }).gameLinkZoom).toBe('auto');
|
|
});
|
|
|
|
it('survives a save and a load, apart from the scale of the game view', () => {
|
|
const project = { ...createEmptyProject('p', 'P'), gameLinkZoom: 'fit' as const, gameScale: 2 };
|
|
const back = deserializeProject(serializeProject(project));
|
|
expect([back.gameLinkZoom, back.gameScale]).toEqual(['fit', 2]);
|
|
});
|
|
|
|
it('reads untrusted JSON as Auto unless it is one of the two choices', () => {
|
|
expect(gameLinkZoomOf({ gameLinkZoom: 'fit' })).toBe('fit');
|
|
expect(gameLinkZoomOf({ gameLinkZoom: 'auto' })).toBe('auto');
|
|
expect(gameLinkZoomOf({ gameLinkZoom: 2 })).toBe('auto');
|
|
expect(gameLinkZoomOf({ gameLinkZoom: 'Fit' })).toBe('auto');
|
|
expect(gameLinkZoomOf({ gameScale: 'fit' })).toBe('auto');
|
|
expect(gameLinkZoomOf(null)).toBe('auto');
|
|
});
|
|
|
|
it('is one undo step', () => {
|
|
const state = loaded();
|
|
state.execute(new SetGameLinkZoomCommand('fit'));
|
|
expect(state.project.gameLinkZoom).toBe('fit');
|
|
expect(state.saveStatus).toBe('dirty');
|
|
state.undo();
|
|
expect(state.project.gameLinkZoom).toBe('auto');
|
|
state.redo();
|
|
expect(state.project.gameLinkZoom).toBe('fit');
|
|
});
|
|
|
|
it('leaves the scale of the game view alone', () => {
|
|
const state = loaded();
|
|
state.setGameScale(3);
|
|
state.execute(new SetGameLinkZoomCommand('fit'));
|
|
expect(state.gameScale).toBe(3);
|
|
});
|
|
|
|
it('fills the window at Fit where Auto stops at the step below', () => {
|
|
const camera = { x: 0, y: 0, props: { width: 300, height: 180, zoom: 1 } };
|
|
const auto = cameraView(camera, 1000, 700, 'auto');
|
|
const fit = cameraView(camera, 1000, 700, 'fit');
|
|
expect(auto.scale).toBe(3);
|
|
expect(auto.rect).toEqual({ x: 50, y: 80, w: 900, h: 540 });
|
|
expect(fit.scale).toBeCloseTo(10 / 3, 9);
|
|
expect(fit.rect.x).toBeCloseTo(0, 9);
|
|
expect(fit.rect.w).toBeCloseTo(1000, 9);
|
|
expect(fit.rect.h).toBeCloseTo(600, 9);
|
|
expect(fit.rect.y).toBeCloseTo(50, 9);
|
|
});
|
|
|
|
it('shrinks the picture at Fit in a window smaller than the camera', () => {
|
|
const camera = { x: 0, y: 0, props: { width: 300, height: 180, zoom: 1 } };
|
|
const fit = cameraView(camera, 200, 400, 'fit');
|
|
expect(fit.scale).toBeCloseTo(2 / 3, 9);
|
|
expect(fit.rect.w).toBeCloseTo(200, 9);
|
|
expect(fit.rect.h).toBeCloseTo(120, 9);
|
|
});
|
|
});
|