Add pixel-size fit and drag-to-reorder in the hierarchy

The aspect button now carries two offers, so it becomes a menu: fit one
side to the texture ratio, or set both sides to the exact pixel size as a
single undo step.

Dropping a row on the outer quarter of another row inserts it into that
gap instead of parenting into it, reordering among the siblings there.
Order is the stored order, so ReorderObjectCommand is a splice; equal z
falls back to tree order, which makes this the way to draw one object in
front of another. Rejected on container content like the other structural
edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 22:20:46 +02:00
1 parent f40b8a0523
commit 4568c6581b
17 files changed
+416 -85

No files matched your search

+27 -15
View File
@@ -106,7 +106,10 @@ shown on the node and editable in the inspector.
**Editor symbols are always on top.** The viewport draws scene content (sprites) in an **Editor symbols are always on top.** The viewport draws scene content (sprites) in an
`objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a `objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a
sprite can never hide a symbol regardless of z or hierarchy order. sprite can never hide a symbol regardless of z or hierarchy order. A viewport label keeps a
constant size on screen however far you zoom, and both kinds of them – the object names of the
Scene tab and the frame names of the Texture tab – read that size from one token
(`Theme.viewportLabelSize`); the two `viewportLabel` styles differ in colour only.
**Z-order is part of the position.** `position.z` is the draw order of a scene object: higher **Z-order is part of the position.** `position.z` is the draw order of a scene object: higher
`z` is drawn on top; objects with equal `z` keep their hierarchy (tree) order. It is edited in the `z` is drawn on top; objects with equal `z` keep their hierarchy (tree) order. It is edited in the
@@ -120,6 +123,11 @@ subtree back. Re-parenting keeps the world position by recomputing the local one
are rejected. In the **Scene objects** panel children are indented below their parent; drag a are rejected. In the **Scene objects** panel children are indented below their parent; drag a
row onto another row to parent it, onto the empty space below the rows (or right-click → row onto another row to parent it, onto the empty space below the rows (or right-click →
Unparent) to move it to the top level; the inspector offers the same as a **Parent** menu. Unparent) to move it to the top level; the inspector offers the same as a **Parent** menu.
Dragging into the **gap** above or below a row – its outer quarter, marked with a line instead of
an outline – orders the object among *that* row's siblings rather than putting it inside, and
re-parents it on the way when the gap belongs to another parent. Since equal `z` falls back to
this order, that is also how two objects at the same depth are put in front of each other. A drag
that lands where it started changes nothing and costs no undo step.
Rows with children **fold**: click the chevron (or press ←/→, or double-click the row) to open Rows with children **fold**: click the chevron (or press ←/→, or double-click the row) to open
and close a subtree, right-click → **Expand all** / **Collapse all** for the whole scene. A and close a subtree, right-click → **Expand all** / **Collapse all** for the whole scene. A
scene opens fully collapsed – which rows are open is view state of the panel, never stored in scene opens fully collapsed – which rows are open is view state of the panel, never stored in
@@ -225,13 +233,17 @@ plain colour and comes back by itself on undo. Deleting the whole texture counts
wearing *any* of its frames, not just the ones naming the texture directly. wearing *any* of its frames, not just the ones naming the texture directly.
**Fitting the sprite to the picture.** The *Width* and *Height* rows of a sprite carry a ↔ / ↕ **Fitting the sprite to the picture.** The *Width* and *Height* rows of a sprite carry a ↔ / ↕
button that sets that one side from the other and the picture's proportions — the frame's button offering two things: **Fit to 64 × 48** sets that one side from the other and the picture's
rectangle when it wears a frame, the whole image otherwise. Like ↺ and ↑ the buttons exist only proportions — the frame's rectangle when it wears a frame, the whole image otherwise — and **Set
when they would do something: no picture, an image not yet measured, or a sprite already in to 64 × 48 px** puts both sides at the picture's own pixel size. Like ↺ and ↑ the button exists
proportion means no button at all. Fitting is an ordinary property write, so on an object it only when it would do something, and follows the same rule for how many: no offer means no button,
becomes an override and it is one undo step. The comparison has a relative tolerance rather than one is a direct click, both make it the menu form ↔ ▾. So a sprite without a picture, or one
being exact — of the 40 000 picture sizes up to 200 × 200, 3135 do not survive the round trip already in proportion *and* at pixel size, carries nothing. Either action is an ordinary property
through a division, and an exact test would leave the button standing after its own click. write, so on an object it becomes an override, and setting both sides is still one undo step. The
proportions are compared with a relative tolerance rather than exactly — of the 40 000 picture
sizes up to 200 × 200, 3135 do not survive the round trip through a division, and an exact test
would leave the button standing after its own click. The pixel size is compared exactly, because
those two numbers are written as they are.
Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill
ignores a `Texture`'s `frame` (measured, see `viewport/textureFill.ts`). One consequence is ignores a `Texture`'s `frame` (measured, see `viewport/textureFill.ts`). One consequence is
@@ -346,7 +358,7 @@ selected and switched back on.
``` ```
web/index.html Vite source page (dev) index.html built page (generated, served) web/index.html Vite source page (dev) index.html built page (generated, served)
src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template) src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), container (the cycle guard)
src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts
@@ -362,6 +374,7 @@ src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template) src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths)
api/ PHP backend data/projects/ persisted projects api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/ tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/
``` ```
@@ -390,8 +403,8 @@ npm run lint # ESLint, type-aware; --fix for the fixable half
npm run format # Prettier over src, tests and tools npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run npm run check # typecheck + lint + format:check + tests, what CI would run
npm run build # writes dist/ and regenerates index.html npm run build # writes dist/ and regenerates index.html
node tools/seed-e2e.mjs && node tools/e2e.mjs # 140 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-e2e.mjs && node tools/e2e.mjs # 258 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # the example game, played end to end node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
``` ```
**Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is **Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is
@@ -459,9 +472,8 @@ object in the selection (hierarchy rows too).
## Not yet ## Not yet
Reordering the hierarchy, several scenes in one run (a `Load scene` node – screens are containers Several scenes in one run (a `Load scene` node – screens are containers
today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to
the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no nine-slice. ↔ / ↕ fit the the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no
proportions, but nothing sets a sprite to the picture's pixel size outright. Slicing cuts a nine-slice. Slicing cuts a regular grid only — no detection of where the sprites actually
regular grid only — no detection of where the sprites actually
sit on the sheet. Unreferenced image files are never collected. sit on the sheet. Unreferenced image files are never collected.
+44
View File
@@ -208,6 +208,50 @@ export class ClearOverrideCommand extends OverrideCommand {
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed * Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
* for the new parent. * for the new parent.
*/ */
/**
* Moves an object among its siblings. Sibling order is the order the stored list happens to have
* (`treeOrder`), so the move is a splice – and because objects sharing a `z` fall back to that
* order, it is also how two overlapping objects are put in front of each other.
*/
export class ReorderObjectCommand implements Command {
readonly label = 'Reorder object';
private previousIndex = -1;
/** `beforeId` empty means "behind its last sibling". */
constructor(
private readonly holderId: string,
private readonly objectId: string,
private readonly beforeId: string,
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
requireStructural(project, holder, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
if (this.beforeId === this.objectId)
throw new CommandError('An object cannot be moved in front of itself');
let at = holder.objects.length;
if (this.beforeId) {
requireStructural(project, holder, this.beforeId);
const before = requireObject(project, this.holderId, this.beforeId);
if (before.parentId !== object.parentId)
throw new CommandError('An object can only be reordered among its own siblings');
at = holder.objects.indexOf(before);
}
this.previousIndex = holder.objects.indexOf(object);
holder.objects.splice(this.previousIndex, 1);
// The index was read before the removal, so anything behind it has moved up by one.
holder.objects.splice(at > this.previousIndex ? at - 1 : at, 0, object);
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
const object = requireObject(project, this.holderId, this.objectId);
holder.objects.splice(holder.objects.indexOf(object), 1);
holder.objects.splice(this.previousIndex, 0, object);
}
}
export class SetParentCommand implements Command { export class SetParentCommand implements Command {
readonly label = 'Set parent'; readonly label = 'Set parent';
private previous: { parentId: string; position: Position } = { private previous: { parentId: string; position: Position } = {
+5
View File
@@ -19,6 +19,11 @@ function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
return tree.objects.filter((o) => o.parentId === parentId); return tree.objects.filter((o) => o.parentId === parentId);
} }
/** Children of `parentId` (`''` = top level) in sibling order – what a reorder rearranges. */
export function childIdsOf(tree: ObjectTree, parentId: string): string[] {
return childrenOf(tree, parentId).map((o) => o.id);
}
function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined { function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined {
return object.parentId ? findObject(tree, object.parentId) : undefined; return object.parentId ? findObject(tree, object.parentId) : undefined;
} }
+9
View File
@@ -33,6 +33,15 @@ export function fitAxisToAspect(size: PixelSize, props: PropertyBag, axis: SizeA
return Number.isFinite(fitted) && fitted > 0 ? fitted : null; return Number.isFinite(fitted) && fitted > 0 ? fitted : null;
} }
/**
* Whether the sprite is drawn at the picture's own pixel size – one image pixel per world unit.
* Exact, unlike `matchesAspect`: these two numbers are written as they are, with no division in
* between that could cost a digit.
*/
export function matchesPixelSize(size: PixelSize, props: PropertyBag): boolean {
return Number(props.width) === size.w && Number(props.height) === size.h;
}
/** /**
* Whether the sprite already has the picture's proportions. Compared as a cross product with a * Whether the sprite already has the picture's proportions. Compared as a cross product with a
* relative tolerance, because fitting one side and then asking about the other must not answer * relative tolerance, because fitting one side and then asking about the other must not answer
+49 -11
View File
@@ -1,29 +1,58 @@
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import type { Asset } from '../../core/model/Asset'; import type { Asset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import { import {
fitAxisToAspect, fitAxisToAspect,
matchesAspect, matchesAspect,
matchesPixelSize,
type PixelSize,
type SizeAxis, type SizeAxis,
texturePixelSize, texturePixelSize,
} from '../../core/properties/aspect'; } from '../../core/properties/aspect';
import { coerceValue, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; import { coerceValue, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import type { FieldActionGroup } from './fieldFactory'; import type { FieldAction, FieldActionGroup } from './fieldFactory';
import type { InspectorHost } from './host'; import type { InspectorHost } from './host';
/** ↔ sets the width, ↕ the height – the side the button changes, not the one it reads. */ /** ↔ sets the width, ↕ the height – the side the button changes, not the one it reads. */
const AXIS_GLYPH: Record<SizeAxis, string> = { width: '↔', height: '↕' }; const AXIS_GLYPH: Record<SizeAxis, string> = { width: '↔', height: '↕' };
/** What a size action writes: one side for a fit, both for the picture's pixel size. */
export type SizeValues = Partial<Record<SizeAxis, number>>;
/** One undo step whether the action wrote one side or both. */
export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: number) => Command): Command {
const commands = (Object.entries(values) as [SizeAxis, number][]).map(([axis, value]) => make(axis, value));
return commands.length === 1 ? commands[0]! : new CompoundCommand('Set size', commands);
}
/** The pixel size as the schema would store it – null where a min/max would change a side. */
function storablePixelSize(asset: Asset, size: PixelSize): SizeValues | null {
const schema = schemaForAsset(asset);
const values: SizeValues = {};
for (const axis of ['width', 'height'] as const) {
const def = schema.find((d) => d.key === axis);
if (!def) return null;
const wanted = axis === 'width' ? size.w : size.h;
const next = Number(coerceValue(def, wanted));
if (next !== wanted) return null;
values[axis] = next;
}
return values;
}
/** /**
* "Fit this side to the picture's proportions" for the Width/Height rows of an asset that also * The size actions of the Width/Height rows of an asset that also wears a texture: fit this side
* wears a texture. Empty for every other row, and – through an action list that comes back * to the picture's proportions, or set both sides to its pixel size. Empty for every other row,
* empty – while no texture or frame is assigned, so the button is not drawn at all. * and – through an action list that comes back empty – while no texture or frame is assigned, so
* the button is not drawn at all.
*/ */
export function aspectActions( export function aspectActions(
host: InspectorHost, host: InspectorHost,
asset: Asset, asset: Asset,
def: PropertyDef, def: PropertyDef,
props: () => PropertyBag | null, props: () => PropertyBag | null,
commit: (value: number) => void, commit: (values: SizeValues) => void,
): FieldActionGroup[] { ): FieldActionGroup[] {
if (def.kind !== 'number' || (def.key !== 'width' && def.key !== 'height')) return []; if (def.kind !== 'number' || (def.key !== 'width' && def.key !== 'height')) return [];
// A size without a picture (collider, camera) has no proportions to fit to. // A size without a picture (collider, camera) has no proportions to fit to.
@@ -35,14 +64,23 @@ export function aspectActions(
available: () => { available: () => {
const bag = props(); const bag = props();
const size = bag && texturePixelSize(host.project(), bag); const size = bag && texturePixelSize(host.project(), bag);
if (!bag || !size || matchesAspect(size, bag)) return []; if (!bag || !size) return [];
const fitted = fitAxisToAspect(size, bag, axis); const actions: FieldAction[] = [];
if (fitted === null) return []; const fitted = matchesAspect(size, bag) ? null : fitAxisToAspect(size, bag, axis);
// Offered only when the schema would store it unchanged: a value its min/max clamps // Offered only when the schema would store it unchanged: a value its min/max clamps
// cannot make the sprite proportional, so the button would never go away again. // cannot make the sprite proportional, so the button would never go away again.
const next = Number(coerceValue(def, fitted)); const next = fitted === null ? null : Number(coerceValue(def, fitted));
if (next !== fitted) return []; if (next !== null && next === fitted) {
return [{ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit(next) }]; actions.push({ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit({ [axis]: next }) });
}
const pixels = matchesPixelSize(size, bag) ? null : storablePixelSize(asset, size);
if (pixels) {
actions.push({
label: `Set to ${size.w} × ${size.h} px`,
onSelect: () => commit(pixels),
});
}
return actions;
}, },
}, },
]; ];
+5 -2
View File
@@ -20,7 +20,7 @@ import {
} from '../../core/properties/PropertySchema'; } from '../../core/properties/PropertySchema';
import { isModified, resolveAssetProps } from '../../core/properties/resolve'; import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { aspectActions } from './aspectAction'; import { aspectActions, sizeCommand } from './aspectAction';
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host'; import { addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers'; import { previewingHandlers } from './previewHandlers';
@@ -101,7 +101,10 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
const target = findAsset(host.state.project, asset.id); const target = findAsset(host.state.project, asset.id);
return target ? resolveAssetProps(target) : null; return target ? resolveAssetProps(target) : null;
}, },
(value) => host.state.execute(new SetAssetPropertyCommand(asset.id, def.key, value)), (values) =>
host.state.execute(
sizeCommand(values, (axis, value) => new SetAssetPropertyCommand(asset.id, axis, value)),
),
), ),
reset: () => { reset: () => {
const target = findAsset(host.state.project, asset.id); const target = findAsset(host.state.project, asset.id);
+1 -1
View File
@@ -56,7 +56,7 @@ export interface FieldRow {
const FIELD_LABEL_WIDTH = 64; const FIELD_LABEL_WIDTH = 64;
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */ /** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
interface FieldAction { export interface FieldAction {
label: string; label: string;
onSelect: () => void; onSelect: () => void;
} }
+5 -2
View File
@@ -42,7 +42,7 @@ import {
} from '../../core/properties/resolve'; } from '../../core/properties/resolve';
import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui'; import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions'; import { addCollider, createContainer, removeObjects } from '../actions';
import { aspectActions } from './aspectAction'; import { aspectActions, sizeCommand } from './aspectAction';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory'; import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers'; import { previewingHandlers } from './previewHandlers';
@@ -379,7 +379,10 @@ function propertyRow(
owner, owner,
def, def,
() => currentProps(host.state, holderId, objectId), () => currentProps(host.state, holderId, objectId),
(value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)), (values) =>
host.state.execute(
sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)),
),
), ),
reset: () => { reset: () => {
const { template, asset } = bases(); const { template, asset } = bases();
+48 -13
View File
@@ -1,19 +1,22 @@
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes'; import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance'; import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve'; import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions'; import { addCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state'; import { type EditorState, objectSelection } from '../state';
/** /**
* Objects of the scene or container being edited, as a tree (children indented below their parent). * Objects of the scene or container being edited, as a tree (children indented below their parent).
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto * Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the * another to parent it there, into the gap above or below a row to order it among that row's
* top level. The object keeps its world position either way. * siblings, or onto the empty space below the rows to move it to the top level. The object keeps
* its world position every time.
* *
* Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them. * Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
* Everything starts collapsed – which ids are open is view state kept here, never part of the * Everything starts collapsed – which ids are open is view state kept here, never part of the
@@ -42,14 +45,7 @@ export class HierarchyPanel extends Panel {
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id); if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
else state.select(id ? objectSelection(holder.id, [id]) : null); else state.select(id ? objectSelection(holder.id, [id]) : null);
}; };
this.list.onDrop = (draggedId, targetId) => { this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot);
const holder = this.holder();
const object = holder && findObject(holder, draggedId);
if (!holder || !object || object.parentId === (targetId ?? '')) return;
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
if (targetId) this.expanded.add(targetId);
state.execute(new SetParentCommand(holder.id, draggedId, targetId ?? ''));
};
this.list.onToggle = (id, expand) => this.setExpanded(id, expand); this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id)); this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
@@ -72,6 +68,45 @@ export class HierarchyPanel extends Panel {
this.refresh(); this.refresh();
} }
/**
* A drop either puts the dragged row inside another one (parenting, which keeps the world
* position) or into a gap, which orders it among the siblings there – and does both when the
* gap belongs to a parent the object did not have. A move that would leave the order as it is
* does nothing, so a drag that lands where it started costs no undo step.
*/
private drop(draggedId: string, spot: DropSpot): void {
const holder = this.holder();
const object = holder && findObject(holder, draggedId);
if (!holder || !object) return;
if (spot.at === 'onto') {
if (object.parentId === spot.targetId) return;
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
this.expanded.add(spot.targetId);
this.state.execute(new SetParentCommand(holder.id, draggedId, spot.targetId));
return;
}
let parentId = '';
let beforeId = '';
if (spot.at !== 'end') {
const target = findObject(holder, spot.targetId);
if (!target) return;
parentId = target.parentId;
const order = childIdsOf(holder, parentId);
beforeId = spot.at === 'before' ? target.id : (order[order.indexOf(target.id) + 1] ?? '');
}
if (beforeId === draggedId) return;
const siblings = childIdsOf(holder, parentId);
const rest = siblings.filter((id) => id !== draggedId);
const at = beforeId ? rest.indexOf(beforeId) : rest.length;
const wanted = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
if (object.parentId === parentId && wanted.join() === siblings.join()) return;
const commands: Command[] = [];
if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId));
commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId));
if (parentId) this.expanded.add(parentId);
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand('Move object', commands));
}
/** /**
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the * Lists another holder's objects – the Container tab hands in its template, the Scene tab the
* active scene again. * active scene again.
+1 -1
View File
@@ -11,7 +11,7 @@ export { Checkbox } from './widgets/Checkbox';
export { Panel } from './widgets/Panel'; export { Panel } from './widgets/Panel';
export { Splitter } from './widgets/Splitter'; export { Splitter } from './widgets/Splitter';
export { ScrollContainer } from './widgets/ScrollContainer'; export { ScrollContainer } from './widgets/ScrollContainer';
export { ListView, type ListItem, type PickModifiers } from './widgets/ListView'; export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView';
export { Popup, type PopupOptions } from './widgets/Popup'; export { Popup, type PopupOptions } from './widgets/Popup';
export { Menu, type MenuItem, type MenuOptions } from './widgets/Menu'; export { Menu, type MenuItem, type MenuOptions } from './widgets/Menu';
export { Dialog, type DialogButton } from './widgets/Dialog'; export { Dialog, type DialogButton } from './widgets/Dialog';
+44 -15
View File
@@ -28,13 +28,19 @@ export interface PickModifiers {
shift: boolean; shift: boolean;
} }
/**
* Where a drop would land. `onto` is the row itself (the tree meaning: inside it), `before` and
* `after` are the gaps around it, `end` is the empty space below the last row.
*/
export type DropSpot = { at: 'onto' | 'before' | 'after'; targetId: string } | { at: 'end' };
interface DragState { interface DragState {
id: string; id: string;
startX: number; startX: number;
startY: number; startY: number;
/** Set once the pointer travelled past `DRAG_THRESHOLD`. */ /** Set once the pointer travelled past `DRAG_THRESHOLD`. */
active: boolean; active: boolean;
targetId: string | null; spot: DropSpot | null;
} }
/** /**
@@ -53,8 +59,8 @@ export class ListView extends Widget {
onActivate?: (id: string) => void; onActivate?: (id: string) => void;
/** Returning the opened menu keeps its row marked for as long as it is up. */ /** Returning the opened menu keeps its row marked for as long as it is up. */
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void; onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */ /** Called when a dragged row is released somewhere that could take it. */
onDrop?: (draggedId: string, targetId: string | null) => void; onDrop?: (draggedId: string, spot: DropSpot) => void;
/** /**
* Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list * Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list
* a tree: every row reserves the chevron slot, so labels stay aligned across a level. * a tree: every row reserves the chevron slot, so labels stay aligned across a level.
@@ -196,7 +202,7 @@ export class ListView extends Widget {
if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id); if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id);
if (e.detail === 2) this.onActivate?.(item.id); if (e.detail === 2) this.onActivate?.(item.id);
else if (this.onDrop) { else if (this.onDrop) {
this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null }; this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null };
} }
} }
} }
@@ -210,34 +216,57 @@ export class ListView extends Widget {
this.cursor = 'grabbing'; this.cursor = 'grabbing';
} }
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const index = Math.floor(local.y / this.rowHeight); drag.spot = this.spotAt(local.x, local.y, drag.id);
const target = local.x >= 0 && local.x <= this.w && index >= 0 ? this.items[index] : undefined;
drag.targetId = target && target.id !== drag.id ? target.id : null;
this.invalidate(); this.invalidate();
} }
/**
* Where a drop at this point would land. The outer quarter of a row is its gap, so the same
* drag can put a row inside another (middle) or next to it (edge); a point on the dragged row
* itself, or outside the list, takes nothing.
*/
private spotAt(x: number, y: number, draggedId: string): DropSpot | null {
if (x < 0 || x > this.w || y < 0) return null;
const index = Math.floor(y / this.rowHeight);
const item = this.items[index];
if (!item) return { at: 'end' };
if (item.id === draggedId) return null;
const offset = y - index * this.rowHeight;
const edge = this.rowHeight / 4;
if (offset < edge) return { at: 'before', targetId: item.id };
if (offset > this.rowHeight - edge) return { at: 'after', targetId: item.id };
return { at: 'onto', targetId: item.id };
}
private onDragEnd(): void { private onDragEnd(): void {
const drag = this.drag; const drag = this.drag;
this.drag = null; this.drag = null;
this.cursor = 'default'; this.cursor = 'default';
if (!drag?.active) return; if (!drag?.active) return;
this.invalidate(); this.invalidate();
this.onDrop?.(drag.id, drag.targetId); if (drag.spot) this.onDrop?.(drag.id, drag.spot);
} }
/** Highlights the row a drag would drop onto, or underlines the list for "drop at top level". */ /** Outlines the row a drag would drop into, or draws a line in the gap it would drop between. */
private drawDropMarker(): void { private drawDropMarker(): void {
this.dropMarker.clear(); this.dropMarker.clear();
const drag = this.drag; const spot = this.drag?.active ? this.drag.spot : null;
if (!drag?.active) return; if (!spot) return;
const index = this.items.findIndex((i) => i.id === drag.targetId); const line = (y: number): void => {
if (index >= 0) { this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent);
};
if (spot.at === 'end') {
line(Math.min(this.h, this.items.length * this.rowHeight) - 1);
return;
}
const index = this.items.findIndex((i) => i.id === spot.targetId);
if (index < 0) return;
if (spot.at === 'onto') {
this.dropMarker this.dropMarker
.rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2) .rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2)
.stroke({ width: 2, color: Theme.colors.accent }); .stroke({ width: 2, color: Theme.colors.accent });
} else { } else {
const y = Math.min(this.h, this.items.length * this.rowHeight) - 1; line((index + (spot.at === 'after' ? 1 : 0)) * this.rowHeight);
this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent);
} }
} }
} }
+17 -1
View File
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
import { CreateTextureCommand } from '../../src/core/commands/assetCommands'; import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands'; import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { PropertyBag } from '../../src/core/model/Properties'; import type { PropertyBag } from '../../src/core/model/Properties';
import { fitAxisToAspect, matchesAspect, texturePixelSize } from '../../src/core/properties/aspect'; import {
fitAxisToAspect,
matchesAspect,
matchesPixelSize,
texturePixelSize,
} from '../../src/core/properties/aspect';
import { makeSpriteScene } from '../helpers'; import { makeSpriteScene } from '../helpers';
const FILE = `${'a1b2c3d4'.repeat(8)}.png`; const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
@@ -54,6 +59,17 @@ describe('fitAxisToAspect', () => {
}); });
}); });
describe('matchesPixelSize', () => {
const size = { w: 64, h: 48 };
it('asks for both sides, unlike the proportions', () => {
expect(matchesPixelSize(size, props({ width: 64, height: 48 }))).toBe(true);
// Proportional, but half the size: the "set to 64 × 48 px" action still has something to do.
expect(matchesPixelSize(size, props({ width: 32, height: 24 }))).toBe(false);
expect(matchesAspect(size, props({ width: 32, height: 24 }))).toBe(true);
});
});
describe('matchesAspect', () => { describe('matchesAspect', () => {
it('compares proportions, not sizes', () => { it('compares proportions, not sizes', () => {
const size = { w: 64, h: 48 }; const size = { w: 64, h: 48 };
+5
View File
@@ -8,6 +8,7 @@ import {
PlaceAssetCommand, PlaceAssetCommand,
RemoveSceneObjectCommand, RemoveSceneObjectCommand,
RemoveSceneObjectsCommand, RemoveSceneObjectsCommand,
ReorderObjectCommand,
ClearOverrideCommand, ClearOverrideCommand,
RenameSceneObjectCommand, RenameSceneObjectCommand,
SetOverrideCommand, SetOverrideCommand,
@@ -333,6 +334,10 @@ describe('the structure of a placed container', () => {
expect(() => stack.execute(new CreateContainerCommand(sceneId, [child(tA)]))).toThrow( expect(() => stack.execute(new CreateContainerCommand(sceneId, [child(tA)]))).toThrow(
/comes from a container template/, /comes from a container template/,
); );
// Order is structure too: it comes from the template, so it is changed there.
expect(() => stack.execute(new ReorderObjectCommand(sceneId, child(tA), child(tB)))).toThrow(
/comes from a container template/,
);
}); });
it('refuses to take an object of the scene as a child', () => { it('refuses to take an object of the scene as a child', () => {
+46
View File
@@ -3,6 +3,7 @@ import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { import {
MoveSceneObjectCommand, MoveSceneObjectCommand,
RemoveSceneObjectCommand, RemoveSceneObjectCommand,
ReorderObjectCommand,
SetParentCommand, SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands'; } from '../../src/core/commands/sceneObjectCommands';
import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph'; import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph';
@@ -11,6 +12,7 @@ import {
descendantsOf, descendantsOf,
findObject, findObject,
localPositionFor, localPositionFor,
type ObjectTree,
treeOrder, treeOrder,
visibleTreeOrder, visibleTreeOrder,
} from '../../src/core/model/hierarchy'; } from '../../src/core/model/hierarchy';
@@ -175,6 +177,50 @@ describe('scene hierarchy', () => {
}); });
}); });
describe('ReorderObjectCommand', () => {
/** Ids in tree order – the order the panel lists and the tie-break for equal `z`. */
const order = (scene: ObjectTree) => treeOrder(scene).map((e) => e.object.id);
it('moves an object in front of a sibling and undo puts it back', () => {
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
expect(order(sceneOf())).toEqual([a, b, c]);
stack.execute(new ReorderObjectCommand(sceneId, c, a));
expect(order(sceneOf())).toEqual([c, a, b]);
stack.undo();
expect(order(sceneOf())).toEqual([a, b, c]);
});
it('moves it behind the last sibling when no successor is named', () => {
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
stack.execute(new ReorderObjectCommand(sceneId, a, ''));
expect(order(sceneOf())).toEqual([b, c, a]);
});
it('orders children among themselves, leaving the subtree where it is', () => {
const f = threeBoxes();
const { stack, sceneId, a, b, c } = f;
const x = place(f, f.spriteId, { x: 0, y: 0 }, a);
const y = place(f, f.spriteId, { x: 0, y: 0 }, a);
expect(order(f.sceneOf())).toEqual([a, x, y, b, c]);
stack.execute(new ReorderObjectCommand(sceneId, y, x));
expect(order(f.sceneOf())).toEqual([a, y, x, b, c]);
// The move is among siblings only: the parent keeps its own place in the list.
expect(f.object(y).parentId).toBe(a);
});
it('refuses a target that is not a sibling, so a reorder never re-parents by accident', () => {
const f = threeBoxes();
const child = place(f, f.spriteId, { x: 0, y: 0 }, f.a);
expect(() => f.stack.execute(new ReorderObjectCommand(f.sceneId, f.b, child))).toThrow(
/among its own siblings/,
);
expect(() => f.stack.execute(new ReorderObjectCommand(f.sceneId, f.b, f.b))).toThrow(
/in front of itself/,
);
expect(order(f.sceneOf())).toEqual([f.a, child, f.b, f.c]);
});
});
describe('runtime hierarchy', () => { describe('runtime hierarchy', () => {
it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => { it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => {
const f = threeBoxes(); const f = threeBoxes();
+34
View File
@@ -127,4 +127,38 @@ export async function parenting(t) {
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
JSON.stringify(o2), JSON.stringify(o2),
); );
// 14d. The gap above a row is a different drop than the row itself: it orders the dragged
// object among that row's siblings instead of putting it inside, and moves nothing.
const rowsBefore = await json('__gm.hierarchyOrder()');
check(
'the blue crate is listed after the red one to begin with',
rowsBefore.indexOf('o2') > rowsBefore.indexOf('o1'),
JSON.stringify(rowsBefore),
);
const redRow3 = await locate('Red crate');
const blueRow3 = await locate('Blue crate');
// 10 px above the centre of a 26 px row is its top quarter – the gap, not the row.
await drag(blueRow3.x, blueRow3.y, redRow3.x, redRow3.y - 10);
await sleep(150);
const rowsAfter = await json('__gm.hierarchyOrder()');
o2 = await object('o2');
check(
'dropping into the gap reorders instead of parenting',
rowsAfter.indexOf('o2') < rowsAfter.indexOf('o1') && o2.parentId === '',
JSON.stringify({ rowsAfter, parentId: o2.parentId }),
);
check(
'and a reorder moves nothing in the world',
o2.position.x === 120 && o2.position.y === -80,
JSON.stringify(o2.position),
);
await shot('31-reordered');
await undo();
await sleep(150);
check(
'undo puts the order back',
same(await json('__gm.hierarchyOrder()'), rowsBefore),
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
} }
+75 -23
View File
@@ -1,6 +1,8 @@
/** /**
* The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the * The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the
* picture the sprite wears, and not exist at all while it wears none. * picture the sprite wears, set both sides to its pixel size, and not exist at all while it wears
* no picture. With both on offer the button is the menu form "↔ ▾", which is what the checks
* below distinguish.
* *
* Runs on the sheet the texture steps imported (64 × 48) and adds a frame with a deliberately * Runs on the sheet the texture steps imported (64 × 48) and adds a frame with a deliberately
* different shape, which is what makes "sheet or frame?" a question the numbers can answer. * different shape, which is what makes "sheet or frame?" a question the numbers can answer.
@@ -14,6 +16,14 @@ const spriteProp = (key) => `__gm.state.project.assets.find((a) => a.id === 'tex
export async function aspect(t) { export async function aspect(t) {
const { check, sleep, json, evaluate, shot, clickText, press, undo, mutate, select } = t; const { check, sleep, json, evaluate, shot, clickText, press, undo, mutate, select } = t;
/** Whether the size button of a row is there at all, in either its plain or its menu form. */
const sizeButton = async (glyph) =>
(await json(`__gm.locate('${glyph}')`))
? 'plain'
: (await json(`__gm.locate('${glyph} ▾')`))
? 'menu'
: '';
await press('Escape'); await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
const sheet = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''"); const sheet = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
@@ -30,23 +40,23 @@ export async function aspect(t) {
await select({ kind: 'asset', assetId: 'tex-sprite' }); await select({ kind: 'asset', assetId: 'tex-sprite' });
await sleep(600); await sleep(600);
check('the sprite asset is in the inspector', Boolean(await json("__gm.locate('Width')"))); check('the sprite asset is in the inspector', Boolean(await json("__gm.locate('Width')")));
check( check('no texture, no aspect buttons', (await sizeButton('↔')) === '' && (await sizeButton('↕')) === '');
'no texture, no aspect buttons',
(await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null,
);
await shot('40-aspect-without-a-texture'); await shot('40-aspect-without-a-texture');
// 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is out of proportion – both appear. // 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
// so both rows offer two things and show the menu form.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`); await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await sleep(500); await sleep(500);
check( check(
'assigning a texture brings both buttons out', 'a texture brings both buttons out, offering fit and pixel size',
Boolean(await json("__gm.locate('↔')")) && Boolean(await json("__gm.locate('↕')")), (await sizeButton('↔')) === 'menu' && (await sizeButton('↕')) === 'menu',
); );
await shot('41-aspect-buttons'); await shot('41-aspect-buttons');
// 28. ↔ moves the width only, to the sheet's ratio: 100 x 64/48. // 28. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
await clickText('↔'); await clickText('↔ ▾');
await sleep(400);
await clickText('Fit to 64 × 48');
await sleep(500); await sleep(500);
const fitted = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; const fitted = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check( check(
@@ -55,16 +65,39 @@ export async function aspect(t) {
JSON.stringify(fitted), JSON.stringify(fitted),
); );
check( check(
'and both buttons go once the sprite is proportional', 'and the fit drops out of the menu once the sprite is proportional',
(await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null, (await sizeButton('↔')) === 'plain' && (await sizeButton('↕')) === 'plain',
); );
await shot('42-aspect-fitted'); await shot('42-aspect-fitted');
// 28a. One command, so it is one undo step – and the buttons come back with the old shape. // 28a. One command, so it is one undo step – and the menu form comes back with the old shape.
await undo(); await undo();
await sleep(500); await sleep(500);
check('undo takes the width back', (await json(spriteProp('width'))) === 100); check('undo takes the width back', (await json(spriteProp('width'))) === 100);
check('and the buttons return with it', Boolean(await json("__gm.locate('↔')"))); check('and the buttons return with it', (await sizeButton('↔')) === 'menu');
await t.redo();
await sleep(500);
// 28b. What is left over is the pixel size, now a direct button: it writes *both* sides, and
// the whole button goes because there is nothing left to offer.
await clickText('↔');
await sleep(500);
const pixels = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
'the pixel-size action sets both sides to the picture',
pixels.w === 64 && pixels.h === 48,
JSON.stringify(pixels),
);
check(
'and nothing is left to offer, so no button is drawn',
(await sizeButton('↔')) === '' && (await sizeButton('↕')) === '',
);
await undo();
await sleep(500);
check(
'both sides come back in one undo step',
near(await json(spriteProp('width')), (100 * 64) / 48) && (await json(spriteProp('height'))) === 100,
);
await t.redo(); await t.redo();
await sleep(500); await sleep(500);
@@ -75,10 +108,12 @@ export async function aspect(t) {
asset.frames.push({ id: 'aspect-frame', name: 'Wide', x: 0, y: 0, w: 40, h: 10 }); asset.frames.push({ id: 'aspect-frame', name: 'Wide', x: 0, y: 0, w: 40, h: 10 });
${spriteProp('texture')} = 'aspect-frame'`); ${spriteProp('texture')} = 'aspect-frame'`);
await sleep(500); await sleep(500);
check('a frame of another shape brings the buttons back', Boolean(await json("__gm.locate('↕')"))); check('a frame of another shape brings the buttons back', (await sizeButton('↕')) === 'menu');
const before = await json(spriteProp('width')); const before = await json(spriteProp('width'));
await clickText('↕'); await clickText('↕ ▾');
await sleep(400);
await clickText('Fit to 40 × 10');
await sleep(500); await sleep(500);
const framed = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) }; const framed = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check( check(
@@ -86,10 +121,7 @@ export async function aspect(t) {
near(framed.h, (before * 10) / 40) && framed.w === before, near(framed.h, (before * 10) / 40) && framed.w === before,
JSON.stringify({ framed, sheetWouldGive: (before * 48) / 64 }), JSON.stringify({ framed, sheetWouldGive: (before * 48) / 64 }),
); );
check( check('and only the pixel size is left to offer', (await sizeButton('↕')) === 'plain');
'and the buttons go again',
(await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null,
);
// 30. The same rows on a placed object write an override rather than the asset value. // 30. The same rows on a placed object write an override rather than the asset value.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`); await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
@@ -97,11 +129,13 @@ export async function aspect(t) {
await sleep(600); await sleep(600);
await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90"); await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90");
await sleep(400); await sleep(400);
check('the object row offers the fit as well', Boolean(await json("__gm.locate('↔')"))); check('the object row offers the fit as well', (await sizeButton('↔')) === 'menu');
// The crowded case: an overridden, out-of-proportion row carries ↕ next to ↑ and ↺. // The crowded case: an overridden, out-of-proportion row carries ↔ ▾ next to ↑ and ↺.
check('and shows it beside the override buttons', (await json('__gm.textOverflows()')).length === 0); check('and shows it beside the override buttons', (await json('__gm.textOverflows()')).length === 0);
await shot('43-aspect-beside-the-override-buttons'); await shot('43-aspect-beside-the-override-buttons');
await clickText('↔'); await clickText('↔ ▾');
await sleep(400);
await clickText('Fit to 64 × 48');
await sleep(500); await sleep(500);
const override = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides"); const override = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
check( check(
@@ -110,4 +144,22 @@ export async function aspect(t) {
JSON.stringify({ override, asset: await json(spriteProp('width')) }), JSON.stringify({ override, asset: await json(spriteProp('width')) }),
); );
await shot('44-aspect-on-an-object'); await shot('44-aspect-on-an-object');
// 30a. The pixel size on an object writes both overrides, and as one undo step.
await clickText('↔');
await sleep(500);
const sized = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
check(
'the pixel size writes both overrides at once',
sized.width === 64 && sized.height === 48,
JSON.stringify(sized),
);
await undo();
await sleep(500);
const back = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
check(
'and one undo takes both back',
near(back.width, (90 * 64) / 48) && back.height === 90,
JSON.stringify(back),
);
} }
+1 -1
View File
@@ -49,7 +49,7 @@ const spawnerGraph = {
}; };
const project = { const project = {
formatVersion: 1, formatVersion: 2,
id: projectId, id: projectId,
name: 'E2E fixture', name: 'E2E fixture',
activeSceneId: 'scene1', activeSceneId: 'scene1',