Trim needless comments and fix the picker width

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

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

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

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

No files matched your search

+9 -44
View File
@@ -4,7 +4,6 @@ import type { AssetType } from './AssetTypes';
import type { PropertyBag } from './Properties';
import type { SceneObject } from './SceneObject';
/** Every asset has an id, a name, a type and a bag of typed properties (see PropertySchema). */
export interface AssetBase {
id: string;
type: AssetType;
@@ -20,58 +19,38 @@ export interface CameraAsset extends AssetBase {
type: 'camera';
}
/**
* A line of text drawn in the scene (titles, HUD readouts, buttons). Its content is the
* ordinary property `text`, so behaviors change it with `Set text` (or `Set property`).
*/
export interface TextAsset extends AssetBase {
type: 'text';
}
/** A scene is an asset that additionally owns the objects placed into it. */
export interface SceneAsset extends AssetBase {
type: 'scene';
objects: SceneObject[];
}
/**
* A behavior is logic: a node graph plus the variables it declares. Variables are ordinary
* properties – `variables` is the asset's own property schema, `props` holds their default
* values, and placed instances override them like any other property.
*/
/** `variables` is this asset's own property schema; `props` holds their default values. */
export interface BehaviorAsset extends AssetBase {
type: 'behavior';
variables: PropertyDef[];
graph: Graph;
}
/**
* A collider is a shape (box or circle) used for collision detection. Placed as a child of
* the object it belongs to (its "body"); its position is the offset from that body.
*/
/** Placed as a child of the object it belongs to; its position is the offset from that body. */
export interface ColliderAsset extends AssetBase {
type: 'collider';
}
/**
* A container is a reusable group of objects (a "prefab"): its `objects` are a template that
* is instantiated below the container whenever it is placed. Template positions are local
* to the container's origin – the centre of the group when it was created from a selection.
* Template ids are private to the asset; every placed copy gets fresh ids and remembers the
* template object it came from (`SceneObject.templateId`).
* `objects` is a template instantiated below the container whenever it is placed. Template
* positions are local to the container's origin, and every placed copy gets fresh ids and
* remembers the template object it came from (`SceneObject.templateId`).
*/
export interface ContainerAsset extends AssetBase {
type: 'container';
objects: SceneObject[];
}
/**
* One named rectangle of a texture, in image pixels – a frame of a sprite sheet.
*
* Its `id` is drawn from the same generator as asset ids and is unique across the document,
* which is what lets a sprite's `texture` property name either a whole texture or one frame
* of it (see `model/texture.ts`).
*/
/** `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`). */
export interface TextureFrame {
id: string;
name: string;
@@ -82,23 +61,9 @@ export interface TextureFrame {
}
/**
* An uploaded image. `file` names it in `data/textures/` and is content-addressed
* (`{sha256}.{ext}`), so the same picture uploaded twice is one file and a stored file can be
* cached forever – its name cannot ever come to mean different bytes.
*
* Like a scene's objects and a behavior's graph, this is intrinsic data rather than a
* property: nothing about it is editable or overridable per instance. What *is* a property
* is the reference to it – sprites carry `texture` (see PropertySchema), which is how
* instance overrides, "Apply to container" and `Set property` work on it for free.
*
* `frames` cuts the picture into named rectangles (the Texture tab draws them); a texture
* with no frames is simply used whole, which is what every single-image texture wants and
* what every document written before frames existed already says.
*
* `width`/`height` are the pixel size as uploaded, kept so a sprite can be sized to its
* picture without waiting for the image to load – and so frames can be validated and sliced
* before it arrives. An empty `file` means the upload failed or has not happened yet; such a
* texture draws as nothing.
* `file` names the image in `data/textures/` and is content-addressed (`{sha256}.{ext}`), so it
* can be cached forever; empty means the upload failed or has not happened yet. `width`/`height`
* are the size as uploaded, so frames can be sliced and validated before the image loads.
*/
export interface TextureAsset extends AssetBase {
type: 'texture';
+3 -10
View File
@@ -1,4 +1,3 @@
/** All asset types. Scenes and textures are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = [
'scene',
'camera',
@@ -12,7 +11,6 @@ export const ASSET_TYPES = [
export type AssetType = (typeof ASSET_TYPES)[number];
/** Display names of the asset types (menus, list hints). */
export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
scene: 'Scene',
camera: 'Camera',
@@ -25,9 +23,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
};
/**
* Types that can be placed into a scene. A texture is not one of them: it is picture data,
* not something with a position – a sprite wears it (see the `texture` property). Dropping a
* texture into the scene therefore creates a sprite, it does not place the texture itself.
* A texture is not placeable: it is picture data a sprite wears, so dropping one into the scene
* creates a sprite instead.
*/
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture'>;
@@ -39,15 +36,11 @@ export function isAssetType(value: unknown): value is AssetType {
return typeof value === 'string' && (ASSET_TYPES as readonly string[]).includes(value);
}
/**
* Types drawn as editor symbols (icon in the overlay layer) instead of as scene content.
* They exist in the scene for their position, not for their looks.
*/
/** Types drawn as an icon in the overlay layer instead of as scene content. */
export function isSymbolType(type: AssetType): boolean {
return type === 'camera' || type === 'behavior' || type === 'collider' || type === 'container';
}
/** Types whose colour is worth a swatch in the lists. */
export function hasColorSwatch(type: AssetType): boolean {
return type === 'sprite' || type === 'text';
}
+7 -24
View File
@@ -2,7 +2,7 @@ import { schemaForAsset } from '../properties/PropertySchema';
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
import type { SceneObject } from './SceneObject';
/** Written into every document. Reserved for format migrations – the loader does not branch on it yet. */
/** Reserved for format migrations – the loader does not branch on it yet. */
export const PROJECT_FORMAT_VERSION = 1;
export interface Project {
@@ -10,7 +10,7 @@ export interface Project {
id: string;
name: string;
assets: Asset[];
/** Scene shown in the viewport and instantiated on Play. Empty string = none. */
/** Empty string = none. */
activeSceneId: string;
}
@@ -22,7 +22,6 @@ export function findAsset(project: Project, assetId: string): Asset | undefined
return project.assets.find((a) => a.id === assetId);
}
/** The scene asset with that id – undefined for other asset types as well. */
export function findScene(project: Project, sceneId: string): SceneAsset | undefined {
const asset = findAsset(project, sceneId);
return asset && isSceneAsset(asset) ? asset : undefined;
@@ -33,9 +32,8 @@ export function listScenes(project: Project): SceneAsset[] {
}
/**
* Everything that holds placed objects. Container assets do so just like scenes: their
* `objects` are the template a placed container is instantiated from. Anything that walks
* "all placed objects" has to cover both, or it silently skips the templates.
* Everything that holds placed objects. A container's `objects` are a template, so anything
* walking "all placed objects" must cover both or it silently skips the templates.
*/
export type ObjectHolder = SceneAsset | ContainerAsset;
@@ -48,7 +46,6 @@ export interface SceneObjectRef {
object: SceneObject;
}
/** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */
export function objectsWhere(
project: Project,
predicate: (object: SceneObject) => boolean,
@@ -65,28 +62,14 @@ export function findUsages(project: Project, assetId: string): SceneObjectRef[]
return objectsWhere(project, (o) => o.assetId === assetId);
}
/** One place where an `asset`-kind property points at an asset. */
export type PropertyUsage =
| { on: 'asset'; asset: Asset; key: string }
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
/**
* Every property that references one of `ids`, on assets and on the overrides of placed
* objects.
*
* This is the other half of "where is this asset used": `findUsages` finds assets that were
* *placed*, which is meaningless for a texture – a texture is never placed, it is worn by
* sprites through their `texture` property. Deleting one therefore has to count both, or it
* reports "0 uses" for a picture that ten sprites are showing.
*
* Several ids rather than one because a texture is referenced by more than its own id: every
* frame cut from it is a reference to the same picture (see `textureRefIds`). Deleting the
* texture has to count the sprites wearing a frame of it too, or it under-reports exactly the
* sheets that were sliced – the ones with the most users.
*
* A reference is deliberately left dangling rather than cleared when its target goes: a
* dangling one resolves to "no picture" everywhere it is read, and it comes back by itself
* when the delete is undone.
* The other half of "where is this asset used": a texture is never placed, it is worn through a
* sprite's `texture` property, so counting uses needs this as well as `findUsages`. Several ids
* because every frame cut from a texture is a reference to the same picture (see `textureRefIds`).
*/
export function findPropertyUsages(project: Project, ids: string | readonly string[]): PropertyUsage[] {
const wanted = new Set((typeof ids === 'string' ? [ids] : ids).filter(Boolean));
+2 -8
View File
@@ -1,32 +1,26 @@
/** Colors are stored as 0xRRGGBB numbers. */
export type PropertyValue = number | string | boolean;
/** Sparse map of property key → value (asset properties or scene-object overrides). */
export type PropertyBag = Record<string, PropertyValue>;
/** Writes `value` into the bag, or removes the key when the value is undefined (an undo of "no value"). */
/** `undefined` removes the key, which is how an undo of "no value" works. */
export function setBagValue(bag: PropertyBag, key: string, value: PropertyValue | undefined): void {
if (value === undefined) delete bag[key];
else bag[key] = value;
}
/** A point in world or screen space. */
export interface Vec2 {
x: number;
y: number;
}
/**
* Where a scene object sits: `x`/`y` in world units, `z` is the draw order. Higher `z` is
* drawn on top; objects with equal `z` keep their hierarchy order.
*/
/** `x`/`y` in world units; `z` is the draw order – higher is on top, ties keep hierarchy order. */
export interface Position extends Vec2 {
z: number;
}
export type Axis = keyof Position;
/** A full position from a 2D point (`z` = 0) or a position (copied). */
export function toPosition(point: Vec2 | Position): Position {
return { x: point.x, y: point.y, z: 'z' in point ? point.z : 0 };
}
+10 -18
View File
@@ -1,42 +1,34 @@
import { type Position, type PropertyBag, toPosition, type Vec2 } from './Properties';
/**
* An asset placed inside a scene. It links to the asset by id, owns a position
* (assets have none) and may override individual asset properties. Overrides are
* sparse: only keys the user changed are stored; everything else follows the asset.
* An asset placed inside a scene. Overrides are sparse: only keys the user changed are stored,
* everything else follows the asset.
*/
export interface SceneObject {
id: string;
assetId: string;
/** Optional display name; falls back to the asset name when empty. */
/** Falls back to the asset name when empty. */
name: string;
/**
* Position **relative to the parent** (world position when there is no parent) plus the
* draw order `z`, which is relative as well. See `hierarchy.ts` for world coordinates.
*/
/** Relative to the parent, `z` included; see `hierarchy.ts` for world coordinates. */
position: Position;
/**
* Editor-only shift of where this object (and everything below it) is *drawn* in the scene
* view – never part of the game. It exists to pull objects apart that sit on top of each
* other, such as the title/win/lose screens of a game, without moving them for real.
* Editor-only shift of where this object and everything below it is drawn – never part of the
* game. It pulls apart objects that sit on top of each other without moving them for real.
*/
editorOffset: Vec2;
overrides: PropertyBag;
/**
* Parent object of the same scene, empty string = top level. Children move with their
* parent and are removed with it. Behaviors treat their parent as the "owner" the graph
* acts on.
* Parent object of the same scene, empty string = top level. Children move with their parent and
* are removed with it; behaviors treat their parent as the "owner" the graph acts on.
*/
parentId: string;
/**
* For objects that belong to a placed container: the id of the template object (inside
* the container asset) this one was instantiated from. Empty for everything else. This is
* the link that lets template changes reach the instances later on.
* The template object inside the container asset this one was instantiated from, empty for
* everything else – the link that lets template changes reach the instances later on.
*/
templateId: string;
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
export function createSceneObject(
id: string,
assetId: string,
+8 -15
View File
@@ -4,7 +4,6 @@ import { chainOf, type ObjectTree } from './hierarchy';
import { findAsset, objectsWhere, type Project, type SceneObjectRef } from './Project';
import type { SceneObject } from './SceneObject';
/** What a placed container's content object is linked to: the container and its template object. */
export interface TemplateLink {
containerObject: SceneObject;
asset: ContainerAsset;
@@ -12,9 +11,8 @@ export interface TemplateLink {
}
/**
* For an object that belongs to a placed container: the nearest container ancestor whose
* asset holds the template this object was instantiated from. Undefined for everything else
* (no template id, or the link is stale).
* The nearest container ancestor whose asset holds the template `object` was instantiated from;
* undefined when it has no template id or the link is stale.
*/
export function templateLinkOf(
project: Project,
@@ -32,23 +30,19 @@ export function templateLinkOf(
}
/**
* Every placed copy of a template object (template ids are unique per project). Template
* objects themselves never carry a template id, so a template is never mistaken for a copy
* of itself even though the search covers container assets too.
* Every placed copy of a template object. Template objects themselves never carry a template id, so
* one is never mistaken for a copy of itself even though the search covers container assets too.
*/
export function instancesOf(project: Project, templateId: string): SceneObjectRef[] {
return objectsWhere(project, (o) => o.templateId === templateId);
}
/** Nesting depth at which a container inside a container inside … stops being expanded. */
const MAX_NESTING = 8;
/**
* The scene objects a placed container consists of: one fresh object per template object,
* parented below `containerObjectId` (or below the copy of their template parent), each
* remembering its template id. A template object that is itself a container is expanded in
* turn – unless the template already spells its content out (that is what a container built
* from a selection containing another container does).
* One fresh object per template object, parented below `containerObjectId` or below the copy of its
* template parent. A template object that is itself a container is expanded in turn, unless the
* template already spells its content out.
*/
export function instantiateContainer(
project: Project,
@@ -68,8 +62,7 @@ export function instantiateContainer(
assetId: template.assetId,
name: template.name,
position: { ...template.position },
// The editor offset stays with the instance, never with the template: it is a view tweak
// on one placed object, not something every copy of the container should inherit.
// The editor offset is a tweak on one placed object, not something copies should inherit.
editorOffset: { x: 0, y: 0 },
overrides: { ...template.overrides },
parentId,
+14 -30
View File
@@ -4,13 +4,8 @@ import type { SceneObject } from './SceneObject';
import { descendantsBelow, parentChain, sumAlongChain } from './walk';
/**
* Parent/child structure of a scene. `scene.objects` stays a flat list (storage order =
* creation order); the tree is implied by `SceneObject.parentId`. Positions are local to
* the parent, so the world position is the sum along the chain – there is no rotation or
* scale yet, which keeps this trivially invertible.
*
* The helpers work on anything that holds a flat object list – a scene, or the template
* inside a container asset.
* `objects` stays a flat list in creation order; the tree is implied by `SceneObject.parentId`.
* Anything holding such a list fits – a scene, or the template inside a container asset.
*/
export interface ObjectTree {
objects: SceneObject[];
@@ -24,12 +19,11 @@ function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
return tree.objects.filter((o) => o.parentId === parentId);
}
/** The parent of an object, or undefined for a top-level one (and for a dangling link). */
function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined {
return object.parentId ? findObject(tree, object.parentId) : undefined;
}
/** All objects below `id`, depth-first, parents before children. */
/** Depth-first, parents before children. */
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
return descendantsBelow(
id,
@@ -38,7 +32,6 @@ export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
);
}
/** The ids of `rootIds` and of everything below them – what group operations act on. */
export function subtreeIds(tree: ObjectTree, rootIds: Iterable<string>): Set<string> {
const ids = new Set<string>();
for (const rootId of rootIds) {
@@ -48,18 +41,18 @@ export function subtreeIds(tree: ObjectTree, rootIds: Iterable<string>): Set<str
return ids;
}
/** Takes the subtrees of `rootIds` out of the tree; returns them with their former indices (see `restoreAt`). */
/** Returns the removed objects with their former indices (see `restoreAt`). */
export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Removed<SceneObject>[] {
const ids = subtreeIds(tree, rootIds);
return removeWhere(tree.objects, (o) => ids.has(o.id));
}
/** An object and every parent above it, nearest first. Stops at a cycle (defensive). */
/** An object and every parent above it, nearest first. */
export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
return [...parentChain(object, (o) => parentOf(tree, o))];
}
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
/** Nearest parent first. */
export function ancestorIdsOf(tree: ObjectTree, id: string): string[] {
const object = findObject(tree, id);
return object
@@ -69,16 +62,12 @@ export function ancestorIdsOf(tree: ObjectTree, id: string): string[] {
: [];
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors. */
export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean {
const object = findObject(tree, objectId);
return object !== undefined && chainOf(tree, object).some((o) => o.id === ancestorId);
}
/**
* Of the given ids, those that have no ancestor among the ids themselves – the tops of the
* selected subtrees. Whole subtrees are what group operations (move, remove, group) act on.
*/
/** Of the given ids, those with no ancestor among the ids themselves. */
export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[] {
const set = new Set(ids);
return ids.filter((id) => !ancestorIdsOf(tree, id).some((ancestorId) => set.has(ancestorId)));
@@ -95,15 +84,13 @@ function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOff
);
}
/** Position of an object in world units (all local positions along the chain summed up). */
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
return positionAlongChain(tree, object, false);
}
/**
* Where the scene view draws an object: its world position plus the editor offsets along the
* chain, so shifting a parent shifts everything below it. This is a view aid only – nothing
* in the game, in collisions or in the graph ever sees it (they all use `worldPosition`).
* World position plus the editor offsets along the chain. A view aid only – the game, collisions
* and the graph all use `worldPosition`.
*/
export function editorPosition(tree: ObjectTree, object: SceneObject): Position {
return positionAlongChain(tree, object, true);
@@ -123,8 +110,8 @@ export interface TreeEntry {
}
/**
* Objects in tree order: top-level objects in storage order, each followed by its subtree.
* This is the order the hierarchy panel shows and the tie-break order for equal `z`.
* Top-level objects in storage order, each followed by its subtree – the order the hierarchy panel
* shows and the tie-break order for equal `z`.
*/
export function treeOrder(tree: ObjectTree): TreeEntry[] {
const result: TreeEntry[] = [];
@@ -137,7 +124,7 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
}
};
visit('', 0);
// Objects whose parent is missing (should not happen after loading) are listed at the top level.
// Objects whose parent is missing are listed at the top level rather than dropped.
for (const object of tree.objects) {
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object))
result.push({ object, depth: 0 });
@@ -146,15 +133,12 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
}
export interface VisibleEntry extends TreeEntry {
/** Whether the object has children at all – only those rows offer an expand/collapse chevron. */
hasChildren: boolean;
}
/**
* Tree order with the subtrees of collapsed objects left out: a row is shown when *every*
* ancestor is expanded. Expansion is a view state (which ids are open), never stored in the
* project – anything not listed counts as collapsed, so a freshly loaded scene shows only its
* top level.
* Tree order with the subtrees of collapsed objects left out. Anything not in `expanded` counts as
* collapsed, so a freshly loaded scene shows only its top level.
*/
export function visibleTreeOrder(tree: ObjectTree, expanded: ReadonlySet<string>): VisibleEntry[] {
const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean));
+15 -60
View File
@@ -2,18 +2,13 @@ import type { TextureAsset, TextureFrame } from './Asset';
import type { PropertyBag } from './Properties';
import { findAsset, type Project } from './Project';
/**
* A stored image file name: `{sha256}.{png|jpg|webp|gif}`. Mirrors `TextureStore::isValidName`
* on the server – the name is derived from the bytes there, so anything else in a document
* was hand-written and must not be turned into a request.
*/
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
const FILE_PATTERN = /^[a-f0-9]{64}\.(png|jpg|webp|gif)$/;
export function isTextureFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
/** A rectangle in image pixels: the part of a texture a frame cuts out. */
export interface FrameRect {
x: number;
y: number;
@@ -21,40 +16,29 @@ export interface FrameRect {
h: number;
}
/** Smallest frame that is still a picture rather than a slip of the mouse. */
export const MIN_FRAME_SIZE = 1;
/**
* A picture ready to be drawn: which file, whether to smooth it when scaled, and which part
* of it. `rect` is null for the whole image – not a rectangle covering everything, because
* the renderer can then skip the texture matrix entirely (Pixi fits an unmatrixed fill to the
* shape by itself) and because "the whole picture" survives the image being re-uploaded at a
* different size.
* `rect` is null for the whole image rather than a rectangle covering it: the renderer can then
* skip the texture matrix (Pixi fits an unmatrixed fill to the shape) and the meaning survives
* the image being re-uploaded at a different size.
*/
export interface TextureRef {
file: string;
/** False for pixel art, where interpolation is the difference between crisp and mush. */
smooth: boolean;
/** Sub-rectangle in image pixels, or null for the whole image. */
rect: FrameRect | null;
/** Size of the whole image in pixels – the reference frame `rect` is expressed in. */
sourceWidth: number;
sourceHeight: number;
}
/** A texture asset together with one of its frames. */
export interface FrameRef {
asset: TextureAsset;
frame: TextureFrame;
}
/**
* The frame with that id, wherever in the project it lives.
*
* Frame ids come from the same generator as asset ids and are unique across the document,
* which is what lets a sprite's `texture` property hold either kind: an asset id means the
* whole picture, a frame id means one cut-out of it. One property, one picker, and every
* sprite written before frames existed keeps meaning exactly what it meant.
* Frame ids share the generator with asset ids and are unique across the document, which lets a
* sprite's `texture` property hold either: an asset id means the whole picture, a frame id a cut-out.
*/
export function findFrame(project: Project, frameId: string): FrameRef | null {
if (!frameId) return null;
@@ -71,12 +55,6 @@ export function textureRefIds(asset: TextureAsset): string[] {
return [asset.id, ...asset.frames.map((f) => f.id)];
}
/**
* The picture a bag of properties points at, or null when it points at nothing usable –
* no reference, a dangling one (the texture or the frame was deleted), or one whose upload
* never finished. Callers draw their plain colour in that case, which is what a sprite
* looked like before textures existed.
*/
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
const id = String(props.texture ?? '');
if (!id) return null;
@@ -98,12 +76,8 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
}
/**
* A rectangle rounded to whole pixels and forced inside the image.
*
* Frames are pixel coordinates into a bitmap, so a fractional one has no meaning – it would
* sample half of a neighbouring pixel along every edge, which on a sprite sheet is the
* classic seam of foreign colour. Dragging produces fractions constantly (the view is
* zoomed), so this is applied on the way into the model rather than trusted from callers.
* Rounds to whole pixels and forces the rectangle inside the image: a fractional frame samples
* half of a neighbouring pixel along every edge, the classic sprite-sheet seam.
*/
export function normalizeFrameRect(rect: FrameRect, imageWidth: number, imageHeight: number): FrameRect {
// An image of unknown size (upload still running) cannot constrain anything; only round.
@@ -121,14 +95,13 @@ function clampInt(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
/** How a sheet is cut into a regular grid – the shape of nearly every sprite sheet. */
export interface SliceOptions {
columns: number;
rows: number;
/** Pixels skipped at the left/top edge of the sheet before the first cell. */
offsetX: number;
offsetY: number;
/** Pixels between neighbouring cells (sheets exported with padding). */
/** Pixels between neighbouring cells. */
spacingX: number;
spacingY: number;
}
@@ -143,20 +116,13 @@ export const DEFAULT_SLICE: SliceOptions = {
};
/**
* The rectangles a grid cuts a sheet into, in reading order (left to right, top to bottom) –
* which is the order sprite sheets are authored in and therefore the order an animation will
* want them.
*
* Cell size is derived from the image rather than asked for: everyone knows how many tiles
* their sheet has, almost nobody knows the pixel size off-hand, and deriving it cannot
* disagree with the picture. A cell that would reach past the image is dropped instead of
* clipped – a clipped one looks like a frame and is not one.
* The rectangles a grid cuts a sheet into, in reading order. Cell size is derived from the image;
* a cell that would reach past the edge is dropped rather than clipped.
*/
export function sliceFrames(imageWidth: number, imageHeight: number, options: SliceOptions): FrameRect[] {
const columns = Math.floor(options.columns);
const rows = Math.floor(options.rows);
// NaN has to be rejected explicitly: every comparison against it is false, so a NaN column
// count would pass `columns < 1` and only produce nothing by accident of the loop condition.
// NaN passes `columns < 1` (every comparison against it is false), so reject it explicitly.
if (!Number.isFinite(columns) || !Number.isFinite(rows)) return [];
if (!Object.values(options).every((value) => Number.isFinite(value))) return [];
if (columns < 1 || rows < 1 || imageWidth <= 0 || imageHeight <= 0) return [];
@@ -175,7 +141,6 @@ export function sliceFrames(imageWidth: number, imageHeight: number, options: Sl
return rects;
}
/** The rectangle part of a frame, and the way to write one back – the only glue between the two. */
export function frameRect(frame: TextureFrame): FrameRect {
return { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
}
@@ -187,14 +152,7 @@ export function setFrameRect(frame: TextureFrame, rect: FrameRect): void {
frame.h = rect.h;
}
/**
* A frame name that is free within its texture: `base`, else "base 2", "base 3" … – the same
* shape `nextAssetName` produces, because a name the user reads should not be numbered by two
* conventions in one document.
*
* Names matter more here than they look: they are what the sprite's picker shows, so two
* frames called "idle" would be one choice repeated rather than two.
*/
/** A name free within the texture: `base`, else "base 2", "base 3" … – the shape `nextAssetName` produces. */
export function uniqueFrameName(asset: TextureAsset, base: string, ignoreId = ''): string {
const taken = takenFrameNames(asset, ignoreId);
const stem = base || 'Frame';
@@ -202,11 +160,8 @@ export function uniqueFrameName(asset: TextureAsset, base: string, ignoreId = ''
}
/**
* `count` free names numbered from 1 – what slicing a sheet wants, where the number is the
* cell's position and "Frame 1" next to "Frame 2" is the whole point.
*
* Numbers already taken are skipped rather than suffixed again: re-slicing a sheet that keeps
* its old frames must not produce "Frame 2 1".
* `count` free names numbered from 1. Taken numbers are skipped rather than suffixed again, so
* re-slicing a sheet that keeps its old frames cannot produce "Frame 2 1".
*/
export function nextFrameNames(asset: TextureAsset, count: number, base = 'Frame'): string[] {
const taken = takenFrameNames(asset);
+5 -20
View File
@@ -1,15 +1,8 @@
/**
* Walking a parent/child structure, generic over the shape. Two hierarchies exist side by
* side – scene objects (`parentId` + lookup in a flat list) and runtime entities (direct
* `parent` pointer) – and both walk the same way. Callers pass in how to get from an item to
* its parent (or children); everything else lives here, so the cycle guard exists once.
*/
import type { Position } from './Properties';
/**
* `start` and every ancestor above it, nearest first. An item that was already visited ends
* the walk instead of looping forever: a cyclic parent chain is always a bug (loading repairs
* one, see `store/serialize`), but no walk at runtime may hang because of it.
* `start` and every ancestor above it, nearest first. A revisited item ends the walk: a cyclic
* parent chain is a bug (loading repairs one, see `store/serialize`) but must not hang a walk.
*/
export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | undefined): Generator<T> {
const seen = new Set<T>();
@@ -23,18 +16,14 @@ export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | unde
}
}
/** Whether the parents of `start` lead into a cycle instead of up to a root. */
export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | undefined): boolean {
let last = start;
for (const item of parentChain(start, parentOf)) last = item;
// The walk stops either above a root or at an already visited item – only the latter still has a parent.
// The walk stops above a root or at an already visited item; only the latter still has a parent.
return Boolean(parentOf(last));
}
/**
* The local positions of `start` and all its ancestors summed up – the world position, as
* long as the hierarchy has no rotation or scale (it has none).
*/
/** Summing local positions gives the world position only because the hierarchy has no rotation or scale. */
export function sumAlongChain<T>(
start: T,
parentOf: (item: T) => T | null | undefined,
@@ -50,11 +39,7 @@ export function sumAlongChain<T>(
return total;
}
/**
* Everything below `rootKey`, depth-first, parents before children. `keyOf` turns a found item
* back into the key its own children are looked up by, which is what lets ids (scene objects)
* and object identity (entities) use the same walk.
*/
/** Everything below `rootKey`, depth-first, parents before children. */
export function descendantsBelow<T, K>(
rootKey: K,
childrenOf: (key: K) => readonly T[],