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:
1 parent
f16801aef3
commit
4d84231b9f
115 files changed
+707
-1564
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)) } };
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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[],
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user