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
@@ -269,8 +269,14 @@ selected and switched back on.
|
||||
`Label.ts`, shared by `Label`, `ListRow` and `Button`). A `Button` keeps the full text and
|
||||
measures from it – only what is drawn is shortened – and a subclass that puts something else
|
||||
inside the control takes its room off `labelSlot()`, so a long value is cut rather than run
|
||||
underneath the chevron. `__gm.textOverflows()` guards the whole class of bug: it reports any
|
||||
visible text drawn wider than the widget owning it (e2e step 14).
|
||||
underneath the chevron. Given a `width`, a button is exactly that wide and truncates instead:
|
||||
the tab-bar buttons and the Graph and Texture pickers keep their size whatever they show.
|
||||
The padding is an inset rather than a claim, so a 24 px icon button spends it on the glyph
|
||||
instead of truncating it away; a button that switches between label forms (`↺` and `↺ ▾`) asks
|
||||
for `minWidth` and grows. `__gm.textOverflows()` guards the whole class of bug: it reports any
|
||||
visible text drawn wider than the widget owning it, `__gm.labelsDropped()` guards the other
|
||||
direction – a button too narrow to draw any of its label – and `__gm.graph.pickerWidth()` /
|
||||
`__gm.texture.pickerWidth()` catch a picker that resizes itself around its content (e2e step 14).
|
||||
- **Vite + TypeScript**, **Vitest** for the pure core.
|
||||
- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with
|
||||
atomic writes and an integer revision that rejects stale saves (HTTP 409).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+2
-2
@@ -10,8 +10,8 @@ import { EditorState } from './state';
|
||||
const API_ENDPOINT = '/game-maker/api/index.php';
|
||||
|
||||
/**
|
||||
* Where uploaded images live. The API writes them; the browser reads them straight off disk,
|
||||
* with no PHP in the path – they are static files whose names are their content hashes.
|
||||
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
|
||||
* static files named by their content hash.
|
||||
*/
|
||||
const TEXTURE_BASE = '/game-maker/data/textures';
|
||||
|
||||
|
||||
+3
-12
@@ -19,7 +19,6 @@ import { installTextureImport } from './textureImport';
|
||||
|
||||
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture';
|
||||
|
||||
/** Tab strip order – also the one list `showTab` walks, so a new tab cannot be half-added. */
|
||||
const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const;
|
||||
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
@@ -38,7 +37,6 @@ export class Editor implements PlayHost {
|
||||
/** Game tab: the running world through its camera. */
|
||||
readonly gameView: GameViewport;
|
||||
readonly graphEditor: GraphEditor;
|
||||
/** Texture tab: one sheet with its frames. */
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly hierarchy: HierarchyPanel;
|
||||
tab: CentreTab = 'scene';
|
||||
@@ -73,7 +71,6 @@ export class Editor implements PlayHost {
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.inspector = new Inspector(state);
|
||||
this.play = new PlaySession(state, this);
|
||||
// A run holds a copy of the project, so it has to end before a different one is loaded.
|
||||
this.projects = new ProjectSession(state, store, () => {
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
});
|
||||
@@ -91,12 +88,9 @@ export class Editor implements PlayHost {
|
||||
this.workspace = new HStack();
|
||||
this.leftColumn = new VStack({ gap: 1 });
|
||||
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter(), textures), 1);
|
||||
// Dropping an image on the canvas or pasting one imports it, wherever the pointer is –
|
||||
// the same import the asset panel's "Texture" entry runs.
|
||||
installTextureImport(app.canvas, { state, textures });
|
||||
this.leftColumn.add(this.hierarchy, 1);
|
||||
|
||||
// Centre: a tab strip above the scene view, the game view, the graph editor or the sheet editor.
|
||||
this.centre = new VStack();
|
||||
const tabs = new HStack({ gap: 2, padding: 4, align: 'center' });
|
||||
tabs.minHeight = Theme.controlHeight + 8;
|
||||
@@ -144,15 +138,13 @@ export class Editor implements PlayHost {
|
||||
attachAutosave(state, store);
|
||||
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
|
||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||
// Selecting a graph node switches to the graph, selecting a frame to the sheet: both are
|
||||
// things that exist *only* in their tab, so a selection you could not see would be a lie.
|
||||
// Selecting the behavior or the texture asset itself does not force anything.
|
||||
// A node and a frame exist only in their own tab, so selecting one switches to it;
|
||||
// selecting the behavior or texture asset itself does not.
|
||||
state.on('selection', () => {
|
||||
const kind = state.selection?.kind;
|
||||
if (kind === 'node' && this.tab !== 'graph') this.showTab('graph');
|
||||
if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||
});
|
||||
// While playing, every edit (command, undo, scrub preview) is mirrored into the world at once.
|
||||
state.on('project', () => this.play.syncFromModel());
|
||||
}
|
||||
|
||||
@@ -167,7 +159,6 @@ export class Editor implements PlayHost {
|
||||
ui().focus.validate();
|
||||
}
|
||||
|
||||
/** Opens the project named in the URL, else the first one on the server, else a new one. */
|
||||
boot(): Promise<void> {
|
||||
return this.projects.boot();
|
||||
}
|
||||
@@ -189,7 +180,7 @@ export class Editor implements PlayHost {
|
||||
this.play.tick(dt);
|
||||
}
|
||||
|
||||
/** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */
|
||||
/** Refreshes the toolbar readout twice a second. */
|
||||
private measureFps(dt: number): void {
|
||||
this.fpsFrames += 1;
|
||||
this.fpsElapsed += dt;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Owns everything that only exists while the project is playing: the world, its systems, the
|
||||
* keyboard and the per-frame step.
|
||||
*
|
||||
* The key rule lives here: play never touches the saved model. `state.enterPlay()` swaps in a
|
||||
* runtime copy, the world is built from that copy, edits made while playing go into it, and
|
||||
* `exitPlay()` throws it away again.
|
||||
* keyboard and the per-frame step. Play never touches the saved model – the world is built
|
||||
* from the runtime copy `state.enterPlay()` swaps in and `exitPlay()` throws away.
|
||||
*/
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem';
|
||||
@@ -32,13 +29,11 @@ export interface WorldSnapshot {
|
||||
log: string[];
|
||||
}
|
||||
|
||||
/** What a play session needs from the surrounding editor, and nothing more. */
|
||||
export interface PlayHost {
|
||||
gameView: GameViewport;
|
||||
graphEditor: GraphEditor;
|
||||
/** Bring the game view to the front – play has started. */
|
||||
showGame(): void;
|
||||
/** Leave the game view if it is the one on screen – play has stopped. */
|
||||
/** Leave the game view if it is the one on screen. */
|
||||
leaveGame(): void;
|
||||
}
|
||||
|
||||
@@ -56,7 +51,6 @@ export class PlaySession {
|
||||
return this.world !== null;
|
||||
}
|
||||
|
||||
/** Live execution data for the graph editor (null when not playing). */
|
||||
traceFor(assetId: string): Trace | null {
|
||||
return this.behaviors?.traceFor(assetId) ?? null;
|
||||
}
|
||||
@@ -65,15 +59,10 @@ export class PlaySession {
|
||||
return this.world?.time ?? 0;
|
||||
}
|
||||
|
||||
/** Mirrors an edit made while playing into the running world at once. */
|
||||
syncFromModel(): void {
|
||||
this.world?.syncFromModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a run: the state switches to a runtime copy, the world is built from it and shown
|
||||
* in the Game tab. All panels stay open and edit the copy.
|
||||
*/
|
||||
play(): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
@@ -97,7 +86,6 @@ export class PlaySession {
|
||||
if (world.stopRequested) this.stop();
|
||||
}
|
||||
|
||||
/** Ends the run: the world and the runtime copy are discarded, the saved model returns untouched. */
|
||||
stop(): void {
|
||||
const world = this.world;
|
||||
this.world = null;
|
||||
@@ -112,7 +100,6 @@ export class PlaySession {
|
||||
this.state.setNotice(lastError ?? '');
|
||||
}
|
||||
|
||||
/** One frame. Does nothing when not playing. */
|
||||
tick(dt: number): void {
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
@@ -122,20 +109,17 @@ export class PlaySession {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
// Restart game: throw the run away and start the scene again from the saved model.
|
||||
if (world.restartRequested) {
|
||||
this.stop();
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||||
// A destroyed entity removes its object – that is a structural change the lists must see.
|
||||
if (world.syncToModel()) this.state.preview();
|
||||
this.state.runtimeTick();
|
||||
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
||||
}
|
||||
|
||||
/** Read-only view of the running world for the check scripts. */
|
||||
snapshot(): WorldSnapshot | null {
|
||||
const world = this.world;
|
||||
if (!world) return null;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger
|
||||
* it. Talks only to the store and the editor state – no widgets of its own beyond the menus.
|
||||
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger it.
|
||||
*/
|
||||
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||
import { listScenes } from '../core/model/Project';
|
||||
@@ -10,8 +9,8 @@ import type { EditorState } from './state';
|
||||
|
||||
export class ProjectSession {
|
||||
/**
|
||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted –
|
||||
* a run in progress has to end first, since it holds a copy of the project being replaced.
|
||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
|
||||
* run in progress holds a copy of the project being replaced and has to end first.
|
||||
*/
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
|
||||
@@ -32,18 +32,13 @@ export function removeObjects(state: EditorState, sceneId: string, objectIds: re
|
||||
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks before deleting an asset, spelling out what goes with it. Both ways of deleting (the
|
||||
* asset list's context menu and the Delete key) go through here, so the warning cannot differ
|
||||
* depending on how the user triggered it.
|
||||
*/
|
||||
/** Asks before deleting an asset, spelling out what goes with it. */
|
||||
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset) return;
|
||||
const usages = findUsages(state.project, assetId).length;
|
||||
// A texture is never placed – it is worn through a property – so counting placed instances
|
||||
// would report "unused" for a picture a dozen sprites are showing. Its frames count as the
|
||||
// texture itself: a sprite wearing one of them loses its picture just the same.
|
||||
// A texture is never placed but worn through a property, so counting placed instances would
|
||||
// report "unused". Its frames count as the texture itself.
|
||||
const ids = asset.type === 'texture' ? textureRefIds(asset) : assetId;
|
||||
const references = findPropertyUsages(state.project, ids).length;
|
||||
const detail =
|
||||
|
||||
+33
-32
@@ -1,11 +1,11 @@
|
||||
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
|
||||
import { ui, Widget } from '../ui';
|
||||
import { Button, ui, Widget } from '../ui';
|
||||
import type { CentreTab, Editor } from './Editor';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/**
|
||||
* Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their
|
||||
* visible text and to read editor state. Read-only access; harmless in production.
|
||||
* visible text and to read editor state.
|
||||
*/
|
||||
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
|
||||
const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor;
|
||||
@@ -25,8 +25,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
// TypeScript does not track the assignment inside `walk`, so it believes `found` is still
|
||||
// `null` here – hence both the disable and the cast below.
|
||||
// TypeScript does not track the assignment inside `walk`, hence the disable and the cast.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!found) return null;
|
||||
const node = found as Text;
|
||||
@@ -51,13 +50,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* Visible texts drawn wider than the widget that owns them – the shape of bug where a label
|
||||
* runs past its own control instead of being shortened to fit.
|
||||
*
|
||||
* The owner's box is its declared `w`/`h` at its global position, NOT `getBounds()`: a Pixi
|
||||
* container has no size of its own, so its bounds are the union of its children – including
|
||||
* the very text that overflows, which makes every overflow measure as zero. Texts inside a
|
||||
* scroll container are skipped, where sticking out is the point and the mask hides it.
|
||||
* Visible texts drawn wider than the widget that owns them. The owner's box is its declared
|
||||
* `w`/`h`, not `getBounds()`: a Pixi container's bounds are the union of its children –
|
||||
* including the overflowing text – so every overflow would measure as zero.
|
||||
*/
|
||||
const textOverflows = (all = false): { text: string; owner: string; over: number }[] => {
|
||||
const out: { text: string; owner: string; over: number }[] = [];
|
||||
@@ -65,11 +60,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
if (!node.visible) return;
|
||||
const isWidget = node instanceof Widget;
|
||||
const box = isWidget ? node : owner;
|
||||
// A mask further up – a panel clipping its contents – does not excuse a label spilling
|
||||
// out of its own control, so the question restarts at every widget. Only a mask at or
|
||||
// below the owner (a text field's scroll window, a scroll container) makes it intended.
|
||||
// `Boolean`, not `!== null`: Pixi's mask getter returns `undefined` when nothing masks,
|
||||
// so comparing against null marks every container as clipped and hides every finding.
|
||||
// A mask further up does not excuse a label spilling out of its own control, so the
|
||||
// question restarts at every widget. `Boolean`, not `!== null`: Pixi's mask getter
|
||||
// returns `undefined` when nothing masks.
|
||||
const masked = (isWidget ? false : clipped) || Boolean(node.mask);
|
||||
if (node instanceof Text) {
|
||||
if (!box || !node.text || (masked && !all)) return;
|
||||
@@ -86,6 +79,22 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
walk(app.stage, null, false);
|
||||
return out;
|
||||
};
|
||||
/**
|
||||
* The counterpart of `textOverflows`: buttons whose label was shortened away to nothing, which
|
||||
* leaves a control that says nothing at all. An empty `Text` is invisible to the overflow walk.
|
||||
*/
|
||||
const labelsDropped = (): { text: string; owner: string; w: number }[] => {
|
||||
const out: { text: string; owner: string; w: number }[] = [];
|
||||
const walk = (node: Container): void => {
|
||||
if (!node.visible) return;
|
||||
if (node instanceof Button && node.text && !node.shownText) {
|
||||
out.push({ text: node.text, owner: node.constructor.name, w: node.w });
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(app.stage);
|
||||
return out;
|
||||
};
|
||||
/** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */
|
||||
const overlayRect = (): { x: number; y: number; w: number; h: number } | null => {
|
||||
const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget);
|
||||
@@ -100,33 +109,30 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
overlayTexts,
|
||||
overlayRect,
|
||||
textOverflows,
|
||||
labelsDropped,
|
||||
viewportCenter: () => centreOf(viewport),
|
||||
viewportInfo: () => viewport.debugInfo(),
|
||||
gameInfo: () => gameView.debugInfo(),
|
||||
layerOf: (id: string) => viewport.layerOf(id),
|
||||
drawOrder: () => viewport.drawOrder(),
|
||||
/** Screen position of an object in the Scene tab (its drawn position through the view). */
|
||||
objectScreen: (id: string) => viewport.objectScreen(id),
|
||||
/** The rectangle it covers on screen – for sampling colours inside it with `pixelAt`. */
|
||||
objectRect: (id: string) => viewport.objectRect(id),
|
||||
/** The same for the Game tab (through the camera), so tests can click what the player sees. */
|
||||
/** The same for the Game tab – through the camera, so what the player sees. */
|
||||
gameScreen: (id: string) => gameView.objectScreen(id),
|
||||
graph: {
|
||||
current: () => graphEditor.currentAssetId,
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||||
// The canvas, not the whole tab: a point measured through the header would sit above
|
||||
// the drawing area a right-click test aims at.
|
||||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||||
// drawing area.
|
||||
center: () => centreOf(graphEditor.canvas),
|
||||
pickerWidth: () => graphEditor.pickerWidth,
|
||||
},
|
||||
texture: {
|
||||
current: () => textureEditor.currentAssetId,
|
||||
/**
|
||||
* Where an image pixel of the shown sheet sits on screen. Frames are dragged in image
|
||||
* coordinates, so without this a check could only drag somewhere and hope – it could not
|
||||
* aim at a known part of the picture and then assert which part came out.
|
||||
*/
|
||||
pickerWidth: () => textureEditor.pickerWidth,
|
||||
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
|
||||
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
|
||||
},
|
||||
world: () => editor.worldSnapshot(),
|
||||
@@ -135,12 +141,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
|
||||
hierarchyExpandAll: () => hierarchy.expandAll(),
|
||||
hierarchyCollapseAll: () => hierarchy.collapseAll(),
|
||||
/**
|
||||
* The colour actually on screen at a point, as `#rrggbb`. Everything else here reports what
|
||||
* the model *says*; this is the only way a check can assert what was really drawn – which is
|
||||
* the whole question for a texture, since a textured and an untextured sprite are identical
|
||||
* in the model apart from one property.
|
||||
*/
|
||||
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
|
||||
pixelAt: (x: number, y: number) => {
|
||||
const { pixels } = app.renderer.extract.pixels({
|
||||
target: app.stage,
|
||||
|
||||
@@ -37,13 +37,8 @@ interface PortAnchor {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Graph tab's canvas: the node graph of one behavior asset. Which behavior that is comes
|
||||
* from the outside (`show`), so the canvas never has an opinion about the selection – the
|
||||
* GraphEditor around it does.
|
||||
*
|
||||
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
|
||||
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
|
||||
* the empty canvas to pan. All edits go through commands (undoable).
|
||||
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
|
||||
* so the canvas never has an opinion about the selection – the GraphEditor around it does.
|
||||
*/
|
||||
export class GraphCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -94,19 +89,17 @@ export class GraphCanvas extends Widget {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Only the highlight of the selected node depends on the selection; which graph is shown
|
||||
// is the editor's decision.
|
||||
// Only the highlight of the selected node depends on the selection.
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('project', () => this.sync());
|
||||
state.on('mode', () => this.redraw());
|
||||
}
|
||||
|
||||
/** The behavior whose graph is shown, if any. */
|
||||
get currentAssetId(): string | null {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Shows a behavior's graph. Switching refits the view, since the next graph is a new subject. */
|
||||
/** Switching refits the view, since the next graph is a new subject. */
|
||||
show(assetId: string | null): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
@@ -115,16 +108,12 @@ export class GraphCanvas extends Widget {
|
||||
this.fitView();
|
||||
}
|
||||
|
||||
/** Recentres the graph in the view – the "reset view" the header offers. */
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.fitView();
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's "Add node": the same palette the right-click opens, dropped in the middle of
|
||||
* the view, since a button press says nothing about where the node should go.
|
||||
*/
|
||||
/** Drops the node in the middle of the view, since a button press names no position. */
|
||||
addNodeFrom(anchor: Widget): void {
|
||||
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||
const centre = this.screenToGraph(this.w / 2, this.h / 2);
|
||||
@@ -148,8 +137,7 @@ export class GraphCanvas extends Widget {
|
||||
private sync(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
// A behavior that was deleted (or undone away) must not linger as a graph of a thing
|
||||
// that no longer exists, so the canvas drops it rather than waiting to be told.
|
||||
// A behavior that was deleted (or undone away) must not linger as a graph.
|
||||
this.assetId = null;
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
@@ -393,7 +381,6 @@ export class GraphCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
const playing = this.state.mode === 'play';
|
||||
// The header names the behavior; the hint only says what the canvas itself is up to.
|
||||
this.hint.text = asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '';
|
||||
|
||||
const trace = playing ? this.traceProvider(asset.id) : null;
|
||||
@@ -447,8 +434,7 @@ export class GraphCanvas extends Widget {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.zoom;
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
|
||||
// marking, so no axis lines.
|
||||
// `pan` is where the graph origin sits on screen, but no axis lines: it marks nothing.
|
||||
drawGrid(this.grid, {
|
||||
originX: this.pan.x,
|
||||
originY: this.pan.y,
|
||||
@@ -479,7 +465,7 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points, as used by every node editor. */
|
||||
/** Horizontal S-curve between two points. */
|
||||
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
|
||||
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
|
||||
|
||||
@@ -9,12 +9,8 @@ import { GraphCanvas } from './GraphCanvas';
|
||||
const INFO_WIDTH = 150;
|
||||
|
||||
/**
|
||||
* The Graph tab: a picker for which behavior is being worked on, the actions that act on the
|
||||
* whole graph, and the canvas the nodes are drawn on.
|
||||
*
|
||||
* It follows the selection – selecting a behavior asset, a placed behavior or one of its nodes
|
||||
* shows that graph – but it keeps showing the last behavior when the selection moves elsewhere.
|
||||
* Otherwise clicking a sprite to check its position would empty the graph you are editing.
|
||||
* The Graph tab. It follows the selection, but keeps showing the last behavior when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the graph being edited.
|
||||
*/
|
||||
export class GraphEditor extends VStack {
|
||||
/** The canvas itself: the live redraw while playing and the check scripts reach for it. */
|
||||
@@ -38,8 +34,7 @@ export class GraphEditor extends VStack {
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
|
||||
// the text it had at the time – so without a floor the readout is clipped from then on.
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
@@ -63,6 +58,11 @@ export class GraphEditor extends VStack {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedBehaviorId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
@@ -93,8 +93,7 @@ export class GraphEditor extends VStack {
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
// A deleted behavior leaves the canvas empty; rather than showing nothing at all the tab
|
||||
// falls back to whatever behavior is left, the way it starts out on the first one.
|
||||
// A deleted behavior leaves the canvas empty; fall back to whatever behavior is left.
|
||||
if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null);
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No behavior');
|
||||
|
||||
@@ -20,11 +20,7 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
|
||||
import { keyLabel } from '../inspector/fieldFactory';
|
||||
|
||||
/**
|
||||
* Node geometry in graph units – what the node lays itself out, draws and hit-tests with.
|
||||
* Exported as the single source of these numbers: edge anchors have to land exactly on the
|
||||
* ports drawn here.
|
||||
*/
|
||||
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
||||
export const NODE_WIDTH = 176;
|
||||
export const TITLE_HEIGHT = 24;
|
||||
export const ROW_HEIGHT = 20;
|
||||
@@ -72,11 +68,7 @@ export interface PortHit {
|
||||
isOutput: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws one graph node: a title bar coloured by category, a line per setting and a row per
|
||||
* port pair (inputs left, outputs right). Everything is derived from the NodeDef; the view
|
||||
* never interprets what a node does. Positioned in graph coordinates by the GraphCanvas.
|
||||
*/
|
||||
/** Draws one graph node entirely from its NodeDef; positioned in graph coordinates by GraphCanvas. */
|
||||
export class NodeView extends Widget {
|
||||
private readonly box = new Graphics();
|
||||
private readonly ports = new Graphics();
|
||||
@@ -103,7 +95,7 @@ export class NodeView extends Widget {
|
||||
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
|
||||
}
|
||||
|
||||
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
|
||||
/** `project` resolves asset references (Spawn, Is asset). */
|
||||
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
|
||||
this.paramLines = this.def.params.map(
|
||||
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
|
||||
@@ -147,7 +139,6 @@ export class NodeView extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Which port (if any) sits under a local point. */
|
||||
portAt(localX: number, localY: number): PortHit | null {
|
||||
for (const { port, isOutput, x, y } of this.portCentres()) {
|
||||
if (Math.hypot(localX - x, localY - y) <= PORT_HIT_RADIUS) return { port, isOutput };
|
||||
@@ -264,8 +255,7 @@ function formatParam(
|
||||
}
|
||||
|
||||
function formatValue(value: Value): string {
|
||||
// Whole numbers stay bare to keep nodes narrow; fractions get the same precision as the
|
||||
// field that edits them, so a node never rounds harder than the inspector.
|
||||
// Whole numbers stay bare to keep nodes narrow; fractions match the field that edits them.
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : formatNumber(value);
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
return asString(value);
|
||||
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
} from './objectSection';
|
||||
|
||||
/**
|
||||
* Shows the properties of the current selection. The rows are generated from the
|
||||
* PropertySchema by one section per selection kind (asset, scene object, graph node, texture
|
||||
* frame); the
|
||||
* panel only decides when a section has to be rebuilt and when its values merely refresh.
|
||||
* Shows the properties of the current selection. The rows are generated from the PropertySchema
|
||||
* by one section per selection kind (asset, scene object, graph node, texture frame); the panel
|
||||
* only decides when a section has to be rebuilt and when its values merely refresh.
|
||||
*/
|
||||
export class Inspector extends Panel {
|
||||
private readonly scroll = new ScrollContainer();
|
||||
|
||||
@@ -29,10 +29,8 @@ const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
};
|
||||
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`. Only the
|
||||
// three VariableKinds ever reach it – the loader clamps any other kind (normalizeVariables) and
|
||||
// the "+ Variable" menu offers no other – so the '?' fallback is a safety net, not a case to
|
||||
// fill in: entries for kinds a variable cannot have (asset, color, enum, key) would be dead.
|
||||
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the
|
||||
// three VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
|
||||
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
|
||||
@@ -26,11 +26,9 @@ import {
|
||||
} from '../../ui';
|
||||
|
||||
/**
|
||||
* What a field needs beyond its own value: the project, to resolve an asset reference (an id)
|
||||
* to a name and to list what the picker may offer.
|
||||
*
|
||||
* A getter rather than the project itself – loading a document and entering play mode replace
|
||||
* the object, and a row that outlives either must not keep answering from the old one.
|
||||
* What a field needs beyond its own value: the project, to resolve an asset reference to a name
|
||||
* and to list what the picker may offer. A getter rather than the project itself – loading and
|
||||
* entering play mode replace that object.
|
||||
*/
|
||||
export interface FieldContext {
|
||||
project(): Project;
|
||||
@@ -103,7 +101,8 @@ export function createFieldRow(
|
||||
|
||||
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
|
||||
const addActions = (glyph: string, available: FieldActions): void => {
|
||||
const button = new Button(glyph, { variant: 'ghost', width: Theme.controlHeight });
|
||||
// A floor, not a width: the same button also carries the two-glyph menu form "↺ ▾".
|
||||
const button = new Button(glyph, { variant: 'ghost', minWidth: Theme.controlHeight });
|
||||
// Asked again on click: the menu must not act on a state that has moved on since the
|
||||
// last refresh (an undo between refreshes, a value edited in the viewport).
|
||||
button.onClick = () => {
|
||||
@@ -212,7 +211,7 @@ export function createField(
|
||||
const items: MenuItem[] = [
|
||||
{ label: mark('', NO_ASSET_LABEL), onSelect: () => handlers.onChange('') },
|
||||
// Built on click, not once per row: assets are created, renamed and deleted while
|
||||
// the inspector stands, and the picker has to show the project as it is now.
|
||||
// the inspector stands.
|
||||
...assetRefChoices(ctx.project(), def.assetTypes).map((asset) => ({
|
||||
label: mark(asset.id, assetChoiceLabel(asset)),
|
||||
onSelect: () => handlers.onChange(asset.id),
|
||||
@@ -231,24 +230,20 @@ export function createField(
|
||||
};
|
||||
}
|
||||
case 'texture': {
|
||||
// The id is kept here for the same reason as in the asset case, and more urgently: a
|
||||
// frame's label repeats its sheet's name, so several entries can read identically.
|
||||
// Kept for the same reason as in the asset case, and more urgently: a frame's label
|
||||
// repeats its sheet's name, so several entries can read identically.
|
||||
let current = String(value);
|
||||
const button = new Dropdown(textureRefLabel(ctx.project(), current));
|
||||
button.onClick = () => {
|
||||
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
||||
const items: MenuItem[] = [
|
||||
{ label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') },
|
||||
// Built on click, not once per row: textures are uploaded and frames are cut, renamed
|
||||
// and sliced away while the inspector stands, and the picker has to show what exists now.
|
||||
...textureRefChoices(ctx.project()).map((choice) => ({
|
||||
label: mark(choice.id, choice.label),
|
||||
onSelect: () => handlers.onChange(choice.id),
|
||||
})),
|
||||
];
|
||||
// Searchable: one sliced sheet alone contributes an entry per frame. The entries read
|
||||
// "Sheet / Frame", so the sheet's own name gathers its frames into one block of the
|
||||
// list and typing either name finds them.
|
||||
// Searchable: one sliced sheet alone contributes an entry per frame.
|
||||
new Menu(items, { searchable: true }).openBelow(button);
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
/** The components of the rectangle, in the order they are shown. */
|
||||
const RECT_COMPONENTS = [
|
||||
{ key: 'x', label: 'X' },
|
||||
{ key: 'y', label: 'Y' },
|
||||
@@ -19,11 +18,7 @@ const RECT_COMPONENTS = [
|
||||
{ key: 'h', label: 'Height' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Structure-defining facts of a frame selection: none. A frame is a name and four numbers
|
||||
* whatever texture it was cut from, so the rows never differ – only the frame disappearing
|
||||
* can force a rebuild, and that is what `refreshFrameSection` reports.
|
||||
*/
|
||||
/** Structure-defining facts of a frame selection: none – only the frame disappearing rebuilds. */
|
||||
export function frameSignature(project: Project, assetId: string, frameId: string): string {
|
||||
return currentFrame(project, assetId, frameId) ? 'frame' : 'missing';
|
||||
}
|
||||
@@ -37,8 +32,8 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId:
|
||||
addTitle(host, 'TEXTURE FRAME');
|
||||
host.nameField = new TextField(frame.name, {
|
||||
onChange: (name) => {
|
||||
// Like an asset name. The command rejects a blank name by itself; what it cannot do is
|
||||
// put the old text back into the field, which is why an empty one is not sent at all.
|
||||
// The command rejects a blank name but cannot put the old text back into the field,
|
||||
// so an empty one is not sent at all.
|
||||
if (name.trim()) host.state.execute(new RenameFrameCommand(assetId, frameId, name.trim()));
|
||||
else host.nameField?.setValue(frame.name);
|
||||
},
|
||||
@@ -81,8 +76,7 @@ function rectRow(
|
||||
): void {
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
// `decimals: 0` is honest here and only here in the inspector: `normalizeFrameRect` rounds
|
||||
// every value that reaches the model, so the field never shortens a number it is holding.
|
||||
// `decimals: 0` is safe here: `normalizeFrameRect` rounds every value that reaches the model.
|
||||
{ key, label, kind: 'number', default: 0, step: 1, decimals: 0 },
|
||||
value,
|
||||
rectHandlers(host, assetId, frameId, key),
|
||||
@@ -93,13 +87,9 @@ function rectRow(
|
||||
|
||||
/**
|
||||
* The whole rectangle with one component replaced; the other three are read from the model at
|
||||
* that moment, so a frame dragged in the Texture tab in between is not undone by typing into a
|
||||
* field beside it.
|
||||
*
|
||||
* Normalising here as well as in the command is not a duplicate rule but the same one applied
|
||||
* to the *preview*: an un-normalised preview would draw the frame off the sheet on the way
|
||||
* through a scrub, and a half-typed "1" of "16" would jump it to the corner. Because it is the
|
||||
* same function the command uses, what is previewed is exactly what is committed.
|
||||
* that moment, so a frame dragged in the Texture tab in between is not undone. Normalising the
|
||||
* preview with the same function the command uses keeps it on the sheet and identical to what
|
||||
* is committed.
|
||||
*/
|
||||
function rectHandlers(
|
||||
host: InspectorHost,
|
||||
@@ -124,9 +114,8 @@ function rectHandlers(
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame is looked up inside the selected texture rather than by its id alone: the
|
||||
* selection names both, and honouring both keeps a selection left over from a deleted texture
|
||||
* from editing whichever frame later inherits the id.
|
||||
* Looked up inside the selected texture rather than by frame id alone, so a selection left over
|
||||
* from a deleted texture cannot edit whichever frame later inherits the id.
|
||||
*/
|
||||
function currentFrame(project: Project, assetId: string, frameId: string): TextureFrame | undefined {
|
||||
return currentTexture(project, assetId)?.frames.find((f) => f.id === frameId);
|
||||
|
||||
@@ -2,11 +2,7 @@ import { Label, type TextField, type VStack } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import type { FieldContext, FieldRow } from './fieldFactory';
|
||||
|
||||
/**
|
||||
* What the inspector sections build into: the panel body and the registry of rows. It is also
|
||||
* the FieldContext the sections hand to the field factory, so a row can resolve an asset
|
||||
* reference without the factory knowing anything about the editor state.
|
||||
*/
|
||||
/** What the inspector sections build into: the panel body and the registry of rows. */
|
||||
export interface InspectorHost extends FieldContext {
|
||||
readonly state: EditorState;
|
||||
readonly body: VStack;
|
||||
|
||||
@@ -88,9 +88,8 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable params are the one kind of setting the node cannot declare on its own: their
|
||||
* options are the variables of the behavior the node sits in. Everything else, asset
|
||||
* references included, is used exactly as declared.
|
||||
* Variable params are the one setting a node cannot declare on its own: their options are the
|
||||
* variables of the behavior it sits in. Everything else is used exactly as declared.
|
||||
*/
|
||||
function resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
|
||||
if (param.kind !== 'enum' || param.key !== 'variable' || asset.type !== 'behavior') return param;
|
||||
|
||||
@@ -287,10 +287,9 @@ function componentRow<V extends object>(
|
||||
* or, inside a container, to the template) and ↑ (apply upwards).
|
||||
*
|
||||
* Inside a container a value has two bases – the template it was instantiated from and, one
|
||||
* step further up, the asset – so ↺/↑ can become a menu. Only bases the value really differs
|
||||
* from are listed, so a menu never contains an entry that would change nothing. Dropping the
|
||||
* override is what makes the object follow the asset again: the template is only read when
|
||||
* the container is placed, never at draw time.
|
||||
* step further up, the asset – so ↺/↑ can become a menu. Dropping the override is what makes
|
||||
* the object follow the asset again: the template is only read when the container is placed,
|
||||
* never at draw time.
|
||||
*/
|
||||
function propertyRow(
|
||||
host: InspectorHost,
|
||||
|
||||
@@ -19,13 +19,9 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
|
||||
|
||||
/**
|
||||
* Row handlers that preview into the model while editing and take that preview back before
|
||||
* committing.
|
||||
*
|
||||
* Scrubbing has to be visible at once, so the value goes straight into the model and is only
|
||||
* redrawn (`preview`), without an undo entry. A command records the value it finds at execute
|
||||
* time, so committing right away would make the last previewed frame the undo target: the
|
||||
* value from before the preview is written back first – including "the key did not exist",
|
||||
* via `remove` – and only then is the command executed.
|
||||
* committing. A command records the value it finds at execute time, so committing right away
|
||||
* would make the last previewed frame the undo target: the value from before the preview is
|
||||
* written back first – including "the key did not exist", via `remove`.
|
||||
*/
|
||||
export function previewingHandlers<Target, Model = PropertyValue>(
|
||||
host: InspectorHost,
|
||||
|
||||
@@ -73,7 +73,7 @@ export class AssetPanel extends Panel {
|
||||
|
||||
/**
|
||||
* A texture is an uploaded picture, so its menu entry opens the file dialog instead of
|
||||
* creating an empty asset – a texture without an image would draw nothing.
|
||||
* creating an empty asset.
|
||||
*/
|
||||
private importTextures(): void {
|
||||
if (!this.textures) {
|
||||
|
||||
+2
-6
@@ -153,9 +153,8 @@ export class EditorState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one history operation. A `CommandError` is the documented way for a command to
|
||||
* reject an edit, so it becomes a notice; anything else is a real defect and keeps
|
||||
* travelling. Undo and redo go through here too – they can reject just like `execute`.
|
||||
* Runs one history operation. A `CommandError` is a command rejecting the edit, so it becomes
|
||||
* a notice; anything else is a real defect and keeps travelling.
|
||||
*/
|
||||
private runEdit(operation: () => boolean): boolean {
|
||||
try {
|
||||
@@ -196,7 +195,6 @@ export class EditorState {
|
||||
|
||||
select(selection: Selection): void {
|
||||
// Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
|
||||
// The type says `objectIds` is always there – untyped callers are exactly why this guard exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (selection?.kind === 'object' && !selection.objectIds)
|
||||
selection = objectSelection(selection.sceneId, [selection.objectId]);
|
||||
@@ -260,8 +258,6 @@ export class EditorState {
|
||||
const s = this.selection;
|
||||
if (!s) return null;
|
||||
if (s.kind === 'node') return s.assetId;
|
||||
// A frame names a texture and can never lead to a behavior. Answered here rather than
|
||||
// fallen through, so what remains below is the asset/object pair the lookup assumes.
|
||||
if (s.kind === 'frame') return null;
|
||||
const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined;
|
||||
const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
|
||||
|
||||
@@ -2,18 +2,13 @@ import type { TextureAsset } from '../../core/model/Asset';
|
||||
import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture';
|
||||
import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui';
|
||||
|
||||
/** Width of the label column, matching the inspector's so the two read as one language. */
|
||||
/** Matches the inspector's label column. */
|
||||
const LABEL_WIDTH = 76;
|
||||
const FIELD_WIDTH = 76;
|
||||
|
||||
/**
|
||||
* Cuts a sheet into a grid. Asks for the number of columns and rows rather than a cell size:
|
||||
* everyone knows how many tiles their sheet has, and deriving the cell size from the picture
|
||||
* cannot disagree with it.
|
||||
*
|
||||
* The dialog states, live, how many frames the current numbers would produce and how big each
|
||||
* one is. That readout is the whole point – it turns "did I mean 8×4 or 4×8" from something
|
||||
* you find out by undoing into something you see before committing.
|
||||
* Cuts a sheet into a grid, asked for as columns and rows rather than a cell size: the counts are
|
||||
* what the user knows, and a cell size derived from the picture cannot disagree with them.
|
||||
*/
|
||||
export class SliceDialog extends Popup {
|
||||
private readonly options: SliceOptions = { ...DEFAULT_SLICE };
|
||||
@@ -60,7 +55,6 @@ export class SliceDialog extends Popup {
|
||||
this.refreshSummary();
|
||||
}
|
||||
|
||||
/** Two numeric fields on one row – the grid's parameters come in x/y pairs. */
|
||||
private pair(
|
||||
labelA: string,
|
||||
keyA: keyof SliceOptions,
|
||||
@@ -90,7 +84,7 @@ export class SliceDialog extends Popup {
|
||||
const field = new NumberField(this.options[key], {
|
||||
min,
|
||||
step: 1,
|
||||
// Counts of cells and pixel offsets; the value is rounded on change, so 0 hides nothing.
|
||||
// Counts and pixel offsets, rounded on change, so 0 decimals hides nothing.
|
||||
decimals: 0,
|
||||
onChange: (value) => {
|
||||
this.options[key] = Math.round(Number(value));
|
||||
@@ -101,7 +95,6 @@ export class SliceDialog extends Popup {
|
||||
return field;
|
||||
}
|
||||
|
||||
/** Live readout of what the current numbers would do, including the refusal case. */
|
||||
private refreshSummary(): void {
|
||||
const rects = sliceFrames(this.asset.width, this.asset.height, this.options);
|
||||
const first = rects[0];
|
||||
|
||||
@@ -43,7 +43,6 @@ const MIN_LABEL_SIZE = 28;
|
||||
const LABEL_INSET = 3;
|
||||
const LABEL_PAD = 2;
|
||||
|
||||
/** A drag of an existing frame: which one, from what, and by which handle. */
|
||||
interface FrameDrag {
|
||||
frameId: string;
|
||||
handle: FrameHandle;
|
||||
@@ -52,7 +51,7 @@ interface FrameDrag {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** A new frame being spanned on empty space (image pixels; nothing is in the model yet). */
|
||||
/** A new frame being spanned, in image pixels; nothing is in the model yet. */
|
||||
interface NewFrame {
|
||||
start: Vec2;
|
||||
current: Vec2;
|
||||
@@ -60,15 +59,8 @@ interface NewFrame {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Texture tab's canvas: one texture's picture with its frames drawn on top, where frames
|
||||
* are created by dragging on empty space and moved or resized by their handles.
|
||||
*
|
||||
* Everything but the picture is drawn in widget-local pixels rather than in a zoomed
|
||||
* container: outlines, handles and names have to keep a constant size on screen, which a
|
||||
* scaled container would defeat. The picture itself is a Sprite whose scale is the zoom, so
|
||||
* the GPU does the sampling – and `scaleMode` follows the texture's own Filter property, so a
|
||||
* pixel-art sheet is inspected as crisp pixels rather than as the mush a smoothed preview
|
||||
* would show.
|
||||
* The Texture tab's canvas. Everything but the picture is drawn in widget-local pixels rather
|
||||
* than in a zoomed container: outlines, handles and names must keep a constant size on screen.
|
||||
*/
|
||||
export class TextureCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -127,7 +119,7 @@ export class TextureCanvas extends Widget {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
/** Which texture is shown. Switching resets the view, since the next picture is a new subject. */
|
||||
/** Switching resets the view, since the next picture is a new subject. */
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
@@ -139,17 +131,12 @@ export class TextureCanvas extends Widget {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Refits the picture into the widget – the "reset view" the toolbar offers. */
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an image pixel currently sits on screen. Only the check scripts need this: dragging
|
||||
* a frame is done in image coordinates, so a test that cannot convert them can drag
|
||||
* somewhere but cannot aim at a known part of the picture.
|
||||
*/
|
||||
/** Where an image pixel sits on screen; only the check scripts need it, to aim drags. */
|
||||
imageToScreen(x: number, y: number): Vec2 {
|
||||
const local = imageToLocal(this.view, x, y);
|
||||
const origin = this.getGlobalPosition();
|
||||
@@ -176,9 +163,8 @@ export class TextureCanvas extends Widget {
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
const asset = this.asset();
|
||||
// Nothing is cut from a picture whose size is unknown: `normalizeFrameRect` has no bounds
|
||||
// to hold such a frame inside, so it would keep a rectangle that lies off the sheet for
|
||||
// good. An unmeasured texture is a failed upload, and the hint says so.
|
||||
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
|
||||
// that lies off the sheet.
|
||||
if (!asset || asset.width <= 0 || asset.height <= 0) return;
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
@@ -191,8 +177,7 @@ export class TextureCanvas extends Widget {
|
||||
const point = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
|
||||
// The selected frame is tested first and by its handles: it is the one being worked on,
|
||||
// and its handles must stay reachable even where a frame drawn later overlaps them.
|
||||
// The selected frame is tested first, so its handles stay reachable under a frame drawn later.
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
|
||||
if (selected && onSelected) {
|
||||
@@ -309,7 +294,6 @@ export class TextureCanvas extends Widget {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
/** Announces what a press would do before it happens (resize arrows, move, crosshair). */
|
||||
private updateCursor(local: Vec2): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
@@ -345,7 +329,6 @@ export class TextureCanvas extends Widget {
|
||||
if (asset && texture) {
|
||||
this.image.texture = texture;
|
||||
this.image.position.set(this.view.panX, this.view.panY);
|
||||
// The sprite is drawn at the picture's own pixel size, so the zoom is the whole scale.
|
||||
this.image.width = asset.width * this.view.zoom;
|
||||
this.image.height = asset.height * this.view.zoom;
|
||||
}
|
||||
@@ -365,11 +348,7 @@ export class TextureCanvas extends Widget {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The checkerboard behind the picture. It is clipped to the picture rather than filling the
|
||||
* widget: its job is to make transparent pixels of *this image* visible, and a board that
|
||||
* ran past the edges would hide where the image actually ends.
|
||||
*/
|
||||
/** Clipped to the picture rather than filling the widget, which would hide where it ends. */
|
||||
private drawChecker(asset: TextureAsset | null, hasPicture: boolean): void {
|
||||
this.checker.clear();
|
||||
if (!asset || !hasPicture || asset.width <= 0) return;
|
||||
@@ -377,8 +356,8 @@ export class TextureCanvas extends Widget {
|
||||
const w = asset.width * this.view.zoom;
|
||||
const h = asset.height * this.view.zoom;
|
||||
this.checker.rect(origin.x, origin.y, w, h).fill(CHECKER_DARK);
|
||||
// Only the squares that land inside the widget are worth emitting; a 4000 px sheet at a
|
||||
// high zoom would otherwise cost tens of thousands of rectangles per redraw.
|
||||
// Only the squares inside the widget: a 4000 px sheet zoomed in would otherwise cost tens of
|
||||
// thousands of rectangles per redraw.
|
||||
const from = (start: number, limit: number) => Math.max(0, Math.floor((limit - start) / CHECKER_SIZE));
|
||||
const x0 = from(origin.x, 0);
|
||||
const y0 = from(origin.y, 0);
|
||||
@@ -467,15 +446,8 @@ export class TextureCanvas extends Widget {
|
||||
const x = at.x + LABEL_INSET;
|
||||
const y = at.y + LABEL_INSET;
|
||||
label.position.set(x + LABEL_PAD, y + LABEL_PAD);
|
||||
/*
|
||||
* A dark plate behind the name, not just a colour choice for the text.
|
||||
*
|
||||
* The name is drawn *on the user's picture*, which may be any colour – the grey used at
|
||||
* first vanished on the light quadrants of a test sheet, and no fixed colour can be safe on
|
||||
* an arbitrary image. The plate makes the background known, so light text on it always
|
||||
* reads. Same reasoning as the scene grid, which is drawn white at a low alpha rather than
|
||||
* in a theme colour so that it survives whatever the scene puts behind it.
|
||||
*/
|
||||
// A dark plate, not just a text colour: the name is drawn on the user's picture, where no
|
||||
// fixed colour is legible against every background.
|
||||
this.overlay
|
||||
.roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2)
|
||||
.fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 });
|
||||
|
||||
@@ -11,13 +11,8 @@ import { TextureCanvas } from './TextureCanvas';
|
||||
const INFO_WIDTH = 190;
|
||||
|
||||
/**
|
||||
* The Texture tab: a picker for which texture is being worked on, the tools that act on the
|
||||
* whole sheet, and the canvas the frames are drawn on.
|
||||
*
|
||||
* It follows the selection the way the graph editor does – selecting a texture asset (or one
|
||||
* of its frames) shows it here – but it does not *force* the tab, and it keeps showing the
|
||||
* last texture when the selection moves elsewhere. Otherwise clicking a sprite to check
|
||||
* whether it wears the right frame would empty the tab you are working in.
|
||||
* The Texture tab. It follows the selection, but keeps showing the last texture when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
|
||||
*/
|
||||
export class TextureEditor extends VStack {
|
||||
private readonly canvas: TextureCanvas;
|
||||
@@ -40,8 +35,7 @@ export class TextureEditor extends VStack {
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved rather than measured: the label is built empty, and a stack sizes it once from
|
||||
// the text it had at the time – so without a floor the readout is clipped from then on.
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
@@ -63,7 +57,11 @@ export class TextureEditor extends VStack {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** Shows a texture explicitly (opening the tab for a freshly imported image). */
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
this.canvas.show(assetId);
|
||||
this.refresh();
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* How the Texture tab maps image pixels onto the widget, and what a drag on a frame means.
|
||||
* Pure geometry – no Pixi, no editor state – so the fiddly part of the editor (which handle
|
||||
* did I grab, what does dragging it do) is unit-testable instead of only clickable.
|
||||
*/
|
||||
/** Geometry for the Texture tab: no Pixi and no editor state, so it stays unit-testable. */
|
||||
import type { FrameRect } from '../../core/model/texture';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
|
||||
@@ -27,7 +23,6 @@ export function localToImage(view: SheetView, x: number, y: number): Vec2 {
|
||||
return { x: (x - view.panX) / view.zoom, y: (y - view.panY) / view.zoom };
|
||||
}
|
||||
|
||||
/** The view that centres the whole picture in a `w`×`h` widget. */
|
||||
export function fitView(imageWidth: number, imageHeight: number, w: number, h: number): SheetView {
|
||||
if (imageWidth <= 0 || imageHeight <= 0 || w <= 0 || h <= 0) return { panX: 0, panY: 0, zoom: 1 };
|
||||
const zoom = clampZoom(Math.min(w / imageWidth, h / imageHeight) * FIT_MARGIN || 1);
|
||||
@@ -50,10 +45,7 @@ export function zoomAbout(view: SheetView, factor: number, localX: number, local
|
||||
return after;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which part of a frame a point grabs: a corner or edge handle (`hx`/`hy` in −1/0/1, naming
|
||||
* the edge each axis is pinned to), `'move'` anywhere else inside it, or null outside.
|
||||
*/
|
||||
/** `hx`/`hy` are −1/0/1, naming the edge each axis is pinned to; `'move'` is anywhere inside. */
|
||||
export type FrameHandle = { hx: -1 | 0 | 1; hy: -1 | 0 | 1 } | 'move';
|
||||
|
||||
/** The nine anchor points of a rectangle; (0, 0) is its centre and is not a handle. */
|
||||
@@ -81,12 +73,8 @@ export function frameHandles(): readonly { hx: -1 | 0 | 1; hy: -1 | 0 | 1 }[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a press at `point` (image pixels) does to `rect`. `tolerance` is the grab radius in
|
||||
* image pixels – the caller converts a constant screen distance, so handles stay equally easy
|
||||
* to hit at every zoom.
|
||||
*
|
||||
* Corners are tested before edges so that at a small frame, where all handles overlap, the
|
||||
* corner wins: a corner resizes both axes and is the one worth reaching for.
|
||||
* What a press at `point` does to `rect`; `tolerance` is the grab radius in image pixels.
|
||||
* Corners are tested before edges, so the corner wins on a frame small enough for both to overlap.
|
||||
*/
|
||||
export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): FrameHandle | null {
|
||||
for (const handle of HANDLES) {
|
||||
@@ -99,9 +87,8 @@ export function handleAt(rect: FrameRect, point: Vec2, tolerance: number): Frame
|
||||
}
|
||||
|
||||
/**
|
||||
* The rectangle a drag produces: `handle` decides which edges follow the pointer. The result
|
||||
* may be inverted or stick out of the image – squaring it up is `normalizeFrameRect`'s job,
|
||||
* so that a drag and a typed-in value are corrected by exactly one piece of code.
|
||||
* The rectangle a drag produces. It may come out inverted or outside the image: squaring it up
|
||||
* is `normalizeFrameRect`'s job, shared with typed-in values.
|
||||
*/
|
||||
export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy: number): FrameRect {
|
||||
if (handle === 'move') return { ...start, x: start.x + dx, y: start.y + dy };
|
||||
@@ -126,7 +113,6 @@ export function rectBetween(a: Vec2, b: Vec2): FrameRect {
|
||||
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
|
||||
}
|
||||
|
||||
/** The mouse cursor a handle asks for, so a resize edge announces itself before it is grabbed. */
|
||||
export function handleCursor(handle: FrameHandle): string {
|
||||
if (handle === 'move') return 'move';
|
||||
if (handle.hx === 0) return 'ns-resize';
|
||||
|
||||
+16
-56
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Getting a picture from the user's machine into the project: pick, drop or paste a file,
|
||||
* shrink it if it is too big to send, upload the bytes, add a texture asset. The three ways in
|
||||
* share every step after the file – so a dropped and a picked image behave identically.
|
||||
*/
|
||||
import { CreateTextureCommand } from '../core/commands/assetCommands';
|
||||
import { nextAssetName } from '../core/commands/naming';
|
||||
import type { TextureAsset } from '../core/model/Asset';
|
||||
@@ -12,16 +7,10 @@ import type { EditorState } from './state';
|
||||
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
|
||||
export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
/**
|
||||
* Longest side a stored texture may have. A 4000×3000 phone photo has no business being a
|
||||
* sprite texture: it costs 48 MB of video memory to draw something a fraction of that size.
|
||||
*/
|
||||
/** Longest side a stored texture may have: a 4000×3000 photo costs 48 MB of video memory. */
|
||||
export const MAX_TEXTURE_SIDE = 2048;
|
||||
|
||||
/**
|
||||
* Byte budget for one upload. The API would take 4 MB, but nginx cuts every request off at
|
||||
* 1 MB and answers with its own HTML page, so nginx is the wall we have to stay under.
|
||||
*/
|
||||
/** Byte budget for one upload: nginx cuts every request off at 1 MB, below the API's own 4 MB. */
|
||||
export const MAX_UPLOAD_BYTES = 950 * 1024;
|
||||
|
||||
/** Tried in order when an image has to be re-encoded; the first result that fits wins. */
|
||||
@@ -30,9 +19,8 @@ const WEBP_QUALITIES = [0.9, 0.75, 0.6] as const;
|
||||
export type ImagePlan = { kind: 'keep' } | { kind: 'reencode'; width: number; height: number };
|
||||
|
||||
/**
|
||||
* How to get an image within both limits. Re-encoding is a last resort – it would turn crisp
|
||||
* pixel art into mush – so anything that is already small enough in both senses is sent byte
|
||||
* for byte as it lies on disk. `bytes` is the encoded file size, not the pixel count.
|
||||
* Re-encoding would turn crisp pixel art into mush, so an image within both limits is kept byte
|
||||
* for byte. `bytes` is the encoded file size, not the pixel count.
|
||||
*/
|
||||
export function planImageFit(width: number, height: number, bytes: number): ImagePlan {
|
||||
const scale = Math.min(1, MAX_TEXTURE_SIDE / Math.max(width, height));
|
||||
@@ -44,11 +32,7 @@ export function planImageFit(width: number, height: number, bytes: number): Imag
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes at decreasing quality and stops at the first result within `MAX_UPLOAD_BYTES`. When
|
||||
* none fits, the last (lowest quality) attempt comes back, so the caller can report how far
|
||||
* off it was. `encode` is a parameter to keep the ladder testable without a canvas.
|
||||
*/
|
||||
/** Encodes at decreasing quality; when none fits the budget, the last attempt comes back anyway. */
|
||||
export async function encodeWithinBudget(encode: (quality: number) => Promise<Blob>): Promise<Blob> {
|
||||
let result = await encode(WEBP_QUALITIES[0]);
|
||||
for (const quality of WEBP_QUALITIES.slice(1)) {
|
||||
@@ -66,11 +50,7 @@ export interface PreparedImage {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes to upload for one picked file: the file itself whenever it already fits (see
|
||||
* `planImageFit`), otherwise a downscaled WebP. Rejects when the file is not a decodable image
|
||||
* or stays too large even at the lowest quality.
|
||||
*/
|
||||
/** The bytes to upload: the file itself when it already fits, otherwise a downscaled WebP. */
|
||||
export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
@@ -81,7 +61,6 @@ export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
try {
|
||||
const plan = planImageFit(bitmap.width, bitmap.height, file.size);
|
||||
if (plan.kind === 'keep') {
|
||||
// Untouched on purpose: re-encoding costs quality and would break pixel art.
|
||||
return {
|
||||
blob: file,
|
||||
type: file.type || 'application/octet-stream',
|
||||
@@ -89,8 +68,7 @@ export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
height: bitmap.height,
|
||||
};
|
||||
}
|
||||
// Only the first frame of an animated GIF survives this, which is all the renderer ever
|
||||
// draws – a texture is one still picture.
|
||||
// Only the first frame of an animated GIF survives this.
|
||||
const canvas = new OffscreenCanvas(plan.width, plan.height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('This browser cannot resize images.');
|
||||
@@ -122,10 +100,8 @@ export interface ImportOutcome {
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the files one after another – prepare, upload, create the asset – selects the last
|
||||
* one that worked and sums the result up as a notice. Sequential on purpose: each asset is
|
||||
* named against the project as it stands *after* the previous one. A file that fails is
|
||||
* reported and the remaining ones are still imported.
|
||||
* Imports the files sequentially, so each asset is named against the project as the previous one
|
||||
* left it. A file that fails is reported in the notice; the remaining ones are still imported.
|
||||
*/
|
||||
export async function importImages(
|
||||
files: readonly File[],
|
||||
@@ -147,11 +123,8 @@ async function importImage(file: File, deps: TextureImportDeps): Promise<ImportO
|
||||
const image = await prepareImage(file);
|
||||
const uploaded = await deps.textures.upload(image.blob, image.type);
|
||||
|
||||
// The server stores by content hash, so importing a picture the project already has gives
|
||||
// back the file it already references. Making a second asset for it would be clutter with a
|
||||
// trap in it: two assets on one file can disagree about `filter`, while the scale mode
|
||||
// belongs to the single shared source. Reuse instead – and say so, rather than silently
|
||||
// doing nothing visible.
|
||||
// The server stores by content hash, so a re-imported picture comes back as the file the
|
||||
// project already references; two assets on one file would disagree about `filter`.
|
||||
const existing = deps.state.project.assets.find(
|
||||
(asset): asset is TextureAsset => asset.type === 'texture' && asset.file === uploaded.file,
|
||||
);
|
||||
@@ -169,11 +142,7 @@ async function importImage(file: File, deps: TextureImportDeps): Promise<ImportO
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for the whole batch: how many arrived, how many were already there, and why the
|
||||
* first casualty did not. Reuse gets its own words – "Imported 1 image" would be a lie about a
|
||||
* picture the project already had, and the user would look for a new asset that never appeared.
|
||||
*/
|
||||
/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */
|
||||
export function importNotice(outcomes: readonly ImportOutcome[]): string {
|
||||
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
|
||||
const reused = outcomes.filter((outcome) => outcome.reused).length;
|
||||
@@ -195,7 +164,6 @@ export function importNotice(outcomes: readonly ImportOutcome[]): string {
|
||||
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
|
||||
}
|
||||
|
||||
/** "player_ship.png" → "player_ship"; a pasted screenshot arrives as "image.png". */
|
||||
function assetNameFor(file: File): string {
|
||||
return file.name.replace(/\.[^.]+$/, '').trim() || 'Texture';
|
||||
}
|
||||
@@ -206,10 +174,7 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
// --- ways in ---------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens the browser's file dialog and resolves with what the user chose – empty when the
|
||||
* dialog was cancelled. The `<input>` lives only for this one call.
|
||||
*/
|
||||
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||||
export function pickImageFiles(): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
@@ -223,13 +188,12 @@ export function pickImageFiles(): Promise<File[]> {
|
||||
resolve(files);
|
||||
};
|
||||
input.addEventListener('change', () => finish([...(input.files ?? [])]));
|
||||
// Without `cancel` a dismissed dialog would leave both the element and this promise behind.
|
||||
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
|
||||
input.addEventListener('cancel', () => finish([]));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** The `+` menu's Texture entry: pick files and import them, reporting anything that goes wrong. */
|
||||
export function pickAndImport(deps: TextureImportDeps): void {
|
||||
void pickImageFiles()
|
||||
.then((files) => importImages(files, deps))
|
||||
@@ -237,12 +201,8 @@ export function pickAndImport(deps: TextureImportDeps): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the two ways of importing an image that need no menu: dropping files onto `target`
|
||||
* (the Pixi canvas) and pasting one from the clipboard. Returns the function that removes both
|
||||
* again.
|
||||
*
|
||||
* Paste is bound to the window because the canvas cannot hold focus, and is ignored while a
|
||||
* DOM text field has it (the `KeyboardBridge` input): Ctrl+V belongs to the text being edited.
|
||||
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
|
||||
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
|
||||
*/
|
||||
export function installTextureImport(target: HTMLElement, deps: TextureImportDeps): () => void {
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
|
||||
+5
-11
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* Keyboard focus lives in the canvas, not in the DOM: exactly one widget is focused at a time
|
||||
* and key events reach it through this manager, which listens on `window`. The keyboard bridge
|
||||
* input is the only DOM element that ever holds real focus, and its keys are routed separately.
|
||||
*/
|
||||
import type { Container } from 'pixi.js';
|
||||
import { collectFocusables, type Widget } from './Widget';
|
||||
|
||||
/**
|
||||
* Tracks the single focused widget and routes keyboard events to it. Tab / Shift+Tab
|
||||
* cycle through focusable widgets in tree order below `root`.
|
||||
* Keyboard focus lives in the canvas, not in the DOM: one widget is focused at a time and keys
|
||||
* reach it from a `window` listener. The keyboard bridge input is the only DOM element that ever
|
||||
* holds real focus, and its keys are routed separately.
|
||||
*/
|
||||
export class FocusManager {
|
||||
/** Global handlers for keys nobody consumed (Delete, Ctrl+Z, …). */
|
||||
@@ -24,7 +20,6 @@ export class FocusManager {
|
||||
return this.focusedWidget;
|
||||
}
|
||||
|
||||
/** Moves focus; the previous widget is blurred before the new one is focused. */
|
||||
focus(widget: Widget | null): void {
|
||||
if (widget === this.focusedWidget) return;
|
||||
const previous = this.focusedWidget;
|
||||
@@ -38,9 +33,8 @@ export class FocusManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer went down on `target`. If that is outside the focused widget, the widget
|
||||
* loses focus *before* the press is handled, so a text field commits its edit before
|
||||
* whatever the click triggers (selection change, inspector rebuild, …) can destroy it.
|
||||
* Blurs before the press is handled, so a text field commits its edit before whatever the
|
||||
* click triggers – a selection change, an inspector rebuild – can destroy it.
|
||||
*/
|
||||
blurIfOutside(target: Container | null): void {
|
||||
const w = this.focusedWidget;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
/**
|
||||
* The one piece of DOM in the whole application: an invisible, off-screen `<input>` that
|
||||
* receives keyboard focus while a canvas text field is being edited. The browser supplies
|
||||
* IME composition, the mobile on-screen keyboard, clipboard and selection semantics; the
|
||||
* canvas field mirrors `value` / selection from here. Nothing visible is DOM.
|
||||
* The one piece of DOM in the application: an invisible `<input>` that holds keyboard focus while
|
||||
* a canvas text field is edited, so the browser supplies IME, clipboard, selection and the mobile
|
||||
* keyboard. The canvas field mirrors its value and selection.
|
||||
*/
|
||||
|
||||
/** A canvas text field while it is being edited; the bridge forwards the input's events to it. */
|
||||
export interface TextEditTarget {
|
||||
/** Called whenever value or selection changed. */
|
||||
onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void;
|
||||
/** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */
|
||||
onBridgeKey(event: KeyboardEvent): boolean;
|
||||
/** The bridge lost DOM focus (user clicked elsewhere, window blur). */
|
||||
/** The bridge lost DOM focus. */
|
||||
onBridgeBlur(): void;
|
||||
}
|
||||
|
||||
@@ -65,7 +63,7 @@ export class KeyboardBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/** Start editing: mirror the field's value into the input and grab DOM focus. */
|
||||
/** Mirrors the field's value into the input and grabs DOM focus. */
|
||||
attach(
|
||||
target: TextEditTarget,
|
||||
value: string,
|
||||
@@ -86,21 +84,18 @@ export class KeyboardBridge {
|
||||
this.input.style.top = `${Math.max(0, y)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a value/selection the canvas side changed (arrow stepping, caret placement) into the
|
||||
* input, so the next keystroke edits the same text the user sees.
|
||||
*/
|
||||
/** Pushes a value the canvas side changed into the input, so the next keystroke edits that text. */
|
||||
setValue(value: string, selectionStart = value.length, selectionEnd = selectionStart): void {
|
||||
this.input.value = value;
|
||||
if (document.activeElement === this.input) this.input.setSelectionRange(selectionStart, selectionEnd);
|
||||
}
|
||||
|
||||
/** Selects the whole input; the field learns about it through the usual `onBridgeInput` sync. */
|
||||
/** The field learns about it through the usual `onBridgeInput` sync. */
|
||||
selectAll(): void {
|
||||
this.input.select();
|
||||
}
|
||||
|
||||
/** Stop editing for `target`. Ignored when another field attached meanwhile, so a late blur cannot steal it. */
|
||||
/** Ignored when another field attached meanwhile, so a late blur cannot steal it. */
|
||||
detach(target: TextEditTarget): void {
|
||||
if (this.target !== target) return;
|
||||
this.target = null;
|
||||
|
||||
+1
-12
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* Design tokens for the canvas UI. Widgets take every color and text style from here and
|
||||
* the sizes they share (row/control heights, spacing, the few named control sizes); sizes
|
||||
* that only one widget uses may stay local to it.
|
||||
*/
|
||||
/** Design tokens for the canvas UI. Sizes only one widget uses may stay local to it. */
|
||||
import { TextStyle } from 'pixi.js';
|
||||
|
||||
export const Theme = {
|
||||
@@ -15,13 +11,9 @@ export const Theme = {
|
||||
padding: 8,
|
||||
gap: 6,
|
||||
radius: 4,
|
||||
/** Horizontal inset of text inside text fields. */
|
||||
fieldPadding: 6,
|
||||
/** Side length of the checkbox square (centred inside the control height). */
|
||||
checkboxSize: 16,
|
||||
/** Side length of the small color marker in list rows. */
|
||||
swatchSize: 12,
|
||||
/** Width of the scrollbar thumb. */
|
||||
scrollbarWidth: 6,
|
||||
/** Horizontal indent per nesting level in tree lists. */
|
||||
treeIndent: 14,
|
||||
@@ -46,9 +38,7 @@ export const Theme = {
|
||||
/** Collider outlines: solid (blocking) and trigger (events only). */
|
||||
collider: 0x4fd18b,
|
||||
trigger: 0xf2c84b,
|
||||
/** Container (group) symbols. */
|
||||
container: 0x3fb8c9,
|
||||
/** Rubber-band selection rectangle. */
|
||||
marquee: 0x4c8dff,
|
||||
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
|
||||
graphBackground: 0x15171b,
|
||||
@@ -72,7 +62,6 @@ export const Theme = {
|
||||
|
||||
const styleCache = new Map<string, TextStyle>();
|
||||
|
||||
/** Named text styles; every piece of text in the UI uses one of these. */
|
||||
export type TextStyleToken = 'body' | 'bodyBold' | 'muted' | 'small' | 'title' | 'mono' | 'accent';
|
||||
|
||||
/** Shared TextStyle instances so Pixi can cache glyph measurements per token. */
|
||||
|
||||
+2
-6
@@ -2,10 +2,7 @@ import type { Application, Container } from 'pixi.js';
|
||||
import { FocusManager } from './FocusManager';
|
||||
import { KeyboardBridge } from './KeyboardBridge';
|
||||
|
||||
/**
|
||||
* Services shared by all widgets: the focus manager, the keyboard bridge and the overlay
|
||||
* layer where popups render above everything else. Created once by `initUI()`.
|
||||
*/
|
||||
/** Services shared by all widgets, created once by `initUI()`. */
|
||||
export interface UIContext {
|
||||
app: Application;
|
||||
focus: FocusManager;
|
||||
@@ -16,7 +13,7 @@ export interface UIContext {
|
||||
|
||||
let context: UIContext | null = null;
|
||||
|
||||
/** Creates the shared services. `root` is where Tab cycling searches; `overlay` must be drawn above it. */
|
||||
/** `root` is where Tab cycling searches; `overlay` must be drawn above it. */
|
||||
export function initUI(app: Application, root: Container, overlay: Container): UIContext {
|
||||
context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay };
|
||||
// Capture phase on the stage runs before any widget handler: a press outside the focused
|
||||
@@ -28,7 +25,6 @@ export function initUI(app: Application, root: Container, overlay: Container): U
|
||||
return context;
|
||||
}
|
||||
|
||||
/** The shared services; throws when called before `initUI()`. */
|
||||
export function ui(): UIContext {
|
||||
if (!context) throw new Error('UI not initialised – call initUI() first');
|
||||
return context;
|
||||
|
||||
+6
-22
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Root of the canvas widget toolkit. Everything visible is a Pixi container; there is no DOM
|
||||
* layout. Sizes are pushed top-down (`resize()`), preferred sizes are pulled bottom-up
|
||||
* (`measure()`), and repaints are coalesced per frame through `invalidate()`.
|
||||
*/
|
||||
import { Container, Rectangle } from 'pixi.js';
|
||||
|
||||
export interface Size {
|
||||
@@ -11,12 +6,9 @@ export interface Size {
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class of every canvas UI element. A widget owns a rectangular area (`w` × `h`) in
|
||||
* its parent's coordinate space. Parents call `resize()`; the widget then lays out its
|
||||
* children in `layout()` and repaints in `redraw()`. `measure()` reports the size the
|
||||
* widget would like to have, used by the stack layouts.
|
||||
*
|
||||
* Note: Pixi's own `width`/`height` scale the container, so sizes live in `w`/`h`.
|
||||
* Base class of every canvas UI element. Sizes are pushed down by the parent (`resize()`),
|
||||
* preferred sizes pulled up (`measure()`), repaints coalesced per frame (`invalidate()`).
|
||||
* Pixi's own `width`/`height` scale the container, so sizes live in `w`/`h` instead.
|
||||
*/
|
||||
export abstract class Widget extends Container {
|
||||
w = 0;
|
||||
@@ -25,7 +17,7 @@ export abstract class Widget extends Container {
|
||||
flex = 0;
|
||||
minWidth = 0;
|
||||
minHeight = 0;
|
||||
/** Whether the widget participates in Tab focus cycling. */
|
||||
/** Whether the widget takes part in Tab cycling. */
|
||||
focusable = false;
|
||||
focused = false;
|
||||
enabled = true;
|
||||
@@ -44,10 +36,7 @@ export abstract class Widget extends Container {
|
||||
return { w: this.minWidth, h: this.minHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size, re-runs `layout()` only when it changed and always repaints. Sizes are
|
||||
* pushed down by the parent; a widget never resizes itself.
|
||||
*/
|
||||
/** Re-runs `layout()` only when the size changed, and always repaints. */
|
||||
resize(w: number, h: number): void {
|
||||
const changed = w !== this.w || h !== this.h;
|
||||
this.w = w;
|
||||
@@ -60,7 +49,6 @@ export abstract class Widget extends Container {
|
||||
/** Position children. Called after a resize; must be cheap and idempotent. */
|
||||
layout(): void {}
|
||||
|
||||
/** Repaint own graphics. Called after resize and by `invalidate()`. */
|
||||
redraw(): void {}
|
||||
|
||||
/** Request a repaint at the end of the current frame (coalesced). */
|
||||
@@ -114,10 +102,7 @@ export abstract class Widget extends Container {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` when a pointer pressed on this widget is released, whether over the widget
|
||||
* (`pointerup`) or anywhere else (`pointerupoutside`). Use it to end drags and scrubs.
|
||||
*/
|
||||
/** Runs `fn` on release, whether the pointer is still over the widget or not. */
|
||||
protected onPointerRelease(fn: () => void): void {
|
||||
this.on('pointerup', fn);
|
||||
this.on('pointerupoutside', fn);
|
||||
@@ -127,7 +112,6 @@ export abstract class Widget extends Container {
|
||||
/** A concrete, empty widget: a sized container for custom drawing or manual child placement. */
|
||||
export class Box extends Widget {}
|
||||
|
||||
/** Depth-first collection of focusable, visible widgets in tree order. */
|
||||
export function collectFocusables(root: Container, out: Widget[] = []): Widget[] {
|
||||
if (!root.visible) return out;
|
||||
if (root instanceof Widget && root.focusable && root.enabled) out.push(root);
|
||||
|
||||
+5
-5
@@ -57,11 +57,14 @@ export function hsvToRgb({ h, s, v }: HSV): number {
|
||||
return (to(r) << 16) | (to(g) << 8) | to(b);
|
||||
}
|
||||
|
||||
// Pixi has no 2D gradients worth the trouble, so pickers paint these into a 2D canvas once and
|
||||
// show the result as a texture.
|
||||
|
||||
// --- gradient rasterisation ------------------------------------------------------------------
|
||||
// Pixi has no 2D gradients worth the trouble, so pickers paint these into a 2D canvas once and
|
||||
// show the result as a texture.
|
||||
|
||||
/** Paints the full hue spectrum left to right over `width` × `height` at the context's origin. */
|
||||
/** Paints the full hue spectrum left to right, at the context's origin. */
|
||||
export function paintHueBar(ctx: CanvasRenderingContext2D, width: number, height: number): void {
|
||||
const grad = ctx.createLinearGradient(0, 0, width, 0);
|
||||
for (let i = 0; i <= 6; i++) grad.addColorStop(i / 6, `hsl(${i * 60}, 100%, 50%)`);
|
||||
@@ -69,10 +72,7 @@ export function paintHueBar(ctx: CanvasRenderingContext2D, width: number, height
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the saturation (x) / value (y) square for `hue`: white on the left fading to the pure
|
||||
* hue on the right, with black fading in towards the bottom.
|
||||
*/
|
||||
/** Paints the saturation (x) / value (y) square for `hue`. */
|
||||
export function paintSvSquare(ctx: CanvasRenderingContext2D, size: number, hue: number): void {
|
||||
ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
+5
-7
@@ -4,15 +4,14 @@ import { Theme } from './Theme';
|
||||
export interface ControlBoxOptions {
|
||||
fill: number;
|
||||
border: number;
|
||||
/** Corner radius; defaults to `Theme.radius`. */
|
||||
radius?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared look of every control: a rounded fill with a 1 px border. The stroke is inset by
|
||||
* half a pixel so it lands on whole device pixels and stays crisp instead of blurring over two.
|
||||
* The shared look of every control. The stroke is inset by half a pixel so it lands on whole
|
||||
* device pixels and stays crisp instead of blurring over two.
|
||||
*/
|
||||
export function drawControlBox(g: Graphics, w: number, h: number, options: ControlBoxOptions): void {
|
||||
const { fill, border, radius = Theme.radius, x = 0, y = 0 } = options;
|
||||
@@ -29,14 +28,13 @@ export interface GridOptions {
|
||||
w: number;
|
||||
h: number;
|
||||
alpha: number;
|
||||
/** Alpha of the two lines through the origin; omitted = no axes (the graph has no origin to show). */
|
||||
/** Alpha of the two lines through the origin; omitted = no axes. */
|
||||
axisAlpha?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The background grid of the scene view and the graph editor. Drawn in white at a low alpha
|
||||
* rather than in a fixed colour, so it reads on whatever background the scene sets.
|
||||
* `g` is cleared by the caller, which also decides when the step is too dense to be useful.
|
||||
* White at a low alpha rather than a fixed colour, so the grid reads on whatever background the
|
||||
* scene sets. `g` is cleared by the caller.
|
||||
*/
|
||||
export function drawGrid(g: Graphics, options: GridOptions): void {
|
||||
const { originX, originY, step, w, h, alpha, axisAlpha } = options;
|
||||
|
||||
@@ -8,9 +8,8 @@ export interface StackOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays out child widgets in a row or column. Children with `flex > 0` share the space
|
||||
* left over after fixed children got their measured size (weighted by `flex`).
|
||||
* Non-widget children are ignored by the layout but still rendered.
|
||||
* Lays out child widgets in a row or column. Children with `flex > 0` share, weighted, the space
|
||||
* left after the fixed ones took their measured size. Non-widget children are still rendered.
|
||||
*/
|
||||
export abstract class Stack extends Widget {
|
||||
gap: number;
|
||||
@@ -27,12 +26,12 @@ export abstract class Stack extends Widget {
|
||||
this.align = options.align ?? 'stretch';
|
||||
}
|
||||
|
||||
/** The children the layout distributes space among: visible widgets, in child order. */
|
||||
/** The children space is distributed among: visible widgets, in child order. */
|
||||
get items(): Widget[] {
|
||||
return this.children.filter((c): c is Widget => c instanceof Widget && c.visible);
|
||||
}
|
||||
|
||||
/** Appends `widget` with the given flex share. Does not lay out; call `relayout()` once the stack is sized. */
|
||||
/** Does not lay out; call `relayout()` once the stack is sized. */
|
||||
add<T extends Widget>(widget: T, flex = 0): T {
|
||||
widget.flex = flex;
|
||||
this.addChild(widget);
|
||||
@@ -96,14 +95,12 @@ export abstract class Stack extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Column layout. */
|
||||
export class VStack extends Stack {
|
||||
constructor(options: StackOptions = {}) {
|
||||
super('y', options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Row layout. */
|
||||
export class HStack extends Stack {
|
||||
constructor(options: StackOptions = {}) {
|
||||
super('x', options);
|
||||
|
||||
+6
-12
@@ -12,11 +12,8 @@ export function centreY(containerHeight: number, itemHeight: number): number {
|
||||
export const NUMBER_DECIMALS = 3;
|
||||
|
||||
/**
|
||||
* Display text for a number. Always shows `decimals` places, so a value can never *look* like
|
||||
* a whole number when it is not: precision used to follow the field's step, which hid the
|
||||
* decimals that dragging in the viewport or a graph node had put into the value. Fields whose
|
||||
* value is integral by nature (a pixel index, a count) pass 0 and are exact all the same.
|
||||
* A value that rounds to zero never carries a minus sign.
|
||||
* Always shows `decimals` places, so a value can never look whole when it is not. A value that
|
||||
* rounds to zero never carries a minus sign.
|
||||
*/
|
||||
export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string {
|
||||
const zero = (0).toFixed(decimals);
|
||||
@@ -25,11 +22,9 @@ export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds `value` onto the grid of `stepSize`, so scrubbing a field lands on clean values:
|
||||
* with step 1 on whole numbers, with step 0.1 on tenths. Needed because neither input is
|
||||
* whole – the pointer delta comes in fractional device pixels (a 150 % display yields
|
||||
* thirds) and the starting value may already be fractional from a viewport drag.
|
||||
* The `toFixed` pass removes binary-float noise (0.1 × 3 = 0.30000000000000004).
|
||||
* Rounds `value` onto the grid of `stepSize`, so scrubbing lands on clean values even though
|
||||
* pointer deltas are fractional. The `toFixed` pass removes binary-float noise (0.1 × 3 =
|
||||
* 0.30000000000000004).
|
||||
*/
|
||||
export function snapToStep(value: number, stepSize: number): number {
|
||||
if (!(stepSize > 0) || !Number.isFinite(value)) return value;
|
||||
@@ -38,8 +33,7 @@ export function snapToStep(value: number, stepSize: number): number {
|
||||
|
||||
/**
|
||||
* Zoom multiplier for one wheel event. Exponential, so scrolling up and back down by the same
|
||||
* amount lands exactly where it started. The caller clamps the result into its own range –
|
||||
* the scene view and the graph editor allow very different zoom levels.
|
||||
* amount lands exactly where it started. The caller clamps into its own range.
|
||||
*/
|
||||
export function zoomFactor(deltaY: number): number {
|
||||
return Math.exp(-deltaY * 0.0015);
|
||||
|
||||
+23
-23
@@ -6,29 +6,26 @@ import { type Size, Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
|
||||
export interface ButtonOptions {
|
||||
/** Visual weight: 'primary' uses the accent color, 'ghost' has no background until hovered. */
|
||||
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
||||
/** Fixed width; otherwise measured from the label. */
|
||||
/** Fixed width; otherwise measured from the label, which is truncated when too long. */
|
||||
width?: number;
|
||||
/**
|
||||
* Label position in a button wider than its text. 'center' suits standalone buttons,
|
||||
* 'left' the ones that read as a list or a field (menu entries, dropdowns).
|
||||
*/
|
||||
/** Floor only – for a button whose label changes between forms and must grow with them. */
|
||||
minWidth?: number;
|
||||
align?: 'center' | 'left';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** Push button; Enter/Space activate it while focused. Clicks fire only for the primary pointer button. */
|
||||
export class Button extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly labelText: Text;
|
||||
/** The label as set. `labelText` holds the possibly shortened version that is on screen. */
|
||||
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
||||
private fullText: string;
|
||||
private pressed = false;
|
||||
/** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */
|
||||
/** Caller-owned: renders like a primary button but is never flipped by a click. */
|
||||
toggled = false;
|
||||
variant: NonNullable<ButtonOptions['variant']>;
|
||||
protected readonly align: NonNullable<ButtonOptions['align']>;
|
||||
protected readonly fixedWidth: number;
|
||||
onClick?: () => void;
|
||||
|
||||
constructor(text: string, options: ButtonOptions = {}) {
|
||||
@@ -39,7 +36,8 @@ export class Button extends Widget {
|
||||
this.focusable = true;
|
||||
this.cursor = 'pointer';
|
||||
this.minHeight = Theme.controlHeight;
|
||||
if (options.width) this.minWidth = options.width;
|
||||
this.fixedWidth = options.width ?? 0;
|
||||
this.minWidth = this.fixedWidth || (options.minWidth ?? 0);
|
||||
this.fullText = text;
|
||||
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') });
|
||||
this.addChild(this.bg, this.labelText);
|
||||
@@ -65,11 +63,15 @@ export class Button extends Widget {
|
||||
});
|
||||
}
|
||||
|
||||
/** The full label, not the truncated version on screen. */
|
||||
get text(): string {
|
||||
return this.fullText;
|
||||
}
|
||||
|
||||
/** What is drawn – shorter than `text` when the button is too narrow to hold it. */
|
||||
get shownText(): string {
|
||||
return this.labelText.text;
|
||||
}
|
||||
|
||||
/** Repaints only; the parent must relayout when the measured width matters. */
|
||||
setText(text: string): void {
|
||||
if (text === this.fullText) return;
|
||||
@@ -83,27 +85,25 @@ export class Button extends Widget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Whether the label sits on a filled accent surface and therefore needs the light style. */
|
||||
protected get onAccent(): boolean {
|
||||
return this.toggled || this.variant === 'primary' || this.variant === 'danger';
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
// Measured from the full label, never from what is currently on screen: measuring the
|
||||
// truncated text would let a button that was once too narrow stay too narrow for good.
|
||||
const h = Math.max(this.minHeight, Theme.controlHeight);
|
||||
// A given width is a width, not a floor: a picker must not resize around the name it shows.
|
||||
if (this.fixedWidth) return { w: this.fixedWidth, h };
|
||||
// From the full label, never the on-screen one: measuring truncated text would keep a button
|
||||
// that was once too narrow too narrow for good.
|
||||
const metrics = CanvasTextMetrics.measureText(this.fullText, this.labelText.style);
|
||||
return {
|
||||
w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2),
|
||||
h: Math.max(this.minHeight, Theme.controlHeight),
|
||||
};
|
||||
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Width the label may occupy. Subclasses that draw something else inside the button take
|
||||
* their room off here, so the text is shortened instead of running underneath it.
|
||||
*/
|
||||
/** Width the label may occupy; subclasses that draw inside the button take their room off here. */
|
||||
protected labelSlot(): number {
|
||||
return this.w - Theme.padding * 2;
|
||||
// The padding is an inset, not a claim: on a 24 px icon button it would leave 8 px and
|
||||
// truncate the glyph away entirely. From 48 px up this is the full padding again.
|
||||
return this.w - Math.min(Theme.padding, Math.floor(this.w / 6)) * 2;
|
||||
}
|
||||
|
||||
override onKeyDown(event: KeyboardEvent): boolean {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { centreY } from '../math';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
/** A square toggle used for boolean properties. */
|
||||
export class Checkbox extends Widget {
|
||||
private readonly box = new Graphics();
|
||||
onChange?: (checked: boolean) => void;
|
||||
@@ -20,14 +19,13 @@ export class Checkbox extends Widget {
|
||||
this.on('pointertap', () => this.toggle());
|
||||
}
|
||||
|
||||
/** Model → view update: does not fire `onChange`. */
|
||||
/** Does not fire `onChange`, unlike `toggle`. */
|
||||
setChecked(checked: boolean): void {
|
||||
if (this.checked === checked) return;
|
||||
this.checked = checked;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** User-driven flip that fires `onChange`. */
|
||||
toggle(): void {
|
||||
this.checked = !this.checked;
|
||||
this.invalidate();
|
||||
|
||||
@@ -7,9 +7,8 @@ import { Button, type ButtonOptions } from './Button';
|
||||
const CHEVRON_SLOT = 16;
|
||||
|
||||
/**
|
||||
* A button that opens a menu: value flush left, chevron flush right. The chevron is drawn
|
||||
* rather than written into the label, so the label stays the plain value – which keeps it
|
||||
* readable, lets it be compared against the menu entries, and stays crisp at any size.
|
||||
* A button that opens a menu: value flush left, chevron flush right. The chevron is drawn rather
|
||||
* than written into the label, so the label stays the plain value.
|
||||
*/
|
||||
export class Dropdown extends Button {
|
||||
private readonly chevron = new Graphics();
|
||||
@@ -21,7 +20,8 @@ export class Dropdown extends Button {
|
||||
|
||||
override measure(): Size {
|
||||
const size = super.measure();
|
||||
return { w: size.w + CHEVRON_SLOT, h: size.h };
|
||||
// A fixed width already includes the chevron's room; only a measured one has to grow.
|
||||
return this.fixedWidth ? size : { w: size.w + CHEVRON_SLOT, h: size.h };
|
||||
}
|
||||
|
||||
/** The chevron's room is not the label's: a long value is shortened, not drawn under it. */
|
||||
|
||||
@@ -6,7 +6,6 @@ import { type Size, Widget } from '../Widget';
|
||||
export interface LabelOptions {
|
||||
style?: TextStyleToken;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
/** Horizontal padding inside the label bounds. */
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,10 @@ import { Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
import type { ListItem } from './ListView';
|
||||
|
||||
/** Width reserved in front of every row of a tree list for the expand/collapse chevron. */
|
||||
/** Width reserved in front of every row of a tree list for the chevron. */
|
||||
const CHEVRON_SLOT = 14;
|
||||
|
||||
/**
|
||||
* One row of a `ListView`: hover/selection background, tree guide, chevron, swatch, label and
|
||||
* hint. Purely visual – pointer handling and selection state live in the list.
|
||||
*/
|
||||
/** One row of a `ListView`. Purely visual: pointer handling and selection state live in the list. */
|
||||
export class ListRow extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly labelText = new Text({ text: '', style: textStyle('body') });
|
||||
@@ -66,7 +63,7 @@ export class ListRow extends Widget {
|
||||
if (this.selected)
|
||||
this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
|
||||
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
|
||||
// Tree guide: a short "└" mark in front of nested rows.
|
||||
// Tree guide: a short "└" in front of nested rows.
|
||||
if (depth > 0) {
|
||||
const gx = indent - 8;
|
||||
this.bg
|
||||
@@ -101,7 +98,7 @@ export class ListRow extends Widget {
|
||||
);
|
||||
}
|
||||
|
||||
/** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */
|
||||
/** Drawn rather than a glyph, so it stays crisp at any size. */
|
||||
private drawChevron(x: number, y: number, expanded: boolean): void {
|
||||
const color = this.selected ? Theme.colors.text : Theme.colors.textMuted;
|
||||
if (expanded)
|
||||
|
||||
@@ -7,25 +7,22 @@ import { ListRow } from './ListRow';
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Small secondary text on the right (e.g. the asset type). */
|
||||
/** Small secondary text on the right. */
|
||||
hint?: string;
|
||||
/** Left indent in levels (hierarchies). */
|
||||
/** Left indent in levels. */
|
||||
depth?: number;
|
||||
/** Optional marker color drawn as a small square before the label. */
|
||||
/** Marker color drawn as a small square before the label. */
|
||||
swatch?: number;
|
||||
/**
|
||||
* Rows that have children: whether their subtree is currently shown. Such rows get a
|
||||
* chevron that reports clicks through `onToggle`. Left undefined for leaves.
|
||||
*/
|
||||
/** Whether the subtree is shown; such rows get a chevron. Left undefined for leaves. */
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
/** Pixels the pointer must travel before a press turns into a drag. */
|
||||
/** Pixels of travel before a press turns into a drag. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
/** Modifier keys held while a row was clicked. */
|
||||
export interface PickModifiers {
|
||||
/** Ctrl (or ⌘) – toggle the row in a multi-selection. */
|
||||
/** Ctrl or ⌘. */
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
}
|
||||
@@ -41,10 +38,8 @@ interface DragState {
|
||||
|
||||
/**
|
||||
* List of rows with one selected row (by id, so it survives `setItems()` rebuilds) and,
|
||||
* optionally, further *highlighted* rows the owner manages (a multi-selection). Emits select,
|
||||
* activate (double-click / Enter) and context events. A click with Ctrl/Shift held does not
|
||||
* change the selection itself but reports the modifiers, so the owner can toggle. With
|
||||
* `onDrop` set, rows can be dragged onto other rows (or onto the empty area below).
|
||||
* optionally, further highlighted rows the owner manages as a multi-selection. A click with
|
||||
* Ctrl/Shift held does not change the selection itself but reports the modifiers.
|
||||
*/
|
||||
export class ListView extends Widget {
|
||||
private items: ListItem[] = [];
|
||||
@@ -117,7 +112,7 @@ export class ListView extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Global centre of a row's chevron, for callers that must hit its real area; null when the row has none. */
|
||||
/** Global centre of a row's chevron; null when the row has none. */
|
||||
chevronGlobal(id: string): { x: number; y: number } | null {
|
||||
return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null;
|
||||
}
|
||||
|
||||
+4
-13
@@ -15,20 +15,13 @@ export interface MenuItem {
|
||||
}
|
||||
|
||||
export interface MenuOptions {
|
||||
/**
|
||||
* Adds a search box above the entries that filters them as you type; Enter picks the first
|
||||
* match. For menus that can grow with the project (the parent picker lists every object).
|
||||
*/
|
||||
/** Adds a search box above the entries that filters as you type; Enter picks the first match. */
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
/** How tall the entry list gets before it starts scrolling. */
|
||||
const MAX_LIST_HEIGHT = 320;
|
||||
|
||||
/**
|
||||
* A vertical list of clickable actions. Picking one closes the menu before it runs.
|
||||
* Entries read as a list: left-aligned, scrolled once they no longer fit on screen.
|
||||
*/
|
||||
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
|
||||
export class Menu extends Popup {
|
||||
private readonly entries: { button: Button; haystack: string }[] = [];
|
||||
private readonly list = new VStack({ gap: 2 });
|
||||
@@ -72,7 +65,6 @@ export class Menu extends Popup {
|
||||
if (this.search) ui().focus.focus(this.search);
|
||||
}
|
||||
|
||||
/** Shows the entries matching the search box and resizes the menu around them. */
|
||||
private filter(): void {
|
||||
const query = this.search?.value.trim().toLowerCase() ?? '';
|
||||
let matches = 0;
|
||||
@@ -88,9 +80,8 @@ export class Menu extends Popup {
|
||||
this.scroll.minHeight = Math.min(size.h, MAX_LIST_HEIGHT);
|
||||
this.scroll.scrollTo(0);
|
||||
this.refreshLayout();
|
||||
// Both calls are needed: a resize alone re-lays out only when the size actually changed,
|
||||
// and two different filters can leave the list exactly as tall as before while showing
|
||||
// an entirely different set of entries.
|
||||
// Both are needed: a resize re-lays out only on an actual size change, and two filters can
|
||||
// leave the list exactly as tall while showing different entries.
|
||||
this.scroll.relayoutContent();
|
||||
this.list.relayout();
|
||||
}
|
||||
|
||||
@@ -6,16 +6,11 @@ import { TextField, type TextFieldOptions } from './TextField';
|
||||
export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | 'onChange'> {
|
||||
min?: number;
|
||||
max?: number;
|
||||
/**
|
||||
* Increment per scrubbed pixel and per arrow key press, and the grid a scrub snaps to.
|
||||
* Does not affect the display, which is governed by `decimals` alone.
|
||||
*/
|
||||
/** Increment per scrubbed pixel and per arrow key, and the grid a scrub snaps to. */
|
||||
step?: number;
|
||||
/**
|
||||
* Decimal places the text shows (default `NUMBER_DECIMALS`). Only set this to a lower
|
||||
* precision where the *stored* value is of that precision too – a pixel index, a count of
|
||||
* tiles. On anything the viewport can drag to a fractional value it would hide the fraction,
|
||||
* and the text would commit the rounded number back into the project.
|
||||
* Decimal places the text shows (default `NUMBER_DECIMALS`). Lower it only where the stored
|
||||
* value has that precision too, or an edit commits the rounded number back into the project.
|
||||
*/
|
||||
decimals?: number;
|
||||
/** Fires continuously while scrubbing or typing a valid number. */
|
||||
@@ -24,15 +19,12 @@ export interface NumberFieldOptions extends Omit<TextFieldOptions, 'onInput' | '
|
||||
onChange?: (value: number) => void;
|
||||
}
|
||||
|
||||
/** Pixels the pointer must travel before a press turns into a scrub instead of a click. */
|
||||
/** Pixels of travel before a press becomes a scrub instead of a click. */
|
||||
const SCRUB_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1),
|
||||
* arrow keys step. A scrub snaps to the grid of its increment – a step-1 field therefore
|
||||
* always ends on a whole number, even when it started fractional. Values are clamped to
|
||||
* min/max and shown with `NUMBER_DECIMALS` decimals unless the field declares otherwise,
|
||||
* so the text never hides that the value is fractional.
|
||||
* Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1), arrow keys
|
||||
* step. A scrub snaps to the grid of its increment, so a step-1 field ends on a whole number.
|
||||
*/
|
||||
export class NumberField extends TextField {
|
||||
private numeric: number;
|
||||
@@ -87,7 +79,6 @@ export class NumberField extends TextField {
|
||||
if (scrub.moved) {
|
||||
this.onNumberChange?.(this.numeric);
|
||||
} else {
|
||||
// A plain click: enter text editing with everything selected.
|
||||
ui().focus.focus(this);
|
||||
this.selectAll();
|
||||
}
|
||||
@@ -99,12 +90,12 @@ export class NumberField extends TextField {
|
||||
return this.numeric;
|
||||
}
|
||||
|
||||
/** The one place the value becomes text, so every path through the field honours `decimals`. */
|
||||
/** The one place the value becomes text, so every path honours `decimals`. */
|
||||
private get display(): string {
|
||||
return formatNumber(this.numeric, this.decimals);
|
||||
}
|
||||
|
||||
/** Model → view update: clamps, and reformats the text unless the user is editing it. */
|
||||
/** Clamps, and reformats the text unless the user is editing it. */
|
||||
setNumber(value: number): void {
|
||||
this.numeric = this.clampValue(value);
|
||||
if (!this.isEditing) {
|
||||
|
||||
@@ -12,10 +12,7 @@ export interface PanelOptions {
|
||||
chrome?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled box with a vertical content stack. The optional header row can hold extra
|
||||
* widgets (e.g. a "+" button) via `header.add()`.
|
||||
*/
|
||||
/** A titled box; the header row can hold extra widgets through `header.add()`. */
|
||||
export class Panel extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
readonly header: HStack;
|
||||
|
||||
+6
-22
@@ -13,11 +13,7 @@ export interface PopupOptions {
|
||||
modal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A floating box on the overlay layer with a click-away backdrop. Base for menus,
|
||||
* pickers and dialogs. `open()` positions it at global coordinates and keeps it inside
|
||||
* the screen; `close()` removes it and calls `onClose`.
|
||||
*/
|
||||
/** A floating box on the overlay layer with a click-away backdrop. */
|
||||
export class Popup extends Widget {
|
||||
private readonly backdrop = new Graphics();
|
||||
private readonly bg = new Graphics();
|
||||
@@ -45,11 +41,7 @@ export class Popup extends Widget {
|
||||
return this.parent !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the popup on the overlay with its top-left corner at the given global position,
|
||||
* shifted as needed to stay on screen. Takes keyboard focus away from the focused widget;
|
||||
* Escape closes.
|
||||
*/
|
||||
/** Mounts at the given global position, shifted to stay on screen. Blurs the focused widget. */
|
||||
open(globalX: number, globalY: number): void {
|
||||
const { overlay, app, focus } = ui();
|
||||
const screen = app.screen;
|
||||
@@ -66,15 +58,11 @@ export class Popup extends Widget {
|
||||
window.addEventListener('keydown', this.onEscape, { capture: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-measures and repositions an open popup after its content changed size (a menu whose
|
||||
* entries were filtered). No-op while closed, so it is safe to call during construction.
|
||||
*/
|
||||
/** Re-measures an open popup after its content changed size. No-op while closed. */
|
||||
refreshLayout(): void {
|
||||
if (this.isOpen) this.place();
|
||||
}
|
||||
|
||||
/** Sizes the popup to its content and puts it at the requested spot, shifted to stay on screen. */
|
||||
private place(): void {
|
||||
const screen = ui().app.screen;
|
||||
const size = this.measure();
|
||||
@@ -84,24 +72,20 @@ export class Popup extends Widget {
|
||||
this.position.set(Math.round(x), Math.round(y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens left-aligned with `anchor`, `gap` px below its bottom edge (dropdown menus, pickers).
|
||||
* The popup is never narrower than the anchor, so a menu reads as the field it drops from.
|
||||
*/
|
||||
/** Opens left-aligned under `anchor`, and never narrower than it. */
|
||||
openBelow(anchor: Widget, gap = 2): void {
|
||||
const g = anchor.getGlobalPosition();
|
||||
this.minWidth = Math.max(this.minWidth, anchor.w);
|
||||
this.open(g.x, g.y + anchor.h + gap);
|
||||
}
|
||||
|
||||
/** Open centered on screen (dialogs). */
|
||||
openCentered(): void {
|
||||
const screen = ui().app.screen;
|
||||
const size = this.measure();
|
||||
this.open((screen.width - size.w) / 2, (screen.height - size.h) / 2);
|
||||
}
|
||||
|
||||
/** Unmounts and fires `onClose`. Safe to call on a popup that is not open. */
|
||||
/** Safe to call on a popup that is not open. */
|
||||
close(): void {
|
||||
if (!this.isOpen) return;
|
||||
window.removeEventListener('keydown', this.onEscape, { capture: true });
|
||||
@@ -109,7 +93,7 @@ export class Popup extends Widget {
|
||||
this.onClose?.();
|
||||
}
|
||||
|
||||
/** Click handler for action buttons: closes the popup first so `action` sees the overlay cleared. */
|
||||
/** Closes first, so `action` runs with the overlay already cleared. */
|
||||
protected closeThen(action?: () => void): () => void {
|
||||
return () => {
|
||||
this.close();
|
||||
|
||||
@@ -3,10 +3,7 @@ import { clamp } from '../math';
|
||||
import { Theme } from '../Theme';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
/**
|
||||
* Vertically scrolls a single content widget that is taller than the container.
|
||||
* The content is given the container's width and its own measured height.
|
||||
*/
|
||||
/** Vertical scroll of one content widget, which gets the container's width and its own height. */
|
||||
export class ScrollContainer extends Widget {
|
||||
private readonly maskShape = new Graphics();
|
||||
private readonly scrollbar = new Graphics();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Widget } from '../Widget';
|
||||
|
||||
/** Thin draggable divider between two panes. Reports the drag delta; the parent resizes. */
|
||||
export class Splitter extends Widget {
|
||||
/** Size across the drag axis. The visible bar fills it, so this is also the grab area. */
|
||||
/** Size across the drag axis; the visible bar fills it, so this is also the grab area. */
|
||||
static readonly THICKNESS = 5;
|
||||
private readonly bar = new Graphics();
|
||||
private dragging = false;
|
||||
|
||||
@@ -15,17 +15,15 @@ export interface TextFieldOptions {
|
||||
/** Fires on Enter / blur when the value changed since editing started. */
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Fires on Enter only (before the blur commit), regardless of whether the value changed.
|
||||
* Use this for "accept" semantics (search boxes, dialogs) instead of `onChange`, which
|
||||
* also fires when focus is taken away by a click elsewhere.
|
||||
* Fires on Enter only, before the blur commit, whether or not the value changed – "accept"
|
||||
* semantics, unlike `onChange`, which also fires when a click elsewhere takes the focus.
|
||||
*/
|
||||
onSubmit?: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text input drawn in the canvas. Keyboard handling is delegated to the
|
||||
* KeyboardBridge while the field is focused; this class only mirrors value, selection
|
||||
* and caret and handles pointer interaction (caret placement, drag selection).
|
||||
* KeyboardBridge while focused; this class only mirrors value, selection and caret.
|
||||
*/
|
||||
export class TextField extends Widget implements TextEditTarget {
|
||||
protected readonly bg = new Graphics();
|
||||
@@ -88,7 +86,7 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
return this.valueText;
|
||||
}
|
||||
|
||||
/** Programmatic update (from the model). Ignored while the user is editing. */
|
||||
/** Ignored while the user is editing. */
|
||||
setValue(value: string): void {
|
||||
if (this.editing || value === this.valueText) return;
|
||||
this.valueText = value;
|
||||
@@ -186,7 +184,7 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
if (changed) this.onInput?.(value);
|
||||
}
|
||||
|
||||
/** Enter submits then commits via blur, Escape reverts, Tab cycles focus; editing keys stay with the browser. */
|
||||
/** Editing keys are left to the browser (returning false). */
|
||||
onBridgeKey(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Enter') {
|
||||
this.onSubmit?.(this.valueText);
|
||||
@@ -207,7 +205,7 @@ export class TextField extends Widget implements TextEditTarget {
|
||||
}
|
||||
|
||||
onBridgeBlur(): void {
|
||||
// DOM focus went elsewhere (browser UI, another tab). Commit and release focus.
|
||||
// DOM focus went elsewhere: browser UI, another tab.
|
||||
this.stopEditing(true);
|
||||
if (ui().focus.focused === this) ui().focus.blur();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* The playing view: the running world seen through the scene's camera. The camera's frame
|
||||
* defines what is visible and is the only thing drawn – it is letterboxed into the widget and
|
||||
* everything outside it is masked away. Without a world or without a camera nothing renders.
|
||||
*
|
||||
* Nothing here edits: the view reports the pointer to the world (so graphs can use Mouse
|
||||
* position, Is hovered and On click) and otherwise only draws.
|
||||
*/
|
||||
/** The running world seen through the scene's camera, letterboxed into the widget. */
|
||||
import { type FederatedPointerEvent, Text } from 'pixi.js';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
@@ -17,7 +10,6 @@ import { type Frame, WorldViewport } from './WorldViewport';
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
export class GameViewport extends WorldViewport {
|
||||
/** Shown while no world is running. */
|
||||
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
|
||||
private playWorld: World | null = null;
|
||||
|
||||
@@ -37,7 +29,7 @@ export class GameViewport extends WorldViewport {
|
||||
this.on('pointerupoutside', () => this.playWorld?.setPointerDown(false));
|
||||
}
|
||||
|
||||
/** The world to draw (null = not playing). */
|
||||
/** null = not playing. */
|
||||
setWorld(world: World | null): void {
|
||||
this.playWorld = world;
|
||||
this.redraw();
|
||||
@@ -62,10 +54,7 @@ export class GameViewport extends WorldViewport {
|
||||
this.hint.position.set(this.w / 2, this.h / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the mouse position to the running world in world units. Outside the camera frame
|
||||
* counts as "not in view" – that region is letterbox, not game.
|
||||
*/
|
||||
/** Outside the camera frame counts as "not in view": that region is letterbox, not game. */
|
||||
private reportPointer(e: FederatedPointerEvent): void {
|
||||
const world = this.playWorld;
|
||||
const view = this.currentView();
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* The editable view of a scene: the model drawn with a grid, selection outlines, drag-to-move
|
||||
* (all selected objects at once), rubber-band selection on empty space, Ctrl-click to add or
|
||||
* remove, middle-drag or Shift-drag panning and wheel zoom.
|
||||
*
|
||||
* It shows `state.project` – in play mode that is the runtime copy, so the live positions are
|
||||
* visible and stay fully editable.
|
||||
* The editable view of a scene. It shows `state.project`, which in play mode is the runtime
|
||||
* copy, so live positions are visible and stay editable.
|
||||
*/
|
||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import { CompoundCommand } from '../core/commands/CompoundCommand';
|
||||
@@ -20,9 +16,8 @@ import { dashedLine } from './symbols';
|
||||
import { freeView, screenToWorld, type View, worldToScreen } from './view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
|
||||
/** Pixels of travel before a press turns into a drag. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
/** Pixels the pointer must travel before a press on an object starts moving it. */
|
||||
const DRAG_THRESHOLD = 2;
|
||||
const GRID_STEP = 100;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
@@ -30,7 +25,6 @@ const MIN_GRID_STEP = 12;
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
|
||||
/** A drag of the selected objects: every moved root with the local position it started from. */
|
||||
interface ObjectDrag {
|
||||
sceneId: string;
|
||||
items: { objectId: string; start: Position }[];
|
||||
@@ -38,7 +32,7 @@ interface ObjectDrag {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** A rubber-band selection in progress (viewport-local pixels). */
|
||||
/** A rubber-band selection in progress, in viewport-local pixels. */
|
||||
interface Marquee {
|
||||
start: Vec2;
|
||||
current: Vec2;
|
||||
@@ -47,7 +41,6 @@ interface Marquee {
|
||||
|
||||
export class SceneViewport extends WorldViewport {
|
||||
private readonly grid = new Graphics();
|
||||
/** Dashed parent links (behavior → owner), drawn in world units beneath the symbols. */
|
||||
private readonly links = new Graphics();
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
@@ -77,7 +70,7 @@ export class SceneViewport extends WorldViewport {
|
||||
});
|
||||
}
|
||||
|
||||
/** World coordinates at the centre of the view (used to place assets). */
|
||||
/** World coordinates at the centre of the view. */
|
||||
worldCenter(): Vec2 {
|
||||
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
|
||||
}
|
||||
@@ -96,7 +89,7 @@ export class SceneViewport extends WorldViewport {
|
||||
return Theme.colors.viewport;
|
||||
}
|
||||
|
||||
/** Without a scene there is nothing to frame – the neutral base colour stands alone. */
|
||||
/** Without a scene there is nothing to frame: the neutral base colour stands alone. */
|
||||
protected override paintsBackground(): boolean {
|
||||
return Boolean(this.state.activeScene);
|
||||
}
|
||||
@@ -114,7 +107,7 @@ export class SceneViewport extends WorldViewport {
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 && e.target === this) {
|
||||
// Empty space: a click deselects, a drag becomes a rubber band (decided on release).
|
||||
// Empty space: a click deselects, a drag becomes a rubber band – decided on release.
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee = {
|
||||
start: { x: local.x, y: local.y },
|
||||
@@ -213,7 +206,7 @@ export class SceneViewport extends WorldViewport {
|
||||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedObjectIds.includes(objectId))
|
||||
this.state.select(objectSelection(scene.id, [objectId]));
|
||||
// Only the tops of the selected subtrees move; their descendants follow through the hierarchy.
|
||||
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
|
||||
const items = subtreeRoots(scene, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(scene, id))
|
||||
.filter((o): o is NonNullable<typeof o> => Boolean(o))
|
||||
@@ -222,7 +215,7 @@ export class SceneViewport extends WorldViewport {
|
||||
this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
}
|
||||
|
||||
/** Rubber band released: selects every object whose drawn position lies inside it. */
|
||||
/** Selects every object whose position – not its bounds – lies inside the band. */
|
||||
private selectInMarquee(marquee: Marquee): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
@@ -277,9 +270,8 @@ export class SceneViewport extends WorldViewport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed line from every parented symbol (behavior, camera) to its parent. Sprites show
|
||||
* their nesting in the hierarchy panel instead, to keep the scene uncluttered; colliders sit
|
||||
* on their body anyway, so they get no line either.
|
||||
* Dashed line from every parented symbol to its parent. Sprites show their nesting in the
|
||||
* hierarchy panel instead, and colliders sit on their body, so neither gets a line.
|
||||
*/
|
||||
private drawParentLinks(items: DrawItem[], scale: number): void {
|
||||
this.links.clear();
|
||||
@@ -295,7 +287,6 @@ export class SceneViewport extends WorldViewport {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name label next to a symbol. */
|
||||
private syncLabel(item: DrawItem, show: boolean, scale: number, size: Size): void {
|
||||
let label = this.labels.get(item.id);
|
||||
if (!show || !isSymbolType(item.type)) {
|
||||
@@ -316,12 +307,11 @@ export class SceneViewport extends WorldViewport {
|
||||
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
|
||||
}
|
||||
|
||||
/** Selection outlines (the primary object gets the corner handles) and the rubber band. */
|
||||
/** Selection outlines – only the primary object gets corner handles – and the rubber band. */
|
||||
private drawGizmos(items: DrawItem[], view: View): void {
|
||||
const s = this.state.selection;
|
||||
if (s?.kind === 'object') {
|
||||
for (const id of s.objectIds) {
|
||||
// Nothing is outlined for a switched-off object – it is not drawn either.
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || !item.enabled) continue;
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
|
||||
@@ -4,21 +4,15 @@ import type { TextureRef } from '../core/model/texture';
|
||||
const DEFAULT_BASE = '/game-maker/data/textures';
|
||||
|
||||
/**
|
||||
* Keeps uploaded images on the GPU, keyed by their file name.
|
||||
*
|
||||
* Nothing is ever evicted, and that is safe rather than sloppy: file names are content
|
||||
* addressed, so a name cannot come to mean different bytes and a cached texture cannot go
|
||||
* stale. It also means the same picture used by a hundred sprites is uploaded once.
|
||||
*
|
||||
* A miss is not an error – it starts the load and reports "not yet", so the caller draws its
|
||||
* plain colour in the meantime and gets a redraw when the picture arrives. That is why a
|
||||
* sprite never flickers and why nothing here needs to be awaited.
|
||||
* Keeps uploaded images on the GPU, keyed by file name. Nothing is evicted: file names are
|
||||
* content addressed, so a cached texture can never go stale. A miss is not an error – it starts
|
||||
* the load and reports "not yet", so callers never await and get a redraw when the picture lands.
|
||||
*/
|
||||
export class TextureLibrary {
|
||||
private base = DEFAULT_BASE;
|
||||
private readonly loaded = new Map<string, Texture>();
|
||||
private readonly loading = new Set<string>();
|
||||
/** Files whose load failed; retried never, so one broken reference cannot spam the network. */
|
||||
/** Never retried, so one broken reference cannot spam the network. */
|
||||
private readonly failed = new Set<string>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
@@ -28,12 +22,9 @@ export class TextureLibrary {
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture if it is already in memory, otherwise null while it loads.
|
||||
*
|
||||
* Caveat of content addressing: two texture *assets* can name the same file (the same image
|
||||
* uploaded twice) while disagreeing about `smooth`. The scale mode belongs to the shared
|
||||
* source, so the last one drawn wins – deterministically, since draw order is stable. The
|
||||
* upload avoids creating that situation by reusing an existing asset for a known file.
|
||||
* The picture if it is in memory, otherwise null while it loads. The scale mode belongs to the
|
||||
* shared source, so two assets naming the same file with different `smooth` fight over it and
|
||||
* the last one drawn wins; upload avoids that by reusing the existing asset for a known file.
|
||||
*/
|
||||
get(ref: TextureRef): Texture | null {
|
||||
const texture = this.loaded.get(ref.file);
|
||||
@@ -44,16 +35,14 @@ export class TextureLibrary {
|
||||
const mode = ref.smooth ? 'linear' : 'nearest';
|
||||
if (texture.source.scaleMode !== mode) {
|
||||
texture.source.scaleMode = mode;
|
||||
// Assigning the mode alone is not enough, and the difference is invisible until a reload:
|
||||
// Pixi's `scaleMode` setter only writes mag/min/mipmapFilter and leaves the style's cached
|
||||
// `_sharedResourceId` in place – but that id is the key the GPU sampler is looked up under,
|
||||
// so the old sampler keeps being used. `update()` drops the id and announces the change.
|
||||
// Pixi's `scaleMode` setter leaves the style's cached `_sharedResourceId` stale, so the old
|
||||
// GPU sampler keeps being used until a reload; `update()` drops the id.
|
||||
texture.source.style.update();
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
/** Runs `fn` whenever a picture has arrived. Returns the unsubscribe. */
|
||||
/** Runs `fn` whenever a picture has arrived; returns the unsubscribe. */
|
||||
onChange(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
@@ -77,8 +66,4 @@ export class TextureLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one library. A second instance would only duplicate GPU uploads – the cache is keyed by
|
||||
* content, so there is nothing an instance could hold that another should not share.
|
||||
*/
|
||||
export const textureLibrary = new TextureLibrary();
|
||||
@@ -1,11 +1,3 @@
|
||||
/**
|
||||
* What the scene view and the game view have in common: turning a list of `DrawItem`s into
|
||||
* Pixi display objects, keeping them in z order, culling what is off screen and clipping the
|
||||
* whole thing to the view rectangle.
|
||||
*
|
||||
* The two differ in *where* the items come from and in what may be done with them, not in how
|
||||
* they are drawn – so the differences are the abstract members below, not `if` branches.
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { sortByZ, type Vec2 } from '../core/model/Properties';
|
||||
@@ -18,7 +10,6 @@ import { drawItem } from './symbols';
|
||||
import { textureLibrary } from './TextureLibrary';
|
||||
import { type View, worldBounds, worldToScreen } from './view';
|
||||
|
||||
/** What a redraw draws: the items and the colour behind them. */
|
||||
export interface Frame {
|
||||
items: DrawItem[];
|
||||
background: number;
|
||||
@@ -26,22 +17,17 @@ export interface Frame {
|
||||
|
||||
export abstract class WorldViewport extends Widget {
|
||||
protected readonly bg = new Graphics();
|
||||
/** Everything that may extend past the viewport is clipped through this container. */
|
||||
protected readonly content = new Container();
|
||||
protected readonly world = new Container();
|
||||
/** Scene objects (sprites), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
|
||||
/** Ordered by `z`, ties by hierarchy order (see `syncDrawOrder`). */
|
||||
protected readonly objects = new Container();
|
||||
/**
|
||||
* Editor symbols (camera icon, labels). Always drawn above `objects` so that a sprite can
|
||||
* never hide them, no matter what z-order the scene ends up with.
|
||||
*/
|
||||
/** Editor symbols, always above `objects` so no sprite can hide them whatever the z-order. */
|
||||
protected readonly overlay = new Container();
|
||||
protected readonly contentMask = new Graphics();
|
||||
protected readonly nodes = new Map<string, Graphics>();
|
||||
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
|
||||
/** Text content of text items: a sibling of the item's node, kept next to it in z. */
|
||||
private readonly texts = new Map<string, Text>();
|
||||
private visibleCount = 0;
|
||||
/** Items of the last redraw (for hit tests such as the rubber band). */
|
||||
protected lastItems: DrawItem[] = [];
|
||||
|
||||
protected constructor(protected readonly state: EditorState) {
|
||||
@@ -55,12 +41,10 @@ export abstract class WorldViewport extends Widget {
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('mode', () => this.redraw());
|
||||
// Every tick moves things: redraw the live view when it is on screen.
|
||||
state.on('runtime', () => {
|
||||
if (this.visible) this.redraw();
|
||||
});
|
||||
// A picture that finished loading changes what is on screen without anything in the model
|
||||
// having moved. Unconditional: this fires once per image, not per frame.
|
||||
// Unconditional: a picture arriving fires once per image, not per frame.
|
||||
textureLibrary.onChange(() => this.redraw());
|
||||
}
|
||||
|
||||
@@ -70,32 +54,26 @@ export abstract class WorldViewport extends Widget {
|
||||
|
||||
// --- what the two views differ in ------------------------------------------------------------
|
||||
|
||||
/** The active mapping, or null when there is nothing to show. */
|
||||
protected abstract currentView(): View | null;
|
||||
|
||||
/** The items to draw and the colour behind them. Only called when `currentView()` is set. */
|
||||
/** Only called when `currentView()` is non-null. */
|
||||
protected abstract collectFrame(): Frame;
|
||||
|
||||
/** Colour of the whole widget, behind and around the view rectangle. */
|
||||
/** Fills the whole widget, behind and around the view rectangle. */
|
||||
protected abstract get baseColor(): number;
|
||||
|
||||
/** Whether the view rectangle is filled with the frame's background colour. */
|
||||
protected paintsBackground(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether item nodes take pointer events (only the editable view lets objects be grabbed). */
|
||||
protected get itemsInteractive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Runs before the items are drawn, with the view (null = nothing to show). */
|
||||
protected beforeItems(_view: View | null): void {}
|
||||
|
||||
/** Runs after the items are drawn – decorations that belong on top. */
|
||||
protected afterItems(_items: DrawItem[], _view: View): void {}
|
||||
|
||||
/** Per item, every redraw: anything drawn beside the item itself (labels). */
|
||||
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _size: Size): void {}
|
||||
|
||||
/** An item disappeared: drop whatever `syncDecorations` created for it. */
|
||||
@@ -105,24 +83,22 @@ export abstract class WorldViewport extends Widget {
|
||||
|
||||
// --- test hooks ------------------------------------------------------------------------------
|
||||
|
||||
/** Whether anything is rendered and how many items are on screen. */
|
||||
debugInfo(): { rendering: boolean; visibleItems: number } {
|
||||
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount };
|
||||
}
|
||||
|
||||
/** Which layer an item is drawn in: symbols live in `overlay`, above all `objects`. */
|
||||
layerOf(objectId: string): 'objects' | 'overlay' | null {
|
||||
const node = this.nodes.get(objectId);
|
||||
if (!node) return null;
|
||||
return node.parent === this.overlay ? 'overlay' : 'objects';
|
||||
}
|
||||
|
||||
/** Object ids in the order they are drawn, back to front. */
|
||||
/** Ids in the order they are drawn, back to front. */
|
||||
drawOrder(): string[] {
|
||||
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||||
}
|
||||
|
||||
/** Global screen position of an object's drawn position, null when not drawn. */
|
||||
/** Where an object is drawn, in global screen coordinates. */
|
||||
objectScreen(objectId: string): Vec2 | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
@@ -132,11 +108,7 @@ export abstract class WorldViewport extends Widget {
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen rectangle an object occupies: its footprint through the current view. Checks
|
||||
* sample colours inside it to assert what was actually drawn, which `objectScreen` alone
|
||||
* cannot support because it reports a point.
|
||||
*/
|
||||
/** The screen rectangle an object occupies, so tests can sample the pixels inside it. */
|
||||
objectRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
@@ -176,13 +148,12 @@ export abstract class WorldViewport extends Widget {
|
||||
this.world.scale.set(view.scale);
|
||||
const bounds = worldBounds(view);
|
||||
|
||||
// Sync display objects with items; anything outside the view is neither drawn nor hit-testable.
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
seen.add(item.id);
|
||||
const size = footprintOf(item.type, item.props);
|
||||
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
|
||||
// in the hierarchy panel, which is where it is selected and switched back on.
|
||||
// A switched-off object is not drawn at all, in the scene view as in the game; it is
|
||||
// selected and switched back on from the hierarchy panel.
|
||||
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
|
||||
let node = this.nodes.get(item.id);
|
||||
if (!node) {
|
||||
@@ -215,9 +186,8 @@ export abstract class WorldViewport extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach the object nodes so that Pixi draws them by ascending `z`, ties in hierarchy
|
||||
* order. Done explicitly (instead of Pixi's lazy `sortableChildren`) so the order is
|
||||
* deterministic and hierarchy changes are honoured too.
|
||||
* Re-attaches the object nodes in ascending `z`, ties in hierarchy order. Explicit rather than
|
||||
* Pixi's lazy `sortableChildren` so the order is deterministic and honours hierarchy changes.
|
||||
*/
|
||||
private syncDrawOrder(items: DrawItem[]): void {
|
||||
const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type)));
|
||||
@@ -225,17 +195,13 @@ export abstract class WorldViewport extends Widget {
|
||||
for (const item of ordered) {
|
||||
const node = this.nodes.get(item.id);
|
||||
if (node) this.objects.addChild(node);
|
||||
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
|
||||
// Text is a sibling because Graphics may not have children; keep it right above its box.
|
||||
const text = this.texts.get(item.id);
|
||||
if (text) this.objects.addChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of a text object: a Pixi Text kept next to the item's node. Its resolution follows
|
||||
* the zoom in coarse steps – re-rendering the glyph texture on every wheel notch would be
|
||||
* wasteful, blurry text is worse.
|
||||
*/
|
||||
/** Resolution follows the zoom in coarse steps: re-rendering the glyphs per wheel notch is wasteful. */
|
||||
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
|
||||
let text = this.texts.get(item.id);
|
||||
if (item.type !== 'text' || !onScreen) {
|
||||
|
||||
+4
-13
@@ -14,11 +14,7 @@ export interface DrawItem {
|
||||
id: string;
|
||||
type: AssetType;
|
||||
name: string;
|
||||
/**
|
||||
* World position (parents already applied) – in the scene view including the editor
|
||||
* offsets along the chain, which is what makes an offset object be drawn, selected and
|
||||
* dragged at the shifted place. `z` is the draw order.
|
||||
*/
|
||||
/** World position, parents already applied, in the scene view including editor offsets. */
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
@@ -27,12 +23,7 @@ export interface DrawItem {
|
||||
parentId: string;
|
||||
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Picture to draw a sprite with, already resolved from the `texture` property – null for
|
||||
* every other type and for a sprite without a usable one. It is resolved here because
|
||||
* drawing has no access to the project: a `DrawItem` is meant to carry everything the
|
||||
* renderer needs.
|
||||
*/
|
||||
/** Resolved here because drawing has no access to the project. */
|
||||
texture: TextureRef | null;
|
||||
}
|
||||
|
||||
@@ -41,7 +32,7 @@ interface DrawList {
|
||||
background: number;
|
||||
}
|
||||
|
||||
/** The scene of the model, in tree order (the tie-break for equal `z`), drawn at the editor positions. */
|
||||
/** The model's scene, in tree order – the tie-break for equal `z` – at the editor positions. */
|
||||
export function collectSceneItems(state: EditorState, fallbackBackground: number): DrawList {
|
||||
const scene = state.activeScene;
|
||||
if (!scene) return { items: [], background: fallbackBackground };
|
||||
@@ -71,7 +62,7 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
|
||||
return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) };
|
||||
}
|
||||
|
||||
/** The running world: scene content only – symbols are editor-only, switched-off objects do not exist for the game. */
|
||||
/** The running world: scene content only, since symbols are editor-only. */
|
||||
export function collectWorldItems(world: World): DrawList {
|
||||
const items = world.entities
|
||||
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
|
||||
|
||||
+10
-28
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* How the scene view draws its items: sprites as boxes, text as a hit box (the glyphs are a
|
||||
* separate Pixi Text), and the editor symbols for cameras, behaviors, containers and
|
||||
* colliders. Everything here draws in world units at the item's origin.
|
||||
*/
|
||||
/** Everything here draws in world units at the item's origin. */
|
||||
import { type Graphics, Matrix, Rectangle, type Texture } from 'pixi.js';
|
||||
import type { TextureRef } from '../core/model/texture';
|
||||
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds';
|
||||
@@ -12,13 +8,10 @@ import type { DrawItem } from './items';
|
||||
import { frameFillMatrix } from './textureFill';
|
||||
import type { TextureLibrary } from './TextureLibrary';
|
||||
|
||||
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
|
||||
/** Screen-pixel half-width of the grab band along a collider's outline. */
|
||||
const COLLIDER_GRAB_BAND = 6;
|
||||
|
||||
/**
|
||||
* Draws `item` into its node. `scale` is the current view scale (pixels per world unit), so
|
||||
* strokes and hit bands can stay constant on screen.
|
||||
*/
|
||||
/** `scale` is the view scale in pixels per world unit, so strokes stay constant on screen. */
|
||||
export function drawItem(
|
||||
node: Graphics,
|
||||
item: DrawItem,
|
||||
@@ -31,10 +24,8 @@ export function drawItem(
|
||||
switch (item.type) {
|
||||
case 'sprite': {
|
||||
const color = Number(item.props.color ?? 0xffffff);
|
||||
// Null while the picture is still loading, so the box shows its plain colour until then –
|
||||
// which is exactly what a sprite looked like before textures existed. No flicker, no
|
||||
// placeholder. With a picture the colour tints it (verified: white x 0x66aaff reads back
|
||||
// as rgb(98,163,245)).
|
||||
// Null while the picture is still loading: the box shows its plain colour until then, and
|
||||
// once the picture is there the colour tints it.
|
||||
const texture = item.texture ? textures.get(item.texture) : null;
|
||||
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color, item.texture));
|
||||
return;
|
||||
@@ -47,7 +38,6 @@ export function drawItem(
|
||||
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
|
||||
return;
|
||||
case 'behavior': {
|
||||
// A diamond with a small "circuit" mark: the logic symbol.
|
||||
const r = BEHAVIOR_SYMBOL / 2;
|
||||
node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
|
||||
node
|
||||
@@ -61,8 +51,6 @@ export function drawItem(
|
||||
return;
|
||||
}
|
||||
case 'container': {
|
||||
// A square with four small boxes inside: the group symbol. Its content is drawn as
|
||||
// ordinary objects (they are ordinary objects, parented below the container).
|
||||
const r = CONTAINER_SYMBOL / 2;
|
||||
node
|
||||
.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3)
|
||||
@@ -86,9 +74,8 @@ export function drawItem(
|
||||
);
|
||||
return;
|
||||
case 'collider': {
|
||||
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
|
||||
// top of their body, so only the outline band is hit-testable: clicks inside still reach
|
||||
// the sprite underneath, the edge grabs the collider.
|
||||
// Only the outline band is hit-testable, so clicks inside still reach the sprite that the
|
||||
// collider sits on.
|
||||
const shape = colliderShape(item.props, { x: 0, y: 0 });
|
||||
if (shape.kind === 'circle') node.circle(0, 0, shape.r);
|
||||
else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h);
|
||||
@@ -103,13 +90,8 @@ export function drawItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* The fill a sprite's box takes: the plain colour while there is no picture, the picture
|
||||
* tinted by that colour once there is one.
|
||||
*
|
||||
* Only a frame gets a matrix. An unmatrixed fill spreads the whole image over the shape, which
|
||||
* is precisely what a whole-image sprite wants, and skipping the matrix there saves Pixi the
|
||||
* inversion on every redraw of every such sprite. See `textureFill.ts` for why a frame cannot
|
||||
* be drawn as a Pixi sub-texture instead.
|
||||
* Only a frame gets a matrix: an unmatrixed fill already spreads the whole image over the shape,
|
||||
* and skipping it spares Pixi an inversion per redraw. See `textureFill.ts`.
|
||||
*/
|
||||
function spriteFill(
|
||||
texture: Texture | null,
|
||||
@@ -122,7 +104,7 @@ function spriteFill(
|
||||
return { texture, color, matrix: new Matrix(m.a, m.b, m.c, m.d, m.tx, m.ty) };
|
||||
}
|
||||
|
||||
/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */
|
||||
/** Hit area covering only the outline band, in local coordinates and world units. */
|
||||
function colliderOutlineHitArea(
|
||||
shape: ColliderShape,
|
||||
band: number,
|
||||
|
||||
@@ -11,31 +11,17 @@ export interface MatrixValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight`
|
||||
* image covers the filled shape – how a sprite wears one frame of a sheet.
|
||||
*
|
||||
* Measured, not assumed. Two things about Pixi v8 make this the answer:
|
||||
*
|
||||
* - A `Texture` built with a `frame` reports the frame's size, but a Graphics **fill ignores
|
||||
* it** and draws the whole source. Verified by reading back pixels: a fill with a 32×32
|
||||
* frame of a four-quadrant sheet showed all four quadrants. Sub-textures are therefore not
|
||||
* a way to draw a frame.
|
||||
* - `generateTextureMatrix` computes `uv = inverse(style.matrix) ∘ normalise(shapeBounds)`,
|
||||
* where the normaliser maps the shape's bounding box onto the unit square. So an absent
|
||||
* matrix spreads the whole image over the shape (which is why a whole-image sprite needs
|
||||
* none), and to spread a sub-rectangle instead, `inverse(style.matrix)` has to be the map
|
||||
* from the unit square onto that rectangle in UV space – making the matrix its inverse.
|
||||
*
|
||||
* Because the normaliser works off the shape's own bounds, this is independent of where the
|
||||
* shape sits and how big it is; verified with an off-origin, non-square shape.
|
||||
* The `matrix` a Graphics fill needs so that only `rect` of a `sourceWidth`×`sourceHeight` image
|
||||
* covers the filled shape. A Pixi v8 Graphics fill ignores a `Texture`'s `frame` and draws the
|
||||
* whole source, so a frame has to be a fill matrix; Pixi inverts it against the shape's own
|
||||
* bounds, hence the inverse of "unit square onto the frame" here.
|
||||
*/
|
||||
export function frameFillMatrix(
|
||||
rect: FrameRect,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
): MatrixValues | null {
|
||||
// Without a known image size the rectangle has no reference frame; drawing the whole
|
||||
// picture is the honest answer, and it is what the sprite showed a moment ago anyway.
|
||||
// Without a known image size the rectangle has no reference frame: draw the whole picture.
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0 || rect.w <= 0 || rect.h <= 0) return null;
|
||||
return {
|
||||
a: sourceWidth / rect.w,
|
||||
|
||||
+3
-12
@@ -1,14 +1,9 @@
|
||||
/**
|
||||
* How world coordinates map onto a viewport. Pure geometry – no Pixi, no editor state – so
|
||||
* both viewports share one definition of "where does this world point land on screen".
|
||||
*/
|
||||
import type { PropertyBag, Vec2 } from '../core/model/Properties';
|
||||
import { cameraFrame, rectAround, type WorldRect } from '../core/properties/camera';
|
||||
|
||||
/**
|
||||
* The world point (`x`, `y`) sits at the centre of the screen rectangle `rect` (viewport-local
|
||||
* pixels) at `scale` pixels per world unit. In the scene view the rectangle is the whole
|
||||
* viewport; in the game view it is the camera's frame fitted into it (letterboxed).
|
||||
* pixels) at `scale` pixels per world unit.
|
||||
*/
|
||||
export interface View {
|
||||
x: number;
|
||||
@@ -17,15 +12,12 @@ export interface View {
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
/** Free-panning view: the whole viewport shows the world around (`x`, `y`) at `zoom`. */
|
||||
/** Free-panning view: the whole viewport is the rectangle. */
|
||||
export function freeView(x: number, y: number, zoom: number, w: number, h: number): View {
|
||||
return { x, y, scale: zoom, rect: { x: 0, y: 0, w, h } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera's frame scaled to fit inside `w`×`h` and centred – the remaining margin is the
|
||||
* letterbox, which is not part of the game area (see `isInside`).
|
||||
*/
|
||||
/** The camera's frame fitted into `w`×`h` and centred; the remaining margin is letterbox. */
|
||||
export function cameraView(camera: { x: number; y: number; props: PropertyBag }, w: number, h: number): View {
|
||||
const frame = cameraFrame(camera.props);
|
||||
const scale = Math.min(w / frame.w, h / frame.h);
|
||||
@@ -46,7 +38,6 @@ export function worldToScreen(view: View, wx: number, wy: number): Vec2 {
|
||||
return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy };
|
||||
}
|
||||
|
||||
/** World region the view covers – what a redraw has to consider at all. */
|
||||
export function worldBounds(view: View): WorldRect {
|
||||
return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale);
|
||||
}
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user