Skip to content

Commit de1e589

Browse files
authored
Merge pull request #387 from bcorfman/pixelscale
Lightweight project pixel-size
2 parents f77cf74 + 9f4b819 commit de1e589

17 files changed

Lines changed: 419 additions & 6 deletions

.plans/editor-workflows-inventory.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ It follows the original rule: identify the smallest reusable workflows first, th
136136
- Scene overflow ```Rename…`, `⧉ Duplicate Scene`, `★ Set as Base / Clear Base`, `Clear Scene…`, or `Delete…`.
137137

138138
#### A31a — Manage Project Root
139-
- Project Tree header → `Manage``Create New`, `Open...`, `Toggle Sync Mode`, `Import YAML`, `Export as YAML`, `Rename`, `History`, or `Clear Project ...`.
139+
- Project Tree header → `Manage``Create New`, `Open...`, `Toggle Sync Mode`, `Import YAML`, `Export as YAML`, `Project Settings...`, `Rename`, `History`, or `Clear Project ...`.
140+
- `Project Settings...` opens a lightweight dialog for project-wide `Pixels Per Unit`.
140141
- `Rename` opens inline rename on the project root row.
141142
- `History` swaps the left pane into `Project Revisions`.
142143

@@ -165,6 +166,7 @@ It follows the original rule: identify the smallest reusable workflows first, th
165166
- Drag an image/spritesheet asset onto the canvas.
166167
- Double-click an image/spritesheet in Assets Dock.
167168
- Scene graph → `Sprites``+ Add ▾``Sprite (from Asset)` → choose asset.
169+
- New sprites derive authored `width/height` from natural asset pixels and the current project `Pixels Per Unit`.
168170

169171
#### A33 — Create Text Entity
170172
- Scene graph → `Text``+ Add`.
@@ -187,6 +189,7 @@ It follows the original rule: identify the smallest reusable workflows first, th
187189
- Drag image/spritesheet asset onto an existing canvas sprite to replace its asset.
188190
- Drag image asset onto Background Layers to create/replace a layer.
189191
- Drag audio asset onto Scene Music to assign music.
192+
- Creating a sprite from empty canvas space uses project pixel scale for the initial world size.
190193

191194
#### A38 — Manage Asset Row Actions
192195
- Asset overflow ```Rename…`, `Delete…`.
@@ -196,6 +199,7 @@ It follows the original rule: identify the smallest reusable workflows first, th
196199

197200
#### A39 — Edit Single Entity Properties
198201
- In Inspector, edit transform, sprite size, text settings, hitbox, physics, visual settings, asset selection, frame settings, alpha/visibility/depth, and flip.
202+
- For asset-backed sprites, `Sprite Size` also shows `Natural Size`, `Project Scale`, `World Size`, and `Use Project Scale`.
199203
- Text entities can also be rasterized to a sprite.
200204
- If the entity is in a formation, `Apply Asset to Formation` pushes the chosen asset to sibling members.
201205

