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
6 changes: 5 additions & 1 deletion .plans/editor-workflows-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ It follows the original rule: identify the smallest reusable workflows first, th
- Scene overflow `⋯` → `Rename…`, `⧉ Duplicate Scene`, `★ Set as Base / Clear Base`, `Clear Scene…`, or `Delete…`.

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

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

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

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

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

Expand Down
24 changes: 21 additions & 3 deletions src/editor/EditorStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type SpriteAssetSpec,
} from '../model/types';
import { createEmptyProject, createEmptyGameScene } from '../model/emptyProject';
import { deriveWorldUnitsFromNaturalPixels, 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 @@ -212,7 +213,7 @@ export type EditorAction =
| { type: 'initialize'; project: ProjectSpec; currentSceneId: Id; startupMode: StartupMode; themeMode: ThemeMode; uiScale: number; showHitboxOverlay: boolean; syncMode: ProjectSyncMode; registry: EditorRegistryConfig }
| { type: 'set-sync-mode'; syncMode: ProjectSyncMode }
| { type: 'reset-project' }
| { type: 'set-project-metadata'; title?: string; publishTitle?: string; publishGithubPagesRepo?: string }
| { type: 'set-project-metadata'; title?: string; publishTitle?: string; publishGithubPagesRepo?: string; pixelsPerUnit?: number }
| { type: 'set-theme-mode'; themeMode: ThemeMode }
| { type: 'set-ui-scale'; uiScale: number }
| { type: 'set-show-hitbox-overlay'; value: boolean }
Expand Down Expand Up @@ -1092,6 +1093,9 @@ function describeEditorAction(stateBefore: EditorState, stateAfter: EditorState,
return 'Imported Demo Pack';
case 'set-project-metadata':
if (stateBefore.project.title !== stateAfter.project.title) return `Renamed to ${stateAfter.project.title?.trim() || 'Untitled Project'}`;
if (stateBefore.project.pixelsPerUnit !== stateAfter.project.pixelsPerUnit) {
return `Set project scale to ${stateAfter.project.pixelsPerUnit ?? 1} px/unit`;
}
if (stateBefore.project.publishTitle !== stateAfter.project.publishTitle) {
return stateAfter.project.publishTitle ? `Set publish title to ${stateAfter.project.publishTitle}` : 'Cleared publish title';
}
Expand Down Expand Up @@ -1271,6 +1275,14 @@ function buildProjectHistoryEventDraftsForAction(
summary: publishRepo ? `Set publish repo to ${publishRepo}` : 'Cleared publish repo',
});
}
if (stateBefore.project.pixelsPerUnit !== stateAfter.project.pixelsPerUnit) {
drafts.push({
kind: 'project.settings.updated',
burstId: `project.settings.updated:${actionBurstToken}`,
scope: { kind: 'project' },
summary: `Set project scale to ${stateAfter.project.pixelsPerUnit ?? 1} px/unit`,
});
}
return drafts.length > 0 ? drafts : undefined;
}
case 'add-image-asset-from-file':
Expand Down Expand Up @@ -2253,6 +2265,9 @@ function applyAction(state: EditorState, action: EditorAction): EditorState {
const nextProject: ProjectSpec = {
...state.project,
...(typeof action.title === 'string' ? { title: action.title } : {}),
...(typeof action.pixelsPerUnit === 'number'
? { pixelsPerUnit: normalizeProjectPixelsPerUnit(action.pixelsPerUnit) }
: {}),
...(typeof action.publishTitle === 'string'
? { publishTitle: action.publishTitle }
: shouldMirrorPublishTitle
Expand Down Expand Up @@ -3200,18 +3215,21 @@ function applyAction(state: EditorState, action: EditorAction): EditorState {
const world = getSceneWorld(scene);
const at = action.at ?? { x: world.width / 2, y: world.height / 2 };
const defaultSize = 64;
const pixelsPerUnit = normalizeProjectPixelsPerUnit(state.project.pixelsPerUnit);
const image = action.assetKind === 'image'
? state.project.assets.images?.[action.assetId]
: undefined;
const spritesheet = action.assetKind === 'spritesheet'
? state.project.assets.spriteSheets?.[action.assetId]
: undefined;
const width = action.assetKind === 'image'
const naturalWidth = action.assetKind === 'image'
? (image?.width ?? defaultSize)
: (spritesheet?.grid?.frameWidth ?? defaultSize);
const height = action.assetKind === 'image'
const naturalHeight = action.assetKind === 'image'
? (image?.height ?? defaultSize)
: (spritesheet?.grid?.frameHeight ?? defaultSize);
const width = deriveWorldUnitsFromNaturalPixels(naturalWidth, pixelsPerUnit);
const height = deriveWorldUnitsFromNaturalPixels(naturalHeight, pixelsPerUnit);

const entity: EntitySpec = resolveEntityDefaults({
id: entityId,
Expand Down
99 changes: 99 additions & 0 deletions src/editor/EntityList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { buildProjectPickerModel, type ProjectPickerFilter } from './projectLibr
import { exportYamlToDisk } from './yamlFileExport';
import { getOpenFilePicker, readFileHandleText } from './yamlFileHandles';
import { parseProjectYaml, serializeProjectToYaml } from '../model/serialization';
import { deriveWorldUnitsFromNaturalPixels, getProjectPixelsPerUnit, normalizeProjectPixelsPerUnit } from '../model/projectPixelScale';
import { EventBus } from '../phaser/EventBus';
import {
buildProjectHistoryViewModel,
Expand Down Expand Up @@ -278,6 +279,8 @@ export function EntityListView({
} | null>(null);
const duplicateDialogRootRef = useRef<HTMLDivElement | null>(null);
const [copyRevisionName, setCopyRevisionName] = useState('');
const [projectSettingsDialogOpen, setProjectSettingsDialogOpen] = useState(false);
const [projectPixelsPerUnitDraft, setProjectPixelsPerUnitDraft] = useState(() => String(getProjectPixelsPerUnit(project)));
const [expandedRevisionId, setExpandedRevisionId] = useState<string | null>(null);
const [historyPaneMode, setHistoryPaneMode] = useState<'active' | 'archived'>('active');
const [historySelectionMode, setHistorySelectionMode] = useState<'none' | 'archive' | 'delete'>('none');
Expand Down Expand Up @@ -312,6 +315,11 @@ export function EntityListView({
setCopyRevisionName(buildCopyRevisionDefaultName(project.title, revision));
}, [archivedRevisions, project.title, revisionDialogs.copyRevisionId, revisions]);

useEffect(() => {
if (!projectSettingsDialogOpen) return;
setProjectPixelsPerUnitDraft(String(getProjectPixelsPerUnit(project)));
}, [project, projectSettingsDialogOpen]);

useEffect(() => {
const previousSidebarScope = previousSidebarScopeRef.current;
if (normalizedSidebarScope === 'projectRevisions' && previousSidebarScope !== 'projectRevisions') {
Expand Down Expand Up @@ -347,6 +355,12 @@ export function EntityListView({
setSelectedHistoryRevisionIds([]);
};

const saveProjectSettings = () => {
const pixelsPerUnit = normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft));
dispatch({ type: 'set-project-metadata', pixelsPerUnit });
setProjectSettingsDialogOpen(false);
};

useEffect(() => {
if (!menuOpen) return;

Expand Down Expand Up @@ -1843,6 +1857,79 @@ export function EntityListView({
</div>
) : null}

{projectSettingsDialogOpen ? (
<div
className="scene-graph-menu"
style={{ position: 'fixed', left: '50%', top: '20%', transform: 'translateX(-50%)', zIndex: 60, minWidth: 420 }}
data-testid="project-settings-dialog"
role="dialog"
aria-label="Project settings"
>
<div className="scene-graph-menu-hint">Project Settings</div>
<div style={{ padding: '0.75rem', display: 'grid', gap: 10 }}>
<label className="field">
<span>Pixels Per Unit</span>
<input
className="text-input"
aria-label="Pixels Per Unit"
data-testid="project-settings-pixels-per-unit-input"
type="text"
inputMode="numeric"
value={projectPixelsPerUnitDraft}
onChange={(event) => setProjectPixelsPerUnitDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
saveProjectSettings();
}
if (event.key === 'Escape') {
event.preventDefault();
setProjectSettingsDialogOpen(false);
}
}}
/>
</label>
<div className="inspector-grid-3">
{[1, 2, 4].map((value) => {
const normalizedValue = normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft));
return (
<button
key={value}
type="button"
className={`button button-compact ${normalizedValue === value ? 'active' : ''}`}
data-testid={`project-settings-preset-${value}`}
onClick={() => setProjectPixelsPerUnitDraft(String(value))}
>
{value}
</button>
);
})}
</div>
<div className="muted">
64px art becomes {deriveWorldUnitsFromNaturalPixels(64, Number(projectPixelsPerUnitDraft) || 1)} world units at {normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft) || 1)} px/unit.
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', padding: '0.75rem' }}>
<button
type="button"
className="button"
data-testid="project-settings-cancel"
onClick={() => setProjectSettingsDialogOpen(false)}
>
Cancel
</button>
<button
type="button"
className="button"
data-testid="project-settings-save"
onClick={saveProjectSettings}
>
Save
</button>
</div>
</div>
) : null}

{duplicateDialog ? (
<div
ref={duplicateDialogRootRef}
Expand Down Expand Up @@ -2049,6 +2136,18 @@ export function EntityListView({
>
Export as YAML
</button>
<button
type="button"
className="scene-graph-menu-item"
data-testid="project-manage-settings"
onClick={() => {
setMenuOpen(null);
setProjectPixelsPerUnitDraft(String(getProjectPixelsPerUnit(project)));
setProjectSettingsDialogOpen(true);
}}
>
Project Settings...
</button>
<div className="scene-graph-menu-divider" />
<button
type="button"
Expand Down
54 changes: 52 additions & 2 deletions src/editor/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EventsPanel } from './EventsPanel';
import { InspectorFoldout, useInspectorFoldouts } from './InspectorFoldout';
import { AttachmentSpec, AttachmentTriggerSpec, InlineBoundsHitConditionSpec, GroupSpec, SceneSpec, EntitySpec, ProjectSpec, type Id, type SpriteAssetSpec, type EditorRegistryConfig } from '../model/types';
import { resolveEntityDefaults } from '../model/entityDefaults';
import { deriveWorldSpriteSize, getNaturalSpriteSize, getProjectPixelsPerUnit } from '../model/projectPixelScale';
import { resolveTextEntityDefaults } from './textEntity';
import { boundsToCenterSpan, computeEdgeSafeBounds, computeTargetAabb } from './boundsHelper';
import { getSceneWorld } from './sceneWorld';
Expand Down Expand Up @@ -464,6 +465,9 @@ function EntityInspector({

const baseWidth = resolved.width;
const baseHeight = resolved.height;
const naturalSpriteSize = project ? getNaturalSpriteSize(project, resolved.asset) : null;
const projectScaledSpriteSize = project ? deriveWorldSpriteSize(project, resolved.asset) : null;
const projectPixelsPerUnit = project ? getProjectPixelsPerUnit(project) : 1;
const displayWidth = displayPixelsFromBaseAndScale(baseWidth, Math.abs(resolved.scaleX));
const displayHeight = displayPixelsFromBaseAndScale(baseHeight, Math.abs(resolved.scaleY));

Expand Down Expand Up @@ -588,7 +592,30 @@ function EntityInspector({
<input className="text-input" type="text" readOnly value={displayHeight} data-testid="sprite-size-height-px-readonly" />
</label>
</div>
<div className="muted" style={{ marginTop: 6 }}>Original (natural): {baseWidth}×{baseHeight} px</div>
{naturalSpriteSize && projectScaledSpriteSize ? (
<>
<div className="muted" style={{ marginTop: 6 }}>Natural Size: {naturalSpriteSize.width}×{naturalSpriteSize.height} px</div>
<div className="muted" style={{ marginTop: 4 }}>Project Scale: {projectPixelsPerUnit} px/unit</div>
<div className="muted" style={{ marginTop: 4 }}>World Size: {projectScaledSpriteSize.width}×{projectScaledSpriteSize.height} units</div>
<div style={{ marginTop: 8 }}>
<button
type="button"
className="button button-compact"
data-testid="sprite-size-use-project-scale"
onClick={() => update({
width: projectScaledSpriteSize.width,
height: projectScaledSpriteSize.height,
scaleX: Math.sign(resolved.scaleX) || 1,
scaleY: Math.sign(resolved.scaleY) || 1,
})}
>
Use Project Scale
</button>
</div>
</>
) : (
<div className="muted" style={{ marginTop: 6 }}>Original (natural): {baseWidth}×{baseHeight} px</div>
)}
</>
) : (
<>
Expand Down Expand Up @@ -649,7 +676,30 @@ function EntityInspector({
<input className="text-input" type="text" readOnly value={Math.round(scaleYPercent * 100) / 100} data-testid="sprite-size-scale-y-percent-readonly" />
</label>
</div>
<div className="muted" style={{ marginTop: 6 }}>Original (natural): {baseWidth}×{baseHeight} px</div>
{naturalSpriteSize && projectScaledSpriteSize ? (
<>
<div className="muted" style={{ marginTop: 6 }}>Natural Size: {naturalSpriteSize.width}×{naturalSpriteSize.height} px</div>
<div className="muted" style={{ marginTop: 4 }}>Project Scale: {projectPixelsPerUnit} px/unit</div>
<div className="muted" style={{ marginTop: 4 }}>World Size: {projectScaledSpriteSize.width}×{projectScaledSpriteSize.height} units</div>
<div style={{ marginTop: 8 }}>
<button
type="button"
className="button button-compact"
data-testid="sprite-size-use-project-scale"
onClick={() => update({
width: projectScaledSpriteSize.width,
height: projectScaledSpriteSize.height,
scaleX: Math.sign(resolved.scaleX) || 1,
scaleY: Math.sign(resolved.scaleY) || 1,
})}
>
Use Project Scale
</button>
</div>
</>
) : (
<div className="muted" style={{ marginTop: 6 }}>Original (natural): {baseWidth}×{baseHeight} px</div>
)}
</>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/editor/projectHistoryEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type ProjectHistoryEventReason = 'autosave' | 'protective' | 'restore' |

export type ProjectHistoryEventKind =
| 'project.renamed'
| 'project.settings.updated'
| 'publish.title.set'
| 'publish.repo.set'
| 'project.default-input-map.set'
Expand Down
1 change: 1 addition & 0 deletions src/model/emptyProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function createEmptyProject(): ProjectSpec {
const scene = createEmptyGameScene('scene-1');
return {
id: 'project-1',
pixelsPerUnit: 1,
assets: { images: {}, spriteSheets: {}, fonts: {} },
audio: { sounds: {} },
inputMaps: {},
Expand Down
Loading
Loading