Both settings lived in the browser's localStorage, so they did not travel with the project and differed per machine. They now belong to the project like the symbol scale. There is no migration, old projects load with the defaults. - New module src/core/model/playSettings.ts reads playFrom and gameScale on load. Unknown or missing values fall back to the start scene and 100%. - src/editor/settings.ts is gone. - Play starts in the project settings dialog is an undo step (SetPlayFromCommand). It is only recorded on a real change. - The game view scale is saved with the project but is no undo step, so Ctrl+Z never undoes a zoom. A scale picked during play is written to the stored project and survives Stop. - Save states of persistent Variables assets (saves.ts) stay in the browser on purpose. They are runtime data of the game, not settings. Tests: - 6 new unit tests in tests/editor/play-settings.test.ts: defaults, save and load, broken JSON, undo of Play starts, scale without undo step, scale picked during play survives Stop. - e2e 03g checks the value in the project, nothing in localStorage and no new undo step. 35f also checks project.playFrom. No new checks, the sum stays at 1375. - Counter checks: no write to the stored project in play, no read on load, and an undo step for the scale each fail exactly their test. - ./check.sh: 158 files, 2332 tests, CHECK EXIT 0 - e2e 1-17,28,30,35,38-40,46: ALL PASSED, 752 checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
5.1 KiB
TypeScript
128 lines
5.1 KiB
TypeScript
import { GAME_SCALE_STEPS, type GameScale } from '../core/model/playSettings';
|
||
import type { PropertyBag, Vec2 } from '../core/model/Properties';
|
||
import { cameraFrame, cameraSize, rectAround, type WorldRect } from '../core/properties/camera';
|
||
|
||
/**
|
||
* The world point (`x`, `y`) sits at the centre of the screen rectangle `rect` (viewport-local
|
||
* pixels) at `scale` pixels per world unit.
|
||
*/
|
||
export interface View {
|
||
x: number;
|
||
y: number;
|
||
scale: number;
|
||
rect: { x: number; y: number; w: number; h: number };
|
||
}
|
||
|
||
/**
|
||
* Free-panning view: the whole viewport is the rectangle. `zoom` counts device pixels per world
|
||
* unit and `ratio` device pixels per CSS pixel, so zoom 1 is one screen pixel per world pixel even
|
||
* under Windows scaling.
|
||
*/
|
||
export function freeView(x: number, y: number, zoom: number, w: number, h: number, ratio = 1): View {
|
||
return { x, y, scale: zoom / ratio, rect: { x: 0, y: 0, w, h } };
|
||
}
|
||
|
||
/**
|
||
* The zoom steps of the scene view, in device pixels per world pixel. Each gives every texel the
|
||
* same number of screen pixels, so pixel art never shows one pixel wider than its neighbour.
|
||
*/
|
||
export const FREE_ZOOM_STEPS = [0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 16, 24] as const;
|
||
|
||
/** The largest step at or below `zoom`, never below the smallest (fit rounds down to it). */
|
||
export function zoomStepBelow(zoom: number): number {
|
||
return [...FREE_ZOOM_STEPS].reverse().find((step) => step <= zoom + 1e-9) ?? FREE_ZOOM_STEPS[0];
|
||
}
|
||
|
||
/** The step nearest to `zoom` on a log scale, for a zoom saved before the steps or on another screen. */
|
||
export function nearestZoomStep(zoom: number): number {
|
||
const distance = (step: number) => Math.abs(Math.log(step / Math.max(zoom, 1e-9)));
|
||
return FREE_ZOOM_STEPS.reduce((best, step) => (distance(step) < distance(best) ? step : best));
|
||
}
|
||
|
||
/** The next step up (`direction` 1) or down (-1) from `zoom`; stays at the ends. */
|
||
export function stepFreeZoom(zoom: number, direction: 1 | -1): number {
|
||
const at = FREE_ZOOM_STEPS.indexOf(nearestZoomStep(zoom) as (typeof FREE_ZOOM_STEPS)[number]);
|
||
return FREE_ZOOM_STEPS[clampIndex(at + direction)]!;
|
||
}
|
||
|
||
function clampIndex(index: number): number {
|
||
return Math.max(0, Math.min(FREE_ZOOM_STEPS.length - 1, index));
|
||
}
|
||
|
||
export { GAME_SCALE_STEPS, type GameScale };
|
||
|
||
/**
|
||
* Device pixels per camera pixel for a camera picture of `sizeW`×`sizeH` in a `w`×`h` view (CSS
|
||
* pixels). `ratio` is device pixels per CSS pixel, 1.25 under Windows scaling at 125%: the steps
|
||
* count device pixels, so 100% stays one screen pixel per camera pixel whatever the scaling.
|
||
*/
|
||
export function gameScaleFactor(
|
||
mode: GameScale,
|
||
sizeW: number,
|
||
sizeH: number,
|
||
w: number,
|
||
h: number,
|
||
ratio = 1,
|
||
): number {
|
||
const fit = Math.min(w / sizeW, h / sizeH) * ratio;
|
||
if (mode === 'fit') return fit;
|
||
if (mode !== 'auto') return mode;
|
||
return Math.max(Math.min(...GAME_SCALE_STEPS), ...GAME_SCALE_STEPS.filter((step) => step <= fit));
|
||
}
|
||
|
||
/** What `mode` comes to for this camera in a `w`×`h` view (the picker shows it for auto and fit). */
|
||
export function cameraFactor(props: PropertyBag, w: number, h: number, mode: GameScale, ratio = 1): number {
|
||
const size = cameraSize(props);
|
||
return gameScaleFactor(mode, size.w, size.h, w, h, ratio);
|
||
}
|
||
|
||
/**
|
||
* The camera's picture at `mode` in `w`×`h`, centred; the remaining margin is letterbox. 100% is
|
||
* one device pixel per camera pixel, so the camera's zoom still magnifies the world on top. A
|
||
* picture larger than the view is cut to it, so the rectangle only ever covers what is on screen.
|
||
* Fixed steps start on a whole device pixel, so pixel art at 100% stays sharp.
|
||
*/
|
||
export function cameraView(
|
||
camera: { x: number; y: number; props: PropertyBag },
|
||
w: number,
|
||
h: number,
|
||
mode: GameScale = 'fit',
|
||
ratio = 1,
|
||
): View {
|
||
const size = cameraSize(camera.props);
|
||
const frame = cameraFrame(camera.props);
|
||
const factor = cameraFactor(camera.props, w, h, mode, ratio) / ratio;
|
||
const scale = (factor * size.w) / frame.w;
|
||
const fw = Math.min(size.w * factor, w);
|
||
const fh = Math.min(size.h * factor, h);
|
||
const snap = mode === 'fit' ? (v: number) => v : (v: number) => Math.round(v * ratio) / ratio;
|
||
return {
|
||
x: camera.x,
|
||
y: camera.y,
|
||
scale,
|
||
rect: { x: snap((w - fw) / 2), y: snap((h - fh) / 2), w: fw, h: fh },
|
||
};
|
||
}
|
||
|
||
export function screenToWorld(view: View, sx: number, sy: number): Vec2 {
|
||
const cx = view.rect.x + view.rect.w / 2;
|
||
const cy = view.rect.y + view.rect.h / 2;
|
||
return { x: (sx - cx) / view.scale + view.x, y: (sy - cy) / view.scale + view.y };
|
||
}
|
||
|
||
export function worldToScreen(view: View, wx: number, wy: number): Vec2 {
|
||
const cx = view.rect.x + view.rect.w / 2;
|
||
const cy = view.rect.y + view.rect.h / 2;
|
||
return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy };
|
||
}
|
||
|
||
export function worldBounds(view: View): WorldRect {
|
||
return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale);
|
||
}
|
||
|
||
/** Whether a viewport-local point lies on the drawn rectangle rather than in the letterbox. */
|
||
export function isInside(view: View, sx: number, sy: number): boolean {
|
||
const { x, y, w, h } = view.rect;
|
||
return sx >= x && sx <= x + w && sy >= y && sy <= y + h;
|
||
}
|