src/editor/EditorStore.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type SpriteAssetSpec,
1919
} from '../model/types';
2020
import { createEmptyProject, createEmptyGameScene } from '../model/emptyProject';
21+
import { deriveWorldUnitsFromNaturalPixels, normalizeProjectPixelsPerUnit } from '../model/projectPixelScale';
2122
import { validateProjectSpec, validateSceneSpec } from '../model/validation';
2223
import { resolveEntityDefaults } from '../model/entityDefaults';
2324
import { applyGroupArrangeLayout, applyGroupGridLayout, applyGroupGridLayoutPreserveMembers, inferGroupGridLayout, type GroupGridLayout } from './formationLayout';
@@ -212,7 +213,7 @@ export type EditorAction =
212213
| { type: 'initialize'; project: ProjectSpec; currentSceneId: Id; startupMode: StartupMode; themeMode: ThemeMode; uiScale: number; showHitboxOverlay: boolean; syncMode: ProjectSyncMode; registry: EditorRegistryConfig }
213214
| { type: 'set-sync-mode'; syncMode: ProjectSyncMode }
214215
| { type: 'reset-project' }
215-
| { type: 'set-project-metadata'; title?: string; publishTitle?: string; publishGithubPagesRepo?: string }
216+
| { type: 'set-project-metadata'; title?: string; publishTitle?: string; publishGithubPagesRepo?: string; pixelsPerUnit?: number }
216217
| { type: 'set-theme-mode'; themeMode: ThemeMode }
217218
| { type: 'set-ui-scale'; uiScale: number }
218219
| { type: 'set-show-hitbox-overlay'; value: boolean }
@@ -1092,6 +1093,9 @@ function describeEditorAction(stateBefore: EditorState, stateAfter: EditorState,
10921093
return 'Imported Demo Pack';
10931094
case 'set-project-metadata':
10941095
if (stateBefore.project.title !== stateAfter.project.title) return `Renamed to ${stateAfter.project.title?.trim() || 'Untitled Project'}`;
1096+
if (stateBefore.project.pixelsPerUnit !== stateAfter.project.pixelsPerUnit) {
1097+
return `Set project scale to ${stateAfter.project.pixelsPerUnit ?? 1} px/unit`;
1098+
}
10951099
if (stateBefore.project.publishTitle !== stateAfter.project.publishTitle) {
10961100
return stateAfter.project.publishTitle ? `Set publish title to ${stateAfter.project.publishTitle}` : 'Cleared publish title';
10971101
}
@@ -1271,6 +1275,14 @@ function buildProjectHistoryEventDraftsForAction(
12711275
summary: publishRepo ? `Set publish repo to ${publishRepo}` : 'Cleared publish repo',
12721276
});
12731277
}
1278+
if (stateBefore.project.pixelsPerUnit !== stateAfter.project.pixelsPerUnit) {
1279+
drafts.push({
1280+
kind: 'project.settings.updated',
1281+
burstId: `project.settings.updated:${actionBurstToken}`,
1282+
scope: { kind: 'project' },
1283+
summary: `Set project scale to ${stateAfter.project.pixelsPerUnit ?? 1} px/unit`,
1284+
});
1285+
}
12741286
return drafts.length > 0 ? drafts : undefined;
12751287
}
12761288
case 'add-image-asset-from-file':
@@ -2253,6 +2265,9 @@ function applyAction(state: EditorState, action: EditorAction): EditorState {
22532265
const nextProject: ProjectSpec = {
22542266
...state.project,
22552267
...(typeof action.title === 'string' ? { title: action.title } : {}),
2268+
...(typeof action.pixelsPerUnit === 'number'
2269+
? { pixelsPerUnit: normalizeProjectPixelsPerUnit(action.pixelsPerUnit) }
2270+
: {}),
22562271
...(typeof action.publishTitle === 'string'
22572272
? { publishTitle: action.publishTitle }
22582273
: shouldMirrorPublishTitle
@@ -3200,18 +3215,21 @@ function applyAction(state: EditorState, action: EditorAction): EditorState {
32003215
const world = getSceneWorld(scene);
32013216
const at = action.at ?? { x: world.width / 2, y: world.height / 2 };
32023217
const defaultSize = 64;
3218+
const pixelsPerUnit = normalizeProjectPixelsPerUnit(state.project.pixelsPerUnit);
32033219
const image = action.assetKind === 'image'
32043220
? state.project.assets.images?.[action.assetId]
32053221
: undefined;
32063222
const spritesheet = action.assetKind === 'spritesheet'
32073223
? state.project.assets.spriteSheets?.[action.assetId]
32083224
: undefined;
3209-
const width = action.assetKind === 'image'
3225+
const naturalWidth = action.assetKind === 'image'
32103226
? (image?.width ?? defaultSize)
32113227
: (spritesheet?.grid?.frameWidth ?? defaultSize);
3212-
const height = action.assetKind === 'image'
3228+
const naturalHeight = action.assetKind === 'image'
32133229
? (image?.height ?? defaultSize)
32143230
: (spritesheet?.grid?.frameHeight ?? defaultSize);
3231+
const width = deriveWorldUnitsFromNaturalPixels(naturalWidth, pixelsPerUnit);
3232+
const height = deriveWorldUnitsFromNaturalPixels(naturalHeight, pixelsPerUnit);
32153233

32163234
const entity: EntitySpec = resolveEntityDefaults({
32173235
id: entityId,

src/editor/EntityList.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { buildProjectPickerModel, type ProjectPickerFilter } from './projectLibr
1111
import { exportYamlToDisk } from './yamlFileExport';
1212
import { getOpenFilePicker, readFileHandleText } from './yamlFileHandles';
1313
import { parseProjectYaml, serializeProjectToYaml } from '../model/serialization';
14+
import { deriveWorldUnitsFromNaturalPixels, getProjectPixelsPerUnit, normalizeProjectPixelsPerUnit } from '../model/projectPixelScale';
1415
import { EventBus } from '../phaser/EventBus';
1516
import {
1617
buildProjectHistoryViewModel,
@@ -278,6 +279,8 @@ export function EntityListView({
278279
} | null>(null);
279280
const duplicateDialogRootRef = useRef<HTMLDivElement | null>(null);
280281
const [copyRevisionName, setCopyRevisionName] = useState('');
282+
const [projectSettingsDialogOpen, setProjectSettingsDialogOpen] = useState(false);
283+
const [projectPixelsPerUnitDraft, setProjectPixelsPerUnitDraft] = useState(() => String(getProjectPixelsPerUnit(project)));
281284
const [expandedRevisionId, setExpandedRevisionId] = useState<string | null>(null);
282285
const [historyPaneMode, setHistoryPaneMode] = useState<'active' | 'archived'>('active');
283286
const [historySelectionMode, setHistorySelectionMode] = useState<'none' | 'archive' | 'delete'>('none');
@@ -312,6 +315,11 @@ export function EntityListView({
312315
setCopyRevisionName(buildCopyRevisionDefaultName(project.title, revision));
313316
}, [archivedRevisions, project.title, revisionDialogs.copyRevisionId, revisions]);
314317

318+
useEffect(() => {
319+
if (!projectSettingsDialogOpen) return;
320+
setProjectPixelsPerUnitDraft(String(getProjectPixelsPerUnit(project)));
321+
}, [project, projectSettingsDialogOpen]);
322+
315323
useEffect(() => {
316324
const previousSidebarScope = previousSidebarScopeRef.current;
317325
if (normalizedSidebarScope === 'projectRevisions' && previousSidebarScope !== 'projectRevisions') {
@@ -347,6 +355,12 @@ export function EntityListView({
347355
setSelectedHistoryRevisionIds([]);
348356
};
349357

358+
const saveProjectSettings = () => {
359+
const pixelsPerUnit = normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft));
360+
dispatch({ type: 'set-project-metadata', pixelsPerUnit });
361+
setProjectSettingsDialogOpen(false);
362+
};
363+
350364
useEffect(() => {
351365
if (!menuOpen) return;
352366

@@ -1843,6 +1857,79 @@ export function EntityListView({
18431857
</div>
18441858
) : null}
18451859

1860+
{projectSettingsDialogOpen ? (
1861+
<div
1862+
className="scene-graph-menu"
1863+
style={{ position: 'fixed', left: '50%', top: '20%', transform: 'translateX(-50%)', zIndex: 60, minWidth: 420 }}
1864+
data-testid="project-settings-dialog"
1865+
role="dialog"
1866+
aria-label="Project settings"
1867+
>
1868+
<div className="scene-graph-menu-hint">Project Settings</div>
1869+
<div style={{ padding: '0.75rem', display: 'grid', gap: 10 }}>
1870+
<label className="field">
1871+
<span>Pixels Per Unit</span>
1872+
<input
1873+
className="text-input"
1874+
aria-label="Pixels Per Unit"
1875+
data-testid="project-settings-pixels-per-unit-input"
1876+
type="text"
1877+
inputMode="numeric"
1878+
value={projectPixelsPerUnitDraft}
1879+
onChange={(event) => setProjectPixelsPerUnitDraft(event.target.value)}
1880+
onKeyDown={(event) => {
1881+
if (event.key === 'Enter') {
1882+
event.preventDefault();
1883+
saveProjectSettings();
1884+
}
1885+
if (event.key === 'Escape') {
1886+
event.preventDefault();
1887+
setProjectSettingsDialogOpen(false);
1888+
}
1889+
}}
1890+
/>
1891+
</label>
1892+
<div className="inspector-grid-3">
1893+
{[1, 2, 4].map((value) => {
1894+
const normalizedValue = normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft));
1895+
return (
1896+
<button
1897+
key={value}
1898+
type="button"
1899+
className={`button button-compact ${normalizedValue === value ? 'active' : ''}`}
1900+
data-testid={`project-settings-preset-${value}`}
1901+
onClick={() => setProjectPixelsPerUnitDraft(String(value))}
1902+
>
1903+
{value}
1904+
</button>
1905+
);
1906+
})}
1907+
</div>
1908+
<div className="muted">
1909+
64px art becomes {deriveWorldUnitsFromNaturalPixels(64, Number(projectPixelsPerUnitDraft) || 1)} world units at {normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft) || 1)} px/unit.
1910+
</div>
1911+
</div>
1912+
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', padding: '0.75rem' }}>
1913+
<button
1914+
type="button"
1915+
className="button"
1916+
data-testid="project-settings-cancel"
1917+
onClick={() => setProjectSettingsDialogOpen(false)}
1918+
>
1919+
Cancel
1920+
</button>
1921+
<button
1922+
type="button"
1923+
className="button"
1924+
data-testid="project-settings-save"
1925+
onClick={saveProjectSettings}
1926+
>
1927+
Save
1928+
</button>
1929+
</div>
1930+
</div>
1931+
) : null}
1932+
18461933
{duplicateDialog ? (
18471934
<div
18481935
ref={duplicateDialogRootRef}
@@ -2049,6 +2136,18 @@ export function EntityListView({
20492136
>
20502137
Export as YAML
20512138
</button>
2139+
<button
2140+
type="button"
2141+
className="scene-graph-menu-item"
2142+
data-testid="project-manage-settings"
2143+
onClick={() => {
2144+
setMenuOpen(null);
2145+
setProjectPixelsPerUnitDraft(String(getProjectPixelsPerUnit(project)));
2146+
setProjectSettingsDialogOpen(true);
2147+
}}
2148+
>
2149+
Project Settings...
2150+
</button>
20522151
<div className="scene-graph-menu-divider" />
20532152
<button
20542153
type="button"

src/editor/Inspector.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { EventsPanel } from './EventsPanel';
77
import { InspectorFoldout, useInspectorFoldouts } from './InspectorFoldout';
88
import { AttachmentSpec, AttachmentTriggerSpec, InlineBoundsHitConditionSpec, GroupSpec, SceneSpec, EntitySpec, ProjectSpec, type Id, type SpriteAssetSpec, type EditorRegistryConfig } from '../model/types';
99
import { resolveEntityDefaults } from '../model/entityDefaults';
10+
import { deriveWorldSpriteSize, getNaturalSpriteSize, getProjectPixelsPerUnit } from '../model/projectPixelScale';
1011
import { resolveTextEntityDefaults } from './textEntity';
1112
import { boundsToCenterSpan, computeEdgeSafeBounds, computeTargetAabb } from './boundsHelper';
1213
import { getSceneWorld } from './sceneWorld';
@@ -464,6 +465,9 @@ function EntityInspector({
464465

465466
const baseWidth = resolved.width;
466467
const baseHeight = resolved.height;
468+
const naturalSpriteSize = project ? getNaturalSpriteSize(project, resolved.asset) : null;
469+
const projectScaledSpriteSize = project ? deriveWorldSpriteSize(project, resolved.asset) : null;
470+
const projectPixelsPerUnit = project ? getProjectPixelsPerUnit(project) : 1;
467471
const displayWidth = displayPixelsFromBaseAndScale(baseWidth, Math.abs(resolved.scaleX));
468472
const displayHeight = displayPixelsFromBaseAndScale(baseHeight, Math.abs(resolved.scaleY));
469473

@@ -588,7 +592,30 @@ function EntityInspector({
588592
<input className="text-input" type="text" readOnly value={displayHeight} data-testid="sprite-size-height-px-readonly" />
589593
</label>
590594
</div>
591-
<div className="muted" style={{ marginTop: 6 }}>Original (natural): {baseWidth}×{baseHeight} px</div>
595+
{naturalSpriteSize && projectScaledSpriteSize ? (
596+
<>
597+
<div className="muted" style={{ marginTop: 6 }}>Natural Size: {naturalSpriteSize.width}×{naturalSpriteSize.height} px</div>
598+
<div className="muted" style={{ marginTop: 4 }}>Project Scale: {projectPixelsPerUnit} px/unit</div>
599+
<div className="muted" style={{ marginTop: 4 }}>World Size: {projectScaledSpriteSize.width}×{projectScaledSpriteSize.height} units</div>
600+
<div style={{ marginTop: 8 }}>
601+
<button
602+
type="button"
603+
className="button button-compact"
604+
data-testid="sprite-size-use-project-scale"
605+
onClick={() => update({
606+
width: projectScaledSpriteSize.width,
607+
height: projectScaledSpriteSize.height,
608+
scaleX: Math.sign(resolved.scaleX) || 1,
609+
scaleY: Math.sign(resolved.scaleY) || 1,
610+
})}
611+
>
612+
Use Project Scale
613+
</button>
614+
</div>
615+
</>
616+
) : (
617+
<div className="muted" style={{ marginTop: 6 }}>Original (natural): {baseWidth}×{baseHeight} px</div>
618+
)}
592619
</>
593620
) : (
594621
<>
@@ -649,7 +676,30 @@ function EntityInspector({
649676
<input className="text-input" type="text" readOnly value={Math.round(scaleYPercent * 100) / 100} data-testid="sprite-size-scale-y-percent-readonly" />
650677
</label>
651678
</div>
652-
<div className="muted" style={{ marginTop: 6 }}>Original (natural): {baseWidth}×{baseHeight} px</div>
679+
{naturalSpriteSize && projectScaledSpriteSize ? (
680+
<>
681+
<div className="muted" style={{ marginTop: 6 }}>Natural Size: {naturalSpriteSize.width}×{naturalSpriteSize.height} px</div>
682+
<div className="muted" style={{ marginTop: 4 }}>Project Scale: {projectPixelsPerUnit} px/unit</div>
683+
<div className="muted" style={{ marginTop: 4 }}>World Size: {projectScaledSpriteSize.width}×{projectScaledSpriteSize.height} units</div>
684+
<div style={{ marginTop: 8 }}>
685+
<button
686+
type="button"
687+
className="button button-compact"
688+
data-testid="sprite-size-use-project-scale"
689+
onClick={() => update({
690+
width: projectScaledSpriteSize.width,
691+
height: projectScaledSpriteSize.height,
692+
scaleX: Math.sign(resolved.scaleX) || 1,
693+
scaleY: Math.sign(resolved.scaleY) || 1,
694+
})}
695+
>
696+
Use Project Scale
697+
</button>
698+
</div>
699+
</>
700+
) : (
701+
<div className="muted" style={{ marginTop: 6 }}>Original (natural): {baseWidth}×{baseHeight} px</div>
702+
)}
653703
</>
654704
)}
655705
</div>

src/editor/projectHistoryEvents.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type ProjectHistoryEventReason = 'autosave' | 'protective' | 'restore' |
44

55
export type ProjectHistoryEventKind =
66
| 'project.renamed'
7+
| 'project.settings.updated'
78
| 'publish.title.set'
89
| 'publish.repo.set'
910
| 'project.default-input-map.set'

src/model/emptyProject.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function createEmptyProject(): ProjectSpec {
1616
const scene = createEmptyGameScene('scene-1');
1717
return {
1818
id: 'project-1',
19+
pixelsPerUnit: 1,
1920
assets: { images: {}, spriteSheets: {}, fonts: {} },
2021
audio: { sounds: {} },
2122
inputMaps: {},

0 commit comments

Comments
 (0)