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

+14 -52
View File
@@ -2,16 +2,11 @@ import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
/**
* Declarative description of one editable property. The inspector is generated from
* these entries, so adding a property to a type is a single line in a schema below.
*/
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
/**
* `decimals` is the number of places the field displays; it belongs only on properties that
* are integral by their meaning (counts, indices). Geometry does not qualify: dragging in the
* viewport puts fractions into a position or a size, and a field that hid them would commit
* the rounded number back into the project.
* `decimals` belongs only on properties that are integral by meaning (counts, indices), not on
* geometry: dragging produces fractions, and a field that hid them would commit the rounded value.
*/
| {
key: string;
@@ -26,40 +21,28 @@ export type PropertyDef =
| { key: string; label: string; kind: 'text'; default: string }
| { key: string; label: string; kind: 'color'; default: number }
| { key: string; label: string; kind: 'boolean'; default: boolean }
/** One of a fixed set of string values, shown as a dropdown. */
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
| { key: string; label: string; kind: 'key'; default: string }
/**
* A reference to another asset, stored as its id (`''` = none) and picked from a
* searchable dropdown. `assetTypes` limits what the picker offers; without it every
* placeable asset is offered, which is what graph nodes acting on an object want.
*
* The id is not validated on load – a reference can dangle, exactly like a graph node's
* asset parameter, and the places that read one have to cope with that.
* An asset id (`''` = none); without `assetTypes` the picker offers every placeable asset.
* Ids are not validated on load, so a reference can dangle and readers must cope.
*/
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
/**
* A picture: the id of a texture asset (the whole image) or of one of its frames. Its own
* kind rather than an `asset` reference with `assetTypes: ['texture']`, because a frame is
* not an asset – it is a rectangle inside one, and the picker has to offer both.
*
* Like an asset reference the id is not validated on load and may dangle; everything that
* reads one falls back to drawing no picture.
* The id of a texture asset or of one of its frames. Its own kind rather than an `asset`
* reference, because a frame is not an asset and the picker has to offer both. May dangle.
*/
| { key: string; label: string; kind: 'texture'; default: string };
export type PropertySchema = readonly PropertyDef[];
/** Kinds a behavior variable may have (what the node graph can compute with). */
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
* Properties every *placeable* asset has, whatever its type. `enabled` is the master switch:
* a disabled object is not drawn, does not collide and its behavior does not run – and the
* same goes for everything parented below it. Assets default to on, so switching a single
* instance off is an ordinary override. Scenes have no such switch (they are not placed).
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
* behavior does not run – and the same goes for everything parented below it.
*/
export const COMMON_PROPS: PropertySchema = [
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
@@ -67,12 +50,11 @@ export const COMMON_PROPS: PropertySchema = [
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
/** Whether a key belongs to the common properties (the inspector groups those separately). */
export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key);
}
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
camera: [
@@ -83,20 +65,11 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
sprite: [
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
/*
* The colour of the sprite: the fill of the box while it has no picture, and a multiplier
* on the picture once it has one. White therefore means "no tint" – which is why it is the
* default. A coloured default would silently stain every texture the moment it is applied,
* and the first thing anyone would do is set it back to white.
*
* Tinting is worth keeping rather than suppressing for textured sprites: it is how one
* white coin picture becomes ten differently coloured coins, per instance, for free.
*/
// The fill while there is no picture, and a tint multiplier once there is one: white = no tint.
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
],
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property:
// only how it is sampled is worth deciding, and that decision is the whole schema.
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
texture: [
{
key: 'filter',
@@ -125,7 +98,6 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
// Triggers report collisions but never block a colliding `Move`; solid colliders do both.
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
],
// A container is pure structure: its content is the template, not a property.
@@ -148,11 +120,6 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
};
/**
* Type schema with the common properties in front – the effective schema of an asset type.
* Only placeable types get them: `enabled` switches off a placed object, and a scene or a
* texture is never placed, so the switch would have nothing to act on.
*/
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type,
@@ -160,17 +127,15 @@ const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
]),
) as Record<AssetType, PropertySchema>;
/** Schema shared by every asset of a type (common properties first). */
export function schemaFor(type: AssetType): PropertySchema {
return FULL_SCHEMAS[type];
}
/** The effective schema of one asset: the type schema, plus declared variables for behaviors. */
/** The type schema, or for behaviors their declared variables. */
export function schemaForAsset(asset: Asset): PropertySchema {
return asset.type === 'behavior' ? [...COMMON_PROPS, ...asset.variables] : FULL_SCHEMAS[asset.type];
}
/** The definition of `key` in the effective schema of `asset`, if it has one. */
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
return schemaForAsset(asset).find((d) => d.key === key);
}
@@ -186,7 +151,6 @@ export function defaultsOf(schema: PropertySchema): PropertyBag {
return bag;
}
/** Builds a variable definition of the given kind with the kind's natural default. */
export function variableDef(key: string, kind: VariableKind, defaultValue?: PropertyValue): PropertyDef {
switch (kind) {
case 'number':
@@ -198,7 +162,7 @@ export function variableDef(key: string, kind: VariableKind, defaultValue?: Prop
}
}
/** Clamp / coerce a raw value so it satisfies the definition. */
/** Clamps / coerces a raw value so it satisfies the definition. */
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
switch (def.kind) {
case 'number': {
@@ -214,8 +178,6 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
}
case 'boolean':
return Boolean(value);
// An asset or texture reference is an id like the other two are plain strings; whether it
// still resolves is not decidable from a single property definition.
case 'text':
case 'key':
case 'asset':
+4 -12
View File
@@ -2,16 +2,11 @@ import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { findAsset, type Project } from '../model/Project';
/** Shown for an `asset` property that references nothing. */
export const NO_ASSET_LABEL = 'None';
/**
* How an `asset` reference reads wherever it is displayed: the referenced asset's name, "None"
* for the empty id, and `<id>?` when the id no longer resolves.
*
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
* only trace left of what was meant, and blanking it would make a deleted asset look like a
* field that was never filled in. `project` may be null while a view has no project yet.
* The asset's name, "None" for the empty id, `<id>?` when it no longer resolves – a dangling
* reference shows its id rather than passing for an empty field.
*/
export function assetRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_ASSET_LABEL;
@@ -19,10 +14,8 @@ export function assetRefLabel(project: Project | null, id: string): string {
}
/**
* The assets an `asset` property offers, in menu order. `assetTypes` restricts them to those
* types; without it every *placeable* asset is offered, which is what a graph node acting on
* an object means by "asset". Sorted by name (then type) so the order does not depend on when
* the assets happened to be created.
* The assets an `asset` property offers, in menu order; without `assetTypes` every placeable one.
* Sorted by name so the order does not depend on creation time.
*/
export function assetRefChoices(project: Project, assetTypes?: readonly AssetType[]): Asset[] {
const allowed = (asset: Asset): boolean =>
@@ -32,7 +25,6 @@ export function assetRefChoices(project: Project, assetTypes?: readonly AssetTyp
.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
}
/** Menu entry for one candidate – the type disambiguates assets that share a name. */
export function assetChoiceLabel(asset: Asset): string {
return `${asset.name} (${asset.type})`;
}
+7 -10
View File
@@ -1,7 +1,6 @@
/**
* Footprints in world units – how much room an object takes up. Shared by the viewport
* (culling, selection gizmos), by pointer hit tests in the running game and by "Add
* collider", so all three agree on how big a thing is.
* Footprints in world units, shared by the viewport, by pointer hit tests in the running game and by
* "Add collider", so all three agree on how big a thing is.
*/
import type { AssetType } from '../model/AssetTypes';
import type { PropertyBag, Vec2 } from '../model/Properties';
@@ -11,16 +10,15 @@ import { defaultsFor } from './PropertySchema';
const TEXT_DEFAULTS = defaultsFor('text');
/** World-unit size of the behavior symbol (a diamond). */
/** Symbol sizes in world units. */
export const BEHAVIOR_SYMBOL = 28;
/** World-unit size of the container symbol (a bracketed square). */
export const CONTAINER_SYMBOL = 24;
/** Rough width of one character relative to the font size (the UI font is a plain sans). */
/** Rough width of one character relative to the font size, for a plain sans. */
const CHAR_WIDTH = 0.55;
const LINE_HEIGHT = 1.25;
/** Size a text object covers, estimated from its content and font size. */
/** Estimated from the content and font size, without measuring the text. */
export function textSize(props: PropertyBag): { w: number; h: number } {
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
const lines = String(props.text ?? TEXT_DEFAULTS.text).split('\n');
@@ -31,13 +29,13 @@ export function textSize(props: PropertyBag): { w: number; h: number } {
};
}
/** Horizontal anchor of a text object: 0 = left edge on the position, 0.5 = centred, 1 = right. */
/** 0 = left edge on the position, 0.5 = centred, 1 = right. */
export function textAnchor(props: PropertyBag): number {
const align = String(props.align ?? TEXT_DEFAULTS.align);
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
}
/** Footprint of an object of the given type, centred on its position (text: see `textAnchor`). */
/** Centred on the object's position; text is anchored instead (see `textAnchor`). */
export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h: number } {
switch (type) {
case 'sprite':
@@ -55,7 +53,6 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
}
}
/** Whether a world point lies inside the footprint of an object placed at `centre`. */
export function footprintContains(type: AssetType, props: PropertyBag, centre: Vec2, point: Vec2): boolean {
const { w, h } = footprintOf(type, props);
const offset = type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
+2 -2
View File
@@ -12,8 +12,8 @@ export interface WorldRect {
}
/**
* Size of the world region a camera shows, in world units: its view size divided by its
* zoom. This is the frame drawn in edit mode and the only region rendered in play mode.
* The world region a camera shows: its view size divided by its zoom. Drawn as a frame in edit mode,
* and the only region rendered in play mode.
*/
export function cameraFrame(props: PropertyBag): { w: number; h: number } {
const zoom = Math.max(0.01, Number(props.zoom ?? DEFAULTS.zoom));
+8 -13
View File
@@ -1,6 +1,6 @@
/**
* Collider geometry in world units. Pure functions shared by the editor (drawing, gizmos)
* and the runtime (CollisionSystem), so both agree on what "overlaps" means.
* Collider geometry in world units, shared by the editor and the runtime so both agree on what
* "overlaps" means.
*/
import type { PropertyBag, Vec2 } from '../model/Properties';
import { defaultsFor } from './PropertySchema';
@@ -17,7 +17,7 @@ type Circle = Extract<ColliderShape, { kind: 'circle' }>;
/** A movement axis in the plane (`z` is draw order, not geometry). */
export type PlaneAxis = 'x' | 'y';
/** The shape described by collider properties, centred on `centre` (a world position). */
/** Centred on `centre`, a world position. */
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
if (props.shape === 'circle')
return {
@@ -40,20 +40,18 @@ export function isTrigger(props: PropertyBag): boolean {
return Boolean(props.isTrigger ?? DEFAULTS.isTrigger);
}
/** Footprint of a collider shape (used for culling and the selection gizmo). */
export function colliderSize(props: PropertyBag): { w: number; h: number } {
const shape = colliderShape(props, { x: 0, y: 0 });
return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h };
}
/** Whether a point lies inside a shape (used for pointer hit tests). */
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (shape.kind === 'circle')
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
}
/** Whether two shapes overlap. Shapes that merely touch along an edge do not overlap. */
/** Shapes that merely touch along an edge do not count as overlapping. */
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
if (a.kind === 'box' && b.kind === 'box') {
return Math.abs(a.x - b.x) * 2 < a.w + b.w && Math.abs(a.y - b.y) * 2 < a.h + b.h;
@@ -70,9 +68,8 @@ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
}
/**
* The centre distance along `axis` at which the two shapes exactly touch, given their
* current offset on the other axis. Only meaningful when the shapes overlap on that other
* axis (otherwise they cannot collide by moving along `axis`); returns 0 when they do not.
* The centre distance along `axis` at which the shapes exactly touch, given their current offset on
* the other axis; 0 when they do not overlap on that other axis and so cannot collide along `axis`.
*/
export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAxis): number {
const other: PlaneAxis = axis === 'x' ? 'y' : 'x';
@@ -95,9 +92,8 @@ export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAx
}
/**
* How far `mover` has to be pushed back against its travel direction `dir` (±1) along
* `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when
* `mover` approached from the far side, i.e. it did not overlap before its move.
* How far `mover` has to be pushed back against its travel direction `dir` (±1) so it just touches
* `obstacle`. Only valid when `mover` did not already overlap before its move.
*/
export function axisCorrection(
mover: ColliderShape,
@@ -109,7 +105,6 @@ export function axisCorrection(
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
}
/** The mixed pair (one box, one circle) sorted by kind. */
function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } {
return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a };
}
+3 -8
View File
@@ -1,8 +1,6 @@
/**
* The `enabled` switch (see `COMMON_PROPS`). Disabling an object takes it out of the running
* game completely – not drawn, no collisions, no behavior ticks – and the same holds for its
* children, because "enabled" is only true when the whole chain up to the root is on. That is
* what makes a container usable as a screen: switch it on to show it, off to hide it.
* The `enabled` switch (see `COMMON_PROPS`). A disabled object is not drawn, does not collide and
* runs no behavior; "enabled" is only true when the whole chain up to the root is on.
*/
import type { PropertyBag } from '../model/Properties';
import { parentChain } from '../model/walk';
@@ -12,10 +10,7 @@ function propsEnabled(props: PropertyBag): boolean {
return props.enabled !== false;
}
/**
* Whether `start` and every ancestor of it are switched on. Generic over the shape so the
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
/** Generic over the shape so scene objects, draw items and runtime entities share one rule. */
export function enabledAlongChain<T>(
start: T,
propsOf: (item: T) => PropertyBag,
+8 -19
View File
@@ -4,8 +4,8 @@ import type { SceneObject } from '../model/SceneObject';
import { propDef, schemaForAsset } from './PropertySchema';
/**
* Effective property values of an asset: schema defaults filled with the asset's own values.
* Keys unknown to the schema are ignored, so stale data never leaks into the editor.
* Schema defaults filled with the asset's own values. Keys unknown to the schema are ignored, so
* stale data never leaks into the editor.
*/
export function resolveAssetProps(asset: Asset): PropertyBag {
const bag: PropertyBag = {};
@@ -16,10 +16,7 @@ export function resolveAssetProps(asset: Asset): PropertyBag {
return bag;
}
/**
* Effective property values of a scene object: asset values with the object's sparse
* overrides applied on top. This is the ONLY place where overrides are merged.
*/
/** The only place where overrides are merged onto asset values. */
export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag {
const bag = resolveAssetProps(asset);
for (const def of schemaForAsset(asset)) {
@@ -33,10 +30,7 @@ export function isOverridden(object: SceneObject, key: string): boolean {
return object.overrides[key] !== undefined;
}
/**
* Whether the asset's own value for `key` differs from the schema default – the asset-level
* counterpart of an override. The inspector highlights such values and offers a reset.
*/
/** Whether the asset's own value differs from the schema default – the asset-level override. */
export function isModified(asset: Asset, key: string): boolean {
const def = propDef(asset, key);
if (!def) return false;
@@ -44,25 +38,20 @@ export function isModified(asset: Asset, key: string): boolean {
return own !== undefined && own !== def.default;
}
/** Value the object would have without its override for `key` (the asset's value). */
/** The value the object would have without its override for `key`. */
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
return resolveAssetProps(asset)[key];
}
/**
* Whether the object's value for `key` really differs from the asset's – an override that
* happens to carry the asset's own value is none. The inspector offers "reset"/"apply" only
* where they would change something, so this is what decides whether the buttons appear.
*/
/** An override that happens to carry the asset's own value does not count as a deviation. */
export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean {
const own = object.overrides[key];
return own !== undefined && own !== inheritedValue(asset, key);
}
/**
* Whether the object's override for `key` differs from the template it was instantiated from.
* Compared as overrides, not as effective values: both sides resolve against the same asset,
* so "neither overrides" means equal even when the asset value changed since.
* Compared as overrides, not as effective values: both sides resolve against the same asset, so
* "neither overrides" means equal even when the asset value changed since.
*/
export function deviatesFromTemplate(object: SceneObject, template: SceneObject, key: string): boolean {
return object.overrides[key] !== template.overrides[key];
+5 -21
View File
@@ -2,31 +2,22 @@ import type { TextureAsset, TextureFrame } from '../model/Asset';
import { findAsset, type Project } from '../model/Project';
import { findFrame } from '../model/texture';
/** Shown for a `texture` property that references nothing. */
export const NO_TEXTURE_LABEL = 'None';
/** Separates a texture's name from a frame's name wherever the two are shown together. */
const FRAME_SEPARATOR = ' / ';
/** One thing a sprite's `texture` property can be set to. */
export interface TextureChoice {
/** Asset id for the whole picture, frame id for one cut-out of it. */
id: string;
label: string;
/** The texture this choice belongs to – frames of one sheet stay together in the menu. */
asset: TextureAsset;
/** Null for the whole-picture entry. */
frame: TextureFrame | null;
}
/**
* How a texture reference reads wherever it is displayed: the texture's name for a whole
* picture, "Sheet / Frame" for a frame, "None" for the empty id, and `<id>?` when the id no
* longer resolves.
*
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
* only trace left of what was meant, and blanking it would make a deleted frame look like a
* field that was never filled in. `project` may be null while a view has no project yet.
* The texture's name, "Sheet / Frame" for a frame, "None" for the empty id, `<id>?` when it no
* longer resolves – a dangling reference shows its id rather than passing for an empty field.
*/
export function textureRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_TEXTURE_LABEL;
@@ -38,16 +29,9 @@ export function textureRefLabel(project: Project | null, id: string): string {
}
/**
* Everything a sprite may wear, in menu order: each texture followed by its own frames.
*
* The whole picture stays on offer next to the frames rather than being replaced by them.
* A texture that was never sliced has no frames at all, and a sheet's full image is
* occasionally what you want (a background, a check of the sheet itself) – forcing a frame
* would mean slicing every single-image texture into one pointless frame.
*
* Textures are sorted by name so the order does not depend on upload time; frames keep the
* order they were created in, which for a sliced sheet is reading order and therefore the
* order the animation runs in.
* Everything a sprite may wear, in menu order: each texture followed by its own frames. Textures
* are sorted by name so the order does not depend on upload time; frames keep creation order,
* which for a sliced sheet is reading order.
*/
export function textureRefChoices(project: Project): TextureChoice[] {
const textures = project.assets