From 4d84231b9f0f48e93c6bd8e84d8fbc862cc64fa4 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 15:52:05 +0200 Subject: [PATCH] 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 --- README.md | 10 +++- src/core/commands/Command.ts | 11 +--- src/core/commands/CommandStack.ts | 7 +-- src/core/commands/CompoundCommand.ts | 5 +- src/core/commands/SetBagValueCommand.ts | 14 ++--- src/core/commands/applyCommands.ts | 32 +++------- src/core/commands/assetCommands.ts | 28 ++------- src/core/commands/colliderCommands.ts | 5 +- src/core/commands/containerCommands.ts | 16 ++--- src/core/commands/frameCommands.ts | 55 ++++------------- src/core/commands/graphCommands.ts | 15 ++--- src/core/commands/naming.ts | 1 - src/core/commands/require.ts | 12 +--- src/core/commands/sceneObjectCommands.ts | 29 +++------ src/core/commands/variableCommands.ts | 9 +-- src/core/graph/Graph.ts | 14 +---- src/core/graph/GraphInterpreter.ts | 19 ++---- src/core/graph/NodeContext.ts | 41 +++++-------- src/core/graph/NodeRegistry.ts | 17 ++---- src/core/graph/nodes/data/Compare.ts | 4 +- src/core/graph/nodes/index.ts | 4 +- src/core/graph/nodes/shared.ts | 9 +-- src/core/model/Asset.ts | 53 +++-------------- src/core/model/AssetTypes.ts | 13 +--- src/core/model/Project.ts | 31 +++------- src/core/model/Properties.ts | 10 +--- src/core/model/SceneObject.ts | 28 ++++----- src/core/model/container.ts | 23 +++----- src/core/model/hierarchy.ts | 44 +++++--------- src/core/model/texture.ts | 75 +++++------------------- src/core/model/walk.ts | 25 ++------ src/core/properties/PropertySchema.ts | 66 +++++---------------- src/core/properties/assetRef.ts | 16 ++--- src/core/properties/bounds.ts | 17 +++--- src/core/properties/camera.ts | 4 +- src/core/properties/collider.ts | 21 +++---- src/core/properties/enabled.ts | 11 +--- src/core/properties/resolve.ts | 27 +++------ src/core/properties/textureRef.ts | 26 ++------ src/core/store/HttpProjectStore.ts | 13 ++-- src/core/store/HttpTextureStore.ts | 11 +--- src/core/store/ProjectStore.ts | 3 +- src/core/store/TextureStore.ts | 12 ++-- src/core/store/http.ts | 7 +-- src/core/store/serialize.ts | 28 ++++----- src/core/util/list.ts | 18 ++---- src/editor/App.ts | 4 +- src/editor/Editor.ts | 15 +---- src/editor/PlaySession.ts | 22 +------ src/editor/ProjectSession.ts | 7 +-- src/editor/actions.ts | 11 +--- src/editor/debugHook.ts | 65 ++++++++++---------- src/editor/graph/GraphCanvas.ts | 30 +++------- src/editor/graph/GraphEditor.ts | 19 +++--- src/editor/graph/NodeView.ts | 18 ++---- src/editor/inspector/Inspector.ts | 7 +-- src/editor/inspector/assetSection.ts | 6 +- src/editor/inspector/fieldFactory.ts | 23 +++----- src/editor/inspector/frameSection.ts | 29 +++------ src/editor/inspector/host.ts | 6 +- src/editor/inspector/nodeSection.ts | 5 +- src/editor/inspector/objectSection.ts | 7 +-- src/editor/inspector/previewHandlers.ts | 10 +--- src/editor/panels/AssetPanel.ts | 2 +- src/editor/state.ts | 8 +-- src/editor/texture/SliceDialog.ts | 15 ++--- src/editor/texture/TextureCanvas.ts | 54 ++++------------- src/editor/texture/TextureEditor.ts | 18 +++--- src/editor/texture/sheetView.ts | 26 ++------ src/editor/textureImport.ts | 72 +++++------------------ src/ui/FocusManager.ts | 16 ++--- src/ui/KeyboardBridge.ts | 21 +++---- src/ui/Theme.ts | 13 +--- src/ui/UIContext.ts | 8 +-- src/ui/Widget.ts | 28 ++------- src/ui/color.ts | 10 ++-- src/ui/draw.ts | 12 ++-- src/ui/layout/Stack.ts | 11 ++-- src/ui/math.ts | 18 ++---- src/ui/widgets/Button.ts | 46 +++++++-------- src/ui/widgets/Checkbox.ts | 4 +- src/ui/widgets/Dropdown.ts | 8 +-- src/ui/widgets/Label.ts | 1 - src/ui/widgets/ListRow.ts | 11 ++-- src/ui/widgets/ListView.ts | 23 +++----- src/ui/widgets/Menu.ts | 17 ++---- src/ui/widgets/NumberField.ts | 25 +++----- src/ui/widgets/Panel.ts | 5 +- src/ui/widgets/Popup.ts | 28 ++------- src/ui/widgets/ScrollContainer.ts | 5 +- src/ui/widgets/Splitter.ts | 2 +- src/ui/widgets/TextField.ts | 14 ++--- src/viewport/GameViewport.ts | 17 +----- src/viewport/SceneViewport.ts | 34 ++++------- src/viewport/TextureLibrary.ts | 35 ++++------- src/viewport/WorldViewport.ts | 64 +++++--------------- src/viewport/items.ts | 17 ++---- src/viewport/symbols.ts | 38 ++++-------- src/viewport/textureFill.ts | 24 ++------ src/viewport/view.ts | 15 +---- tests/core/asset-ref.test.ts | 8 +-- tests/core/frame-commands.test.ts | 9 +-- tests/core/holders.test.ts | 6 +- tests/core/node-catalog.test.ts | 25 ++++---- tests/core/texture.test.ts | 11 +--- tests/editor/sheet-view.test.ts | 15 ++--- tests/ui/math.test.ts | 3 +- tests/viewport/texture-fill.test.ts | 28 +++------ tests/viewport/view.test.ts | 5 +- tools/e2e/04-assets.mjs | 8 +-- tools/e2e/06-graph.mjs | 8 +-- tools/e2e/11-menus.mjs | 18 ++---- tools/e2e/12-textures.mjs | 36 ++++-------- tools/e2e/13-frames.mjs | 46 ++++----------- tools/e2e/14-overflow.mjs | 57 +++++++++++++++--- 115 files changed, 707 insertions(+), 1564 deletions(-) diff --git a/README.md b/README.md index 8fccefd0..26002ede 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/core/commands/Command.ts b/src/core/commands/Command.ts index 3ee37122..ecf118c3 100644 --- a/src/core/commands/Command.ts +++ b/src/core/commands/Command.ts @@ -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; diff --git a/src/core/commands/CommandStack.ts b/src/core/commands/CommandStack.ts index a1050175..9bf8c73c 100644 --- a/src/core/commands/CommandStack.ts +++ b/src/core/commands/CommandStack.ts @@ -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; diff --git a/src/core/commands/CompoundCommand.ts b/src/core/commands/CompoundCommand.ts index 82de5dc4..06d15743 100644 --- a/src/core/commands/CompoundCommand.ts +++ b/src/core/commands/CompoundCommand.ts @@ -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( diff --git a/src/core/commands/SetBagValueCommand.ts b/src/core/commands/SetBagValueCommand.ts index 3331b9e3..90ff19eb 100644 --- a/src/core/commands/SetBagValueCommand.ts +++ b/src/core/commands/SetBagValueCommand.ts @@ -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 implements Command { readonly label: string; @@ -28,8 +26,8 @@ export abstract class SetBagValueCommand 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; diff --git a/src/core/commands/applyCommands.ts b/src/core/commands/applyCommands.ts index c0b69f39..3b20696e 100644 --- a/src/core/commands/applyCommands.ts +++ b/src/core/commands/applyCommands.ts @@ -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 { read(object: SceneObject): T; write(object: SceneObject, value: T): void; @@ -43,7 +38,6 @@ const positionSlot = (axis: Axis): Slot => ({ }, }); -/** A slot's value on one object before it was overwritten. */ interface SlotMemo { object: SceneObject; slot: Slot; @@ -51,9 +45,8 @@ interface SlotMemo { } /** - * 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( project: Project, @@ -83,9 +76,8 @@ function restoreSlots(memos: readonly SlotMemo[]): 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[] = []; @@ -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[] = []; @@ -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 }; diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 62148659..6346ae0e 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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; - /** Objects removed per scene or container asset (both hold a flat object list). */ private removedObjects: { holderId: string; removed: Removed[] }[] = []; 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 { protected constructor( private readonly assetId: string, @@ -189,7 +175,6 @@ abstract class AssetPropertyCommand extends SetBagValueCommand { } } -/** 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}`); diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 60e7936a..830eda44 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -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'; diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 79820e27..94e01736 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -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, diff --git a/src/core/commands/frameCommands.ts b/src/core/commands/frameCommands.ts index b68ce86e..8b722815 100644 --- a/src/core/commands/frameCommands.ts +++ b/src/core/commands/frameCommands.ts @@ -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); diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 054bde18..8954d7f1 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -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 { } } -/** - * 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[] = []; diff --git a/src/core/commands/naming.ts b/src/core/commands/naming.ts index 319adfb0..b3102589 100644 --- a/src/core/commands/naming.ts +++ b/src/core/commands/naming.ts @@ -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, candidate: (i: number) => string, from: number): string { for (let i = from; ; i++) { const name = candidate(i); diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 801ba559..e8eff9bd 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -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}`); diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 5cfc202b..3cd280f8 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -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[] = []; @@ -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 { protected constructor( private readonly sceneId: string, @@ -176,7 +167,6 @@ abstract class OverrideCommand extends SetBagValueCommand { } } -/** 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'; diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 5227b17e..9ee5e3b4 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -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); } diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index b26bcf4c..f79ce475 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -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'; diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 8857cd20..a65c1f0f 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -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>(); private outputCache = new Map>(); 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); diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 445ad5f3..1b010d98 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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, diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 614d74d7..9e4bee60 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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; /** 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, 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': diff --git a/src/core/graph/nodes/data/Compare.ts b/src/core/graph/nodes/data/Compare.ts index aaee54ce..3c0fed45 100644 --- a/src/core/graph/nodes/data/Compare.ts +++ b/src/core/graph/nodes/data/Compare.ts @@ -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)) } }; }, diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 27b178d9..f530dc8c 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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 diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index cc9a486a..797a1bcb 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -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, diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 2042b747..4d216093 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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'; diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index cd01908f..52b1f09b 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { scene: 'Scene', camera: 'Camera', @@ -25,9 +23,8 @@ export const ASSET_TYPE_LABELS: Record = { }; /** - * 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; @@ -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'; } diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 66dd5a84..beda7fae 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -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)); diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index ebd9f3e3..d39df599 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -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; -/** 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 }; } diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index 12cb05cd..99fd95f1 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -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, diff --git a/src/core/model/container.ts b/src/core/model/container.ts index 0a705ffd..7104b291 100644 --- a/src/core/model/container.ts +++ b/src/core/model/container.ts @@ -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, diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index b97fe478..9c9c6089 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -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): Set { const ids = new Set(); for (const rootId of rootIds) { @@ -48,18 +41,18 @@ export function subtreeIds(tree: ObjectTree, rootIds: Iterable): Set[] { 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): VisibleEntry[] { const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean)); diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 18276002..96884663 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -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); diff --git a/src/core/model/walk.ts b/src/core/model/walk.ts index 1d6b1dec..dcdfde4d 100644 --- a/src/core/model/walk.ts +++ b/src/core/model/walk.ts @@ -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(start: T, parentOf: (item: T) => T | null | undefined): Generator { const seen = new Set(); @@ -23,18 +16,14 @@ export function* parentChain(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(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( start: T, parentOf: (item: T) => T | null | undefined, @@ -50,11 +39,7 @@ export function sumAlongChain( 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( rootKey: K, childrenOf: (key: K) => readonly T[], diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 873d9027..37a754e1 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }], camera: [ @@ -83,20 +65,11 @@ const ASSET_SCHEMAS: Record = { 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 = { { 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 = { ], }; -/** - * 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 = Object.fromEntries( (Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [ type, @@ -160,17 +127,15 @@ const FULL_SCHEMAS: Record = Object.fromEntries( ]), ) as Record; -/** 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': diff --git a/src/core/properties/assetRef.ts b/src/core/properties/assetRef.ts index 77236590..90e4f1cf 100644 --- a/src/core/properties/assetRef.ts +++ b/src/core/properties/assetRef.ts @@ -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 `?` 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, `?` 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})`; } diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 01b7b907..8dbafc22 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -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; diff --git a/src/core/properties/camera.ts b/src/core/properties/camera.ts index b07de847..9a3bc2da 100644 --- a/src/core/properties/camera.ts +++ b/src/core/properties/camera.ts @@ -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)); diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index ceddb5a2..447d0ccd 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -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; /** 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 }; } diff --git a/src/core/properties/enabled.ts b/src/core/properties/enabled.ts index ac5c0fa4..481a8fe4 100644 --- a/src/core/properties/enabled.ts +++ b/src/core/properties/enabled.ts @@ -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( start: T, propsOf: (item: T) => PropertyBag, diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts index fa4951d4..cc4a4054 100644 --- a/src/core/properties/resolve.ts +++ b/src/core/properties/resolve.ts @@ -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]; diff --git a/src/core/properties/textureRef.ts b/src/core/properties/textureRef.ts index a669cf35..a0584dba 100644 --- a/src/core/properties/textureRef.ts +++ b/src/core/properties/textureRef.ts @@ -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 `?` 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, `?` 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 diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts index 3f9e8a86..70d8b58a 100644 --- a/src/core/store/HttpProjectStore.ts +++ b/src/core/store/HttpProjectStore.ts @@ -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('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(method: string, route: string, payload?: unknown): Promise { 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; } diff --git a/src/core/store/HttpTextureStore.ts b/src/core/store/HttpTextureStore.ts index cd992eed..2fd63b9a 100644 --- a/src/core/store/HttpTextureStore.ts +++ b/src/core/store/HttpTextureStore.ts @@ -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('DELETE', `textures/${encodeURIComponent(file)}`); } - /** One round trip; every failure becomes an `Error` carrying the server's message. */ private async request( 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; } diff --git a/src/core/store/ProjectStore.ts b/src/core/store/ProjectStore.ts index 5fbcc8e2..692d4289 100644 --- a/src/core/store/ProjectStore.ts +++ b/src/core/store/ProjectStore.ts @@ -20,11 +20,10 @@ export class StaleRevisionError extends Error { } } -/** Persistence boundary. The editor only talks to this interface. */ export interface ProjectStore { list(): Promise; load(id: string): Promise; - /** 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; create(name: string): Promise; delete(id: string): Promise; diff --git a/src/core/store/TextureStore.ts b/src/core/store/TextureStore.ts index 4ec2f61b..8790e5cc 100644 --- a/src/core/store/TextureStore.ts +++ b/src/core/store/TextureStore.ts @@ -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; list(): Promise; diff --git a/src/core/store/http.ts b/src/core/store/http.ts index 05efd610..c74b0625 100644 --- a/src/core/store/http.ts +++ b/src/core/store/http.ts @@ -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 = { 400: 'Bad request', 403: 'Forbidden', @@ -16,7 +14,6 @@ const STATUS_MESSAGES: Record = { 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}`); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 7dca71b2..8221ea7b 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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(); const variables = new Map(); 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): 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, @@ -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( diff --git a/src/core/util/list.ts b/src/core/util/list.ts index 91d03c90..aa6fd03d 100644 --- a/src/core/util/list.ts +++ b/src/core/util/list.ts @@ -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 { 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(list: T[], predicate: (item: T) => boolean): Removed | 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(list: T[], predicate: (item: T) => boolean): Removed[] { const removed: Removed[] = []; list.forEach((item, index) => { @@ -25,15 +20,12 @@ export function removeWhere(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(list: T[], removed: readonly Removed[]): 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(list: readonly T[], id: string): T[] { return list.filter((item) => item.id !== id); } diff --git a/src/editor/App.ts b/src/editor/App.ts index 043a425c..ca8882ca 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -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'; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 039e75a0..f5f1afe0 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 { 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; diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index bcbac2c2..cbb03625 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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; diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index dfb50f3c..ecab7218 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -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, diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 638f398c..376aa748 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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 = diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 6293157c..033a01cc 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index ab525df8..28656fe4 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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); diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 5098024a..67566e66 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -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'); diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index a051887f..b6d717c2 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -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); diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 2b809227..ef2b0d31 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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(); diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index b652e957..998d08cc 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -29,10 +29,8 @@ const VARIABLE_KIND_LABELS: Record = { 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> = { number: '#', boolean: '✓', diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 98037bc9..f05d5646 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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 { diff --git a/src/editor/inspector/frameSection.ts b/src/editor/inspector/frameSection.ts index 979207e9..9e7ae16d 100644 --- a/src/editor/inspector/frameSection.ts +++ b/src/editor/inspector/frameSection.ts @@ -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); diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts index f3b535b7..5e933eef 100644 --- a/src/editor/inspector/host.ts +++ b/src/editor/inspector/host.ts @@ -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; diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index c0524694..68dfc738 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -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; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index adefef25..20809214 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -287,10 +287,9 @@ function componentRow( * 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, diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index a7399bdc..0539de18 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -19,13 +19,9 @@ export interface PreviewedValue { /** * 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( host: InspectorHost, diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 82a16fbd..f5e4e46b 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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) { diff --git a/src/editor/state.ts b/src/editor/state.ts index 329278c6..b716cd5c 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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; diff --git a/src/editor/texture/SliceDialog.ts b/src/editor/texture/SliceDialog.ts index ab07327b..d38db097 100644 --- a/src/editor/texture/SliceDialog.ts +++ b/src/editor/texture/SliceDialog.ts @@ -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]; diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index a6ab5762..28cd9d97 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -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 }); diff --git a/src/editor/texture/TextureEditor.ts b/src/editor/texture/TextureEditor.ts index 721bd856..c1b11874 100644 --- a/src/editor/texture/TextureEditor.ts +++ b/src/editor/texture/TextureEditor.ts @@ -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(); diff --git a/src/editor/texture/sheetView.ts b/src/editor/texture/sheetView.ts index 4ca58289..7c339090 100644 --- a/src/editor/texture/sheetView.ts +++ b/src/editor/texture/sheetView.ts @@ -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'; diff --git a/src/editor/textureImport.ts b/src/editor/textureImport.ts index e08acb74..c240ee34 100644 --- a/src/editor/textureImport.ts +++ b/src/editor/textureImport.ts @@ -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): Promise { 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 { let bitmap: ImageBitmap; try { @@ -81,7 +61,6 @@ export async function prepareImage(file: File): Promise { 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 { 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 asset.type === 'texture' && asset.file === uploaded.file, ); @@ -169,11 +142,7 @@ async function importImage(file: File, deps: TextureImportDeps): Promise 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 `` lives only for this one call. - */ +/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */ export function pickImageFiles(): Promise { return new Promise((resolve) => { const input = document.createElement('input'); @@ -223,13 +188,12 @@ export function pickImageFiles(): Promise { 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) => { diff --git a/src/ui/FocusManager.ts b/src/ui/FocusManager.ts index dac2f074..57f571c6 100644 --- a/src/ui/FocusManager.ts +++ b/src/ui/FocusManager.ts @@ -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; diff --git a/src/ui/KeyboardBridge.ts b/src/ui/KeyboardBridge.ts index 9bd4f2d5..6801393e 100644 --- a/src/ui/KeyboardBridge.ts +++ b/src/ui/KeyboardBridge.ts @@ -1,17 +1,15 @@ /** - * The one piece of DOM in the whole application: an invisible, off-screen `` 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 `` 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; diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 43653502..be0e002a 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -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(); -/** 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. */ diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts index b5418831..1aa54b00 100644 --- a/src/ui/UIContext.ts +++ b/src/ui/UIContext.ts @@ -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; diff --git a/src/ui/Widget.ts b/src/ui/Widget.ts index cfae3aac..3fe7a48b 100644 --- a/src/ui/Widget.ts +++ b/src/ui/Widget.ts @@ -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); diff --git a/src/ui/color.ts b/src/ui/color.ts index c4268305..7f0b59f3 100644 --- a/src/ui/color.ts +++ b/src/ui/color.ts @@ -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); diff --git a/src/ui/draw.ts b/src/ui/draw.ts index c2e1fdc6..149928e2 100644 --- a/src/ui/draw.ts +++ b/src/ui/draw.ts @@ -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; diff --git a/src/ui/layout/Stack.ts b/src/ui/layout/Stack.ts index 045e086a..333fa773 100644 --- a/src/ui/layout/Stack.ts +++ b/src/ui/layout/Stack.ts @@ -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(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); diff --git a/src/ui/math.ts b/src/ui/math.ts index 724fdc01..c8a04ac4 100644 --- a/src/ui/math.ts +++ b/src/ui/math.ts @@ -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); diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index 66c1793b..fadaac5c 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -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; protected readonly align: NonNullable; + 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 { diff --git a/src/ui/widgets/Checkbox.ts b/src/ui/widgets/Checkbox.ts index 6dda4cd7..5db1db4a 100644 --- a/src/ui/widgets/Checkbox.ts +++ b/src/ui/widgets/Checkbox.ts @@ -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(); diff --git a/src/ui/widgets/Dropdown.ts b/src/ui/widgets/Dropdown.ts index d1286339..3f5fbdef 100644 --- a/src/ui/widgets/Dropdown.ts +++ b/src/ui/widgets/Dropdown.ts @@ -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. */ diff --git a/src/ui/widgets/Label.ts b/src/ui/widgets/Label.ts index 440307c0..1094f38e 100644 --- a/src/ui/widgets/Label.ts +++ b/src/ui/widgets/Label.ts @@ -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; } diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts index 4f6bdb11..715ab1d9 100644 --- a/src/ui/widgets/ListRow.ts +++ b/src/ui/widgets/ListRow.ts @@ -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) diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 9077cde9..972230bc 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -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; } diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index 1f7af668..7e3be86c 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -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(); } diff --git a/src/ui/widgets/NumberField.ts b/src/ui/widgets/NumberField.ts index 10938820..ddc7a051 100644 --- a/src/ui/widgets/NumberField.ts +++ b/src/ui/widgets/NumberField.ts @@ -6,16 +6,11 @@ import { TextField, type TextFieldOptions } from './TextField'; export interface NumberFieldOptions extends Omit { 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 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) { diff --git a/src/ui/widgets/Panel.ts b/src/ui/widgets/Panel.ts index ec19da6e..8cfaf21f 100644 --- a/src/ui/widgets/Panel.ts +++ b/src/ui/widgets/Panel.ts @@ -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; diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index e96a8125..284be0bc 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -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(); diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index df1a5c3f..cfde123d 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -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(); diff --git a/src/ui/widgets/Splitter.ts b/src/ui/widgets/Splitter.ts index 55e6eb6f..ac336aef 100644 --- a/src/ui/widgets/Splitter.ts +++ b/src/ui/widgets/Splitter.ts @@ -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; diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index beb0c574..ca4a6b12 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -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(); } diff --git a/src/viewport/GameViewport.ts b/src/viewport/GameViewport.ts index 772897cb..52d154a7 100644 --- a/src/viewport/GameViewport.ts +++ b/src/viewport/GameViewport.ts @@ -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(); diff --git a/src/viewport/SceneViewport.ts b/src/viewport/SceneViewport.ts index c8929a0d..330e3aef 100644 --- a/src/viewport/SceneViewport.ts +++ b/src/viewport/SceneViewport.ts @@ -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(); @@ -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 => 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); diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 68dfa3ed..129fe5a0 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -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(); private readonly loading = new Set(); - /** 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(); 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(); diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index 5a8254d9..781d0299 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -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(); - /** 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(); 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(); 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) { diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 9e4648e1..43a7a8fb 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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)) diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 594a3eb2..b1bd7d04 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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, diff --git a/src/viewport/textureFill.ts b/src/viewport/textureFill.ts index 02fbe938..2b5ec3c6 100644 --- a/src/viewport/textureFill.ts +++ b/src/viewport/textureFill.ts @@ -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, diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 56801850..0952aa34 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -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); } diff --git a/tests/core/asset-ref.test.ts b/tests/core/asset-ref.test.ts index dfd938cc..1b53b77f 100644 --- a/tests/core/asset-ref.test.ts +++ b/tests/core/asset-ref.test.ts @@ -1,7 +1,6 @@ /** * The two pure halves of the `asset` property kind: how a stored id reads, and which assets a - * picker may offer for it. Both are used by the inspector row and by the graph node view, so - * they are pinned here rather than through a widget. + * picker may offer for it. Used by the inspector row and the graph node view alike. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; @@ -11,7 +10,6 @@ import type { Project } from '../../src/core/model/Project'; import { assetChoiceLabel, assetRefChoices, assetRefLabel } from '../../src/core/properties/assetRef'; import { makeProject, type ProjectFixture } from '../helpers'; -/** Adds an asset and returns its id, so a test can reference it. */ function add(fixture: ProjectFixture, type: AssetType, name: string): string { const command = new CreateAssetCommand(type, name); fixture.stack.execute(command); @@ -123,8 +121,8 @@ describe('assetRefChoices', () => { }); describe('node asset params', () => { - // There used to be a second notion of an asset reference: an enum with empty options that the - // graph inspector filled in. This keeps the nodes on the real kind. + // Guards against the old second notion of an asset reference: an enum with empty options + // that the graph inspector filled in. it('use the asset kind and offer every placeable asset', () => { const params = BUILT_IN_NODES.flatMap((def) => def.params).filter((p) => p.key === 'asset'); expect(params.length).toBeGreaterThan(0); diff --git a/tests/core/frame-commands.test.ts b/tests/core/frame-commands.test.ts index deaf94db..2667c4e9 100644 --- a/tests/core/frame-commands.test.ts +++ b/tests/core/frame-commands.test.ts @@ -25,14 +25,12 @@ function makeSheet(width = 64, height = 64) { return { ...fixture, textureId: create.asset.id, asset, frames: () => asset().frames }; } -/** Adds a frame through the command stack and returns its id. */ function addFrame(sheet: ReturnType, rect: FrameRect, name?: string): string { const command = new AddFrameCommand(sheet.textureId, rect, name); sheet.stack.execute(command); return command.frameId; } -/** A detached copy of the frame list, to compare a state against after an undo. */ function snapshot(frames: readonly TextureFrame[]): TextureFrame[] { return frames.map((f) => ({ ...f })); } @@ -113,8 +111,8 @@ describe('SetFrameRectCommand', () => { const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }); const frame = sheet.frames()[0]!; - // What a live preview does: write into the frame, then hand the command the previewed - // rectangle and put the starting one back before executing (see `SceneViewport`). + // What a live preview does: hand the command the previewed rectangle, with the starting + // one put back before executing (see `SceneViewport`). frame.x = 40; const previewed = { x: frame.x, y: frame.y, w: frame.w, h: frame.h }; frame.x = 0; @@ -251,8 +249,7 @@ describe('SliceFramesCommand', () => { }); it('rejects a slice of an image whose size is unknown rather than doing nothing', () => { - // Width 0 is a texture whose upload has not been measured yet; slicing it would silently - // produce no frames at all. + // Width 0 is an unmeasured upload; slicing it would silently produce no frames at all. const sheet = makeSheet(0, 0); expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false))).toThrow( CommandError, diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index fbefa686..c614a4e6 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -1,7 +1,6 @@ /** - * Container assets hold placed objects just like scenes do. Anything that walks "all placed - * objects" has to cover both – the tests here pin the places that used to look at scenes only. - * The history tests pin that a rejected undo leaves the stacks usable. + * Container assets hold placed objects just like scenes do, so anything that walks "all placed + * objects" has to cover both. Plus: a rejected undo leaves the stacks usable. */ import { describe, expect, it } from 'vitest'; import { type Command, CommandError } from '../../src/core/commands/Command'; @@ -71,7 +70,6 @@ describe('RenameVariableCommand', () => { }); }); -/** Applies a marker to the project; its undo refuses to run. */ class RefusingUndo implements Command { readonly label = 'Refusing'; execute(project: { name: string }): void { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 7ccb02e8..fa988c2c 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1,8 +1,6 @@ /** - * One table covering every built-in node that has a `run`, plus a guard that keeps it that way: - * adding a node without a case here fails `covers every built-in node`. Nodes without `run` - * (the events) are seeded by the interpreter, so they are listed with the test that drives them - * and checked in tests/core/behavior-events.test.ts. + * One table covering every built-in node that has a `run`: adding a node without a case here + * fails `covers every built-in node`. The run-less event nodes are listed in `EVENT_NODES`. */ import { describe, expect, it } from 'vitest'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; @@ -29,7 +27,6 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => return { result: requireNodeDef(type).run!(ctx, c.inputs ?? {}, c.params ?? {}), ctx }; }; -/** A handle with a fixed position, for the geometry nodes. */ const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); const pointer = (overrides: Partial): Partial => ({ @@ -38,6 +35,7 @@ const pointer = (overrides: Partial): Partial = { // --- flow ------------------------------------------------------------------------------------ + Branch: [ { name: 'true takes then', inputs: { condition: true }, next: 'then' }, { name: 'false takes else', inputs: { condition: false }, next: 'else' }, @@ -47,6 +45,7 @@ const CASES: Record = { Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }], // --- variables ------------------------------------------------------------------------------- + GetVariable: [ { name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } }, { name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } }, @@ -69,6 +68,7 @@ const CASES: Record = { ], // --- references and properties ---------------------------------------------------------------- + Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }], Owner: [ { name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } }, @@ -154,6 +154,7 @@ const CASES: Record = { ], // --- literals and math ------------------------------------------------------------------------- + Number: [ { name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } }, { name: 'unset is 0', outputs: { value: 0 } }, @@ -210,8 +211,7 @@ const CASES: Record = { { name: 'modulo zero is 0, not NaN', inputs: { a: 125, b: 0 }, outputs: { result: 0 } }, ], Random: [ - // The spread is checked in "Random stays inside [min, max)" below; an empty range is the - // one case with a single right answer. + // The spread is checked in "Random stays inside [min, max)" below. { name: 'an empty range always yields min', inputs: { min: 3, max: 3 }, outputs: { result: 3 } }, ], Time: [ @@ -223,6 +223,7 @@ const CASES: Record = { ], // --- text and comparison ----------------------------------------------------------------------- + ToText: [ { name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } }, { name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } }, @@ -267,6 +268,7 @@ const CASES: Record = { ], // --- vectors and input -------------------------------------------------------------------------- + Length: [ { name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } }, { name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } }, @@ -390,6 +392,7 @@ const CASES: Record = { ], // --- actions -------------------------------------------------------------------------------------- + Move: [ { name: 'moves by (dx, dy) × scale × dt', @@ -479,8 +482,8 @@ function eqNamesakes(): Record { } /** - * Nodes without a `run`: the interpreter seeds their outputs when the BehaviorSystem fires them, - * so they are only observable through a running world. The value says which test drives it. + * Nodes without a `run`: only observable through a running world, since the BehaviorSystem + * seeds their outputs. The value names the test that drives each one. */ const EVENT_NODES: Record = { OnStart: 'graph.test.ts', @@ -513,8 +516,7 @@ describe('node catalog', () => { const keys = [...def.inputs, ...def.outputs].map((p) => p.key); expect(def.label, `${def.type} has a label`).not.toBe(''); expect(def.description, `${def.type} has a description`).not.toBe(''); - // Inputs and outputs are addressed by key within their own side; a key may repeat across - // sides (Normalize reads x/y and writes x/y), never within one. + // Keys are unique per side, but may repeat across sides (Normalize reads and writes x/y). expect(new Set(def.inputs.map((p) => p.key)).size, `${def.type} input keys unique`).toBe( def.inputs.length, ); @@ -545,7 +547,6 @@ for (const [type, cases] of Object.entries(CASES)) { }); } -// Cases whose point is the mutation they cause – stated here rather than squeezed into the table. describe('action nodes write through to the object they are given', () => { it('Move applies dx/dy × scale × dt', () => { const target = at('m', 0, 0); diff --git a/tests/core/texture.test.ts b/tests/core/texture.test.ts index ec515e09..7d5654af 100644 --- a/tests/core/texture.test.ts +++ b/tests/core/texture.test.ts @@ -18,7 +18,6 @@ import { makeSpriteScene, place, roundTrip } from '../helpers'; const FILE = `${'a1b2c3d4'.repeat(8)}.png`; const OTHER_FILE = `${'0f'.repeat(32)}.webp`; -/** Adds a texture asset carrying `file` and returns it. */ function addTexture(fixture: ReturnType, file = FILE, name = 'Bricks') { const command = new CreateTextureCommand(name, { file, width: 64, height: 48 }); fixture.stack.execute(command); @@ -51,7 +50,7 @@ describe('resolveTexture', () => { const sprite = findAsset(fixture.project, fixture.spriteId)!; sprite.props.texture = texture.id; - // A whole-texture reference carries no rectangle: the renderer then needs no matrix at all. + // A whole-texture reference carries no rectangle. const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48 }; expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true }); texture.props.filter = 'nearest'; @@ -64,8 +63,6 @@ describe('resolveTexture', () => { texture.frames.push({ id: 'f1', name: 'Idle', x: 16, y: 8, w: 32, h: 24 }); const sprite = findAsset(fixture.project, fixture.spriteId)!; - // The same property holds either kind of id, which is what keeps sprites written before - // frames existed meaning exactly what they meant. sprite.props.texture = 'f1'; expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, @@ -146,8 +143,7 @@ describe('findPropertyUsages', () => { sprite.props.texture = texture.id; const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 }); - // A texture is never placed, so the "is it used?" question `findUsages` answers is blind - // to it – that is exactly the gap this function fills. + // A texture is never placed, so `findUsages` is blind to it – hence this function. expect(findUsages(fixture.project, texture.id)).toHaveLength(0); const usages = findPropertyUsages(fixture.project, texture.id); @@ -165,8 +161,7 @@ describe('findPropertyUsages', () => { texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 8, h: 8 }); findAsset(fixture.project, fixture.spriteId)!.props.texture = 'f1'; - // Asking by the asset id alone under-reports exactly the sheets that were sliced – the - // ones with the most users – which is why deleting one asks about every id it owns. + // The asset id alone under-reports sliced sheets, so deleting one asks about every id it owns. expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(0); expect(findPropertyUsages(fixture.project, textureRefIds(texture))).toHaveLength(1); }); diff --git a/tests/editor/sheet-view.test.ts b/tests/editor/sheet-view.test.ts index 15844df5..03cbda35 100644 --- a/tests/editor/sheet-view.test.ts +++ b/tests/editor/sheet-view.test.ts @@ -22,7 +22,6 @@ describe('sheet view mapping', () => { const view = { panX: 100, panY: 50, zoom: 2 }; expect(imageToLocal(view, 0, 0)).toEqual({ x: 100, y: 50 }); expect(imageToLocal(view, 8, 4)).toEqual({ x: 116, y: 58 }); - // Round trip: the two are inverses, which is what lets a drag be computed in either space. expect(localToImage(view, 116, 58)).toEqual({ x: 8, y: 4 }); }); @@ -32,7 +31,7 @@ describe('sheet view mapping', () => { expect(view.zoom).toBeCloseTo(3.6); expect(view.panX).toBeCloseTo((400 - 100 * 3.6) / 2); expect(view.panY).toBeCloseTo((400 - 50 * 3.6) / 2); - // The whole picture is inside the widget, which is the only thing "fit" has to guarantee. + // Fitting only has to guarantee that the whole picture is inside the widget. const end = imageToLocal(view, 100, 50); expect(view.panX).toBeGreaterThanOrEqual(0); expect(end.x).toBeLessThanOrEqual(400); @@ -77,8 +76,8 @@ describe('grabbing a frame', () => { }); it('prefers a corner where handles overlap, so a tiny frame stays resizable', () => { - // A 4x4 frame with a 6 px tolerance: every handle matches the centre of the frame, and - // grabbing it must resize rather than move, or a small frame could never be adjusted. + // A 4x4 frame with a 6 px tolerance: every handle matches its centre, and a grab there + // must resize, or the frame could never be adjusted. const tiny: FrameRect = { x: 0, y: 0, w: 4, h: 4 }; expect(handleAt(tiny, { x: 2, y: 2 }, 6)).toEqual({ hx: -1, hy: -1 }); }); @@ -98,7 +97,6 @@ describe('dragging a frame', () => { }); it('pins the opposite edge when a corner is dragged', () => { - // Dragging the top-left corner right and down shrinks the frame; the bottom-right stays. const dragged = dragFrame(RECT, { hx: -1, hy: -1 }, 4, 6); expect(dragged).toEqual({ x: 14, y: 26, w: 36, h: 24 }); expect(dragged.x + dragged.w).toBe(RECT.x + RECT.w); @@ -111,8 +109,8 @@ describe('dragging a frame', () => { }); it('is allowed to invert – squaring that up belongs to one place only', () => { - // Pulling the left edge past the right one yields a negative width on purpose: - // `normalizeFrameRect` corrects it, so a drag and a typed value are corrected identically. + // The negative width is on purpose: `normalizeFrameRect` corrects a drag and a typed + // value identically. const dragged = dragFrame(RECT, { hx: -1, hy: 0 }, 60, 0); expect(dragged.w).toBeLessThan(0); }); @@ -124,8 +122,7 @@ describe('dragging a frame', () => { }); it('walks a frame through the eight handles back to where it started', () => { - // Each handle dragged by (d) and then by (−d) is the identity; a sign error in any one of - // the eight branches shows up here without needing eight separate expectations. + // A sign error in any of the eight branches breaks the drag-and-back identity. const handles: FrameHandle[] = [ 'move', { hx: -1, hy: -1 }, diff --git a/tests/ui/math.test.ts b/tests/ui/math.test.ts index 19952d39..01277c82 100644 --- a/tests/ui/math.test.ts +++ b/tests/ui/math.test.ts @@ -52,8 +52,7 @@ describe('formatNumber', () => { }); it('shows a field of integral values without a decimal point at all', () => { - // What `NumberFieldOptions.decimals: 0` buys: a pixel index or a count of tiles reads - // "33" and "4" instead of "33.000" and "4.000". + // What `NumberFieldOptions.decimals: 0` buys: a pixel index reads "33", not "33.000". expect(formatNumber(33, 0)).toBe('33'); expect(formatNumber(-4, 0)).toBe('-4'); // The minus-zero rule holds at every precision, not just the default one. diff --git a/tests/viewport/texture-fill.test.ts b/tests/viewport/texture-fill.test.ts index def83f33..15b42dce 100644 --- a/tests/viewport/texture-fill.test.ts +++ b/tests/viewport/texture-fill.test.ts @@ -1,19 +1,15 @@ /** - * The fill matrix is what makes a sprite wear one frame of a sheet, and it is only ever seen - * as pixels on a canvas – so it is pinned here against the identity its doc comment states, - * `uv = inverse(matrix) ∘ normalise(shapeBounds)`, rather than against the formula it uses. - * The checks therefore invert the matrix themselves and ask where the corners of the shape - * land in the image; that is an independent statement of what the renderer must achieve. + * The fill matrix that makes a sprite wear one frame of a sheet. Pinned against the identity + * `uv = inverse(matrix) ∘ normalise(shapeBounds)` rather than against the formula it uses, so + * the checks invert the matrix themselves and ask where the shape's corners land in the image. */ import { describe, expect, it } from 'vitest'; import type { FrameRect } from '../../src/core/model/texture'; import { frameFillMatrix, type MatrixValues } from '../../src/viewport/textureFill'; /** - * Where the shape point `(u, v)` – normalised, so (0,0) is the shape's top-left corner and - * (1,1) its bottom-right – samples the image, in UV coordinates over the whole picture. - * This is Pixi's `inverse(style.matrix)` applied to the normalised point, written out for a - * general affine matrix so the test does not assume the axis-aligned shape of the real one. + * Where the normalised shape point `(u, v)` samples the image: Pixi's `inverse(style.matrix)`, + * written out for a general affine matrix so the test does not assume an axis-aligned one. */ function sampleAt(m: MatrixValues, u: number, v: number): { u: number; v: number } { const det = m.a * m.d - m.b * m.c; @@ -41,8 +37,8 @@ function expectCovers(m: MatrixValues, rect: FrameRect, sourceWidth: number, sou } /** - * Compares the six numbers one by one instead of the object as a whole: a frame sitting on the - * top or left edge produces a negative zero, which `toEqual` counts as a different value. + * Number by number rather than `toEqual`: a frame on the top or left edge produces a negative + * zero, which `toEqual` counts as a different value. */ function expectMatrix(m: MatrixValues, expected: MatrixValues): void { for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const) expect(m[key]).toBeCloseTo(expected[key], 12); @@ -66,14 +62,12 @@ describe('frameFillMatrix', () => { const rect = { x: 32, y: 0, w: 32, h: 32 }; const m = frameFillMatrix(rect, 64, 64)!; - // Half the sheet across, so the shape has to be stretched twofold in both directions, and - // shifted by one shape-width to start at the sheet's midpoint. + // Half the sheet across: stretched twofold, shifted by one shape-width to the midpoint. expectMatrix(m, { a: 2, b: 0, c: 0, d: 2, tx: -1, ty: 0 }); expectCovers(m, rect, 64, 64); }); it('stretches the axes independently for a non-square frame', () => { - // A column strip: an eighth of the sheet wide and its full height. const rect = { x: 0, y: 0, w: 16, h: 64 }; const m = frameFillMatrix(rect, 128, 64)!; @@ -82,9 +76,7 @@ describe('frameFillMatrix', () => { }); it('offsets in frame-widths, not in pixels or UV', () => { - // A small frame well inside a large sheet: three frame-widths from the left, two frame - // heights down. This is the case that catches an offset expressed in the wrong unit, - // because pixels, UV and frame-widths all differ here. + // Pixels, UV and frame-widths all differ here, so an offset in the wrong unit is caught. const rect = { x: 48, y: 16, w: 16, h: 8 }; const m = frameFillMatrix(rect, 256, 128)!; @@ -93,8 +85,6 @@ describe('frameFillMatrix', () => { }); it('is the identity for a frame covering the whole sheet', () => { - // Which is the reason a whole-image sprite is drawn with no matrix at all: the matrix it - // would get says nothing, and an absent one is what Pixi is fastest at. const rect = { x: 0, y: 0, w: 200, h: 100 }; const m = frameFillMatrix(rect, 200, 100)!; diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index af284f95..1a5ad1aa 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -1,7 +1,4 @@ -/** - * The viewport transforms used to be private methods of a Pixi widget and could only be - * checked through the browser. They are plain geometry, so they are pinned here. - */ +/** The viewport transforms: plain geometry, so pinned here rather than through the browser. */ import { describe, expect, it } from 'vitest'; import { cameraView, diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 90eb0e97..d93eaa52 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -19,11 +19,9 @@ export async function assets(t) { check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names)); await shot('12-new-asset'); - // 12. Asset defaults: the Crate asset deviates in two properties – height 60 (schema default - // 100) and its red colour (the sprite default is white, i.e. "no tint") – so the rows - // carry two resets. Resetting height puts it back to 100 and removes *its* button while - // the colour keeps one; undo brings 60 back. Rows run Enabled, Width, Height, Color, - // Texture, so height's reset is the first and colour's the last. + // 12. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour, + // so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's + // reset is the first on screen and colour's the last. await select({ kind: 'asset', assetId: 'crate' }); await sleep(200); const heightReset = await evaluate(`__gm.locate('↺')`); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 6d96d8f5..1a213a56 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -186,9 +186,8 @@ export async function graph(t) { await undo(); check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys()); - // 13h. The tab keeps its subject. A selection about something else (nothing, a camera) leaves - // the graph alone – clicking a sprite to look at it must not empty the editor you are - // working in – and the header picker switches behaviors without any selection at all. + // 13h. The tab keeps its subject: selecting something else leaves the graph alone, and the + // header picker switches behaviors without any selection at all. const size = await graphCounts(); const readout = `${size.nodes} nodes · ${size.edges} links`; check( @@ -208,8 +207,7 @@ export async function graph(t) { 'graph editor keeps its behavior for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === 'mover', ); - // The picker is the *last* "Mover" on screen: the assets panel shows one too, and the - // inspector is on the camera. + // The picker is the *last* "Mover" on screen: the assets panel shows one too. const pickerAt = await locate('Mover', true); await click(pickerAt.x, pickerAt.y); await sleep(150); diff --git a/tools/e2e/11-menus.mjs b/tools/e2e/11-menus.mjs index f7f91bb9..ab09eee1 100644 --- a/tools/e2e/11-menus.mjs +++ b/tools/e2e/11-menus.mjs @@ -38,23 +38,17 @@ export async function menus(t) { await shot('31-long-parent-menu'); // 18a. The entries scroll under the wheel; the search box above them does not move. - // - // Anchored on a filler row rather than the list's "None" entry: "None" is also what an empty - // dropdown reads, so `locate('None', true)` could just as well find a *field* in the - // inspector behind the menu – one that never moves, whatever the wheel does. "Filler 1" - // exists only inside this list. + // Anchored on a filler row: "None" is also what an empty dropdown reads, so locating it could + // find an inspector field behind the menu instead – one that never moves. const firstBefore = await locate('Filler 1', true); const searchBefore = await locate('Search…', true); - // Move onto the list before turning the wheel. A wheel event is delivered to whatever is - // *hovered*, and the last thing the pointer touched was the field above the menu – without - // this the scroll is routed at that field instead, which is what a user could never do. + // Move onto the list first: a wheel event goes to whatever is hovered, and the pointer last + // touched the field above the menu. await mouse('mouseMoved', rect.x + rect.w / 2, rect.y + rect.h / 2); await sleep(50); await mouse('mouseWheel', rect.x + rect.w / 2, rect.y + rect.h / 2, 'none', { deltaX: 0, deltaY: 240 }); - // Read twice, and wait properly. The scroll lands a frame or two later than one might - // expect: measured here, the list had not moved 40 ms after the wheel and had moved at - // 340 ms. A single early reading made this check fail in the full suite while passing on - // its own — the two readings keep that visible instead of turning it back into a mystery. + // Measured here: the list had not moved 40 ms after the wheel but had at 340 ms. Both + // readings are reported so an early one cannot look like a mystery failure again. await sleep(40); const rightAfter = await locate('Filler 1', true); await sleep(300); diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index 70817b8e..6f45cbb4 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -1,10 +1,6 @@ /** * Uploading a picture and wearing it. Runs last because it adds an asset and repaints the * scene; the checks that count assets live in earlier steps. - * - * The image is built in the page and handed over as a paste, which is a real user gesture and - * exercises the whole chain: decode → upload → the server storing it under its content hash → - * the asset → the renderer. Nothing here is stubbed. */ /** Paints a four-quadrant PNG in the page and pastes it. The quadrants make the mapping readable. */ @@ -23,7 +19,6 @@ const PASTE_IMAGE = `(async () => { window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); })()`; -/** How far apart two `#rrggbb` colours are, summed over the channels. */ function colourDistance(a, b) { const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const [ar, ag, ab] = channels(a); @@ -34,9 +29,8 @@ function colourDistance(a, b) { export async function textures(t) { const { check, sleep, json, evaluate, shot, mutate, press } = t; - // The previous step leaves a picker open and a text field focused. Both matter here: paste is - // deliberately ignored while an input has focus, so that Ctrl+V inside a field still edits - // text. Closing and blurring is what clicking the canvas would do. + // The previous step leaves a picker open and a text field focused, and paste is deliberately + // ignored while an input has focus, so that Ctrl+V inside a field still edits text. await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await sleep(200); @@ -61,9 +55,8 @@ export async function textures(t) { JSON.stringify({ w: texture.w, h: texture.h }), ); - // 19a. Importing the same picture again is a no-op with an explanation: the server hands back - // the file it already has (the name is the hash), so the existing asset is reused rather - // than a second one made for the same bytes. + // 19a. Importing the same picture again reuses the asset: the server hands back the file it + // already has, since the name is the content hash. await evaluate(PASTE_IMAGE); await sleep(2500); const again = await json( @@ -81,15 +74,9 @@ export async function textures(t) { ); // 19b. The picture is actually drawn – a sprite wearing it is no longer one flat colour. - // - // On a sprite of its own, deliberately: eleven steps of edits have been through the fixture's - // objects by now (resized, re-parented, cloned forty times, covered by colliders and gizmos), - // and a colour read off the screen must not depend on any of that. This one is white (so the - // picture shows its own colours instead of a tint), large enough that a quarter-width sample - // sits far from every quadrant border, and on top of everything else. - // Both are clones of things that already exist rather than hand-written literals: a scene - // object has fields a literal keeps forgetting (`editorOffset` was the one that bit here – - // the position went NaN and the object was culled without an error). + // On a fresh white sprite of its own, so the sample does not depend on eleven steps of edits + // to the fixture. Cloned rather than written as a literal: a literal missed `editorOffset`, + // which sent the position to NaN and culled the object without an error. await mutate(` const project = __gm.state.project; const sprite = structuredClone(project.assets.find((a) => a.type === 'sprite')); @@ -138,12 +125,9 @@ export async function textures(t) { ); await shot('32-textured-sprite'); - // 19b-i. Switching the Filter property takes effect at once, without a reload. - // - // Read off the screen rather than off `scaleMode`, because the property was always assigned - // correctly – what used to survive the change was the GPU sampler behind it. The measurement - // is what "smooth" means: across the red/green border, nearest sampling shows only the two - // pure colours, linear sampling blends them into something that is neither. + // 19b-i. Switching the Filter property takes effect at once, without a reload. Read off the + // screen rather than off `scaleMode`: the property was always assigned correctly, it was the + // GPU sampler behind it that used to survive the change. const enlarge = (w, h) => ` const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); sprite.props.width = ${w}; diff --git a/tools/e2e/13-frames.mjs b/tools/e2e/13-frames.mjs index 77a8923f..c3c88695 100644 --- a/tools/e2e/13-frames.mjs +++ b/tools/e2e/13-frames.mjs @@ -1,13 +1,10 @@ /** * The Texture tab: cutting frames out of a sheet, and a sprite wearing one. * - * Runs after the texture step, whose four-quadrant 64x48 picture is what makes this checkable - * at all – a frame over one quadrant has a colour of its own, so "which part of the sheet is - * on screen" is a question `pixelAt` can answer. Nothing here is stubbed: the frame is made by - * dragging on the canvas, and the sprite is read back off the rendered pixels. + * Runs after the texture step, whose four-quadrant 64x48 picture gives every quadrant a colour + * of its own – that is what makes "which part of the sheet is on screen" a `pixelAt` question. */ -/** How far apart two `#rrggbb` colours are, summed over the channels. */ function colourDistance(a, b) { const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const [ar, ag, ab] = channels(a); @@ -21,21 +18,12 @@ const PALETTE = { green: '#3cc85a', blue: '#466ef0', white: '#f5f5f5', - /** - * The sprite's own colour for the fallback check, deliberately a colour the sheet does not - * contain. The sheet's fourth quadrant is near-white, so leaving the sprite white would make - * "it fell back to its colour" and "it is showing that quadrant" the same measurement. - */ plain: '#ff00ff', }; /** - * Which of the known colours a sampled pixel is: the nearest one by channel distance. - * - * Asked this way rather than "is it exactly green" on purpose. The scene carries editor gizmos - * by this point – a collider's fill is drawn over its body at 12 % alpha – so a sample can be - * tinted by a few percent without anything being wrong. That shifts a colour slightly; it - * cannot make one quadrant of the sheet read as another, which is the only question here. + * Nearest known colour rather than an exact match: editor gizmos (a collider fill at 12 % alpha) + * tint a sample by a few percent, which still cannot make one quadrant read as another. */ function nearestColour(sample) { let best = ''; @@ -68,9 +56,8 @@ export async function frames(t) { ); await shot('33-texture-tab'); - // 21. Dragging on the picture cuts a frame. The drag is aimed at the top-right quadrant of - // the sheet (32,0)-(64,24), inset by a pixel so the press cannot land on the edge of a - // neighbouring frame and be read as a resize instead of a new rectangle. + // 21. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24), + // inset by a pixel: a press on a frame edge is read as a resize, not a new rectangle. const from = await json('__gm.texture.pointAt(33, 1)'); const to = await json('__gm.texture.pointAt(63, 23)'); check('the sheet is on screen', Boolean(from) && Boolean(to), JSON.stringify({ from, to })); @@ -111,11 +98,7 @@ export async function frames(t) { await t.redo(); await sleep(400); - // 22. A sprite wearing the frame shows that part of the sheet and nothing else. This is the - // check the whole feature exists for: the model difference between "whole picture" and - // "this frame" is one id, so only the drawn pixels can tell them apart. - // The sprite from the texture step is reused, white and unparented, but its texture - // reference was left dangling there – it is pointed at the frame here. + // 22. A sprite wearing the frame shows that part of the sheet and nothing else. await evaluate(`(() => { const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); sprite.props.color = 0xffffff; @@ -132,9 +115,8 @@ export async function frames(t) { json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`); const corners = [await at(-0.3, -0.3), await at(0.3, -0.3), await at(-0.3, 0.3), await at(0.3, 0.3)]; const read = corners.map(nearestColour); - // Without the frame these four corners read red / green / blue / white – that is what the - // texture step asserts one screen earlier. Reading green four times is therefore the exact - // statement that the fill was confined to one quadrant of the sheet. + // Without the frame these corners read red / green / blue / white, as the texture step + // asserts – so four greens is the statement that the fill stayed inside one quadrant. check( 'the whole sprite shows the one quadrant the frame cuts out', read.every((c) => c === 'green'), @@ -143,10 +125,9 @@ export async function frames(t) { await shot('35-sprite-wearing-a-frame'); } - // 23. Slicing offers a live count before it commits – the readout is what makes the numbers - // answerable without undoing. - // Repainted magenta before slicing: with the frame gone the sprite falls back to this, and - // magenta is nowhere in the sheet, so the fallback cannot be confused with a part of it. + // 23. Slicing offers a live count before it commits. + // Repainted magenta first: magenta is nowhere in the sheet, so the fallback check below + // cannot be confused with a part of the picture. await evaluate(`(() => { __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff; __gm.state.preview(); @@ -180,8 +161,7 @@ export async function frames(t) { check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0])); await shot('36-sliced-sheet'); - // 23a. The sprite pointed at the frame that slicing removed falls back to its colour rather - // than to a wrong part of the picture. + // 23a. A sprite pointed at a frame slicing removed falls back to its colour. await evaluate("__gm.showTab('scene')"); await sleep(700); if (box) { diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index 697f2aad..044264b8 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -2,10 +2,8 @@ * Labels must be shortened to fit their control, not written past it. Runs last because it * renames assets to absurd lengths to provoke the case. * - * `__gm.textOverflows()` is a general guard rather than a check on one widget: it walks every - * visible text and reports the ones drawn wider than the widget that owns them. It was verified - * to actually catch this class of bug – against a build without the fix it reported the graph - * picker at 106 px over, and nothing else. + * `__gm.textOverflows()` walks every visible text and reports the ones drawn wider than their + * widget; against a build without the fix it reported the graph picker at 106 px over. */ /** A name far wider than any 200 px picker, so a control that does not truncate must spill. */ @@ -14,6 +12,17 @@ const LONG_NAME = 'Player movement with jumping and coyote time'; export async function overflow(t) { const { check, sleep, json, evaluate, shot, mutate } = t; + const pickerWidths = async () => { + const out = {}; + for (const tab of ['graph', 'texture']) { + await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); + await sleep(300); + out[tab] = await json(`__gm.${tab}.pickerWidth()`); + } + return out; + }; + const before = await pickerWidths(); + await mutate(` for (const asset of __gm.state.project.assets) { if (asset.type === 'behavior' || asset.type === 'texture') asset.name = ${JSON.stringify(LONG_NAME)}; @@ -21,8 +30,18 @@ export async function overflow(t) { __gm.state.select(null)`); await sleep(600); - // 21. Every tab, because each one owns a different set of controls: the two header bars with - // their pickers, the inspector's dropdowns, and the toolbar. + // 21. The pickers keep the width they were given. `width` used to set only a minimum, so a + // picker grew with whatever name it happened to show and the header reflowed on load. + const after = await pickerWidths(); + for (const tab of ['graph', 'texture']) { + check( + `the ${tab} picker keeps its width when the name gets long`, + before[tab] === 200 && after[tab] === 200, + `before ${before[tab]}, after ${after[tab]}`, + ); + } + + // 21a. Every tab, because each one owns a different set of controls. for (const tab of ['scene', 'graph', 'texture', 'game']) { await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await sleep(400); @@ -34,7 +53,7 @@ export async function overflow(t) { ); } - // 21a. Shortened, not simply dropped: the value must still be readable and marked as cut. + // 21b. Shortened, not simply dropped: the value must still be readable and marked as cut. await evaluate("__gm.showTab('graph')"); await sleep(400); const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement')); @@ -45,7 +64,7 @@ export async function overflow(t) { ); await shot('36-long-labels'); - // 21b. An inspector full of dropdowns – the parent row and the enum fields – with a selection + // 21c. An inspector full of dropdowns – the parent row and the enum fields – with a selection // whose asset name is now overlong too. await evaluate("__gm.showTab('scene')"); await sleep(300); @@ -53,4 +72,26 @@ export async function overflow(t) { await sleep(500); const inspector = await json('__gm.textOverflows()'); check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector)); + + // 21d. The other direction: a control narrow enough to swallow its whole label draws nothing + // at all, which the overflow walk cannot see – an empty text spills over no edge. + for (const tab of ['scene', 'graph', 'texture']) { + await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); + await sleep(400); + const dropped = await json('__gm.labelsDropped()'); + check( + `no button is left without its label in the ${tab} tab`, + dropped.length === 0, + JSON.stringify(dropped), + ); + } + // The narrowest buttons in the editor sit on a behaviour's variable rows. + await evaluate("__gm.state.select({ kind: 'asset', assetId: 'mover' })"); + await sleep(400); + const onVariables = await json('__gm.labelsDropped()'); + check( + 'the variable rows keep their buttons labelled', + onVariables.length === 0, + JSON.stringify(onVariables), + ); }