feat(scene): shared zoom/pan for control and presentation

Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 14:08:19 +08:00
parent 2c0e6fbf09
commit eb127c11c2
13 changed files with 688 additions and 19 deletions
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
} from '../../shared/types/sceneView';
import { SceneViewStore } from './sceneViewStore';
describe('SceneViewStore', () => {
it('resets to default camera', () => {
const store = new SceneViewStore();
store.dispatch({ kind: 'set', camera: { scale: 2, ox: 0.2, oy: 0.8 } });
const next = store.dispatch({ kind: 'reset' });
assert.equal(next.scale, 1);
assert.equal(next.ox, 0.5);
assert.equal(next.oy, 0.5);
});
it('clamps scale and origin', () => {
const store = new SceneViewStore();
const next = store.dispatch({ kind: 'set', camera: { scale: 99, ox: -1, oy: 2 } });
assert.equal(next.scale, 8);
assert.equal(next.ox, 0);
assert.equal(next.oy, 1);
});
});
describe('sceneViewZoomAt / panBy', () => {
it('zooms toward cursor and keeps that content point under cursor', () => {
const hostW = 1000;
const hostH = 500;
const containW = 800;
const containH = 400;
const hostX = 700;
const hostY = 250;
const next = sceneViewZoomAt(DEFAULT_SCENE_VIEW_CAMERA, {
hostW,
hostH,
containW,
containH,
hostX,
hostY,
factor: 2,
});
assert.ok(next.scale > 1.5);
const displayW = containW * next.scale;
const displayH = containH * next.scale;
const left = hostW / 2 - next.ox * displayW;
const top = hostH / 2 - next.oy * displayH;
const ix = (hostX - left) / displayW;
const iy = (hostY - top) / displayH;
// At scale=1 contain is centered; cursor was at content x=(700-100)/800=0.75
assert.ok(Math.abs(ix - 0.75) < 1e-6);
assert.ok(Math.abs(iy - 0.5) < 1e-6);
});
it('pans in host pixels', () => {
const cam = { scale: 2, ox: 0.5, oy: 0.5 };
const next = sceneViewPanBy(cam, { containW: 400, containH: 200, dx: 80, dy: 0 });
// displayW=800; dx=80 → ox decreases by 0.1
assert.ok(Math.abs(next.ox - 0.4) < 1e-6);
assert.equal(next.oy, 0.5);
});
});
+64
View File
@@ -0,0 +1,64 @@
import {
clampSceneViewCamera,
DEFAULT_SCENE_VIEW_CAMERA,
type SceneViewCamera,
type SceneViewEvent,
type SceneViewState,
} from '../../shared/types';
function emptyState(): SceneViewState {
return {
revision: 1,
...DEFAULT_SCENE_VIEW_CAMERA,
};
}
export class SceneViewStore {
private state: SceneViewState = emptyState();
getState(): SceneViewState {
return this.state;
}
reset(): SceneViewState {
if (
this.state.scale === 1 &&
this.state.ox === 0.5 &&
this.state.oy === 0.5
) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
...DEFAULT_SCENE_VIEW_CAMERA,
};
return this.state;
}
dispatch(event: SceneViewEvent): SceneViewState {
switch (event.kind) {
case 'reset':
return this.reset();
case 'set': {
const camera: SceneViewCamera = clampSceneViewCamera(event.camera);
if (
camera.scale === this.state.scale &&
camera.ox === this.state.ox &&
camera.oy === this.state.oy
) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
...camera,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}