Trim needless comments and fix the picker width

Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.

ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.

__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 15:52:05 +02:00
1 parent f16801aef3
commit 4d84231b9f
115 files changed
+707 -1564

No files matched your search

+3 -8
View File
@@ -1,14 +1,9 @@
import type { Project } from '../model/Project';
/**
* An undoable edit of the project. Commands mutate the project in place and remember
* whatever they need to revert. They validate first: `execute` throws a `CommandError` on
* an invalid operation (e.g. placing a scene into a scene) before it changes anything.
*
* The same holds for `undo`: when its target is gone it throws a `CommandError` rather than
* reverting half of the edit. The command then stays on the undo stack (see `CommandStack`)
* and the editor shows the message. Validation belongs in `execute`/`undo`, never in the
* constructor – commands are built outside the editor's error handling.
* An undoable edit of the project, mutating it in place. Both `execute` and `undo` validate before
* changing anything and throw a `CommandError` instead of leaving half an edit behind. Validation
* belongs in them, never in the constructor – commands are built outside the error handling.
*/
export interface Command {
readonly label: string;
+1 -6
View File
@@ -18,11 +18,7 @@ export class CommandStack {
this.redoStack.length = 0;
}
/**
* Reverts the last command; false when there is none. The command moves between the stacks
* only after it succeeded – a throwing `undo` leaves the history exactly as it was instead
* of dropping the command from both stacks.
*/
/** The command moves between the stacks only after it succeeded, so a throwing `undo` loses nothing. */
undo(): boolean {
const command = this.undoStack[this.undoStack.length - 1];
if (!command) return false;
@@ -32,7 +28,6 @@ export class CommandStack {
return true;
}
/** Re-applies the last undone command; false when there is none. Moves it only on success. */
redo(): boolean {
const command = this.redoStack[this.redoStack.length - 1];
if (!command) return false;
+2 -3
View File
@@ -2,9 +2,8 @@ import type { Project } from '../model/Project';
import type { Command } from './Command';
/**
* Several commands as one undo step (moving a multi-selection, for instance). Executes in
* order; if one of them throws, the ones already done are rolled back before re-throwing,
* so the project is never left half-changed.
* Several commands as one undo step. If one throws, the ones already done are rolled back before
* re-throwing, so the project is never left half-changed.
*/
export class CompoundCommand implements Command {
constructor(
+6 -8
View File
@@ -3,13 +3,11 @@ import type { Project } from '../model/Project';
import type { Command } from './Command';
/**
* The skeleton every "write one key into a property bag" edit shares: remember what the bag
* held, write the new value, put the old one back on undo. A key that is not in the bag is
* `undefined` on both ends – as a `computeNext` result it removes the key (this is how
* clearing an override works), and as remembered value it makes undo remove it again.
* A missing key is `undefined` on both ends: as a `computeNext` result it removes the key (this is
* how clearing an override works), and as remembered value it makes undo remove it again.
*
* Subclasses look their target up on every call instead of holding on to it, so an undo whose
* target is gone fails with a `CommandError` instead of writing into an orphaned object.
* Subclasses resolve their target on every call rather than holding it, so an undo whose target is
* gone fails with a `CommandError` instead of writing into an orphaned object.
*/
export abstract class SetBagValueCommand<T> implements Command {
readonly label: string;
@@ -28,8 +26,8 @@ export abstract class SetBagValueCommand<T> implements Command {
protected abstract bagOf(target: T): PropertyBag;
/**
* The value to write, `undefined` to remove the key. Called before anything is mutated, so
* this is where a subclass rejects an invalid edit (an unknown property key, say).
* The value to write, `undefined` to remove the key. Called before anything is mutated, so this
* is where a subclass rejects an invalid edit.
*/
protected abstract computeNext(target: T, project: Project): PropertyValue | undefined;
+9 -23
View File
@@ -1,9 +1,7 @@
/**
* "Apply" is the opposite direction of an override: a value the user changed on a placed
* object is written back to where it came from – the asset, or (for objects that are part
* of a placed container) the container's template. The rule for instances follows the usual
* prefab semantics: copies that were still at the template's value follow the change,
* copies the user modified on their own keep their value.
* "Apply" is the opposite direction of an override: a value changed on a placed object is written
* back to the asset, or to the template of the container it is part of. Prefab semantics for the
* other copies: those still at the template's value follow, those modified on their own do not.
*/
import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container';
import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties';
@@ -22,10 +20,7 @@ function requireLink(project: Project, sceneId: string, object: SceneObject): Te
return link;
}
/**
* One value that is applied on its own: an override key, or a single axis of the position.
* Applying works the same for both – the slot is the only thing that differs.
*/
/** One value that is applied on its own: an override key, or a single axis of the position. */
interface Slot<T> {
read(object: SceneObject): T;
write(object: SceneObject, value: T): void;
@@ -43,7 +38,6 @@ const positionSlot = (axis: Axis): Slot<number> => ({
},
});
/** A slot's value on one object before it was overwritten. */
interface SlotMemo<T> {
object: SceneObject;
slot: Slot<T>;
@@ -51,9 +45,8 @@ interface SlotMemo<T> {
}
/**
* Writes `values` into the template's slots and carries every instance that still agreed
* with the template along – copies the user changed on their own keep their value. Returns
* the memos in application order; `restoreSlots` undoes them.
* Writes `values` into the template's slots and carries along every instance that still agreed with
* it. Returns the memos in application order; `restoreSlots` undoes them.
*/
function applyToTemplate<T>(
project: Project,
@@ -83,9 +76,8 @@ function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
}
/**
* Writes the object's value for `key` into its asset and drops the override – the object
* follows the asset again, as does everything else without an override of its own. For a
* container instance the template's override is dropped too (and instances that shared it).
* Writes the object's value for `key` into its asset and drops the override. For a container
* instance the template's override is dropped too, along with the instances that shared it.
*/
export class ApplyOverrideToAssetCommand implements Command {
readonly label: string;
@@ -124,7 +116,6 @@ export class ApplyOverrideToAssetCommand implements Command {
}
}
/** Writes the object's override for `key` into its container template (instances at the old value follow). */
export class ApplyOverrideToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<PropertyValue | undefined>[] = [];
@@ -148,7 +139,6 @@ export class ApplyOverrideToContainerCommand implements Command {
}
}
/** Puts the object's override for `key` back to what its container template says. */
export class RevertOverrideToTemplateCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
@@ -173,10 +163,7 @@ export class RevertOverrideToTemplateCommand implements Command {
}
}
/**
* Writes the object's local position (one axis, or all) into its container template.
* Instances whose axis still matched the template follow; the others keep their own value.
*/
/** Writes the object's local position – one axis, or all – into its container template. */
export class ApplyPositionToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<number>[] = [];
@@ -206,7 +193,6 @@ export class ApplyPositionToContainerCommand implements Command {
}
}
/** Puts the object's local position (one axis, or all) back to its container template. */
export class RevertPositionToTemplateCommand implements Command {
readonly label: string;
private previous: Position = { x: 0, y: 0, z: 0 };
+6 -22
View File
@@ -13,11 +13,6 @@ import { type Command, CommandError } from './Command';
import { requireAsset, requirePropDef } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
/**
* A new asset of the given type with default properties (and empty content for scenes,
* behaviors, containers). A texture starts without a picture; the upload fills it in, which
* is why `CreateTextureCommand` exists rather than a plain create followed by an edit.
*/
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
const base = { id, name, props: defaultsFor(type) };
switch (type) {
@@ -70,19 +65,14 @@ export interface TextureImage {
height: number;
}
/**
* A texture asset for a stored image, without frames – slicing it is a separate, undoable
* edit. Kept apart from `buildAsset` so callers that need a `TextureAsset` get one, instead
* of the `Asset` union they would have to narrow again.
*/
/** Apart from `buildAsset` so callers get a `TextureAsset` rather than the `Asset` union. */
export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
return { id, name, props: defaultsFor('texture'), type: 'texture', ...image, frames: [] };
}
/**
* Adds a texture asset for an image that is already stored on the server. Uploading is I/O and
* therefore happens before the command: this one only records the result, so undo and redo of
* an import cost nothing and never touch the network.
* The image is uploaded before the command runs; this only records the result, so undo and redo
* never touch the network.
*/
export class CreateTextureCommand implements Command {
readonly label: string;
@@ -94,8 +84,7 @@ export class CreateTextureCommand implements Command {
}
execute(project: Project): void {
// The name is the hash the server derived from the bytes; anything else never was uploaded
// and would leave a texture that silently draws nothing.
// The name is the hash the server derived from the bytes; anything else was never uploaded.
if (!isTextureFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored image file`);
project.assets.push(this.asset);
@@ -107,14 +96,12 @@ export class CreateTextureCommand implements Command {
}
/**
* Deletes an asset and, as a cascade, every scene object referencing it – together with the
* objects parented below those (a subtree is removed as a whole). Container templates
* referencing the asset lose those objects the same way.
* Deletes an asset and, as a cascade, every scene object referencing it, together with the objects
* parented below those – in container templates as well as in scenes.
*/
export class DeleteAssetCommand implements Command {
readonly label = 'Delete asset';
private removed?: Removed<Asset>;
/** Objects removed per scene or container asset (both hold a flat object list). */
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
private previousActiveScene = '';
@@ -170,7 +157,6 @@ export class RenameAssetCommand implements Command {
}
}
/** The half of `SetBagValueCommand` both asset property edits share: the bag is the asset's own. */
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
protected constructor(
private readonly assetId: string,
@@ -189,7 +175,6 @@ abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
}
}
/** Sets an asset property (coerced against its schema entry). */
export class SetAssetPropertyCommand extends AssetPropertyCommand {
constructor(
assetId: string,
@@ -204,7 +189,6 @@ export class SetAssetPropertyCommand extends AssetPropertyCommand {
}
}
/** Puts an asset property back to its schema default (the asset-level "clear override"). */
export class ResetAssetPropertyCommand extends AssetPropertyCommand {
constructor(assetId: string, key: string) {
super(assetId, key, `Reset ${key}`);
+2 -3
View File
@@ -9,9 +9,8 @@ import { nextAssetName } from './naming';
import { requireAsset, requireObject, requireScene } from './require';
/**
* The one-click way to give an object a collider: creates a box collider asset sized like
* the object (sprites: width × height; anything else: schema defaults) and places it as a
* child at the object's centre. Undo removes both the placed collider and the asset.
* Creates a collider asset sized like the object (sprites: width × height; anything else: schema
* defaults) and places it as a child at the object's centre.
*/
export class AddColliderCommand implements Command {
readonly label = 'Add collider';
+5 -11
View File
@@ -18,13 +18,10 @@ import { nextAssetName } from './naming';
import { requireObject, requireScene } from './require';
/**
* Turns the selected objects (with everything parented below them) into a new container
* asset and replaces them in the scene by one placed container – exactly as if that asset
* had been placed there. The container's origin is the centre of the selection's bounding
* box (x/y of the objects' world positions; z = 0), so the template is centred around
* (0, 0, 0). The objects keep their ids and world positions: the selected roots are simply
* re-parented under the container with recomputed local positions. When all roots share a
* parent the container is placed below that parent, otherwise at the top level.
* Turns the selected objects, with everything parented below them, into a container asset placed
* in their stead. The origin is the centre of the selection's bounding box (z = 0), so the template
* is centred around (0, 0, 0); the objects keep their ids and world positions. The container goes
* below the roots' shared parent, or at the top level when they do not share one.
*/
export class CreateContainerCommand implements Command {
readonly label = 'Create container';
@@ -42,7 +39,6 @@ export class CreateContainerCommand implements Command {
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.sceneId, id));
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
// Centre of the selection in world space – the container's origin.
const worlds = selected.map((o) => worldPosition(scene, o));
const centre: Position = {
x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2),
@@ -50,7 +46,7 @@ export class CreateContainerCommand implements Command {
z: 0,
};
// The subtrees that move into the container, in tree order (parents first).
// Members are collected in tree order, so a parent is always built before its children.
const roots = new Set(
subtreeRoots(
scene,
@@ -64,7 +60,6 @@ export class CreateContainerCommand implements Command {
const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId));
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
// Template: same structure with private ids, roots relative to the centre.
const asset = buildAsset(
'container',
nextAssetName(project, 'Container'),
@@ -89,7 +84,6 @@ export class CreateContainerCommand implements Command {
});
}
// Scene: the container takes the roots' place, the members become its instance.
this.previous = members.map((o) => ({
id: o.id,
parentId: o.parentId,
+11 -44
View File
@@ -1,8 +1,6 @@
/**
* Undoable edits of a texture's frames – the named rectangles a sprite sheet is cut into
* (see `model/texture.ts`). Every rectangle that enters the model passes `normalizeFrameRect`
* here rather than at the call site: frames come from dragging in a zoomed view, so callers
* produce fractional and inverted rectangles as a matter of course.
* Every rectangle passes `normalizeFrameRect` here rather than at the call site: frames come from
* dragging in a zoomed view, so callers produce fractional and inverted rectangles routinely.
*/
import { newId } from '../ids';
import type { TextureAsset, TextureFrame } from '../model/Asset';
@@ -21,10 +19,8 @@ import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset } from './require';
/** The name new frames are derived from when the caller has nothing better to offer. */
const DEFAULT_NAME = 'Frame';
/** The asset as a texture, rejecting both "no such asset" and "that one is not a picture". */
function requireTexture(project: Project, assetId: string): TextureAsset {
const asset = requireAsset(project, assetId);
if (asset.type !== 'texture') throw new CommandError(`${asset.name} is not a texture`);
@@ -37,10 +33,7 @@ function requireFrame(asset: TextureAsset, frameId: string): TextureFrame {
return frame;
}
/**
* Adds one frame to a texture. The id is known before execution, which is what lets the caller
* select the new frame right after executing – the same trick `PlaceAssetCommand` uses.
*/
/** `frameId` is known before execution, so the caller can select the new frame right after executing. */
export class AddFrameCommand implements Command {
readonly label = 'Add frame';
readonly frameId = newId();
@@ -55,8 +48,7 @@ export class AddFrameCommand implements Command {
const asset = requireTexture(project, this.assetId);
asset.frames.push({
id: this.frameId,
// Resolved on every execution rather than in the constructor: a redo has to fit the
// names that exist when it runs, not the ones that existed when the command was built.
// Resolved per execution: a redo has to fit the names that exist when it runs.
name: uniqueFrameName(asset, this.name),
...normalizeFrameRect(this.rect, asset.width, asset.height),
});
@@ -69,13 +61,8 @@ export class AddFrameCommand implements Command {
}
/**
* Moves and resizes a frame – one command per drag, not one per pointer move.
*
* A drag previews itself by writing into the frame directly; on release the caller puts the
* starting rectangle back and executes this once, exactly as `SceneViewport` does for
* `MoveSceneObjectCommand`. The rectangle to revert to is therefore read from the model at
* execution time: a caller that forgets to undo its preview would record the preview as the
* state to return to, and the whole drag would collapse into one undo step of nothing.
* One command per drag, not per pointer move. The rectangle to revert to is read from the model at
* execution time, so a caller that previews a drag in place must restore the start rect before executing.
*/
export class SetFrameRectCommand implements Command {
readonly label = 'Move frame';
@@ -96,13 +83,11 @@ export class SetFrameRectCommand implements Command {
undo(project: Project): void {
const asset = requireTexture(project, this.assetId);
// Restored verbatim instead of normalized again: undo owes the user the state that was
// there, even if it came out of a document written before the image was measured.
// Restored verbatim rather than normalized again: undo owes the state that was there.
setFrameRect(requireFrame(asset, this.frameId), this.previous);
}
}
/** Renames a frame. The name is what the sprite's texture picker shows, so it may not be blank. */
export class RenameFrameCommand implements Command {
readonly label = 'Rename frame';
private previous = '';
@@ -128,12 +113,8 @@ export class RenameFrameCommand implements Command {
}
/**
* Removes a frame, putting it back at its former index on undo – the frame order is the order
* of the sheet, so appending it again would silently reshuffle the picture.
*
* Sprites wearing the frame are left pointing at it. A dangling reference reads as "no
* picture" everywhere (`resolveTexture`) and comes back by itself when this is undone, which
* clearing the references could not offer.
* Undo restores the former index, because frame order is sheet order. Sprites are left pointing at
* the frame: a dangling reference reads as "no picture" and comes back by itself on undo.
*/
export class RemoveFrameCommand implements Command {
readonly label = 'Remove frame';
@@ -157,14 +138,6 @@ export class RemoveFrameCommand implements Command {
}
}
/**
* Cuts a sheet into a grid of frames (see `sliceFrames`), either adding them to the frames
* already there or replacing those.
*
* A grid that yields nothing is rejected rather than applied as an empty change: it means the
* image has not been measured yet or the cells came out smaller than a pixel, and both look to
* the user like the Slice button doing nothing at all.
*/
export class SliceFramesCommand implements Command {
readonly label: string;
private created: TextureFrame[] = [];
@@ -188,8 +161,8 @@ export class SliceFramesCommand implements Command {
this.previous = [...asset.frames];
if (this.replace) asset.frames = [];
// A redo reuses the frames the first run created: while the command sat on the undo stack
// a sprite may have been pointed at one of them, and a fresh id would break that.
// A redo reuses the frames the first run created; a sprite may point at one, and fresh ids
// would break that.
if (this.created.length === 0) this.created = this.buildFrames(asset, rects);
else asset.frames.push(...this.created);
}
@@ -198,17 +171,11 @@ export class SliceFramesCommand implements Command {
requireTexture(project, this.assetId).frames = this.previous;
}
/**
* Frames named after their position in the sheet ("Frame 1" is the first cell in reading
* order). The whole run of names is asked for at once, so that keeping the existing frames
* shifts the numbering past them instead of suffixing a number onto a number.
*/
private buildFrames(asset: TextureAsset, rects: readonly FrameRect[]): TextureFrame[] {
const names = nextFrameNames(asset, rects.length, DEFAULT_NAME);
const created = rects.map((rect, index) => ({
id: newId(),
name: names[index]!,
// A no-op for a whole-pixel grid, but the offsets come from a form and may be anything.
...normalizeFrameRect(rect, asset.width, asset.height),
}));
asset.frames.push(...created);
+5 -10
View File
@@ -16,7 +16,6 @@ import { type Command, CommandError } from './Command';
import { requireBehavior, requireNode, requireNodeDef } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
/** Adds a node of the given type at graph coordinates. Params start at their defaults. */
export class AddNodeCommand implements Command {
readonly label: string;
readonly node: GraphNode;
@@ -28,8 +27,8 @@ export class AddNodeCommand implements Command {
y: number,
params: PropertyBag = {},
) {
// An unknown type is rejected in `execute`, not here: constructors run outside the
// editor's error handling, so throwing here would crash instead of showing a notice.
// An unknown type is rejected in `execute`: constructors run outside the editor's error
// handling, so throwing here would crash instead of showing a notice.
const def = nodeDef(type);
this.node = {
id: newId(),
@@ -80,8 +79,8 @@ export class RemoveNodeCommand implements Command {
}
/**
* Sets a node's graph position. A caller that previewed the drag must restore the node
* before executing: the previous position is read at execution time.
* The previous position is read at execution time, so a caller that previewed the drag must restore
* the node before executing.
*/
export class MoveNodeCommand implements Command {
readonly label = 'Move node';
@@ -134,10 +133,7 @@ export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
}
}
/**
* Connects an output to an input. Validates port existence and type compatibility; a data
* input holds at most one edge, so an existing one is replaced (and restored on undo).
*/
/** A data input holds at most one edge, so an existing one is replaced (and restored on undo). */
export class ConnectCommand implements Command {
readonly label = 'Connect';
readonly edge: GraphEdge;
@@ -175,7 +171,6 @@ export class ConnectCommand implements Command {
}
}
/** Removes one edge. */
export class DisconnectCommand implements Command {
readonly label = 'Disconnect';
private removed: Removed<GraphEdge>[] = [];
-1
View File
@@ -1,7 +1,6 @@
import type { BehaviorAsset } from '../model/Asset';
import type { Project } from '../model/Project';
/** The first of `candidate(from)`, `candidate(from + 1)`, … that is not taken. */
function firstFree(taken: ReadonlySet<string>, candidate: (i: number) => string, from: number): string {
for (let i = from; ; i++) {
const name = candidate(i);
+3 -9
View File
@@ -1,8 +1,4 @@
/**
* Lookups that throw a `CommandError` when the target does not exist. Commands validate
* their inputs with these before mutating anything, so an invalid edit is rejected with a
* message the editor can show.
*/
/** Lookups that throw a `CommandError` when the target does not exist. */
import { findNode, type GraphNode } from '../graph/Graph';
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset';
@@ -43,9 +39,8 @@ export function requireNode(project: Project, assetId: string, nodeId: string):
}
/**
* The definition of a node type. `NodeRegistry.requireNodeDef` throws a plain `Error`, which
* the editor does not catch – a command must reject an unknown type as a `CommandError` so it
* becomes a notice instead of a crash.
* `NodeRegistry.requireNodeDef` throws a plain `Error`, which the editor does not catch; a command
* needs a `CommandError` so an unknown type becomes a notice instead of a crash.
*/
export function requireNodeDef(type: string): NodeDef {
const def = nodeDef(type);
@@ -53,7 +48,6 @@ export function requireNodeDef(type: string): NodeDef {
return def;
}
/** The schema entry for `key` on the asset – a property the asset's type (or the behavior) declares. */
export function requirePropDef(asset: Asset, key: string): PropertyDef {
const def = propDef(asset, key);
if (!def) throw new CommandError(`Property "${key}" is not defined for ${asset.type}`);
+9 -20
View File
@@ -18,14 +18,12 @@ import { requireAsset, requireObject, requirePropDef, requireScene } from './req
import { SetBagValueCommand } from './SetBagValueCommand';
/**
* Places an asset into a scene, creating a scene object. Scenes cannot be placed. The
* position is a world position; with a parent it is converted to the parent's local space.
* The position is a world position; with a parent it is converted to the parent's local space.
* Placing a container also creates its content (see `instantiateContainer`).
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
/** Every object this command added: the placed one plus a container's content. */
private createdIds: string[] = [];
constructor(
@@ -54,7 +52,7 @@ export class PlaceAssetCommand implements Command {
}
}
/** Removes objects (a multi-selection) together with everything parented below them, as one undo step. */
/** Removes objects together with everything parented below them, as one undo step. */
export class RemoveSceneObjectsCommand implements Command {
readonly label: string;
private removed: Removed<SceneObject>[] = [];
@@ -76,7 +74,6 @@ export class RemoveSceneObjectsCommand implements Command {
}
}
/** Removes one object and everything parented to it. */
export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
constructor(sceneId: string, objectId: string) {
super(sceneId, [objectId]);
@@ -84,9 +81,8 @@ export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
}
/**
* Sets the full local position (x, y and draw order z) of a scene object. A caller that
* previewed the move must restore the model before executing: the previous value is read
* from the object at execution time.
* The previous position is read at execution time, so a caller that previewed the move must
* restore the model before executing.
*/
export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
@@ -109,11 +105,7 @@ export class MoveSceneObjectCommand implements Command {
}
}
/**
* Sets the editor-only offset an object is drawn at in the scene view (see
* `SceneObject.editorOffset`). Undoable like any other edit, but it changes nothing the game
* can see – it only pulls overlapping objects apart while working on them.
*/
/** Editor-only: undoable like any other edit, but it changes nothing the game can see. */
export class SetEditorOffsetCommand implements Command {
readonly label = 'Editor offset';
private previous: Vec2 = { x: 0, y: 0 };
@@ -156,7 +148,6 @@ export class RenameSceneObjectCommand implements Command {
}
}
/** The half of `SetBagValueCommand` both override edits share: the bag is the object's overrides. */
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
protected constructor(
private readonly sceneId: string,
@@ -176,7 +167,6 @@ abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
}
}
/** Sets a per-object override of an asset property. */
export class SetOverrideCommand extends OverrideCommand {
constructor(
sceneId: string,
@@ -192,22 +182,21 @@ export class SetOverrideCommand extends OverrideCommand {
}
}
/** Removes an override so the object follows its asset again. */
export class ClearOverrideCommand extends OverrideCommand {
constructor(sceneId: string, objectId: string, key: string) {
super(sceneId, objectId, key, `Reset ${key}`);
}
// Unlike setting one, clearing an override does not check the key against the schema: a
// key the asset no longer declares is exactly what one wants to be able to clear.
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one
// wants to be able to clear.
protected override computeNext(): undefined {
return undefined;
}
}
/**
* Re-parents an object ('' = top level). The object keeps its world position: its local
* position is recomputed for the new parent. Cycles (an object below itself) are rejected.
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
* for the new parent.
*/
export class SetParentCommand implements Command {
readonly label = 'Set parent';
+3 -6
View File
@@ -5,7 +5,7 @@ import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireBehavior } from './require';
/** Declares a new variable on a behavior; its default value is written to `props`. */
/** The variable's default value is written to `props` as well. */
export class AddVariableCommand implements Command {
readonly label: string;
readonly def: PropertyDef;
@@ -76,10 +76,7 @@ export class RemoveVariableCommand implements Command {
}
}
/**
* Renames a variable everywhere: definition, default, instance overrides (in scenes and in
* container templates) and graph nodes referring to it.
*/
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
export class RenameVariableCommand implements Command {
readonly label: string;
@@ -124,7 +121,7 @@ export class RenameVariableCommand implements Command {
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
/** Identifier-like, and not one of the common property keys (a variable "enabled" would shadow the switch). */
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
export function isValidVariableName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name);
}
+3 -11
View File
@@ -1,9 +1,6 @@
import type { PropertyBag } from '../model/Properties';
/**
* Port data types. `exec` carries control flow (Blueprint-style white arrows); the others
* carry values. `any` accepts every value type (variables, logging).
*/
/** `exec` carries control flow, the others values; `any` accepts every value type. */
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'any';
export interface PortRef {
@@ -12,9 +9,8 @@ export interface PortRef {
}
/**
* One node of a behavior graph. `type` names a NodeDef in the registry; `params` holds
* the node's own settings (e.g. which key) and inline values of unconnected data inputs,
* both keyed by the param / port key.
* `type` names a NodeDef in the registry; `params` holds both the node's own settings and the
* inline values of unconnected data inputs, keyed by param or port key.
*/
export interface GraphNode {
id: string;
@@ -24,7 +20,6 @@ export interface GraphNode {
params: PropertyBag;
}
/** A connection from an output port to an input port. */
export interface GraphEdge {
id: string;
from: PortRef;
@@ -49,17 +44,14 @@ export function edgesInto(graph: Graph, nodeId: string, port: string): GraphEdge
return graph.edges.filter((e) => e.to.node === nodeId && e.to.port === port);
}
/** Edges leaving one output port. */
export function edgesOutOf(graph: Graph, nodeId: string, port: string): GraphEdge[] {
return graph.edges.filter((e) => e.from.node === nodeId && e.from.port === port);
}
/** Every edge touching a node (used when removing it). */
export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] {
return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId);
}
/** Whether a value may flow from an output of type `from` into an input of type `to`. */
export function portsCompatible(from: PortType, to: PortType): boolean {
if (from === 'exec' || to === 'exec') return from === to;
return from === to || from === 'any' || to === 'any';
+5 -14
View File
@@ -10,21 +10,17 @@ import type { Value } from './values';
const MAX_STEPS_PER_FIRE = 1000;
export interface TraceListener {
/** An exec edge was followed. */
onEdge(edgeId: string): void;
/** A node ran (exec) or was evaluated (data). */
onNode(nodeId: string): void;
}
/**
* Runs one behavior graph for one instance. Blueprint semantics: an event node starts a
* *pulse*; the interpreter follows exec edges, and whenever a node needs a data input it
* pulls the value from the connected output – evaluating pure data nodes lazily and
* reading exec nodes' outputs from the pulse cache. Per-node state (timers) lives here,
* so every instance has its own.
* Runs one behavior graph for one instance. An event node starts a *pulse*; the interpreter follows
* exec edges and pulls data inputs from the connected output, evaluating pure data nodes lazily and
* reading exec nodes' outputs from the pulse cache.
*/
export class GraphInterpreter {
/** Per-node persistent state across pulses (timer accumulators etc.). */
/** Per-node state that persists across pulses (timer accumulators etc.). */
readonly nodeState = new Map<string, Record<string, number>>();
private outputCache = new Map<string, Record<string, Value>>();
private steps = 0;
@@ -32,15 +28,11 @@ export class GraphInterpreter {
constructor(readonly graph: Graph) {}
/** Nodes of one event type, in graph order. */
eventNodes(type: string): GraphNode[] {
return this.graph.nodes.filter((n) => n.type === type);
}
/**
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
*/
/** Fires every event node of `type` passing `filter`, seeding its outputs with `outputs`. */
fire(
type: string,
ctx: NodeContext,
@@ -60,7 +52,6 @@ export class GraphInterpreter {
return fired;
}
/** Follows the exec edges leaving `port` of `node`, running each target in order. */
private follow(node: GraphNode, port: string, ctx: NodeContext): void {
for (const edge of edgesOutOf(this.graph, node.id, port)) {
const target = this.node(edge.to.node);
+14 -27
View File
@@ -3,12 +3,11 @@ import { sumAlongChain } from '../model/walk';
import { enabledAlongChain } from '../properties/enabled';
/**
* What a graph sees of a running entity. The runtime's Entity satisfies this shape; unit
* tests use plain objects. Mutating `x`/`y`/`props` is how actions take effect.
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
* `x`/`y`/`props` is how actions take effect.
*/
export interface EntityHandle {
id: string;
/** Asset this entity was instantiated from (what `Is asset` compares against). */
assetId: string;
name: string;
/** Position local to `parent` (world position for top-level entities). */
@@ -16,11 +15,9 @@ export interface EntityHandle {
y: number;
z: number;
props: PropertyBag;
/** The entity this one is parented to; children move with their parent. */
parent: EntityHandle | null;
}
/** World position of a handle: its local position plus those of all ancestors. */
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
return sumAlongChain(
handle,
@@ -29,7 +26,7 @@ export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z
);
}
/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */
/** Whether a handle and every ancestor of it are switched on. */
export function isHandleEnabled(handle: EntityHandle): boolean {
return enabledAlongChain(
handle,
@@ -38,11 +35,10 @@ export function isHandleEnabled(handle: EntityHandle): boolean {
);
}
/** Where the mouse is (world units) and what it did this frame. */
/** Position in world units. */
export interface PointerState {
x: number;
y: number;
/** Whether the pointer is over the game view at all. */
inside: boolean;
down: boolean;
/** Went down / up during this frame. */
@@ -51,45 +47,36 @@ export interface PointerState {
}
/**
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
* by test doubles. Everything the graph does to the world goes through here, so the core
* stays free of runtime imports.
* Everything the graph does to the world goes through here, so the core stays free of runtime
* imports. Implemented by the runtime (BehaviorSystem) and by test doubles.
*/
export interface NodeContext {
/** The behavior entity itself. */
self: EntityHandle;
/** The behavior's parent object (the one the graph acts on), if any. */
/** The behavior's parent object – the one the graph acts on. */
owner: EntityHandle | null;
/** Mutable variable state of this behavior instance. */
variables: PropertyBag;
/** Seconds since the world started and since the last tick. */
time: number;
dt: number;
findByName(name: string): EntityHandle | null;
isKeyDown(key: string): boolean;
/** Mouse state of this frame, in world units. */
pointer: PointerState;
/**
* Whether a world point lies on `target` – on its colliders when it has any, otherwise on
* its own footprint or that of anything parented below it (so a button made of a container
* with a sprite and a label is hit as a whole).
* Whether a world point lies on `target`: on its colliders when it has any, otherwise on its own
* footprint or that of anything parented below it.
*/
hitTest(target: EntityHandle, x: number, y: number): boolean;
/**
* Whether any collider of `a` overlaps any collider of `b` right now. A collider entity
* stands for itself; any other entity stands for its direct collider children.
*/
/** A collider entity stands for itself; any other entity for its direct collider children. */
isTouching(a: EntityHandle, b: EntityHandle): boolean;
/**
* Moves `target` by (dx, dy) in local units, stopping its solid colliders at other solid
* colliders axis by axis (x first, then y) so it slides along walls. Reports which axes hit.
* Moves `target` by (dx, dy) in local units, stopping solid colliders at other solid ones axis by
* axis (x first, then y) so it slides along walls.
*/
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
log(message: string): void;
/**
* Places a new instance of an asset into the running scene and returns it. The position is
* local to `parent` (world when none). Returns null when the asset does not exist or cannot
* be placed (a scene). Spawned behaviors run their `On start` on the next tick.
* The position is local to `parent` (world when none); null when the asset does not exist or
* cannot be placed. A spawned behavior runs its `On start` on the next tick.
*/
spawn(
assetId: string,
+5 -12
View File
@@ -15,31 +15,25 @@ export interface PortDef {
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
export interface RunResult {
/** Values for the node's data outputs (cached for the rest of the pulse). */
/** Cached for the rest of the pulse. */
outputs?: Record<string, Value>;
/** Exec output(s) to continue along; omitted = the node ends the flow. */
next?: string | string[];
}
/**
* Describes one node type: its ports, its inline settings and how it runs. The editor
* palette, port rendering, connection validation and the inspector fields are all derived
* from this – adding a node type means adding one definition, no UI code.
*/
/** Palette, port rendering, connection validation and inspector fields are all derived from this. */
export interface NodeDef {
type: string;
label: string;
category: NodeCategory;
/** Shown in the palette and the inspector. */
description: string;
inputs: PortDef[];
outputs: PortDef[];
/** Node settings that are not ports (key name, operator, variable). */
params: PropertyDef[];
/**
* Executes the node. Pure data nodes (no exec pins) are evaluated on demand whenever an
* input downstream needs their value; exec nodes run when the flow reaches them.
* Event nodes have no `run`; the interpreter seeds their outputs when it fires them.
* Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes
* run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs.
*/
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
}
@@ -74,12 +68,11 @@ export function hasExecInput(def: NodeDef): boolean {
return def.inputs.some((p) => p.type === 'exec');
}
/** Data inputs that may carry an inline value when unconnected (numbers, strings, booleans, any). */
/** Data inputs that may carry an inline value while unconnected. */
export function inlineInputs(def: NodeDef): PortDef[] {
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object');
}
/** A PropertyDef for editing the inline value of a port. */
export function inlineInputDef(port: PortDef): PropertyDef {
switch (port.type) {
case 'boolean':
+2 -2
View File
@@ -28,8 +28,8 @@ export const Compare: NodeDef = {
},
],
run: (_ctx, inputs, params) => {
// The fallback matches the param's declared default, so a node whose setting is missing
// (an old document, a hand-written graph) behaves like a freshly created one.
// The fallback matches the param's declared default, so a node with no setting behaves
// like a freshly created one.
const compare = OPERATORS[String(params.op)] ?? OPERATORS['<']!;
return { outputs: { result: compare(asNumber(inputs.a), asNumber(inputs.b)) } };
},
+2 -2
View File
@@ -68,8 +68,8 @@ import { GetVariable } from './variable/GetVariable';
import { SetVariable } from './variable/SetVariable';
/**
* The built-in node set, one definition per file. Importing this module registers them;
* the palette sorts by category and label, so the order here is documentation only.
* Importing this module registers the built-in nodes. The palette sorts by category and label, so
* the order here is documentation only.
*/
export const BUILT_IN_NODES: readonly NodeDef[] = [
// events
+1 -8
View File
@@ -12,7 +12,6 @@ export const str = (key: string, label = key): PortDef => ({ key, label, type: '
export const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
export const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
/** The same port with an inline value it carries while unconnected. */
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
/** Outputs of the three collision events: the other body, its collider and the own collider. */
@@ -36,15 +35,11 @@ export const variableParam: PropertyDef = {
options: [],
};
/**
* A reference to an asset the node acts on (spawns, compares against). No `assetTypes`: any
* placeable asset is a valid answer, and restricting them would be guessing for the user.
*/
/** No `assetTypes`: any placeable asset is a valid answer here. */
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
// --- node factories --------------------------------------------------------------------------
/** A data node computing one number from `a` and `b`. */
export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef {
return {
type,
@@ -58,7 +53,6 @@ export function binaryMath(type: string, description: string, fn: (a: number, b:
};
}
/** A data node computing one number from `value`. */
export function unaryMath(type: string, description: string, fn: (value: number) => number): NodeDef {
return {
type,
@@ -72,7 +66,6 @@ export function unaryMath(type: string, description: string, fn: (value: number)
};
}
/** A data node combining the booleans `a` and `b`. */
export function binaryLogic(
type: string,
description: string,
+9 -44
View File
@@ -4,7 +4,6 @@ import type { AssetType } from './AssetTypes';
import type { PropertyBag } from './Properties';
import type { SceneObject } from './SceneObject';
/** Every asset has an id, a name, a type and a bag of typed properties (see PropertySchema). */
export interface AssetBase {
id: string;
type: AssetType;
@@ -20,58 +19,38 @@ export interface CameraAsset extends AssetBase {
type: 'camera';
}
/**
* A line of text drawn in the scene (titles, HUD readouts, buttons). Its content is the
* ordinary property `text`, so behaviors change it with `Set text` (or `Set property`).
*/
export interface TextAsset extends AssetBase {
type: 'text';
}
/** A scene is an asset that additionally owns the objects placed into it. */
export interface SceneAsset extends AssetBase {
type: 'scene';
objects: SceneObject[];
}
/**
* A behavior is logic: a node graph plus the variables it declares. Variables are ordinary
* properties – `variables` is the asset's own property schema, `props` holds their default
* values, and placed instances override them like any other property.
*/
/** `variables` is this asset's own property schema; `props` holds their default values. */
export interface BehaviorAsset extends AssetBase {
type: 'behavior';
variables: PropertyDef[];
graph: Graph;
}
/**
* A collider is a shape (box or circle) used for collision detection. Placed as a child of
* the object it belongs to (its "body"); its position is the offset from that body.
*/
/** Placed as a child of the object it belongs to; its position is the offset from that body. */
export interface ColliderAsset extends AssetBase {
type: 'collider';
}
/**
* A container is a reusable group of objects (a "prefab"): its `objects` are a template that
* is instantiated below the container whenever it is placed. Template positions are local
* to the container's origin – the centre of the group when it was created from a selection.
* Template ids are private to the asset; every placed copy gets fresh ids and remembers the
* template object it came from (`SceneObject.templateId`).
* `objects` is a template instantiated below the container whenever it is placed. Template
* positions are local to the container's origin, and every placed copy gets fresh ids and
* remembers the template object it came from (`SceneObject.templateId`).
*/
export interface ContainerAsset extends AssetBase {
type: 'container';
objects: SceneObject[];
}
/**
* One named rectangle of a texture, in image pixels – a frame of a sprite sheet.
*
* Its `id` is drawn from the same generator as asset ids and is unique across the document,
* which is what lets a sprite's `texture` property name either a whole texture or one frame
* of it (see `model/texture.ts`).
*/
/** `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`). */
export interface TextureFrame {
id: string;
name: string;
@@ -82,23 +61,9 @@ export interface TextureFrame {
}
/**
* An uploaded image. `file` names it in `data/textures/` and is content-addressed
* (`{sha256}.{ext}`), so the same picture uploaded twice is one file and a stored file can be
* cached forever – its name cannot ever come to mean different bytes.
*
* Like a scene's objects and a behavior's graph, this is intrinsic data rather than a
* property: nothing about it is editable or overridable per instance. What *is* a property
* is the reference to it – sprites carry `texture` (see PropertySchema), which is how
* instance overrides, "Apply to container" and `Set property` work on it for free.
*
* `frames` cuts the picture into named rectangles (the Texture tab draws them); a texture
* with no frames is simply used whole, which is what every single-image texture wants and
* what every document written before frames existed already says.
*
* `width`/`height` are the pixel size as uploaded, kept so a sprite can be sized to its
* picture without waiting for the image to load – and so frames can be validated and sliced
* before it arrives. An empty `file` means the upload failed or has not happened yet; such a
* texture draws as nothing.
* `file` names the image in `data/textures/` and is content-addressed (`{sha256}.{ext}`), so it
* can be cached forever; empty means the upload failed or has not happened yet. `width`/`height`
* are the size as uploaded, so frames can be sliced and validated before the image loads.
*/
export interface TextureAsset extends AssetBase {
type: 'texture';
+3 -10
View File
@@ -1,4 +1,3 @@
/** All asset types. Scenes and textures are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = [
'scene',
'camera',
@@ -12,7 +11,6 @@ export const ASSET_TYPES = [
export type AssetType = (typeof ASSET_TYPES)[number];
/** Display names of the asset types (menus, list hints). */
export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
scene: 'Scene',
camera: 'Camera',
@@ -25,9 +23,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
};
/**
* Types that can be placed into a scene. A texture is not one of them: it is picture data,
* not something with a position – a sprite wears it (see the `texture` property). Dropping a
* texture into the scene therefore creates a sprite, it does not place the texture itself.
* A texture is not placeable: it is picture data a sprite wears, so dropping one into the scene
* creates a sprite instead.
*/
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture'>;
@@ -39,15 +36,11 @@ export function isAssetType(value: unknown): value is AssetType {
return typeof value === 'string' && (ASSET_TYPES as readonly string[]).includes(value);
}
/**
* Types drawn as editor symbols (icon in the overlay layer) instead of as scene content.
* They exist in the scene for their position, not for their looks.
*/
/** Types drawn as an icon in the overlay layer instead of as scene content. */
export function isSymbolType(type: AssetType): boolean {
return type === 'camera' || type === 'behavior' || type === 'collider' || type === 'container';
}
/** Types whose colour is worth a swatch in the lists. */
export function hasColorSwatch(type: AssetType): boolean {
return type === 'sprite' || type === 'text';
}
+7 -24
View File
@@ -2,7 +2,7 @@ import { schemaForAsset } from '../properties/PropertySchema';
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
import type { SceneObject } from './SceneObject';
/** Written into every document. Reserved for format migrations – the loader does not branch on it yet. */
/** Reserved for format migrations – the loader does not branch on it yet. */
export const PROJECT_FORMAT_VERSION = 1;
export interface Project {
@@ -10,7 +10,7 @@ export interface Project {
id: string;
name: string;
assets: Asset[];
/** Scene shown in the viewport and instantiated on Play. Empty string = none. */
/** Empty string = none. */
activeSceneId: string;
}
@@ -22,7 +22,6 @@ export function findAsset(project: Project, assetId: string): Asset | undefined
return project.assets.find((a) => a.id === assetId);
}
/** The scene asset with that id – undefined for other asset types as well. */
export function findScene(project: Project, sceneId: string): SceneAsset | undefined {
const asset = findAsset(project, sceneId);
return asset && isSceneAsset(asset) ? asset : undefined;
@@ -33,9 +32,8 @@ export function listScenes(project: Project): SceneAsset[] {
}
/**
* Everything that holds placed objects. Container assets do so just like scenes: their
* `objects` are the template a placed container is instantiated from. Anything that walks
* "all placed objects" has to cover both, or it silently skips the templates.
* Everything that holds placed objects. A container's `objects` are a template, so anything
* walking "all placed objects" must cover both or it silently skips the templates.
*/
export type ObjectHolder = SceneAsset | ContainerAsset;
@@ -48,7 +46,6 @@ export interface SceneObjectRef {
object: SceneObject;
}
/** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */
export function objectsWhere(
project: Project,
predicate: (object: SceneObject) => boolean,
@@ -65,28 +62,14 @@ export function findUsages(project: Project, assetId: string): SceneObjectRef[]
return objectsWhere(project, (o) => o.assetId === assetId);
}
/** One place where an `asset`-kind property points at an asset. */
export type PropertyUsage =
| { on: 'asset'; asset: Asset; key: string }
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
/**
* Every property that references one of `ids`, on assets and on the overrides of placed
* objects.
*
* This is the other half of "where is this asset used": `findUsages` finds assets that were
* *placed*, which is meaningless for a texture – a texture is never placed, it is worn by
* sprites through their `texture` property. Deleting one therefore has to count both, or it
* reports "0 uses" for a picture that ten sprites are showing.
*
* Several ids rather than one because a texture is referenced by more than its own id: every
* frame cut from it is a reference to the same picture (see `textureRefIds`). Deleting the
* texture has to count the sprites wearing a frame of it too, or it under-reports exactly the
* sheets that were sliced – the ones with the most users.
*
* A reference is deliberately left dangling rather than cleared when its target goes: a
* dangling one resolves to "no picture" everywhere it is read, and it comes back by itself
* when the delete is undone.
* The other half of "where is this asset used": a texture is never placed, it is worn through a
* sprite's `texture` property, so counting uses needs this as well as `findUsages`. Several ids
* because every frame cut from a texture is a reference to the same picture (see `textureRefIds`).
*/
export function findPropertyUsages(project: Project, ids: string | readonly string[]): PropertyUsage[] {
const wanted = new Set((typeof ids === 'string' ? [ids] : ids).filter(Boolean));
+2 -8
View File
@@ -1,32 +1,26 @@
/** Colors are stored as 0xRRGGBB numbers. */
export type PropertyValue = number | string | boolean;
/** Sparse map of property key → value (asset properties or scene-object overrides). */
export type PropertyBag = Record<string, PropertyValue>;
/** Writes `value` into the bag, or removes the key when the value is undefined (an undo of "no value"). */
/** `undefined` removes the key, which is how an undo of "no value" works. */
export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void {
if (value === undefined) delete bag[key];
else bag[key] = value;
}
/** A point in world or screen space. */
export interface Vec2 {
x: number;
y: number;
}
/**
* Where a scene object sits: `x`/`y` in world units, `z` is the draw order. Higher `z` is
* drawn on top; objects with equal `z` keep their hierarchy order.
*/
/** `x`/`y` in world units; `z` is the draw order – higher is on top, ties keep hierarchy order. */
export interface Position extends Vec2 {
z: number;
}
export type Axis = keyof Position;
/** A full position from a 2D point (`z` = 0) or a position (copied). */
export function toPosition(point: Vec2 | Position): Position {
return { x: point.x, y: point.y, z: 'z' in point ? point.z : 0 };
}
+10 -18
View File
@@ -1,42 +1,34 @@
import { type Position, type PropertyBag, toPosition, type Vec2 } from './Properties';
/**
* An asset placed inside a scene. It links to the asset by id, owns a position
* (assets have none) and may override individual asset properties. Overrides are
* sparse: only keys the user changed are stored; everything else follows the asset.
* An asset placed inside a scene. Overrides are sparse: only keys the user changed are stored,
* everything else follows the asset.
*/
export interface SceneObject {
id: string;
assetId: string;
/** Optional display name; falls back to the asset name when empty. */
/** Falls back to the asset name when empty. */
name: string;
/**
* Position **relative to the parent** (world position when there is no parent) plus the
* draw order `z`, which is relative as well. See `hierarchy.ts` for world coordinates.
*/
/** Relative to the parent, `z` included; see `hierarchy.ts` for world coordinates. */
position: Position;
/**
* Editor-only shift of where this object (and everything below it) is *drawn* in the scene
* view – never part of the game. It exists to pull objects apart that sit on top of each
* other, such as the title/win/lose screens of a game, without moving them for real.
* Editor-only shift of where this object and everything below it is drawn – never part of the
* game. It pulls apart objects that sit on top of each other without moving them for real.
*/
editorOffset: Vec2;
overrides: PropertyBag;
/**
* Parent object of the same scene, empty string = top level. Children move with their
* parent and are removed with it. Behaviors treat their parent as the "owner" the graph
* acts on.
* Parent object of the same scene, empty string = top level. Children move with their parent and
* are removed with it; behaviors treat their parent as the "owner" the graph acts on.
*/
parentId: string;
/**
* For objects that belong to a placed container: the id of the template object (inside
* the container asset) this one was instantiated from. Empty for everything else. This is
* the link that lets template changes reach the instances later on.
* The template object inside the container asset this one was instantiated from, empty for
* everything else – the link that lets template changes reach the instances later on.
*/
templateId: string;
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
export function createSceneObject(
id: string,
assetId: string,
+8 -15
View File
@@ -4,7 +4,6 @@ import { chainOf, type ObjectTree } from './hierarchy';
import { findAsset, objectsWhere, type Project, type SceneObjectRef } from './Project';
import type { SceneObject } from './SceneObject';
/** What a placed container's content object is linked to: the container and its template object. */
export interface TemplateLink {
containerObject: SceneObject;
asset: ContainerAsset;
@@ -12,9 +11,8 @@ export interface TemplateLink {
}
/**
* For an object that belongs to a placed container: the nearest container ancestor whose
* asset holds the template this object was instantiated from. Undefined for everything else
* (no template id, or the link is stale).
* The nearest container ancestor whose asset holds the template `object` was instantiated from;
* undefined when it has no template id or the link is stale.
*/
export function templateLinkOf(
project: Project,
@@ -32,23 +30,19 @@ export function templateLinkOf(
}
/**
* Every placed copy of a template object (template ids are unique per project). Template
* objects themselves never carry a template id, so a template is never mistaken for a copy
* of itself even though the search covers container assets too.
* Every placed copy of a template object. Template objects themselves never carry a template id, so
* one is never mistaken for a copy of itself even though the search covers container assets too.
*/
export function instancesOf(project: Project, templateId: string): SceneObjectRef[] {
return objectsWhere(project, (o) => o.templateId === templateId);
}
/** Nesting depth at which a container inside a container inside … stops being expanded. */
const MAX_NESTING = 8;
/**
* The scene objects a placed container consists of: one fresh object per template object,
* parented below `containerObjectId` (or below the copy of their template parent), each
* remembering its template id. A template object that is itself a container is expanded in
* turn – unless the template already spells its content out (that is what a container built
* from a selection containing another container does).
* One fresh object per template object, parented below `containerObjectId` or below the copy of its
* template parent. A template object that is itself a container is expanded in turn, unless the
* template already spells its content out.
*/
export function instantiateContainer(
project: Project,
@@ -68,8 +62,7 @@ export function instantiateContainer(
assetId: template.assetId,
name: template.name,
position: { ...template.position },
// The editor offset stays with the instance, never with the template: it is a view tweak
// on one placed object, not something every copy of the container should inherit.
// The editor offset is a tweak on one placed object, not something copies should inherit.
editorOffset: { x: 0, y: 0 },
overrides: { ...template.overrides },
parentId,
+14 -30
View File
@@ -4,13 +4,8 @@ import type { SceneObject } from './SceneObject';
import { descendantsBelow, parentChain, sumAlongChain } from './walk';
/**
* Parent/child structure of a scene. `scene.objects` stays a flat list (storage order =
* creation order); the tree is implied by `SceneObject.parentId`. Positions are local to
* the parent, so the world position is the sum along the chain – there is no rotation or
* scale yet, which keeps this trivially invertible.
*
* The helpers work on anything that holds a flat object list – a scene, or the template
* inside a container asset.
* `objects` stays a flat list in creation order; the tree is implied by `SceneObject.parentId`.
* Anything holding such a list fits – a scene, or the template inside a container asset.
*/
export interface ObjectTree {
objects: SceneObject[];
@@ -24,12 +19,11 @@ function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
return tree.objects.filter((o) => o.parentId === parentId);
}
/** The parent of an object, or undefined for a top-level one (and for a dangling link). */
function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined {
return object.parentId ? findObject(tree, object.parentId) : undefined;
}
/** All objects below `id`, depth-first, parents before children. */
/** Depth-first, parents before children. */
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
return descendantsBelow(
id,
@@ -38,7 +32,6 @@ export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
);
}
/** The ids of `rootIds` and of everything below them – what group operations act on. */
export function subtreeIds(tree: ObjectTree, rootIds: Iterable<string>): Set<string> {
const ids = new Set<string>();
for (const rootId of rootIds) {
@@ -48,18 +41,18 @@ export function subtreeIds(tree: ObjectTree, rootIds: Iterable<string>): Set<str
return ids;
}
/** Takes the subtrees of `rootIds` out of the tree; returns them with their former indices (see `restoreAt`). */
/** Returns the removed objects with their former indices (see `restoreAt`). */
export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Removed<SceneObject>[] {
const ids = subtreeIds(tree, rootIds);
return removeWhere(tree.objects, (o) => ids.has(o.id));
}
/** An object and every parent above it, nearest first. Stops at a cycle (defensive). */
/** An object and every parent above it, nearest first. */
export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
return [...parentChain(object, (o) => parentOf(tree, o))];
}
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
/** Nearest parent first. */
export function ancestorIdsOf(tree: ObjectTree, id: string): string[] {
const object = findObject(tree, id);
return object
@@ -69,16 +62,12 @@ export function ancestorIdsOf(tree: ObjectTree, id: string): string[] {
: [];
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors. */
export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean {
const object = findObject(tree, objectId);
return object !== undefined && chainOf(tree, object).some((o) => o.id === ancestorId);
}
/**
* Of the given ids, those that have no ancestor among the ids themselves – the tops of the
* selected subtrees. Whole subtrees are what group operations (move, remove, group) act on.
*/
/** Of the given ids, those with no ancestor among the ids themselves. */
export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[] {
const set = new Set(ids);
return ids.filter((id) => !ancestorIdsOf(tree, id).some((ancestorId) => set.has(ancestorId)));
@@ -95,15 +84,13 @@ function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOff
);
}
/** Position of an object in world units (all local positions along the chain summed up). */
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
return positionAlongChain(tree, object, false);
}
/**
* Where the scene view draws an object: its world position plus the editor offsets along the
* chain, so shifting a parent shifts everything below it. This is a view aid only – nothing
* in the game, in collisions or in the graph ever sees it (they all use `worldPosition`).
* World position plus the editor offsets along the chain. A view aid only – the game, collisions
* and the graph all use `worldPosition`.
*/
export function editorPosition(tree: ObjectTree, object: SceneObject): Position {
return positionAlongChain(tree, object, true);
@@ -123,8 +110,8 @@ export interface TreeEntry {
}
/**
* Objects in tree order: top-level objects in storage order, each followed by its subtree.
* This is the order the hierarchy panel shows and the tie-break order for equal `z`.
* Top-level objects in storage order, each followed by its subtree – the order the hierarchy panel
* shows and the tie-break order for equal `z`.
*/
export function treeOrder(tree: ObjectTree): TreeEntry[] {
const result: TreeEntry[] = [];
@@ -137,7 +124,7 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
}
};
visit('', 0);
// Objects whose parent is missing (should not happen after loading) are listed at the top level.
// Objects whose parent is missing are listed at the top level rather than dropped.
for (const object of tree.objects) {
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object))
result.push({ object, depth: 0 });
@@ -146,15 +133,12 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
}
export interface VisibleEntry extends TreeEntry {
/** Whether the object has children at all – only those rows offer an expand/collapse chevron. */
hasChildren: boolean;
}
/**
* Tree order with the subtrees of collapsed objects left out: a row is shown when *every*
* ancestor is expanded. Expansion is a view state (which ids are open), never stored in the
* project – anything not listed counts as collapsed, so a freshly loaded scene shows only its
* top level.
* Tree order with the subtrees of collapsed objects left out. Anything not in `expanded` counts as
* collapsed, so a freshly loaded scene shows only its top level.
*/
export function visibleTreeOrder(tree: ObjectTree, expanded: ReadonlySet<string>): VisibleEntry[] {
const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean));
+15 -60
View File
@@ -2,18 +2,13 @@ import type { TextureAsset, TextureFrame } from './Asset';
import type { PropertyBag } from './Properties';
import { findAsset, type Project } from './Project';
/**
* A stored image file name: `{sha256}.{png|jpg|webp|gif}`. Mirrors `TextureStore::isValidName`
* on the server – the name is derived from the bytes there, so anything else in a document
* was hand-written and must not be turned into a request.
*/
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
const FILE_PATTERN = /^[a-f0-9]{64}\.(png|jpg|webp|gif)$/;
export function isTextureFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
/** A rectangle in image pixels: the part of a texture a frame cuts out. */
export interface FrameRect {
x: number;
y: number;
@@ -21,40 +16,29 @@ export interface FrameRect {
h: number;
}
/** Smallest frame that is still a picture rather than a slip of the mouse. */
export const MIN_FRAME_SIZE = 1;
/**
* A picture ready to be drawn: which file, whether to smooth it when scaled, and which part
* of it. `rect` is null for the whole image – not a rectangle covering everything, because
* the renderer can then skip the texture matrix entirely (Pixi fits an unmatrixed fill to the
* shape by itself) and because "the whole picture" survives the image being re-uploaded at a
* different size.
* `rect` is null for the whole image rather than a rectangle covering it: the renderer can then
* skip the texture matrix (Pixi fits an unmatrixed fill to the shape) and the meaning survives
* the image being re-uploaded at a different size.
*/
export interface TextureRef {
file: string;
/** False for pixel art, where interpolation is the difference between crisp and mush. */
smooth: boolean;
/** Sub-rectangle in image pixels, or null for the whole image. */
rect: FrameRect | null;
/** Size of the whole image in pixels – the reference frame `rect` is expressed in. */
sourceWidth: number;
sourceHeight: number;
}
/** A texture asset together with one of its frames. */
export interface FrameRef {
asset: TextureAsset;
frame: TextureFrame;
}
/**
* The frame with that id, wherever in the project it lives.
*
* Frame ids come from the same generator as asset ids and are unique across the document,
* which is what lets a sprite's `texture` property hold either kind: an asset id means the
* whole picture, a frame id means one cut-out of it. One property, one picker, and every
* sprite written before frames existed keeps meaning exactly what it meant.
* Frame ids share the generator with asset ids and are unique across the document, which lets a
* sprite's `texture` property hold either: an asset id means the whole picture, a frame id a cut-out.
*/
export function findFrame(project: Project, frameId: string): FrameRef | null {
if (!frameId) return null;
@@ -71,12 +55,6 @@ export function textureRefIds(asset: TextureAsset): string[] {
return [asset.id, ...asset.frames.map((f) => f.id)];
}
/**
* The picture a bag of properties points at, or null when it points at nothing usable –
* no reference, a dangling one (the texture or the frame was deleted), or one whose upload
* never finished. Callers draw their plain colour in that case, which is what a sprite
* looked like before textures existed.
*/
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
const id = String(props.texture ?? '');
if (!id) return null;
@@ -98,12 +76,8 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
}
/**
* A rectangle rounded to whole pixels and forced inside the image.
*
* Frames are pixel coordinates into a bitmap, so a fractional one has no meaning – it would
* sample half of a neighbouring pixel along every edge, which on a sprite sheet is the
* classic seam of foreign colour. Dragging produces fractions constantly (the view is
* zoomed), so this is applied on the way into the model rather than trusted from callers.
* Rounds to whole pixels and forces the rectangle inside the image: a fractional frame samples
* half of a neighbouring pixel along every edge, the classic sprite-sheet seam.
*/
export function normalizeFrameRect(rect: FrameRect, imageWidth: number, imageHeight: number): FrameRect {
// An image of unknown size (upload still running) cannot constrain anything; only round.
@@ -121,14 +95,13 @@ function clampInt(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
/** How a sheet is cut into a regular grid – the shape of nearly every sprite sheet. */
export interface SliceOptions {
columns: number;
rows: number;
/** Pixels skipped at the left/top edge of the sheet before the first cell. */
offsetX: number;
offsetY: number;
/** Pixels between neighbouring cells (sheets exported with padding). */
/** Pixels between neighbouring cells. */
spacingX: number;
spacingY: number;
}
@@ -143,20 +116,13 @@ export const DEFAULT_SLICE: SliceOptions = {
};
/**
* The rectangles a grid cuts a sheet into, in reading order (left to right, top to bottom) –
* which is the order sprite sheets are authored in and therefore the order an animation will
* want them.
*
* Cell size is derived from the image rather than asked for: everyone knows how many tiles
* their sheet has, almost nobody knows the pixel size off-hand, and deriving it cannot
* disagree with the picture. A cell that would reach past the image is dropped instead of
* clipped – a clipped one looks like a frame and is not one.
* The rectangles a grid cuts a sheet into, in reading order. Cell size is derived from the image;
* a cell that would reach past the edge is dropped rather than clipped.
*/
export function sliceFrames(imageWidth: number, imageHeight: number, options: SliceOptions): FrameRect[] {
const columns = Math.floor(options.columns);
const rows = Math.floor(options.rows);
// NaN has to be rejected explicitly: every comparison against it is false, so a NaN column
// count would pass `columns < 1` and only produce nothing by accident of the loop condition.
// NaN passes `columns < 1` (every comparison against it is false), so reject it explicitly.
if (!Number.isFinite(columns) || !Number.isFinite(rows)) return [];
if (!Object.values(options).every((value) => Number.isFinite(value))) return [];
if (columns < 1 || rows < 1 || imageWidth <= 0 || imageHeight <= 0) return [];
@@ -175,7 +141,6 @@ export function sliceFrames(imageWidth: number, imageHeight: number, options: Sl
return rects;
}
/** The rectangle part of a frame, and the way to write one back – the only glue between the two. */
export function frameRect(frame: TextureFrame): FrameRect {
return { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
}
@@ -187,14 +152,7 @@ export function setFrameRect(frame: TextureFrame, rect: FrameRect): void {
frame.h = rect.h;
}
/**
* A frame name that is free within its texture: `base`, else "base 2", "base 3" … – the same
* shape `nextAssetName` produces, because a name the user reads should not be numbered by two
* conventions in one document.
*
* Names matter more here than they look: they are what the sprite's picker shows, so two
* frames called "idle" would be one choice repeated rather than two.
*/
/** A name free within the texture: `base`, else "base 2", "base 3" … – the shape `nextAssetName` produces. */
export function uniqueFrameName(asset: TextureAsset, base: string, ignoreId = ''): string {
const taken = takenFrameNames(asset, ignoreId);
const stem = base || 'Frame';
@@ -202,11 +160,8 @@ export function uniqueFrameName(asset: TextureAsset, base: string, ignoreId = ''
}
/**
* `count` free names numbered from 1 – what slicing a sheet wants, where the number is the
* cell's position and "Frame 1" next to "Frame 2" is the whole point.
*
* Numbers already taken are skipped rather than suffixed again: re-slicing a sheet that keeps
* its old frames must not produce "Frame 2 1".
* `count` free names numbered from 1. Taken numbers are skipped rather than suffixed again, so
* re-slicing a sheet that keeps its old frames cannot produce "Frame 2 1".
*/
export function nextFrameNames(asset: TextureAsset, count: number, base = 'Frame'): string[] {
const taken = takenFrameNames(asset);
+5 -20
View File
@@ -1,15 +1,8 @@
/**
* Walking a parent/child structure, generic over the shape. Two hierarchies exist side by
* side – scene objects (`parentId` + lookup in a flat list) and runtime entities (direct
* `parent` pointer) – and both walk the same way. Callers pass in how to get from an item to
* its parent (or children); everything else lives here, so the cycle guard exists once.
*/
import type { Position } from './Properties';
/**
* `start` and every ancestor above it, nearest first. An item that was already visited ends
* the walk instead of looping forever: a cyclic parent chain is always a bug (loading repairs
* one, see `store/serialize`), but no walk at runtime may hang because of it.
* `start` and every ancestor above it, nearest first. A revisited item ends the walk: a cyclic
* parent chain is a bug (loading repairs one, see `store/serialize`) but must not hang a walk.
*/
export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | undefined): Generator<T> {
const seen = new Set<T>();
@@ -23,18 +16,14 @@ export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | unde
}
}
/** Whether the parents of `start` lead into a cycle instead of up to a root. */
export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | undefined): boolean {
let last = start;
for (const item of parentChain(start, parentOf)) last = item;
// The walk stops either above a root or at an already visited item – only the latter still has a parent.
// The walk stops above a root or at an already visited item; only the latter still has a parent.
return Boolean(parentOf(last));
}
/**
* The local positions of `start` and all its ancestors summed up – the world position, as
* long as the hierarchy has no rotation or scale (it has none).
*/
/** Summing local positions gives the world position only because the hierarchy has no rotation or scale. */
export function sumAlongChain<T>(
start: T,
parentOf: (item: T) => T | null | undefined,
@@ -50,11 +39,7 @@ export function sumAlongChain<T>(
return total;
}
/**
* Everything below `rootKey`, depth-first, parents before children. `keyOf` turns a found item
* back into the key its own children are looked up by, which is what lets ids (scene objects)
* and object identity (entities) use the same walk.
*/
/** Everything below `rootKey`, depth-first, parents before children. */
export function descendantsBelow<T, K>(
rootKey: K,
childrenOf: (key: K) => readonly T[],
+14 -52
View File
@@ -2,16 +2,11 @@ import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
/**
* Declarative description of one editable property. The inspector is generated from
* these entries, so adding a property to a type is a single line in a schema below.
*/
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
/**
* `decimals` is the number of places the field displays; it belongs only on properties that
* are integral by their meaning (counts, indices). Geometry does not qualify: dragging in the
* viewport puts fractions into a position or a size, and a field that hid them would commit
* the rounded number back into the project.
* `decimals` belongs only on properties that are integral by meaning (counts, indices), not on
* geometry: dragging produces fractions, and a field that hid them would commit the rounded value.
*/
| {
key: string;
@@ -26,40 +21,28 @@ export type PropertyDef =
| { key: string; label: string; kind: 'text'; default: string }
| { key: string; label: string; kind: 'color'; default: number }
| { key: string; label: string; kind: 'boolean'; default: boolean }
/** One of a fixed set of string values, shown as a dropdown. */
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
| { key: string; label: string; kind: 'key'; default: string }
/**
* A reference to another asset, stored as its id (`''` = none) and picked from a
* searchable dropdown. `assetTypes` limits what the picker offers; without it every
* placeable asset is offered, which is what graph nodes acting on an object want.
*
* The id is not validated on load – a reference can dangle, exactly like a graph node's
* asset parameter, and the places that read one have to cope with that.
* An asset id (`''` = none); without `assetTypes` the picker offers every placeable asset.
* Ids are not validated on load, so a reference can dangle and readers must cope.
*/
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
/**
* A picture: the id of a texture asset (the whole image) or of one of its frames. Its own
* kind rather than an `asset` reference with `assetTypes: ['texture']`, because a frame is
* not an asset – it is a rectangle inside one, and the picker has to offer both.
*
* Like an asset reference the id is not validated on load and may dangle; everything that
* reads one falls back to drawing no picture.
* The id of a texture asset or of one of its frames. Its own kind rather than an `asset`
* reference, because a frame is not an asset and the picker has to offer both. May dangle.
*/
| { key: string; label: string; kind: 'texture'; default: string };
export type PropertySchema = readonly PropertyDef[];
/** Kinds a behavior variable may have (what the node graph can compute with). */
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
* Properties every *placeable* asset has, whatever its type. `enabled` is the master switch:
* a disabled object is not drawn, does not collide and its behavior does not run – and the
* same goes for everything parented below it. Assets default to on, so switching a single
* instance off is an ordinary override. Scenes have no such switch (they are not placed).
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
* behavior does not run – and the same goes for everything parented below it.
*/
export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
@@ -67,12 +50,11 @@ export const COMMON_PROPS: PropertySchema = [
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
/** Whether a key belongs to the common properties (the inspector groups those separately). */
export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key);
}
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
camera: [
@@ -83,20 +65,11 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
sprite: [
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
/*
* The colour of the sprite: the fill of the box while it has no picture, and a multiplier
* on the picture once it has one. White therefore means "no tint" – which is why it is the
* default. A coloured default would silently stain every texture the moment it is applied,
* and the first thing anyone would do is set it back to white.
*
* Tinting is worth keeping rather than suppressing for textured sprites: it is how one
* white coin picture becomes ten differently coloured coins, per instance, for free.
*/
// The fill while there is no picture, and a tint multiplier once there is one: white = no tint.
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
],
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property:
// only how it is sampled is worth deciding, and that decision is the whole schema.
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
texture: [
{
key: 'filter',
@@ -125,7 +98,6 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
// Triggers report collisions but never block a colliding `Move`; solid colliders do both.
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
],
// A container is pure structure: its content is the template, not a property.
@@ -148,11 +120,6 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
};
/**
* Type schema with the common properties in front – the effective schema of an asset type.
* Only placeable types get them: `enabled` switches off a placed object, and a scene or a
* texture is never placed, so the switch would have nothing to act on.
*/
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type,
@@ -160,17 +127,15 @@ const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
]),
) as Record<AssetType, PropertySchema>;
/** Schema shared by every asset of a type (common properties first). */
export function schemaFor(type: AssetType): PropertySchema {
return FULL_SCHEMAS[type];
}
/** The effective schema of one asset: the type schema, plus declared variables for behaviors. */
/** The type schema, or for behaviors their declared variables. */
export function schemaForAsset(asset: Asset): PropertySchema {
return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type];
}
/** The definition of `key` in the effective schema of `asset`, if it has one. */
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
return schemaForAsset(asset).find((d) => d.key === key);
}
@@ -186,7 +151,6 @@ export function defaultsOf(schema: PropertySchema): PropertyBag {
return bag;
}
/** Builds a variable definition of the given kind with the kind's natural default. */
export function variableDef(key: string, kind: VariableKind, defaultValue?: PropertyValue): PropertyDef {
switch (kind) {
case 'number':
@@ -198,7 +162,7 @@ export function variableDef(key: string, kind: VariableKind, defaultValue?: Prop
}
}
/** Clamp / coerce a raw value so it satisfies the definition. */
/** Clamps / coerces a raw value so it satisfies the definition. */
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
switch (def.kind) {
case 'number': {
@@ -214,8 +178,6 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
}
case 'boolean':
return Boolean(value);
// An asset or texture reference is an id like the other two are plain strings; whether it
// still resolves is not decidable from a single property definition.
case 'text':
case 'key':
case 'asset':
+4 -12
View File
@@ -2,16 +2,11 @@ import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { findAsset, type Project } from '../model/Project';
/** Shown for an `asset` property that references nothing. */
export const NO_ASSET_LABEL = 'None';
/**
* How an `asset` reference reads wherever it is displayed: the referenced asset's name, "None"
* for the empty id, and `<id>?` when the id no longer resolves.
*
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
* only trace left of what was meant, and blanking it would make a deleted asset look like a
* field that was never filled in. `project` may be null while a view has no project yet.
* The asset's name, "None" for the empty id, `<id>?` when it no longer resolves – a dangling
* reference shows its id rather than passing for an empty field.
*/
export function assetRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_ASSET_LABEL;
@@ -19,10 +14,8 @@ export function assetRefLabel(project: Project | null, id: string): string {
}
/**
* The assets an `asset` property offers, in menu order. `assetTypes` restricts them to those
* types; without it every *placeable* asset is offered, which is what a graph node acting on
* an object means by "asset". Sorted by name (then type) so the order does not depend on when
* the assets happened to be created.
* The assets an `asset` property offers, in menu order; without `assetTypes` every placeable one.
* Sorted by name so the order does not depend on creation time.
*/
export function assetRefChoices(project: Project, assetTypes?: readonly AssetType[]): Asset[] {
const allowed = (asset: Asset): boolean =>
@@ -32,7 +25,6 @@ export function assetRefChoices(project: Project, assetTypes?: readonly AssetTyp
.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
}
/** Menu entry for one candidate – the type disambiguates assets that share a name. */
export function assetChoiceLabel(asset: Asset): string {
return `${asset.name} (${asset.type})`;
}
+7 -10
View File
@@ -1,7 +1,6 @@
/**
* Footprints in world units – how much room an object takes up. Shared by the viewport
* (culling, selection gizmos), by pointer hit tests in the running game and by "Add
* collider", so all three agree on how big a thing is.
* Footprints in world units, shared by the viewport, by pointer hit tests in the running game and by
* "Add collider", so all three agree on how big a thing is.
*/
import type { AssetType } from '../model/AssetTypes';
import type { PropertyBag, Vec2 } from '../model/Properties';
@@ -11,16 +10,15 @@ import { defaultsFor } from './PropertySchema';
const TEXT_DEFAULTS = defaultsFor('text');
/** World-unit size of the behavior symbol (a diamond). */
/** Symbol sizes in world units. */
export const BEHAVIOR_SYMBOL = 28;
/** World-unit size of the container symbol (a bracketed square). */
export const CONTAINER_SYMBOL = 24;
/** Rough width of one character relative to the font size (the UI font is a plain sans). */
/** Rough width of one character relative to the font size, for a plain sans. */
const CHAR_WIDTH = 0.55;
const LINE_HEIGHT = 1.25;
/** Size a text object covers, estimated from its content and font size. */
/** Estimated from the content and font size, without measuring the text. */
export function textSize(props: PropertyBag): { w: number; h: number } {
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n');
@@ -31,13 +29,13 @@ export function textSize(props: PropertyBag): { w: number; h: number } {
};
}
/** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */
/** 0 = left edge on the position, 0.5 = centred, 1 = right. */
export function textAnchor(props: PropertyBag): number {
const align = String(props.align ?? TEXT_DEFAULTS.align);
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
}
/** Footprint of an object of the given type, centred on its position (text: see `textAnchor`). */
/** Centred on the object's position; text is anchored instead (see `textAnchor`). */
export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h: number } {
switch (type) {
case 'sprite':
@@ -55,7 +53,6 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
}
}
/** Whether a world point lies inside the footprint of an object placed at `centre`. */
export function footprintContains(type: AssetType, props: PropertyBag, centre: Vec2, point: Vec2): boolean {
const { w, h } = footprintOf(type, props);
const offset = type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
+2 -2
View File
@@ -12,8 +12,8 @@ export interface WorldRect {
}
/**
* Size of the world region a camera shows, in world units: its view size divided by its
* zoom. This is the frame drawn in edit mode and the only region rendered in play mode.
* The world region a camera shows: its view size divided by its zoom. Drawn as a frame in edit mode,
* and the only region rendered in play mode.
*/
export function cameraFrame(props: PropertyBag): { w: number; h: number } {
const zoom = Math.max(0.01, Number(props.zoom ?? DEFAULTS.zoom));
+8 -13
View File
@@ -1,6 +1,6 @@
/**
* Collider geometry in world units. Pure functions shared by the editor (drawing, gizmos)
* and the runtime (CollisionSystem), so both agree on what "overlaps" means.
* Collider geometry in world units, shared by the editor and the runtime so both agree on what
* "overlaps" means.
*/
import type { PropertyBag, Vec2 } from '../model/Properties';
import { defaultsFor } from './PropertySchema';
@@ -17,7 +17,7 @@ type Circle = Extract<ColliderShape, { kind: 'circle' }>;
/** A movement axis in the plane (`z` is draw order, not geometry). */
export type PlaneAxis = 'x' | 'y';
/** The shape described by collider properties, centred on `centre` (a world position). */
/** Centred on `centre`, a world position. */
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
if (props.shape === 'circle')
return {
@@ -40,20 +40,18 @@ export function isTrigger(props: PropertyBag): boolean {
return Boolean(props.isTrigger ?? DEFAULTS.isTrigger);
}
/** Footprint of a collider shape (used for culling and the selection gizmo). */
export function colliderSize(props: PropertyBag): { w: number; h: number } {
const shape = colliderShape(props, { x: 0, y: 0 });
return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h };
}
/** Whether a point lies inside a shape (used for pointer hit tests). */
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle')
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
}
/** Whether two shapes overlap. Shapes that merely touch along an edge do not overlap. */
/** Shapes that merely touch along an edge do not count as overlapping. */
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
if (a.kind === 'box' && b.kind === 'box') {
return Math.abs(a.x - b.x) * 2 < a.w + b.w && Math.abs(a.y - b.y) * 2 < a.h + b.h;
@@ -70,9 +68,8 @@ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
}
/**
* The centre distance along `axis` at which the two shapes exactly touch, given their
* current offset on the other axis. Only meaningful when the shapes overlap on that other
* axis (otherwise they cannot collide by moving along `axis`); returns 0 when they do not.
* The centre distance along `axis` at which the shapes exactly touch, given their current offset on
* the other axis; 0 when they do not overlap on that other axis and so cannot collide along `axis`.
*/
export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAxis): number {
const other: PlaneAxis = axis === 'x' ? 'y' : 'x';
@@ -95,9 +92,8 @@ export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAx
}
/**
* How far `mover` has to be pushed back against its travel direction `dir` (±1) along
* `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when
* `mover` approached from the far side, i.e. it did not overlap before its move.
* How far `mover` has to be pushed back against its travel direction `dir` (±1) so it just touches
* `obstacle`. Only valid when `mover` did not already overlap before its move.
*/
export function axisCorrection(
mover: ColliderShape,
@@ -109,7 +105,6 @@ export function axisCorrection(
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
}
/** The mixed pair (one box, one circle) sorted by kind. */
function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } {
return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a };
}
+3 -8
View File
@@ -1,8 +1,6 @@
/**
* The `enabled` switch (see `COMMON_PROPS`). Disabling an object takes it out of the running
* game completely – not drawn, no collisions, no behavior ticks – and the same holds for its
* children, because "enabled" is only true when the whole chain up to the root is on. That is
* what makes a container usable as a screen: switch it on to show it, off to hide it.
* The `enabled` switch (see `COMMON_PROPS`). A disabled object is not drawn, does not collide and
* runs no behavior; "enabled" is only true when the whole chain up to the root is on.
*/
import type { PropertyBag } from '../model/Properties';
import { parentChain } from '../model/walk';
@@ -12,10 +10,7 @@ function propsEnabled(props: PropertyBag): boolean {
return props.enabled !== false;
}
/**
* Whether `start` and every ancestor of it are switched on. Generic over the shape so the
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
/** Generic over the shape so scene objects, draw items and runtime entities share one rule. */
export function enabledAlongChain<T>(
start: T,
propsOf: (item: T) => PropertyBag,
+8 -19
View File
@@ -4,8 +4,8 @@ import type { SceneObject } from '../model/SceneObject';
import { propDef, schemaForAsset } from './PropertySchema';
/**
* Effective property values of an asset: schema defaults filled with the asset's own values.
* Keys unknown to the schema are ignored, so stale data never leaks into the editor.
* Schema defaults filled with the asset's own values. Keys unknown to the schema are ignored, so
* stale data never leaks into the editor.
*/
export function resolveAssetProps(asset: Asset): PropertyBag {
const bag: PropertyBag = {};
@@ -16,10 +16,7 @@ export function resolveAssetProps(asset: Asset): PropertyBag {
return bag;
}
/**
* Effective property values of a scene object: asset values with the object's sparse
* overrides applied on top. This is the ONLY place where overrides are merged.
*/
/** The only place where overrides are merged onto asset values. */
export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag {
const bag = resolveAssetProps(asset);
for (const def of schemaForAsset(asset)) {
@@ -33,10 +30,7 @@ export function isOverridden(object: SceneObject, key: string): boolean {
return object.overrides[key] !== undefined;
}
/**
* Whether the asset's own value for `key` differs from the schema default – the asset-level
* counterpart of an override. The inspector highlights such values and offers a reset.
*/
/** Whether the asset's own value differs from the schema default – the asset-level override. */
export function isModified(asset: Asset, key: string): boolean {
const def = propDef(asset, key);
if (!def) return false;
@@ -44,25 +38,20 @@ export function isModified(asset: Asset, key: string): boolean {
return own !== undefined && own !== def.default;
}
/** Value the object would have without its override for `key` (the asset's value). */
/** The value the object would have without its override for `key`. */
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
return resolveAssetProps(asset)[key];
}
/**
* Whether the object's value for `key` really differs from the asset's – an override that
* happens to carry the asset's own value is none. The inspector offers "reset"/"apply" only
* where they would change something, so this is what decides whether the buttons appear.
*/
/** An override that happens to carry the asset's own value does not count as a deviation. */
export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean {
const own = object.overrides[key];
return own !== undefined && own !== inheritedValue(asset, key);
}
/**
* Whether the object's override for `key` differs from the template it was instantiated from.
* Compared as overrides, not as effective values: both sides resolve against the same asset,
* so "neither overrides" means equal even when the asset value changed since.
* Compared as overrides, not as effective values: both sides resolve against the same asset, so
* "neither overrides" means equal even when the asset value changed since.
*/
export function deviatesFromTemplate(object: SceneObject, template: SceneObject, key: string): boolean {
return object.overrides[key] !== template.overrides[key];
+5 -21
View File
@@ -2,31 +2,22 @@ import type { TextureAsset, TextureFrame } from '../model/Asset';
import { findAsset, type Project } from '../model/Project';
import { findFrame } from '../model/texture';
/** Shown for a `texture` property that references nothing. */
export const NO_TEXTURE_LABEL = 'None';
/** Separates a texture's name from a frame's name wherever the two are shown together. */
const FRAME_SEPARATOR = ' / ';
/** One thing a sprite's `texture` property can be set to. */
export interface TextureChoice {
/** Asset id for the whole picture, frame id for one cut-out of it. */
id: string;
label: string;
/** The texture this choice belongs to – frames of one sheet stay together in the menu. */
asset: TextureAsset;
/** Null for the whole-picture entry. */
frame: TextureFrame | null;
}
/**
* How a texture reference reads wherever it is displayed: the texture's name for a whole
* picture, "Sheet / Frame" for a frame, "None" for the empty id, and `<id>?` when the id no
* longer resolves.
*
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
* only trace left of what was meant, and blanking it would make a deleted frame look like a
* field that was never filled in. `project` may be null while a view has no project yet.
* The texture's name, "Sheet / Frame" for a frame, "None" for the empty id, `<id>?` when it no
* longer resolves – a dangling reference shows its id rather than passing for an empty field.
*/
export function textureRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_TEXTURE_LABEL;
@@ -38,16 +29,9 @@ export function textureRefLabel(project: Project | null, id: string): string {
}
/**
* Everything a sprite may wear, in menu order: each texture followed by its own frames.
*
* The whole picture stays on offer next to the frames rather than being replaced by them.
* A texture that was never sliced has no frames at all, and a sheet's full image is
* occasionally what you want (a background, a check of the sheet itself) – forcing a frame
* would mean slicing every single-image texture into one pointless frame.
*
* Textures are sorted by name so the order does not depend on upload time; frames keep the
* order they were created in, which for a sliced sheet is reading order and therefore the
* order the animation runs in.
* Everything a sprite may wear, in menu order: each texture followed by its own frames. Textures
* are sorted by name so the order does not depend on upload time; frames keep creation order,
* which for a sliced sheet is reading order.
*/
export function textureRefChoices(project: Project): TextureChoice[] {
const textures = project.assets
+4 -9
View File
@@ -8,10 +8,7 @@ import {
} from './ProjectStore';
import { deserializeProject, serializeProject } from './serialize';
/**
* Talks to the PHP backend in `api/index.php`. Routing goes through the `r` query
* parameter (`?r=projects/{id}`) because the site's nginx config rejects PATH_INFO.
*/
/** Routing goes through the `r` query parameter because the site's nginx config rejects PATH_INFO. */
export class HttpProjectStore implements ProjectStore {
constructor(private readonly endpoint: string) {}
@@ -49,7 +46,7 @@ export class HttpProjectStore implements ProjectStore {
await this.request<void>('DELETE', `projects/${encodeURIComponent(id)}`);
}
/** One JSON round trip; 409 becomes a `StaleRevisionError`, every other failure an `Error` with the server's message. */
/** 409 becomes a `StaleRevisionError`, every other failure an `Error` with the server's message. */
private async request<T>(method: string, route: string, payload?: unknown): Promise<T> {
const url = `${this.endpoint}?r=${encodeURIComponent(route)}`;
const response = await fetch(url, {
@@ -63,10 +60,8 @@ export class HttpProjectStore implements ProjectStore {
try {
data = text ? JSON.parse(text) : null;
} catch {
// Not every failure comes from our API: nginx rejects an oversized body itself and
// answers with an HTML page. Such a response is explained by its status, and saying
// "invalid JSON" instead of "too large" would send the user looking in the wrong place.
// A *successful* response that is not JSON stays a defect and keeps its old message.
// nginx rejects an oversized body itself and answers with an HTML page, so a failure is
// explained by its status rather than as "invalid JSON".
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
data = null;
}
+3 -8
View File
@@ -1,11 +1,7 @@
import { statusMessage } from './http';
import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStore';
/**
* Talks to the texture routes of `api/index.php`. Routing goes through the `r` query parameter
* (`?r=textures/{file}`) for the same reason as `HttpProjectStore`: nginx does not pass
* PATH_INFO to PHP.
*/
/** Routing goes through the `r` query parameter because nginx does not pass PATH_INFO to PHP. */
export class HttpTextureStore implements TextureStore {
constructor(private readonly endpoint: string) {}
@@ -21,7 +17,6 @@ export class HttpTextureStore implements TextureStore {
await this.request<void>('DELETE', `textures/${encodeURIComponent(file)}`);
}
/** One round trip; every failure becomes an `Error` carrying the server's message. */
private async request<T>(
method: string,
route: string,
@@ -39,8 +34,8 @@ export class HttpTextureStore implements TextureStore {
try {
data = text ? JSON.parse(text) : null;
} catch {
// An upload is exactly the request nginx refuses before PHP sees it, and its answer is
// an HTML page. Explain such a failure from the status; a successful non-JSON body is a defect.
// An upload is the request nginx refuses before PHP sees it, answering with an HTML page,
// so a failure is explained from the status instead.
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
data = null;
}
+1 -2
View File
@@ -20,11 +20,10 @@ export class StaleRevisionError extends Error {
}
}
/** Persistence boundary. The editor only talks to this interface. */
export interface ProjectStore {
list(): Promise<ProjectSummary[]>;
load(id: string): Promise<LoadedProject>;
/** Saves the project and returns the new revision. Rejects with StaleRevisionError on conflict. */
/** Returns the new revision; rejects with `StaleRevisionError` on conflict. */
save(project: Project, revision: number): Promise<number>;
create(name: string): Promise<LoadedProject>;
delete(id: string): Promise<void>;
+4 -8
View File
@@ -1,4 +1,3 @@
/** What the server made of an uploaded image; the payload a texture asset is built from. */
export interface UploadedTexture {
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
file: string;
@@ -7,7 +6,6 @@ export interface UploadedTexture {
bytes: number;
}
/** One stored image as the server lists it. */
export interface StoredTexture {
file: string;
bytes: number;
@@ -15,15 +13,13 @@ export interface StoredTexture {
}
/**
* Upload boundary for images, the counterpart of `ProjectStore`. The editor only talks to this
* interface. An upload is raw bytes rather than a multipart form: name, size and format are
* derived from the image itself on the server, so there is nothing else to send.
* An upload is raw bytes rather than a multipart form: name, size and format are derived from the
* image itself on the server, so there is nothing else to send.
*/
export interface TextureStore {
/**
* Stores an image and returns its file name and pixel size. `type` only fills the
* Content-Type header – the server trusts the bytes, not the label – and uploading the same
* picture twice yields the same file, because the name is the hash of its content.
* `type` only fills the Content-Type header – the server trusts the bytes, not the label – and
* uploading the same picture twice yields the same file, the hash of its content.
*/
upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture>;
list(): Promise<StoredTexture[]>;
+2 -5
View File
@@ -1,10 +1,8 @@
/**
* The bit of HTTP handling both stores share. It exists because a failed response does not
* always come from our API: nginx caps request bodies at 1 MB and answers a larger one with
* its own HTML page, so there is no `{error}` object to read the reason from.
* A failed response does not always come from our API: nginx caps request bodies at 1 MB and
* answers a larger one with its own HTML page, so there is no `{error}` object to read.
*/
/** Reasons for the statuses that can arrive without an API body of their own. */
const STATUS_MESSAGES: Record<number, string> = {
400: 'Bad request',
403: 'Forbidden',
@@ -16,7 +14,6 @@ const STATUS_MESSAGES: Record<number, string> = {
504: 'Server timed out',
};
/** What to tell the user about a failed response whose body is not our JSON – the status is all there is. */
export function statusMessage(status: number, statusText: string): string {
return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`);
}
+12 -16
View File
@@ -1,4 +1,4 @@
// The graph package index registers the built-in nodes; unknown node types are dropped below.
// Importing the graph package index is what registers the built-in nodes.
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
@@ -20,16 +20,14 @@ import {
} from '../properties/PropertySchema';
/**
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
* dropped, missing ones get defaults, values are coerced against the schema, dangling
* scene objects are removed. Future format migrations live here as well.
* Turns untrusted JSON (server file, import) into a well-formed Project: unknown keys are dropped,
* missing ones get defaults, values are coerced against the schema, dangling objects are removed.
*/
export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const r = asRecord(raw);
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
// Pass 1: asset ids and their effective schemas (behaviors declare their own variables),
// needed to coerce scene-object overrides in pass 2.
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
const schemas = new Map<string, PropertySchema>();
const variables = new Map<string, PropertyDef[]>();
for (const a of rawAssets) {
@@ -43,8 +41,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
}
}
// Pass 2: build assets. Frame ids live in the same reference space as asset ids (a sprite's
// `texture` may name either), so they are handed the ids already spoken for.
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
// is handed the ids already spoken for.
const assets: Asset[] = [];
const takenIds = new Set(schemas.keys());
for (const a of rawAssets) {
@@ -106,10 +104,9 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
assetId: o.assetId,
name: typeof o.name === 'string' ? o.name : '',
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
// Editor-only view shift; absent in documents written before it existed.
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
overrides: normalizeBag(o.overrides, schema, true),
// `ownerId` is the pre-hierarchy name of the same link (documents saved before parenting).
// `ownerId` is the pre-hierarchy name of the same link.
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
templateId: typeof o.templateId === 'string' ? o.templateId : '',
});
@@ -150,7 +147,7 @@ function normalizeGraph(raw: unknown): Graph {
const n = asRecord(item);
const id = typeof n.id === 'string' ? n.id : '';
const type = typeof n.type === 'string' ? n.type : '';
if (!id || nodeIds.has(id) || !nodeDef(type)) continue; // unknown node types are dropped
if (!id || nodeIds.has(id) || !nodeDef(type)) continue;
nodeIds.add(id);
const params: PropertyBag = {};
for (const [key, value] of Object.entries(asRecord(n.params))) {
@@ -187,9 +184,8 @@ function normalizeGraph(raw: unknown): Graph {
}
/**
* The picture of a texture asset. A file name that is not the server's content-addressed form
* is dropped rather than kept: it can only have been hand-written, and a request built from it
* would 404. Such a texture loads as "no picture", which draws as the sprite's plain colour.
* A file name that is not the server's content-addressed form is dropped: a request built from it
* would 404, and the texture then loads as "no picture".
*/
function normalizeTexture(
raw: Record<string, unknown>,
@@ -204,8 +200,8 @@ function normalizeTexture(
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
const f = asRecord(item);
const id = typeof f.id === 'string' ? f.id : '';
// A frame id shares the reference space with asset ids (a sprite may name either), so a
// duplicate would make one reference mean two pictures. Drop it rather than resolve it.
// Frame ids share the reference space with asset ids, so a duplicate would make one
// reference mean two pictures.
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const rect = normalizeFrameRect(
+5 -13
View File
@@ -1,21 +1,16 @@
/** An item taken out of a list together with the index it had, so it can be put back exactly. */
export interface Removed<T> {
item: T;
index: number;
}
/**
* Removes the first item matching `predicate` in place and returns it with its former index.
* `undefined` means nothing matched – the list is then untouched, so a caller may reject the
* edit afterwards without having to revert anything.
*/
/** In place; `undefined` means nothing matched and the list is untouched. */
export function removeFirst<T>(list: T[], predicate: (item: T) => boolean): Removed<T> | undefined {
const index = list.findIndex(predicate);
// A valid index makes `splice` return exactly one element; the `!` is that index check.
// A valid index makes `splice` return exactly one element, which is what the `!` rests on.
return index < 0 ? undefined : { item: list.splice(index, 1)[0]!, index };
}
/** Removes every item matching `predicate` in place; returns them with their former indices, ascending. */
/** In place; the returned indices are the former ones, ascending. */
export function removeWhere<T>(list: T[], predicate: (item: T) => boolean): Removed<T>[] {
const removed: Removed<T>[] = [];
list.forEach((item, index) => {
@@ -25,15 +20,12 @@ export function removeWhere<T>(list: T[], predicate: (item: T) => boolean): Remo
return removed;
}
/** Puts removed items back at their former indices (ascending order restores the list exactly). */
/** Inserting in ascending index order is what restores the list exactly. */
export function restoreAt<T>(list: T[], removed: readonly Removed<T>[]): void {
for (const entry of [...removed].sort((a, b) => a.index - b.index)) list.splice(entry.index, 0, entry.item);
}
/**
* A **new** list without the item carrying `id` – unlike the `remove*` helpers above this one
* does not mutate, which is what undoing a `push` needs: `list = removeById(list, id)`.
*/
/** Returns a new list; unlike the helpers above this one does not mutate. */
export function removeById<T extends { id: string }>(list: readonly T[], id: string): T[] {
return list.filter((item) => item.id !== id);
}