Skip to content

Commit 39ff14a

Browse files
authored
Merge pull request #388 from bcorfman/rescale
Add rescale after Pixels per Unit setting is changed
2 parents de1e589 + be32ba1 commit 39ff14a

3 files changed

Lines changed: 232 additions & 2 deletions

File tree

src/editor/EditorStore.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import {
1818
type SpriteAssetSpec,
1919
} from '../model/types';
2020
import { createEmptyProject, createEmptyGameScene } from '../model/emptyProject';
21-
import { deriveWorldUnitsFromNaturalPixels, normalizeProjectPixelsPerUnit } from '../model/projectPixelScale';
21+
import {
22+
deriveWorldSpriteSize,
23+
deriveWorldUnitsFromNaturalPixels,
24+
getProjectPixelsPerUnit,
25+
normalizeProjectPixelsPerUnit,
26+
} from '../model/projectPixelScale';
2227
import { validateProjectSpec, validateSceneSpec } from '../model/validation';
2328
import { resolveEntityDefaults } from '../model/entityDefaults';
2429
import { applyGroupArrangeLayout, applyGroupGridLayout, applyGroupGridLayoutPreserveMembers, inferGroupGridLayout, type GroupGridLayout } from './formationLayout';
@@ -2198,6 +2203,46 @@ function recordHistoryForAction(stateBefore: EditorState, stateAfter: EditorStat
21982203
};
21992204
}
22002205

