Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions src/editor/EditorStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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'
Expand All @@ -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':
Expand Down
109 changes: 109 additions & 0 deletions tests/e2e/project-pixel-scale.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, { width?: number; height?: number }> } } | 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),
}));
});
});
73 changes: 73 additions & 0 deletions tests/editor/editor-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading