diff --git a/src/editor/EditorStore.tsx b/src/editor/EditorStore.tsx index 0800e64..032810c 100644 --- a/src/editor/EditorStore.tsx +++ b/src/editor/EditorStore.tsx @@ -18,7 +18,12 @@ import { type SpriteAssetSpec, } from '../model/types'; import { createEmptyProject, createEmptyGameScene } from '../model/emptyProject'; -import { deriveWorldUnitsFromNaturalPixels, normalizeProjectPixelsPerUnit } from '../model/projectPixelScale'; +import { + deriveWorldSpriteSize, + deriveWorldUnitsFromNaturalPixels, + getProjectPixelsPerUnit, + normalizeProjectPixelsPerUnit, +} from '../model/projectPixelScale'; import { validateProjectSpec, validateSceneSpec } from '../model/validation'; import { resolveEntityDefaults } from '../model/entityDefaults'; import { applyGroupArrangeLayout, applyGroupGridLayout, applyGroupGridLayoutPreserveMembers, inferGroupGridLayout, type GroupGridLayout } from './formationLayout'; @@ -2198,6 +2203,46 @@ function recordHistoryForAction(stateBefore: EditorState, stateAfter: EditorStat }; } +function reapplyProjectPixelScaleToMatchingSprites( + previousProject: ProjectSpec, + nextProject: ProjectSpec, +): ProjectSpec { + const previousPixelsPerUnit = getProjectPixelsPerUnit(previousProject); + const nextPixelsPerUnit = getProjectPixelsPerUnit(nextProject); + if (previousPixelsPerUnit === nextPixelsPerUnit) return nextProject; + + let scenesChanged = false; + const scenes = Object.fromEntries( + Object.entries(nextProject.scenes).map(([sceneId, scene]) => { + let entitiesChanged = false; + const entities = Object.fromEntries( + Object.entries(scene.entities).map(([entityId, entity]) => { + const resolved = resolveEntityDefaults(entity); + if (!resolved.asset) return [entityId, entity]; + + const previousWorldSize = deriveWorldSpriteSize(previousProject, resolved.asset); + const nextWorldSize = deriveWorldSpriteSize(nextProject, resolved.asset); + if (!previousWorldSize || !nextWorldSize) return [entityId, entity]; + if (resolved.width !== previousWorldSize.width || resolved.height !== previousWorldSize.height) return [entityId, entity]; + + entitiesChanged = true; + return [entityId, { + ...entity, + width: nextWorldSize.width, + height: nextWorldSize.height, + }]; + }), + ); + + if (!entitiesChanged) return [sceneId, scene]; + scenesChanged = true; + return [sceneId, { ...scene, entities }]; + }), + ) as ProjectSpec['scenes']; + + return scenesChanged ? { ...nextProject, scenes } : nextProject; +} + function applyAction(state: EditorState, action: EditorAction): EditorState { switch (action.type) { case 'initialize': @@ -2262,7 +2307,7 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { && typeof action.publishTitle !== 'string' && (typeof state.project.publishTitle !== 'string' || state.project.publishTitle.trim().length === 0) ); - const nextProject: ProjectSpec = { + const nextProjectDraft: ProjectSpec = { ...state.project, ...(typeof action.title === 'string' ? { title: action.title } : {}), ...(typeof action.pixelsPerUnit === 'number' @@ -2275,6 +2320,9 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { : {}), ...(typeof action.publishGithubPagesRepo === 'string' ? { publishGithubPagesRepo: action.publishGithubPagesRepo } : {}), }; + const nextProject = typeof action.pixelsPerUnit === 'number' + ? reapplyProjectPixelScaleToMatchingSprites(state.project, nextProjectDraft) + : nextProjectDraft; return { ...state, project: nextProject, dirty: true, error: undefined, projectRootEditing: false }; } case 'set-theme-mode': diff --git a/tests/e2e/project-pixel-scale.spec.ts b/tests/e2e/project-pixel-scale.spec.ts new file mode 100644 index 0000000..f68841b --- /dev/null +++ b/tests/e2e/project-pixel-scale.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { dismissViewHint, getEntitySpriteWorldRect, getState, seedProject } from './helpers'; + +test.describe('Project pixel scale', () => { + test('project settings rescale baseline asset-backed sprites on the canvas immediately @smoke', async ({ page }) => { + const project = { + id: 'project-pixel-scale', + title: 'Pixel Scale Demo', + pixelsPerUnit: 1, + assets: { + images: { + hero: { + id: 'hero', + width: 64, + height: 64, + source: { + kind: 'embedded', + dataUrl: 'data:image/png;base64,AAAA', + originalName: 'hero.png', + mimeType: 'image/png', + }, + }, + }, + spriteSheets: {}, + fonts: {}, + }, + audio: { sounds: {} }, + inputMaps: {}, + collections: {}, + counters: {}, + scenes: { + 'scene-1': { + id: 'scene-1', + name: 'Scene 1', + world: { width: 800, height: 600 }, + entities: { + hero: { + id: 'hero', + name: 'hero', + x: 240, + y: 180, + width: 64, + height: 64, + rotationDeg: 0, + scaleX: 1, + scaleY: 1, + asset: { + source: { kind: 'asset', assetId: 'hero' }, + imageType: 'image', + frame: { kind: 'single' }, + }, + }, + }, + groups: {}, + attachments: {}, + eventBlocks: {}, + actions: {}, + handlers: {}, + backgroundLayers: [], + collisionRules: [], + triggers: [], + }, + }, + initialSceneId: 'scene-1', + } as any; + + await seedProject(page, project); + await dismissViewHint(page); + + await expect.poll(async () => await getEntitySpriteWorldRect(page, 'hero')).toBeTruthy(); + const beforeRect = await getEntitySpriteWorldRect(page, 'hero'); + expect(beforeRect).toBeTruthy(); + + await page.getByTestId('project-tree-manage-button').click(); + await page.getByTestId('project-manage-settings').click(); + await expect(page.getByTestId('project-settings-dialog')).toBeVisible(); + await page.getByTestId('project-settings-preset-2').click(); + await page.getByTestId('project-settings-save').click(); + + await expect.poll(async () => { + const state = await getState<{ project?: { pixelsPerUnit?: number }, scene?: { entities?: Record } } | null>(page); + return { + pixelsPerUnit: state?.project?.pixelsPerUnit ?? null, + width: state?.scene?.entities?.hero?.width ?? null, + height: state?.scene?.entities?.hero?.height ?? null, + }; + }).toEqual({ + pixelsPerUnit: 2, + width: 32, + height: 32, + }); + + await expect.poll(async () => { + const rect = await getEntitySpriteWorldRect(page, 'hero'); + if (!rect || !beforeRect) return null; + return { + width: rect.maxX - rect.minX, + height: rect.maxY - rect.minY, + beforeWidth: beforeRect.maxX - beforeRect.minX, + beforeHeight: beforeRect.maxY - beforeRect.minY, + }; + }).toEqual(expect.objectContaining({ + width: expect.closeTo((beforeRect!.maxX - beforeRect!.minX) / 2, 1), + height: expect.closeTo((beforeRect!.maxY - beforeRect!.minY) / 2, 1), + beforeWidth: expect.any(Number), + beforeHeight: expect.any(Number), + })); + }); +}); diff --git a/tests/editor/editor-store.test.ts b/tests/editor/editor-store.test.ts index 0798bc2..99102fd 100644 --- a/tests/editor/editor-store.test.ts +++ b/tests/editor/editor-store.test.ts @@ -95,6 +95,79 @@ describe('EditorStore reducer', () => { expect(clamped.project.pixelsPerUnit).toBe(1); }); + it('recomputes existing asset-backed sprite sizes when they still match the prior project scale baseline', () => { + const state = { + ...seededState(), + project: { + ...sampleProject, + pixelsPerUnit: 1, + assets: { + ...sampleProject.assets, + images: { + hero: { + id: 'hero', + width: 64, + height: 64, + source: { + kind: 'embedded', + dataUrl: 'data:image/png;base64,AAAA', + originalName: 'hero.png', + mimeType: 'image/png', + }, + }, + }, + }, + scenes: { + ...sampleProject.scenes, + [sampleProject.initialSceneId]: { + ...sampleProject.scenes[sampleProject.initialSceneId], + entities: { + auto: { + id: 'auto', + x: 10, + y: 10, + width: 64, + height: 64, + scaleX: 1, + scaleY: 1, + asset: { + source: { kind: 'asset', assetId: 'hero' }, + imageType: 'image', + frame: { kind: 'single' }, + }, + }, + custom: { + id: 'custom', + x: 20, + y: 20, + width: 40, + height: 40, + scaleX: 1, + scaleY: 1, + asset: { + source: { kind: 'asset', assetId: 'hero' }, + imageType: 'image', + frame: { kind: 'single' }, + }, + }, + }, + }, + }, + }, + } as any; + + const next = reducer(state, { + type: 'set-project-metadata', + pixelsPerUnit: 2, + } as any); + + const scene = next.project.scenes[next.currentSceneId]; + expect(scene.entities.auto.width).toBe(32); + expect(scene.entities.auto.height).toBe(32); + expect(scene.entities.custom.width).toBe(40); + expect(scene.entities.custom.height).toBe(40); + }); + it('mirrors a project rename into the publish title only when the publish title is empty', () => { const state = { ...seededState(),