2206+
function reapplyProjectPixelScaleToMatchingSprites(
2207+
previousProject: ProjectSpec,
2208+
nextProject: ProjectSpec,
2209+
): ProjectSpec {
2210+
const previousPixelsPerUnit = getProjectPixelsPerUnit(previousProject);
2211+
const nextPixelsPerUnit = getProjectPixelsPerUnit(nextProject);
2212+
if (previousPixelsPerUnit === nextPixelsPerUnit) return nextProject;
2213+
2214+
let scenesChanged = false;
2215+
const scenes = Object.fromEntries(
2216+
Object.entries(nextProject.scenes).map(([sceneId, scene]) => {
2217+
let entitiesChanged = false;
2218+
const entities = Object.fromEntries(
2219+
Object.entries(scene.entities).map(([entityId, entity]) => {
2220+
const resolved = resolveEntityDefaults(entity);
2221+
if (!resolved.asset) return [entityId, entity];
2222+
2223+
const previousWorldSize = deriveWorldSpriteSize(previousProject, resolved.asset);
2224+
const nextWorldSize = deriveWorldSpriteSize(nextProject, resolved.asset);
2225+
if (!previousWorldSize || !nextWorldSize) return [entityId, entity];
2226+
if (resolved.width !== previousWorldSize.width || resolved.height !== previousWorldSize.height) return [entityId, entity];
2227+
2228+
entitiesChanged = true;
2229+
return [entityId, {
2230+
...entity,
2231+
width: nextWorldSize.width,
2232+
height: nextWorldSize.height,
2233+
}];
2234+
}),
2235+
);
2236+
2237+
if (!entitiesChanged) return [sceneId, scene];
2238+
scenesChanged = true;
2239+
return [sceneId, { ...scene, entities }];
2240+
}),
2241+
) as ProjectSpec['scenes'];
2242+
2243+
return scenesChanged ? { ...nextProject, scenes } : nextProject;
2244+
}
2245+
22012246
function applyAction(state: EditorState, action: EditorAction): EditorState {
22022247
switch (action.type) {
22032248
case 'initialize':
@@ -2262,7 +2307,7 @@ function applyAction(state: EditorState, action: EditorAction): EditorState {
22622307
&& typeof action.publishTitle !== 'string'
22632308
&& (typeof state.project.publishTitle !== 'string' || state.project.publishTitle.trim().length === 0)
22642309
);
2265-
const nextProject: ProjectSpec = {
2310+
const nextProjectDraft: ProjectSpec = {
22662311
...state.project,
22672312
...(typeof action.title === 'string' ? { title: action.title } : {}),
22682313
...(typeof action.pixelsPerUnit === 'number'
@@ -2275,6 +2320,9 @@ function applyAction(state: EditorState, action: EditorAction): EditorState {
22752320
: {}),
22762321
...(typeof action.publishGithubPagesRepo === 'string' ? { publishGithubPagesRepo: action.publishGithubPagesRepo } : {}),
22772322
};
2323+
const nextProject = typeof action.pixelsPerUnit === 'number'
2324+
? reapplyProjectPixelScaleToMatchingSprites(state.project, nextProjectDraft)
2325+
: nextProjectDraft;
22782326
return { ...state, project: nextProject, dirty: true, error: undefined, projectRootEditing: false };
22792327
}
22802328
case 'set-theme-mode':
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { expect, test } from '@playwright/test';
2+
import { dismissViewHint, getEntitySpriteWorldRect, getState, seedProject } from './helpers';
3+
4+
test.describe('Project pixel scale', () => {
5+
test('project settings rescale baseline asset-backed sprites on the canvas immediately @smoke', async ({ page }) => {
6+
const project = {
7+
id: 'project-pixel-scale',
8+
title: 'Pixel Scale Demo',
9+
pixelsPerUnit: 1,
10+
assets: {
11+
images: {
12+
hero: {
13+
id: 'hero',
14+
width: 64,
15+
height: 64,
16+
source: {
17+
kind: 'embedded',
18+
dataUrl: 'data:image/png;base64,AAAA',
19+
originalName: 'hero.png',
20+
mimeType: 'image/png',
21+
},
22+
},
23+
},
24+
spriteSheets: {},
25+
fonts: {},
26+
},
27+
audio: { sounds: {} },
28+
inputMaps: {},
29+
collections: {},
30+
counters: {},
31+
scenes: {
32+
'scene-1': {
33+
id: 'scene-1',
34+
name: 'Scene 1',
35+
world: { width: 800, height: 600 },
36+
entities: {
37+
hero: {
38+
id: 'hero',
39+
name: 'hero',
40+
x: 240,
41+
y: 180,
42+
width: 64,
43+
height: 64,
44+
rotationDeg: 0,
45+
scaleX: 1,
46+
scaleY: 1,
47+
asset: {
48+
source: { kind: 'asset', assetId: 'hero' },
49+
imageType: 'image',
50+
frame: { kind: 'single' },
51+
},
52+
},
53+
},
54+
groups: {},
55+
attachments: {},
56+
eventBlocks: {},
57+
actions: {},
58+
handlers: {},
59+
backgroundLayers: [],
60+
collisionRules: [],
61+
triggers: [],
62+
},
63+
},
64+
initialSceneId: 'scene-1',
65+
} as any;
66+
67+
await seedProject(page, project);
68+
await dismissViewHint(page);
69+
70+
await expect.poll(async () => await getEntitySpriteWorldRect(page, 'hero')).toBeTruthy();
71+
const beforeRect = await getEntitySpriteWorldRect(page, 'hero');
72+
expect(beforeRect).toBeTruthy();
73+
74+
await page.getByTestId('project-tree-manage-button').click();
75+
await page.getByTestId('project-manage-settings').click();
76+
await expect(page.getByTestId('project-settings-dialog')).toBeVisible();
77+
await page.getByTestId('project-settings-preset-2').click();
78+
await page.getByTestId('project-settings-save').click();
79+
80+
await expect.poll(async () => {
81+
const state = await getState<{ project?: { pixelsPerUnit?: number }, scene?: { entities?: Record<string, { width?: number; height?: number }> } } | null>(page);
82+
return {
83+
pixelsPerUnit: state?.project?.pixelsPerUnit ?? null,
84+
width: state?.scene?.entities?.hero?.width ?? null,
85+
height: state?.scene?.entities?.hero?.height ?? null,
86+
};
87+
}).toEqual({
88+
pixelsPerUnit: 2,
89+
width: 32,
90+
height: 32,
91+
});
92+
93+
await expect.poll(async () => {
94+
const rect = await getEntitySpriteWorldRect(page, 'hero');
95+
if (!rect || !beforeRect) return null;
96+
return {
97+
width: rect.maxX - rect.minX,
98+
height: rect.maxY - rect.minY,
99+
beforeWidth: beforeRect.maxX - beforeRect.minX,
100+
beforeHeight: beforeRect.maxY - beforeRect.minY,
101+
};
102+
}).toEqual(expect.objectContaining({
103+
width: expect.closeTo((beforeRect!.maxX - beforeRect!.minX) / 2, 1),
104+
height: expect.closeTo((beforeRect!.maxY - beforeRect!.minY) / 2, 1),
105+
beforeWidth: expect.any(Number),
106+
beforeHeight: expect.any(Number),
107+
}));
108+
});
109+
});

tests/editor/editor-store.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,79 @@ describe('EditorStore reducer', () => {
9595
expect(clamped.project.pixelsPerUnit).toBe(1);
9696
});
9797

98+
it('recomputes existing asset-backed sprite sizes when they still match the prior project scale baseline', () => {
99+
const state = {
100+
...seededState(),
101+
project: {
102+
...sampleProject,
103+
pixelsPerUnit: 1,
104+
assets: {
105+
...sampleProject.assets,
106+
images: {
107+
hero: {
108+
id: 'hero',
109+
width: 64,
110+
height: 64,
111+
source: {
112+
kind: 'embedded',
113+
dataUrl: 'data:image/png;base64,AAAA',
114+
originalName: 'hero.png',
115+
mimeType: 'image/png',
116+
},
117+
},
118+
},
119+
},
120+
scenes: {
121+
...sampleProject.scenes,
122+
[sampleProject.initialSceneId]: {
123+
...sampleProject.scenes[sampleProject.initialSceneId],
124+
entities: {
125+
auto: {
126+
id: 'auto',
127+
x: 10,
128+
y: 10,
129+
width: 64,
130+
height: 64,
131+
scaleX: 1,
132+
scaleY: 1,
133+
asset: {
134+
source: { kind: 'asset', assetId: 'hero' },
135+
imageType: 'image',
136+
frame: { kind: 'single' },
137+
},
138+
},
139+
custom: {
140+
id: 'custom',
141+
x: 20,
142+
y: 20,
143+
width: 40,
144+
height: 40,
145+
scaleX: 1,
146+
scaleY: 1,
147+
asset: {
148+
source: { kind: 'asset', assetId: 'hero' },
149+
imageType: 'image',
150+
frame: { kind: 'single' },
151+
},
152+
},
153+
},
154+
},
155+
},
156+
},
157+
} as any;
158+
159+
const next = reducer(state, {
160+
type: 'set-project-metadata',
161+
pixelsPerUnit: 2,
162+
} as any);
163+
164+
const scene = next.project.scenes[next.currentSceneId];
165+
expect(scene.entities.auto.width).toBe(32);
166+
expect(scene.entities.auto.height).toBe(32);
167+
expect(scene.entities.custom.width).toBe(40);
168+
expect(scene.entities.custom.height).toBe(40);
169+
});
170+
98171
it('mirrors a project rename into the publish title only when the publish title is empty', () => {
99172
const state = {
100173
...seededState(),

0 commit comments

Comments
 (0)