diff --git a/README.md b/README.md index 3da7e27b..6e1429b3 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,10 @@ way in. ## In one screen -- An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, collider, rigidbody, - container, texture, vector art, pixel art, palette, font, sound, animation, state machine, shader, - particles, variables, tile, tilemap) with properties and no position. +- An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, variables, struct, + collider, rigidbody, container, clip area, texture, vector art, pixel art, palette, animation, + curve, tile, tilemap, state machine, shader, font, sound, particles) with properties and no + position. - A **scene object** places an asset: position, sparse **overrides** of its properties, an optional parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑. - A **container** is a reusable group edited once and placed many times; its content is resolved @@ -27,7 +28,8 @@ way in. account sees only its own projects. The Account menu at the far right of the toolbar changes the password and signs out. - **Play** runs a throwaway copy of the project; every panel stays usable and Stop brings back - exactly what was saved. + exactly what was saved. **Release…** shares a link that plays the saved game on its own, without + the editor and without signing in. ## Quick start diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index c7a5ab05..2fa47d74 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -15,7 +15,7 @@ import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; import { isSoundFile } from '../model/sound'; import { removeSubtrees } from '../model/hierarchy'; import type { PropertyBag, PropertyValue } from '../model/Properties'; -import { findAssetOf, objectHolders, type Project } from '../model/Project'; +import { objectHolders, type Project } from '../model/Project'; import { type Scope, scopeOf, @@ -29,7 +29,7 @@ import { isTextureFile } from '../model/texture'; import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema'; import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; -import { requireAsset, requirePropDef, requireStateMachine } from './require'; +import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require'; import { SetBagValueCommand } from './SetBagValueCommand'; export function buildAsset(type: AssetType, name: string, id = newId()): Asset { @@ -314,19 +314,13 @@ export class SetVariablesPersistentCommand implements Command { } execute(project: Project): void { - const asset = this.target(project); + const asset = requireVariablesAsset(project, this.assetId); this.previous = asset.persistent === true; setPersistent(asset, this.persistent); } undo(project: Project): void { - setPersistent(this.target(project), this.previous); - } - - private target(project: Project): VariablesAsset { - const asset = findAssetOf(project, this.assetId, 'variables'); - if (!asset) throw new CommandError(`Variables ${this.assetId} not found`); - return asset; + setPersistent(requireVariablesAsset(project, this.assetId), this.previous); } } @@ -353,7 +347,7 @@ export class SetVariablesScopeCommand implements Command { } execute(project: Project): void { - const asset = this.target(project); + const asset = requireVariablesAsset(project, this.assetId); this.previous = sharedScopeOf(asset); this.wasPersistent = asset.persistent === true; setSharedScope(asset, this.scope); @@ -361,16 +355,10 @@ export class SetVariablesScopeCommand implements Command { } undo(project: Project): void { - const asset = this.target(project); + const asset = requireVariablesAsset(project, this.assetId); setSharedScope(asset, this.previous); setPersistent(asset, this.wasPersistent); } - - private target(project: Project): VariablesAsset { - const asset = findAssetOf(project, this.assetId, 'variables'); - if (!asset) throw new CommandError(`Variables ${this.assetId} not found`); - return asset; - } } /** diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 07fdb3df..abdc4448 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -10,6 +10,7 @@ import type { TilemapAsset, TilemapLayer, VariableOwnerAsset, + VariablesAsset, VectorArtAsset, VectorShape, } from '../model/Asset'; @@ -131,6 +132,12 @@ export function requireCurve(project: Project, assetId: string): CurveAsset { return asset; } +export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset { + const asset = findAssetOf(project, assetId, 'variables'); + if (!asset) throw new CommandError(`Variables ${assetId} not found`); + return asset; +} + export function requireStateMachine(project: Project, assetId: string): StateMachineAsset { const asset = findAssetOf(project, assetId, 'statemachine'); if (!asset) throw new CommandError(`State machine ${assetId} not found`); diff --git a/src/core/commands/structTypeCommands.ts b/src/core/commands/structTypeCommands.ts index deebf1bd..d30b1562 100644 --- a/src/core/commands/structTypeCommands.ts +++ b/src/core/commands/structTypeCommands.ts @@ -197,6 +197,18 @@ function variableOfField(field: StructFieldDef, value: PropertyValue | undefined return def; } +/** A new Struct asset declaring `fields`, each at the value `values` holds for it, named after `name`. */ +function structAssetFrom( + project: Project, + fields: readonly StructFieldDef[], + values: Readonly>, + name: string, +): StructAsset { + const variables = fields.map((f) => variableOfField(f, values[f.key])); + const props = Object.fromEntries(variables.map((v) => [v.key, v.default])); + return { id: newId(), type: 'struct', name: nextAssetName(project, name), props, variables }; +} + /** * Turns the fields a struct holds by itself into a Struct asset, and types the struct with it. The * value it holds now becomes the asset's defaults, so the place reads exactly as before. @@ -212,11 +224,6 @@ export class MakeStructTypeCommand implements Command { private readonly path: readonly string[], ) {} - /** The asset made, once the command ran – for the caller to select it. */ - get assetId(): string { - return this.asset?.id ?? ''; - } - execute(project: Project): void { const owner = requireVariableOwner(project, this.ownerId); const def = requireStructVariable(project, this.ownerId, this.key); @@ -226,11 +233,8 @@ export class MakeStructTypeCommand implements Command { throw new CommandError('This struct has a type already'); if (!this.asset) { const value = valueAt(owner.props[def.key] ?? def.default, this.path); - const fields = isStruct(value) ? value : {}; - const variables = at.fields.map((f) => variableOfField(f, fields[f.key])); - const props = Object.fromEntries(variables.map((v) => [v.key, v.default])); - const name = nextAssetName(project, this.path.at(-1) ?? this.key); - this.asset = { id: newId(), type: 'struct', name, props, variables }; + const values = isStruct(value) ? value : {}; + this.asset = structAssetFrom(project, at.fields, values, this.path.at(-1) ?? this.key); } project.assets.push(this.asset); this.rec = new Recorder(); @@ -325,10 +329,6 @@ export class MakeListItemTypeCommand implements Command { constructor(private readonly target: ListTarget) {} - get assetId(): string { - return this.asset?.id ?? ''; - } - execute(project: Project): void { const { def, decl } = requireListDecl(project, this.target); if (decl.itemType && structAssetOf(project, decl.itemType)) @@ -344,10 +344,7 @@ export class MakeListItemTypeCommand implements Command { values[field.key] = item[field.key]!; fields.push(field); } - const variables = fields.map((f) => variableOfField(f, values[f.key])); - const props = Object.fromEntries(variables.map((v) => [v.key, v.default])); - const name = nextAssetName(project, this.target.path.at(-1) ?? this.target.key); - this.asset = { id: newId(), type: 'struct', name, props, variables }; + this.asset = structAssetFrom(project, fields, values, this.target.path.at(-1) ?? this.target.key); } project.assets.push(this.asset); this.rec = new Recorder(); @@ -370,7 +367,6 @@ function itemFieldDefs(item: StructValue): StructFieldDef[] { return { key, kind, fields: itemFieldDefs(value), ...(typeId ? { structType: typeId } : {}) }; } if (kind === 'struct') return { key, kind, fields: [] }; - if (kind === 'list') return { key, kind }; return { key, kind }; }); } diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 7d98b3a9..34138e29 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -4,8 +4,11 @@ import { type PropertyBag, type PropertyValue, type StructValue, + withFieldRenamed, + withoutField, } from '../model/Properties'; import { + type BehaviorAsset, isKeptVariable, isPrivateVariable, setKeptVariable, @@ -180,6 +183,13 @@ export class RenameVariableCommand extends RenameCommand { } } +/** The behavior, once it is known to declare `key`. */ +function requireBehaviorVariable(project: Project, assetId: string, key: string): BehaviorAsset { + const asset = requireBehavior(project, assetId); + if (!asset.variables.some((v) => v.key === key)) throw new CommandError(`Variable "${key}" not found`); + return asset; +} + /** Whether a behavior's variable carries on from one `Run behavior` call to the next (#229). */ export class SetVariableKeptCommand implements Command { readonly label: string; @@ -194,9 +204,7 @@ export class SetVariableKeptCommand implements Command { } execute(project: Project): void { - const asset = requireBehavior(project, this.assetId); - if (!asset.variables.some((v) => v.key === this.key)) - throw new CommandError(`Variable "${this.key}" not found`); + const asset = requireBehaviorVariable(project, this.assetId, this.key); this.previous = isKeptVariable(asset, this.key); setKeptVariable(asset, this.key, this.kept); } @@ -224,9 +232,7 @@ export class SetVariablePrivateCommand implements Command { } execute(project: Project): void { - const asset = requireBehavior(project, this.assetId); - if (!asset.variables.some((v) => v.key === this.key)) - throw new CommandError(`Variable "${this.key}" not found`); + const asset = requireBehaviorVariable(project, this.assetId, this.key); this.previous = isPrivateVariable(asset, this.key); setPrivateVariable(asset, this.key, this.hidden); this.overrides = this.hidden ? takeOverrides(project, this.assetId, this.key) : []; @@ -362,19 +368,6 @@ function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string) return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) }; } -function withoutField(value: StructValue, key: string): StructValue { - const next: Record = { ...value }; - delete next[key]; - return next; -} - -/** Rebuilt rather than reassigned, so the field keeps its place in the value as well. */ -function renameField(value: StructValue, from: string, to: string): StructValue { - const next: Record = {}; - for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item; - return next; -} - export class AddStructFieldCommand implements Command { readonly label: string; @@ -471,6 +464,6 @@ export class RenameStructFieldCommand extends RenameCommand { if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`); owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f))); for (const site of structSites(project, this.assetId, this.key, this.path)) - site.write(renameField(site.read(), from, to)); + site.write(withFieldRenamed(site.read(), from, to)); } } diff --git a/src/core/graph/liveObjects.ts b/src/core/graph/liveObjects.ts index 94d8dd0d..34f0f2de 100644 --- a/src/core/graph/liveObjects.ts +++ b/src/core/graph/liveObjects.ts @@ -1,6 +1,7 @@ import { isList, isStruct, + mapNested, NO_OBJECT, type PropertyBag, type PropertyValue, @@ -111,16 +112,9 @@ function mapObjects( swap: (held: EntityHandle) => unknown, place?: (at: PropertyValue) => unknown, ): unknown { - if (isEntity(value as Value)) return swap(value as EntityHandle); - if (place && tagOf(value) === 'object') return place(value as PropertyValue); - if (isList(value)) { - const items = value.map((item) => mapObjects(item, swap, place)); - return items.some((item, i) => item !== value[i]) ? items : value; - } - if (isStruct(value) && tagOf(value) === undefined) { - const entries = Object.entries(value); - const mapped = entries.map(([key, field]) => [key, mapObjects(field, swap, place)] as const); - return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value; - } - return value; + return mapNested(value, (part) => { + if (isEntity(part as Value)) return swap(part as EntityHandle); + if (place && tagOf(part) === 'object') return place(part as PropertyValue); + return undefined; + }); } diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 1e16f253..645077af 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -22,7 +22,7 @@ export const BEHAVIOR_END = 'BehaviorEnd'; export const RUN_BEHAVIOR = 'RunBehavior'; export const VARIABLES = 'Variables'; export const PROPERTIES = 'Properties'; -export const ON_START = 'OnStart'; +const ON_START = 'OnStart'; export const BEHAVIOR_PARAM = 'behavior'; export const VARIABLES_PARAM = 'variablesAsset'; diff --git a/src/core/graph/nodes/action/PlaySound.ts b/src/core/graph/nodes/action/PlaySound.ts index ce046e41..07315416 100644 --- a/src/core/graph/nodes/action/PlaySound.ts +++ b/src/core/graph/nodes/action/PlaySound.ts @@ -26,7 +26,7 @@ const loopPort = (): PortDef => ({ * A boolean arriving over an edge is taken literally, which is what wiring a comparison or a * variable into this input is for. */ -export function loopChoice(value: Value | undefined): boolean | undefined { +function loopChoice(value: Value | undefined): boolean | undefined { if (typeof value === 'boolean') return value; const text = asString(value); if (text === 'true') return true; diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 7c2b4348..95b8bf7f 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -47,7 +47,7 @@ export const contactOutputs = (): PortDef[] => [ export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' }; /** No `assetTypes`: any placeable asset is a valid answer here. */ -export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' }; +const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' }; /** * The `asset` input of `Spawn` and `Is asset`; unconnected, it shows the asset picker. The key is the diff --git a/src/core/graph/propertyPorts.ts b/src/core/graph/propertyPorts.ts index ce5d9f67..42287c71 100644 --- a/src/core/graph/propertyPorts.ts +++ b/src/core/graph/propertyPorts.ts @@ -14,7 +14,7 @@ export const OBJECT_BEHAVIOR_PARAM = 'objectBehavior'; * What a Properties node offers ports from. Textures and scenes are not objects in a running game, * so there is nothing to hand the node for them. */ -export const PROPERTY_TARGETS = [ +const PROPERTY_TARGETS = [ 'any', 'sprite', 'text', diff --git a/src/core/graph/stateMachine.ts b/src/core/graph/stateMachine.ts index 50c08ba5..f08eb425 100644 --- a/src/core/graph/stateMachine.ts +++ b/src/core/graph/stateMachine.ts @@ -5,7 +5,7 @@ import type { MachineState, MachineTransition, StateMachine } from './lookup'; import type { NodeDef, PortDef, PortSource } from './NodeRegistry'; export const STATE = 'State'; -export const STATE_MACHINE = 'StateMachine'; +const STATE_MACHINE = 'StateMachine'; export const STATE_TRANSITION = 'StateTransition'; export const STATE_NAME_PARAM = 'name'; /** The input naming the machine on `State branch` and `Set state`. */ diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 04fa6375..aa51a02c 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -113,7 +113,7 @@ export function findGraphAsset(project: Project, assetId: string): GraphAsset | */ export type ObjectHolder = SceneAsset | ContainerAsset; -export function isObjectHolder(asset: Asset): asset is ObjectHolder { +function isObjectHolder(asset: Asset): asset is ObjectHolder { return asset.type === 'scene' || asset.type === 'container'; } diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index 507e7344..c22d533c 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -60,6 +60,38 @@ export function withItemType(value: StructValue, typeId: string): StructValue { return typeId ? { ...fields, [ITEM_TYPE_KEY]: typeId } : fields; } +export function withoutField(value: StructValue, key: string): StructValue { + const { [key]: _dropped, ...rest } = value; + return rest; +} + +/** Rebuilt rather than reassigned, so the field keeps its place in the value. */ +export function withFieldRenamed(value: StructValue, from: string, to: string): StructValue { + const next: Record = {}; + for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item; + return next; +} + +/** + * `value` with each part `swap` answers for replaced by the answer, through lists and untagged + * structs to any depth; `undefined` keeps a part and looks inside it. What is untouched keeps its + * identity, so a caller can tell whether anything changed. + */ +export function mapNested(value: unknown, swap: (part: unknown) => unknown): unknown { + const swapped = swap(value); + if (swapped !== undefined) return swapped; + if (isList(value)) { + const items = value.map((item) => mapNested(item, swap)); + return items.some((item, i) => item !== value[i]) ? items : value; + } + if (isStruct(value) && tagOf(value) === undefined) { + const entries = Object.entries(value); + const mapped = entries.map(([key, field]) => [key, mapNested(field, swap)] as const); + return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value; + } + return value; +} + /** The value as a run holds it: every item-type mark taken out, at any depth. */ export function withoutItemTypes(value: T): T { if (isList(value)) return (value.some(holdsItemType) ? value.map(withoutItemTypes) : value) as T; diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index ad6ed0e0..52803331 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -20,7 +20,7 @@ import type { SceneObject } from './SceneObject'; import { mixColor } from '../util/color'; /** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */ -export const PLAYBACKS = ['loop', 'once', 'once-reset', 'pingpong'] as const; +const PLAYBACKS = ['loop', 'once', 'once-reset', 'pingpong'] as const; export type Playback = (typeof PLAYBACKS)[number]; /** What an animation can be built over: something placed, or a whole scene. */ diff --git a/src/core/model/curvePresets.ts b/src/core/model/curvePresets.ts index e811cbdd..2ee52cb0 100644 --- a/src/core/model/curvePresets.ts +++ b/src/core/model/curvePresets.ts @@ -45,7 +45,7 @@ export function easeCurve(shape: EasingShape, direction: EasingDirection): Vecto return curveFromEase((t) => easedShare(interpolation, t)); } -export function curveFromEase(ease: (t: number) => number): VectorPoint[] { +function curveFromEase(ease: (t: number) => number): VectorPoint[] { let at = [0, 1]; let points = traced(ease, at); for (let round = 0; round < MAX_ROUNDS; round++) { diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index d1a88c94..a4b26701 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -54,7 +54,7 @@ export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Re } /** An object and every parent above it, nearest first. */ -export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] { +function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] { return [...parentChain(object, (o) => parentOf(tree, o))]; } diff --git a/src/core/model/instance.ts b/src/core/model/instance.ts index 2911d290..a9817a81 100644 --- a/src/core/model/instance.ts +++ b/src/core/model/instance.ts @@ -70,7 +70,7 @@ function mergeEdits( }; } -export function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined { +function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined { return findAssetOf(project, object.assetId, 'container'); } diff --git a/src/core/model/matrix.ts b/src/core/model/matrix.ts index b6140a39..4d0d72cd 100644 --- a/src/core/model/matrix.ts +++ b/src/core/model/matrix.ts @@ -90,7 +90,7 @@ export function angleOf(m: Mat): number { return Math.atan2(m.b, m.a); } -export function isMirrored(m: Mat): boolean { +function isMirrored(m: Mat): boolean { return determinant(m) < 0; } diff --git a/src/core/model/objectRefs.ts b/src/core/model/objectRefs.ts index 257fb273..5717b2fa 100644 --- a/src/core/model/objectRefs.ts +++ b/src/core/model/objectRefs.ts @@ -6,7 +6,7 @@ import { findAsset, type Project } from './Project'; import { treeOrder, type ObjectTree } from './hierarchy'; import { resolveObjects } from './instance'; -import { isList, isStruct, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties'; +import { mapNested, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties'; import type { SceneObject } from './SceneObject'; import { Recorder } from '../util/recorder'; @@ -108,19 +108,10 @@ export function anchorReferences(objects: readonly SceneObject[]): void { * keep their identity, so a caller can tell whether anything changed. */ function withReferences(value: PropertyValue, swap: (ref: string) => string | undefined): PropertyValue { - if (tagOf(value) === 'object') { - const ref = referencedId(value); + return mapNested(value, (part) => { + if (tagOf(part) !== 'object') return undefined; + const ref = referencedId(part as PropertyValue); const next = ref ? swap(ref) : undefined; - return next === undefined || next === ref ? value : objectReference(next); - } - if (isList(value)) { - const items = value.map((item) => withReferences(item, swap)); - return items.some((item, i) => item !== value[i]) ? items : value; - } - if (isStruct(value) && tagOf(value) === undefined) { - const entries = Object.entries(value); - const mapped = entries.map(([key, field]) => [key, withReferences(field, swap)] as const); - return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value; - } - return value; + return next === undefined || next === ref ? part : objectReference(next); + }) as PropertyValue; } diff --git a/src/core/model/playSettings.ts b/src/core/model/playSettings.ts index 7790c905..5c3f0e94 100644 --- a/src/core/model/playSettings.ts +++ b/src/core/model/playSettings.ts @@ -16,7 +16,7 @@ export const GAME_SCALE_STEPS = [0.25, 0.5, 1, 2, 3, 4, 5, 6]; export const DEFAULT_GAME_SCALE: GameScale = 1; -export function isGameScale(value: unknown): value is GameScale { +function isGameScale(value: unknown): value is GameScale { return value === 'auto' || value === 'fit' || GAME_SCALE_STEPS.includes(value as number); } diff --git a/src/core/model/sceneVisibility.ts b/src/core/model/sceneVisibility.ts index 70dd03ac..b2130507 100644 --- a/src/core/model/sceneVisibility.ts +++ b/src/core/model/sceneVisibility.ts @@ -37,7 +37,7 @@ export function visibilityParts(type: PlaceableAssetType): VisibilityPart[] { } /** What a type shows until it is switched: all of it, except a name nothing showed before. */ -export function defaultVisibility(type: PlaceableAssetType): TypeVisibility { +function defaultVisibility(type: PlaceableAssetType): TypeVisibility { return { symbol: true, label: hasSceneIcon(type), shape: true }; } diff --git a/src/core/model/structTypes.ts b/src/core/model/structTypes.ts index 2daac6c6..fb1d8ca1 100644 --- a/src/core/model/structTypes.ts +++ b/src/core/model/structTypes.ts @@ -49,12 +49,13 @@ import { type StructValue, tagOf, textOf, + withFieldRenamed, withItemType, } from './Properties'; import { findAsset, findAssetOf, type Project } from './Project'; -export const PACK_STRUCT = 'PackStruct'; -export const EXPAND_STRUCT = 'ExpandStruct'; +const PACK_STRUCT = 'PackStruct'; +const EXPAND_STRUCT = 'ExpandStruct'; /** Deeper than this only a cycle written by hand can go; it is cut there. */ const MAX_DEPTH = 32; @@ -72,7 +73,7 @@ export function nodeStructType(node: GraphNode): string { } /** A declared variable as the struct field it becomes in every value of the type. */ -export function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined { +function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined { if (def.kind === 'struct') return { key: def.key, @@ -90,7 +91,7 @@ export function typeFields(asset: StructAsset): StructFieldDef[] { } /** A new value of the type: every field at the default the asset declares for it. */ -export function typeDefault(asset: StructAsset): StructValue { +function typeDefault(asset: StructAsset): StructValue { return structOf(typeFields(asset), asset.props); } @@ -196,7 +197,7 @@ function holdersOf( * `value` in the shape of `fields`: what it has keeps its value (brought to the field's kind), what * it lacks takes the type's default, and what the type does not declare falls away. */ -export function shapedStruct( +function shapedStruct( project: Project, fields: readonly StructFieldDef[], value: PropertyValue | undefined, @@ -280,7 +281,7 @@ function renamedItems(project: Project, value: PropertyValue, rename: FieldRenam const path = [...inside, ...rename.path]; const at = valueAt(next, path); if (isStruct(at) && rename.from in at) - next = withValueAt(next, path, renamedKey(at, rename.from, rename.to)) ?? next; + next = withValueAt(next, path, withFieldRenamed(at, rename.from, rename.to)) ?? next; } const out: Record = {}; for (const [key, item] of Object.entries(next as StructValue)) @@ -375,12 +376,6 @@ export interface FieldRename { to: string; } -function renamedKey(value: StructValue, from: string, to: string): StructValue { - const next: Record = {}; - for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item; - return next; -} - /** Carries a renamed field's values over, before the shapes are rebuilt – which would drop them. */ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void { const { from, to } = rename; @@ -391,7 +386,7 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void const value = holder.read(); const at = valueAt(value, path); if (!isStruct(at) || !(from in at)) continue; - holder.write(withValueAt(value, path, renamedKey(at, from, to)) ?? value!); + holder.write(withValueAt(value, path, withFieldRenamed(at, from, to)) ?? value!); } } for (const variable of everyVariable(project)) diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index 047ff8d1..897586af 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -175,14 +175,6 @@ export function tilemapLayerNames(project: Project): string[] { return [...new Set(names)]; } -/** The tiles a tilemap paints with, in the order they first appear – its palette in use. */ -export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] { - const ids: string[] = []; - for (const layer of asset.layers) - for (const id of Object.values(layer.cells)) if (id && !ids.includes(id)) ids.push(id); - return ids; -} - /** * The cell a point in the tilemap's own frame falls into. The grid is centred on the object's * position, as every footprint is, so the top-left corner lies half a map up and to the left. diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 1e35164d..76fa9c2a 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -244,6 +244,15 @@ function passesFilter(tile: TileAsset, oneWay: OneWayFilter, material?: TileMate return oneWay === true || tileOneWayAngle(tile) === oneWay; } +/** The solid tiles painted on the map, each once, in the order the layers first name them. */ +function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] { + const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells))); + return [...ids].flatMap((id) => { + const tile = findAssetOf(project, id, 'tile'); + return tile && isTileSolid(tile) ? [tile] : []; + }); +} + /** The surfaces of the solid tiles painted on the map that pass `oneWay`, each once. */ export function tileMaterials( project: Project, @@ -251,30 +260,17 @@ export function tileMaterials( oneWay: OneWayFilter = false, ): TileMaterial[] { const materials: TileMaterial[] = []; - const seen = new Set(); - for (const layer of asset.layers) - for (const tileId of cellsTileIds(layer.cells)) { - if (seen.has(tileId)) continue; - seen.add(tileId); - const tile = findAssetOf(project, tileId, 'tile'); - if (!tile || !isTileSolid(tile) || !passesFilter(tile, oneWay)) continue; - const material = tileMaterial(tile); - if (!materials.some((known) => sameMaterial(known, material))) materials.push(material); - } + for (const tile of solidTiles(project, asset)) { + if (!passesFilter(tile, oneWay)) continue; + const material = tileMaterial(tile); + if (!materials.some((known) => sameMaterial(known, material))) materials.push(material); + } return materials.sort((a, b) => a.friction - b.friction || a.bounciness - b.bounciness); } /** The angles of the solid one-way tiles painted on the map, each once, smallest first. */ export function oneWayAngles(project: Project, asset: TilemapAsset): number[] { - const angles = new Set(); - const seen = new Set(); - for (const layer of asset.layers) - for (const tileId of cellsTileIds(layer.cells)) { - if (seen.has(tileId)) continue; - seen.add(tileId); - const tile = findAssetOf(project, tileId, 'tile'); - if (tile && isTileSolid(tile) && isTileOneWay(tile)) angles.add(tileOneWayAngle(tile)); - } + const angles = new Set(solidTiles(project, asset).filter(isTileOneWay).map(tileOneWayAngle)); return [...angles].sort((a, b) => a - b); } diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index 0cf9bd04..2bd44f11 100644 --- a/src/core/model/usages.ts +++ b/src/core/model/usages.ts @@ -6,7 +6,7 @@ import { isKindWith } from '../properties/kinds'; import { schemaForAsset } from '../properties/PropertySchema'; import { referencesAny, trackDef } from './animation'; -import type { AnimationAsset, Asset, TileAsset, TilemapAsset } from './Asset'; +import type { AnimationAsset, Asset, TileAsset } from './Asset'; import { overrideSites } from './instance'; import { type ObjectHolder, objectHolders, type Project } from './Project'; import type { SceneObject } from './SceneObject'; @@ -68,15 +68,6 @@ export function tilesUsing(project: Project, ids: readonly string[]): TileAsset[ ); } -/** The tilemaps painted with any of these tiles – intrinsic data, like a tile's pictures. */ -export function tilemapsUsing(project: Project, ids: readonly string[]): TilemapAsset[] { - const wanted = new Set(ids); - return project.assets.filter( - (a): a is TilemapAsset => - a.type === 'tilemap' && a.layers.some((l) => Object.values(l.cells).some((id) => wanted.has(id))), - ); -} - /** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */ export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] { const wanted = new Set(ids); diff --git a/src/core/model/vectorBoolean.ts b/src/core/model/vectorBoolean.ts index 7e5026a6..26ba89f1 100644 --- a/src/core/model/vectorBoolean.ts +++ b/src/core/model/vectorBoolean.ts @@ -17,6 +17,7 @@ import { ClipType, FillRule, type PathD, type PathsD } from 'clipper2-ts/dist/Co import { ClipperD, type PolyPathD, PolyTreeD } from 'clipper2-ts/dist/Engine.js'; import type { VectorRing, VectorShape } from './Asset'; import type { Vec2 } from './Properties'; +import { cubicAt } from '../util/vec'; import { handleAt, isDrawableRing } from './vectorart'; import { addCurve, @@ -162,12 +163,3 @@ function curveSteps(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2): number { ); return Math.min(MAX_CURVE_STEPS, Math.max(1, Math.ceil(Math.sqrt((0.75 * m) / FLATTEN_TOLERANCE)))); } - -function cubicAt(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 { - const u = 1 - t; - const a = u * u * u; - const b = 3 * u * u * t; - const c = 3 * u * t * t; - const d = t * t * t; - return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y }; -} diff --git a/src/core/model/vectorCurves.ts b/src/core/model/vectorCurves.ts index 23825810..cd727ac8 100644 --- a/src/core/model/vectorCurves.ts +++ b/src/core/model/vectorCurves.ts @@ -11,6 +11,7 @@ */ import type { VectorPoint } from './Asset'; import type { Vec2 } from './Properties'; +import { cubicAt, splitCubic } from '../util/vec'; /** How far a sampled curve may stray from the true one, in canvas pixels. */ export const FLATTEN_TOLERANCE = 0.2; @@ -149,10 +150,11 @@ function edgeOn(from: PointOnCurve[], to: PointOnCurve[], table: CurveTable): Cu /** Whether the piece of `segment` from `t0` to `t1` hugs the straight line between its two ends. */ function staysOnCurve(segment: CurveSegment, t0: number, t1: number): boolean { - const a = cubicAt(segment, t0); - const b = cubicAt(segment, t1); + const at = (t: number) => cubicAt(segment.p0, segment.p1, segment.p2, segment.p3, t); + const a = at(t0); + const b = at(t1); for (let step = 1; step < 4; step++) { - const point = cubicAt(segment, t0 + (t1 - t0) * (step / 4)); + const point = at(t0 + (t1 - t0) * (step / 4)); const along = onChord(a, b, point); if (!along || along.gap > CHORD_SLACK) return false; } @@ -203,31 +205,6 @@ function build( function subCurve(segment: CurveSegment, t0: number, t1: number): Vec2[] { if (t0 > t1) return subCurve(segment, t1, t0).reverse(); const whole = [segment.p0, segment.p1, segment.p2, segment.p3]; - const left = t1 >= 1 ? whole : split(whole, t1).left; - return t1 <= 0 ? left : split(left, t0 / t1).right; -} - -/** de Casteljau: the cubic cut in two at `t`, both halves as control points. */ -function split(p: Vec2[], t: number): { left: Vec2[]; right: Vec2[] } { - const a = lerp(p[0]!, p[1]!, t); - const b = lerp(p[1]!, p[2]!, t); - const c = lerp(p[2]!, p[3]!, t); - const d = lerp(a, b, t); - const e = lerp(b, c, t); - const mid = lerp(d, e, t); - return { left: [p[0]!, a, d, mid], right: [mid, e, c, p[3]!] }; -} - -function lerp(a: Vec2, b: Vec2, t: number): Vec2 { - return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; -} - -function cubicAt(segment: { p0: Vec2; p1: Vec2; p2: Vec2; p3: Vec2 }, t: number): Vec2 { - const u = 1 - t; - const a = u * u * u; - const b = 3 * u * u * t; - const c = 3 * u * t * t; - const d = t * t * t; - const { p0, p1, p2, p3 } = segment; - return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y }; + const left = t1 >= 1 ? whole : splitCubic(whole, t1).left; + return t1 <= 0 ? left : splitCubic(left, t0 / t1).right; } diff --git a/src/core/particles/emission.ts b/src/core/particles/emission.ts index f8465d26..15f3d6a0 100644 --- a/src/core/particles/emission.ts +++ b/src/core/particles/emission.ts @@ -23,7 +23,7 @@ export interface LiveRange { to: number; } -export const NO_PARTICLES: LiveRange = { from: 0, to: -1 }; +const NO_PARTICLES: LiveRange = { from: 0, to: -1 }; /** * When particle `index` was born, in seconds since the source started. A continuous source emits on diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index fc839038..f457e2ec 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -280,7 +280,7 @@ export const SORT_WITH_PARENT_KEY = 'sortWithParent'; * Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`). * A project stored before it reads as off, so it draws as it did; a new asset starts with it on. */ -export const SORT_PROPS: PropertySchema = [ +const SORT_PROPS: PropertySchema = [ { key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true }, ]; @@ -295,7 +295,7 @@ const CONTAINER_SORT_PROPS: PropertySchema = [ /** A tilemap has no single place in the draw order, and the other types are not drawn at all. */ const SORT_TYPES: ReadonlySet = new Set(['sprite', 'text', 'particles', 'container']); -export function hasSortProps(type: AssetType): boolean { +function hasSortProps(type: AssetType): boolean { return SORT_TYPES.has(type); } diff --git a/src/core/properties/clip.ts b/src/core/properties/clip.ts index 8bcecc65..3381944e 100644 --- a/src/core/properties/clip.ts +++ b/src/core/properties/clip.ts @@ -18,7 +18,7 @@ export interface ClipNode { } /** The area's own rectangle in world units. */ -export function clipCorners(props: PropertyBag, m: Mat): Vec2[] { +function clipCorners(props: PropertyBag, m: Mat): Vec2[] { return footprintCorners('clip', props, m); } diff --git a/src/core/properties/declaredRows.ts b/src/core/properties/declaredRows.ts index 8d7deeb1..29adb8c7 100644 --- a/src/core/properties/declaredRows.ts +++ b/src/core/properties/declaredRows.ts @@ -4,9 +4,9 @@ import { flatDef, type FlatDef, type PropertyDef } from './PropertySchema'; /** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */ export const PROPERTY_NAME_PARAM = 'name'; -export const PROPERTY_DEFAULT_PARAM = 'default'; -export const PROPERTY_MIN_PARAM = 'min'; -export const PROPERTY_MAX_PARAM = 'max'; +const PROPERTY_DEFAULT_PARAM = 'default'; +const PROPERTY_MIN_PARAM = 'min'; +const PROPERTY_MAX_PARAM = 'max'; /** The params of a `Property` node, the same in both graphs but for the step of their number fields. */ export function propertyNodeParams(step: number): PropertyDef[] { diff --git a/src/core/properties/shaders.ts b/src/core/properties/shaders.ts index 8c92ab06..256b7bee 100644 --- a/src/core/properties/shaders.ts +++ b/src/core/properties/shaders.ts @@ -31,7 +31,7 @@ export function propertyName(node: GraphNode): string { * rows the sprite brings itself: both rows would share the key and the uniform would read the * sprite's width instead of the declared default (#63). The compiler names which of the two it is. */ -export function isValidPropertyName(name: string): boolean { +function isValidPropertyName(name: string): boolean { return isValidFieldName(name) && !isSpriteProp(name); } diff --git a/src/core/properties/transform.ts b/src/core/properties/transform.ts index a2ea01a6..ee1a6cc5 100644 --- a/src/core/properties/transform.ts +++ b/src/core/properties/transform.ts @@ -32,7 +32,7 @@ export interface LocalTransform { mirrorY: boolean; } -export const NEUTRAL: LocalTransform = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false }; +const NEUTRAL: LocalTransform = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false }; export function transformOf(props: PropertyBag): LocalTransform { return { diff --git a/src/core/store/HttpAuthStore.ts b/src/core/store/HttpAuthStore.ts index 03540c00..4e813940 100644 --- a/src/core/store/HttpAuthStore.ts +++ b/src/core/store/HttpAuthStore.ts @@ -1,5 +1,15 @@ import { type AuthStore, roleOf, type SignedIn, WrongCredentialsError } from './AuthStore'; -import { apiRequest } from './http'; +import { apiRequest, jsonBody } from './http'; + +/** + * A wrong password is the expected answer of a login screen, not a breakdown: it gets its own type + * so the screen can say one thing about it and another about a server that is down. Changing the + * password answers the same way when the current one was wrong; a 422 (too short) carries the + * server's wording and stays generic. + */ +function wrongCredentials(status: number, data: unknown): Error | undefined { + return status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined; +} export class HttpAuthStore implements AuthStore { constructor(private readonly endpoint: string) {} @@ -18,14 +28,8 @@ export class HttpAuthStore implements AuthStore { this.endpoint, 'POST', 'auth/login', - { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }, - // A wrong password is the expected answer of a login screen, not a breakdown: it gets its - // own type so the screen can say one thing about it and another about a server that is down. - (status, data) => - status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined, + jsonBody({ username, password }), + wrongCredentials, ); return { user: body.user, role: roleOf(body.role) }; } @@ -39,14 +43,8 @@ export class HttpAuthStore implements AuthStore { this.endpoint, 'POST', 'auth/password', - { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ currentPassword, newPassword }), - }, - // Same split as `logIn`: a 401 here means the current password was wrong, which the dialog - // says in its own words. A 422 (too short) carries the server's wording and stays generic. - (status, data) => - status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined, + jsonBody({ currentPassword, newPassword }), + wrongCredentials, ); } } diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts index 625b838d..a7671b03 100644 --- a/src/core/store/HttpProjectStore.ts +++ b/src/core/store/HttpProjectStore.ts @@ -1,11 +1,6 @@ import type { Project } from '../model/Project'; -import { apiRequest } from './http'; -import { - type LoadedProject, - type ProjectStore, - type ProjectSummary, - StaleRevisionError, -} from './ProjectStore'; +import { apiRequest, jsonBody } from './http'; +import { type LoadedProject, type ProjectStore, type ProjectSummary, staleRevision } from './ProjectStore'; import { deserializeProject, serializeProject } from './serialize'; export class HttpProjectStore implements ProjectStore { @@ -47,18 +42,6 @@ export class HttpProjectStore implements ProjectStore { } private async request(method: string, route: string, payload?: unknown): Promise { - return apiRequest( - this.endpoint, - method, - route, - { - headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {}, - body: payload !== undefined ? JSON.stringify(payload) : undefined, - }, - (status, data) => - status === 409 - ? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1) - : undefined, - ); + return apiRequest(this.endpoint, method, route, jsonBody(payload), staleRevision); } } diff --git a/src/core/store/HttpSnapshotStore.ts b/src/core/store/HttpSnapshotStore.ts index 5b2d71e1..d0c7a732 100644 --- a/src/core/store/HttpSnapshotStore.ts +++ b/src/core/store/HttpSnapshotStore.ts @@ -1,5 +1,5 @@ -import { apiRequest } from './http'; -import { StaleRevisionError } from './ProjectStore'; +import { apiRequest, jsonBody } from './http'; +import { staleRevision } from './ProjectStore'; import type { SnapshotStore, SnapshotSummary } from './SnapshotStore'; export class HttpSnapshotStore implements SnapshotStore { @@ -26,19 +26,7 @@ export class HttpSnapshotStore implements SnapshotStore { } private request(method: string, path: string, payload?: unknown): Promise { - return apiRequest( - this.endpoint, - method, - path, - { - headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {}, - body: payload !== undefined ? JSON.stringify(payload) : undefined, - }, - (status, data) => - status === 409 - ? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1) - : undefined, - ); + return apiRequest(this.endpoint, method, path, jsonBody(payload), staleRevision); } } diff --git a/src/core/store/HttpUsersStore.ts b/src/core/store/HttpUsersStore.ts index f1bb292a..ce9c6744 100644 --- a/src/core/store/HttpUsersStore.ts +++ b/src/core/store/HttpUsersStore.ts @@ -1,5 +1,5 @@ import { type Role, roleOf } from './AuthStore'; -import { apiRequest } from './http'; +import { apiRequest, jsonBody } from './http'; import type { Account, AccountChange, UsersStore } from './UsersStore'; /** A refusal of the API (4xx with an `{error}`) as its bare sentence, without route and status around it. */ @@ -17,18 +17,14 @@ export class HttpUsersStore implements UsersStore { } async create(username: string, password: string, role: Role): Promise { - await apiRequest(this.endpoint, 'POST', 'users', this.json({ username, password, role }), refusal); + await apiRequest(this.endpoint, 'POST', 'users', jsonBody({ username, password, role }), refusal); } async update(username: string, change: AccountChange): Promise { - await apiRequest(this.endpoint, 'PATCH', `users/${username}`, this.json(change), refusal); + await apiRequest(this.endpoint, 'PATCH', `users/${username}`, jsonBody(change), refusal); } async remove(username: string): Promise { await apiRequest(this.endpoint, 'DELETE', `users/${username}`, {}, refusal); } - - private json(body: object): { headers: Record; body: string } { - return { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; - } } diff --git a/src/core/store/ProjectStore.ts b/src/core/store/ProjectStore.ts index 5b604217..181d26d8 100644 --- a/src/core/store/ProjectStore.ts +++ b/src/core/store/ProjectStore.ts @@ -20,6 +20,13 @@ export class StaleRevisionError extends Error { } } +/** The API's 409, with the revision the server holds, as a `StaleRevisionError` (see `apiRequest`). */ +export function staleRevision(status: number, data: unknown): Error | undefined { + return status === 409 + ? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1) + : undefined; +} + export interface ProjectStore { list(): Promise; /** diff --git a/src/core/store/ReleaseStore.ts b/src/core/store/ReleaseStore.ts index 73f978e6..bce3d5b8 100644 --- a/src/core/store/ReleaseStore.ts +++ b/src/core/store/ReleaseStore.ts @@ -27,7 +27,7 @@ export interface ReleaseStore { } /** The page a released game is played on, next to the editor's own. */ -export const PLAY_PAGE = '/game-maker/play.html'; +const PLAY_PAGE = '/game-maker/play.html'; export function releaseUrl(origin: string, token: string): string { return `${origin}${PLAY_PAGE}?game=${token}`; diff --git a/src/core/store/http.ts b/src/core/store/http.ts index ba708284..c50dbdef 100644 --- a/src/core/store/http.ts +++ b/src/core/store/http.ts @@ -14,10 +14,17 @@ const STATUS_MESSAGES: Record = { 504: 'Server timed out', }; -export function statusMessage(status: number, statusText: string): string { +function statusMessage(status: number, statusText: string): string { return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`); } +/** The request part of a JSON payload; nothing at all without one. */ +export function jsonBody(payload?: unknown): Pick { + return payload === undefined + ? {} + : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }; +} + /** * One call against the PHP API. Routing goes through the `r` query parameter because the site's * nginx config does not pass PATH_INFO to PHP; `query` adds further parameters beside it, which is diff --git a/src/core/util/vec.ts b/src/core/util/vec.ts index 54243b30..923e19f6 100644 --- a/src/core/util/vec.ts +++ b/src/core/util/vec.ts @@ -11,3 +11,28 @@ export function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 { const t = length > 0 ? Math.min(1, Math.max(0, dot({ x: p.x - a.x, y: p.y - a.y }, ab) / length)) : 0; return { x: a.x + ab.x * t, y: a.y + ab.y * t }; } + +export function lerpVec(a: Vec2, b: Vec2, t: number): Vec2 { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +/** The point at `t` of the cubic Bezier with these control points. */ +export function cubicAt(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 { + const u = 1 - t; + const a = u * u * u; + const b = 3 * u * u * t; + const c = 3 * u * t * t; + const d = t * t * t; + return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y }; +} + +/** de Casteljau: the cubic cut in two at `t`, both halves as control points. */ +export function splitCubic(p: readonly Vec2[], t: number): { left: Vec2[]; right: Vec2[] } { + const a = lerpVec(p[0]!, p[1]!, t); + const b = lerpVec(p[1]!, p[2]!, t); + const c = lerpVec(p[2]!, p[3]!, t); + const d = lerpVec(a, b, t); + const e = lerpVec(b, c, t); + const mid = lerpVec(d, e, t); + return { left: [p[0]!, a, d, mid], right: [mid, e, c, p[3]!] }; +} diff --git a/src/editor/FilePicker.ts b/src/editor/FilePicker.ts index 3e776a15..8f0c4e01 100644 --- a/src/editor/FilePicker.ts +++ b/src/editor/FilePicker.ts @@ -27,7 +27,7 @@ import { dialogButtons } from './FormDialog'; /** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */ export const PICKER_WIDTH = 560; -export const PICKER_HEIGHT = 340; +const PICKER_HEIGHT = 340; /** One offered file, with the asset that already wears it (if any). */ export type PickerItem = S & { assetName: string | null }; @@ -46,7 +46,7 @@ export function withAssetNames( } /** The line under the list for a picked file: its upload name and size, and who wears it. */ -export function describeStored(item: PickerItem): string { +function describeStored(item: PickerItem): string { const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes); return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; } diff --git a/src/editor/PointKindButton.ts b/src/editor/PointKindButton.ts new file mode 100644 index 00000000..2a7b5948 --- /dev/null +++ b/src/editor/PointKindButton.ts @@ -0,0 +1,49 @@ +import { Dropdown, Menu } from '../ui'; +import { POINT_KINDS, type PointKind } from './polygonEdit'; + +/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */ +export const POINT_BUTTON_WIDTH = 150; + +const POINT_KIND_LABELS: Record = { + straight: 'Straight', + corner: 'Corner', + smooth: 'Smooth', + symmetric: 'Symmetric', +}; + +/** What the button edits: the point last picked on a canvas, null while none is. */ +export interface PointKindTarget { + readonly pickedKind: PointKind | null; + setPickedKind(kind: PointKind): void; +} + +/** + * The header's point type (#195), in the Vector art and the Curve tab: the four types a point can + * be, the one it is now ticked. + */ +export class PointKindButton extends Dropdown { + constructor(private readonly target: () => PointKindTarget) { + super(pointLabel(null), { width: POINT_BUTTON_WIDTH }); + this.onClick = () => this.openMenu(); + } + + /** Follows the picked point: its type, or disabled while none is picked. */ + sync(): void { + const kind = this.target().pickedKind; + this.setText(pointLabel(kind)); + this.setEnabled(kind !== null); + } + + private openMenu(): void { + const items = POINT_KINDS.map((kind) => ({ + label: POINT_KIND_LABELS[kind], + checked: () => this.target().pickedKind === kind, + onSelect: () => this.target().setPickedKind(kind), + })); + new Menu(items).openBelow(this); + } +} + +function pointLabel(kind: PointKind | null): string { + return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`; +} diff --git a/src/editor/TabButton.ts b/src/editor/TabButton.ts index 5142d48b..cf9f2912 100644 --- a/src/editor/TabButton.ts +++ b/src/editor/TabButton.ts @@ -7,7 +7,7 @@ const RADIUS = 6; * Inset of icon and name inside a card. Half the theme padding was too tight once the cards got a * border of their own, so the row keeps its own value instead of `Theme.padding`. */ -export const TAB_PADDING = 6; +const TAB_PADDING = 6; /** * One card of the tab row. It is drawn as a real index card rather than as a toggled button: a diff --git a/src/editor/animation/timelineView.ts b/src/editor/animation/timelineView.ts index ff38d685..b979d508 100644 --- a/src/editor/animation/timelineView.ts +++ b/src/editor/animation/timelineView.ts @@ -22,7 +22,7 @@ export const RULER_HEIGHT = 22; export const ROW_HEIGHT = 26; export const MIN_CELL_WIDTH = 4; export const MAX_CELL_WIDTH = 48; -export const DEFAULT_CELL_WIDTH = 16; +const DEFAULT_CELL_WIDTH = 16; /** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */ export const KEY_SIZE = 10; /** Every `n`-th frame gets a number on the ruler; chosen so the numbers never overlap. */ diff --git a/src/editor/assetImport.ts b/src/editor/assetImport.ts index 16b215b1..f019227d 100644 --- a/src/editor/assetImport.ts +++ b/src/editor/assetImport.ts @@ -147,7 +147,7 @@ export function assetNameForFile(fileName: string, fallback: string): string { } /** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */ -export function pickFiles(accept: string): Promise { +function pickFiles(accept: string): Promise { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts index e8bde947..a67e4778 100644 --- a/src/editor/clipboard.ts +++ b/src/editor/clipboard.ts @@ -101,7 +101,7 @@ function pickedObject(state: EditorState, holderId: string): string { * is an original or an earlier paste; otherwise under the parent it had, if this holder has it, * else on top. */ -export function pasteParents( +function pasteParents( project: Project, holder: ObjectHolder, clip: ObjectClip, @@ -156,7 +156,7 @@ export function pasteObjects( } /** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */ -export function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean { +function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean { return paletteEntries(project, asset).some( (entry) => entry.def.type === node.type && @@ -165,7 +165,7 @@ export function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): } /** The part of a graph copy `asset` can take, and the nodes it cannot. */ -export function fittingPart( +function fittingPart( project: Project, asset: GraphAsset, clip: GraphClip, diff --git a/src/editor/curve/CurveCanvas.ts b/src/editor/curve/CurveCanvas.ts index d19625fb..02175f98 100644 --- a/src/editor/curve/CurveCanvas.ts +++ b/src/editor/curve/CurveCanvas.ts @@ -14,8 +14,8 @@ import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve'; import { findAssetOf } from '../../core/model/Project'; import type { Vec2 } from '../../core/model/Properties'; -import { handleAt } from '../../core/model/vectorart'; import { cancelFrameWork, onFrame, pressCount, Theme, textStyle } from '../../ui'; +import { DRAG_THRESHOLD, drawPointGrip, POINT_SIZE } from '../pointGrips'; import { type PointKind, pointKind, @@ -45,10 +45,6 @@ import { withCurvePointSet, } from './curveView'; -/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */ -const POINT_SIZE = 8; -const HANDLE_RADIUS = 4; -const DRAG_THRESHOLD = 3; /** Steps the drawn line is walked in; a bounce needs this many to keep its corners. */ const LINE_STEPS = 240; /** Where the preview's second dot runs, in units of `t` right of the graph. */ @@ -468,28 +464,8 @@ export class CurveCanvas extends SheetCanvas { /** Every point with its handles, in widget pixels so they keep their size. */ private drawPoints(points: readonly VectorPoint[]): void { - const half = POINT_SIZE / 2; - for (const [index, point] of points.entries()) { - const at = this.local(point.x, point.y); - for (const side of ['in', 'out'] as const) { - const handle = handleAt(point, side); - if (!handle) continue; - const tip = this.local(handle.x, handle.y); - this.overlay - .moveTo(at.x, at.y) - .lineTo(tip.x, tip.y) - .stroke({ width: 1, color: Theme.colors.marquee }); - this.overlay - .circle(tip.x, tip.y, HANDLE_RADIUS) - .fill(Theme.colors.viewport) - .stroke({ width: 1, color: Theme.colors.marquee }); - } - const isPicked = index === this.picked; - this.overlay - .rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE) - .fill(isPicked ? Theme.colors.accent : Theme.colors.accentText) - .stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent }); - } + for (const [index, point] of points.entries()) + drawPointGrip(this.overlay, point, index === this.picked, (t, v) => this.local(t, v)); } /** The playhead's knob: under the graph and its `t` numbers, below whatever the curve reaches. */ diff --git a/src/editor/curve/CurveEditor.ts b/src/editor/curve/CurveEditor.ts index b43aeb01..f26a13e7 100644 --- a/src/editor/curve/CurveEditor.ts +++ b/src/editor/curve/CurveEditor.ts @@ -3,26 +3,13 @@ import { curvePresets } from '../../core/model/curvePresets'; import { Button, Dropdown, Label, Menu, type MenuEntry, NumberField } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; import { SnapToggle } from '../graph/SnapToggle'; -import { POINT_KINDS, type PointKind } from '../polygonEdit'; +import { PointKindButton } from '../PointKindButton'; import type { EditorState } from '../state'; import { CurveCanvas } from './CurveCanvas'; const INFO_WIDTH = 90; -/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */ -const POINT_WIDTH = 150; const PLACE_WIDTH = 64; -const POINT_KIND_LABELS: Record = { - straight: 'Straight', - corner: 'Corner', - smooth: 'Smooth', - symmetric: 'Symmetric', -}; - -function pointLabel(kind: PointKind | null): string { - return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`; -} - /** * The Curve tab (#247): the canvas edits the curve, the header starts it over from a built-in ease, * sets the picked point's type as in the Vector art tab and its place by number, snaps, and runs the @@ -31,7 +18,7 @@ function pointLabel(kind: PointKind | null): string { export class CurveEditor extends AssetTabEditor { /** Public for the check scripts. */ readonly presetButton = new Dropdown('Preset'); - readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH }); + readonly pointButton = new PointKindButton(() => this.canvas); readonly tField: NumberField; readonly vField: NumberField; readonly snap = new SnapToggle(() => this.canvas); @@ -40,7 +27,6 @@ export class CurveEditor extends AssetTabEditor { constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); this.presetButton.onClick = () => this.openPresetMenu(); - this.pointButton.onClick = () => this.openPointMenu(); this.tField = this.placeField((t, commit) => this.movePicked(t, null, commit)); this.vField = this.placeField((v, commit) => this.movePicked(null, v, commit)); this.previewButton = new Button('Preview', { @@ -109,19 +95,8 @@ export class CurveEditor extends AssetTabEditor { new Menu(entries, { searchable: true }).openBelow(this.presetButton); } - private openPointMenu(): void { - const items = POINT_KINDS.map((kind) => ({ - label: POINT_KIND_LABELS[kind], - checked: () => this.canvas.pickedKind === kind, - onSelect: () => this.canvas.setPickedKind(kind), - })); - new Menu(items).openBelow(this.pointButton); - } - private syncPoint(): void { - const kind = this.canvas.pickedKind; - this.pointButton.setText(pointLabel(kind)); - this.pointButton.setEnabled(kind !== null); + this.pointButton.sync(); const place = this.canvas.pickedPlace; this.tField.setNumber(place?.x ?? 0); this.vField.setNumber(place?.y ?? 0); diff --git a/src/editor/curve/curveView.ts b/src/editor/curve/curveView.ts index 8a499ecd..5955b5b8 100644 --- a/src/editor/curve/curveView.ts +++ b/src/editor/curve/curveView.ts @@ -25,7 +25,7 @@ const SEEK_STEPS = 1000; const FIT_MARGIN = 0.12; /** One pass of the preview: the dot runs `PREVIEW_RUN` seconds, then rests at the end. */ -export const PREVIEW_RUN = 1; +const PREVIEW_RUN = 1; const PREVIEW_REST = 0.5; export function curveToImage(points: readonly VectorPoint[]): VectorRing { diff --git a/src/editor/font/FontPicker.ts b/src/editor/font/FontPicker.ts index 2dae0804..4be72135 100644 --- a/src/editor/font/FontPicker.ts +++ b/src/editor/font/FontPicker.ts @@ -67,7 +67,7 @@ class FontList extends RowList { } } -export class FontPicker extends FilePicker { +class FontPicker extends FilePicker { declare protected readonly list: FontList; constructor(private readonly deps: FontImportDeps) { diff --git a/src/editor/fontImport.ts b/src/editor/fontImport.ts index e34b1820..f6604957 100644 --- a/src/editor/fontImport.ts +++ b/src/editor/fontImport.ts @@ -4,7 +4,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd import type { EditorState } from './state'; /** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */ -export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2'; +const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2'; const FONT_EXTENSION = /\.(ttf|otf|woff|woff2)$/i; diff --git a/src/editor/graph/transitions.ts b/src/editor/graph/transitions.ts index 023906c4..d6d0b060 100644 --- a/src/editor/graph/transitions.ts +++ b/src/editor/graph/transitions.ts @@ -18,7 +18,7 @@ export interface Rect { /** Length of an arrowhead along the line, and half its width across. */ export const ARROW = 10; -export const ARROW_HALF_WIDTH = 4.5; +const ARROW_HALF_WIDTH = 4.5; /** Air between a node's border and the end of the arrow touching it. */ export const BORDER_GAP = 7; /** How far apart two transitions between the same pair of states are pushed. */ diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 0fdbc27b..593bbedd 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -315,7 +315,7 @@ export interface Field { * The row alone, or – for a field with something below it – the row with that under it, `depth` * indent steps in. */ -export function withBelow(row: Widget, field: Field, depth: number): Widget { +function withBelow(row: Widget, field: Field, depth: number): Widget { if (!field.below) return row; const stack = new VStack({ gap: Theme.gap }); stack.add(row); @@ -362,7 +362,7 @@ export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef } /** The handlers of a struct field that is a struct again: its own fields answer one step deeper. */ -export function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers { +function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers { const per = handlers.structFieldHandlers; return per ? { ...READ_ONLY, structFieldHandlers: (path, field) => per([key, ...path], field) } : READ_ONLY; } diff --git a/src/editor/inspector/objectQueries.ts b/src/editor/inspector/objectQueries.ts index 5be7fc07..98746691 100644 --- a/src/editor/inspector/objectQueries.ts +++ b/src/editor/inspector/objectQueries.ts @@ -11,8 +11,10 @@ import { type ObjectRef, resolvedHolder, } from '../../core/model/instance'; +import type { Asset } from '../../core/model/Asset'; import type { Axis, PropertyBag } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project'; +import type { SceneObject } from '../../core/model/SceneObject'; import { deviatesFromAsset, deviatesFromTemplate, @@ -28,6 +30,18 @@ export function viewOf(project: Project, holderId: string): ObjectHolder | undef return holder && resolvedHolder(project, holder); } +/** The object as the inspector addresses it, with its holder and the asset it was placed from. */ +export function shownObject( + project: Project, + holderId: string, + objectId: string, +): { view: ObjectHolder; object: SceneObject; asset: Asset } | null { + const view = viewOf(project, holderId); + const object = view && findObject(view, objectId); + const asset = object && findAsset(project, object.assetId); + return view && object && asset ? { view, object, asset } : null; +} + export function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined { const holder = findObjectHolder(project, holderId); return holder && instanceTargetOf(project, holder, objectId); @@ -56,10 +70,9 @@ export function deviations( objectId: string, key: string, ): { template: boolean; asset: boolean; applyToAsset: boolean } { - const view = viewOf(state.project, holderId); - const object = view && findObject(view, objectId); - const asset = object && findAsset(state.project, object.assetId); - if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false }; + const shown = shownObject(state.project, holderId, objectId); + if (!shown) return { template: false, asset: false, applyToAsset: false }; + const { object, asset } = shown; const target = targetOf(state.project, holderId, objectId); const fromAsset = deviatesFromAsset(state.project, object, asset, key); return { diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 1ced80c6..21f0d005 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -63,7 +63,15 @@ import { parentRow } from './parentRow'; import { listSource, previewingHandlers, structFields } from './previewHandlers'; import { overrideValue, tilemapSizeValue } from './valueSpecs'; import { buildVariable } from './variableRows'; -import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; +import { + currentProps, + currentRef, + deviations, + positionMoved, + shownObject, + targetOf, + viewOf, +} from './objectQueries'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; /** @@ -140,10 +148,9 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void { const { project } = host.state; - const view = viewOf(project, holderId); - const object = view && findObject(view, objectId); - const asset = object && findAsset(project, object.assetId); - if (!view || !object || !asset) return; + const shown = shownObject(project, holderId, objectId); + if (!shown) return; + const { view, object, asset } = shown; const target = targetOf(project, holderId, objectId); addTitle(host, `${asset.type.toUpperCase()} OBJECT`); @@ -230,10 +237,9 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object */ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: string, inTab = false): void { const { project } = host.state; - const view = viewOf(project, holderId); - const object = view && findObject(view, objectId); - const asset = object && findAsset(project, object.assetId); - if (!object || !asset) return; + const shown = shownObject(project, holderId, objectId); + if (!shown) return; + const { object, asset } = shown; const values = resolveSceneObjectProps(project, asset, object); const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values)); host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId); @@ -339,10 +345,9 @@ function contentSection( /** Pushes the current model values into the rows. False when the object is gone (rebuild). */ export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean { const { project } = host.state; - const view = viewOf(project, holderId); - const object = view && findObject(view, objectId); - const asset = object && findAsset(project, object.assetId); - if (!view || !object || !asset) return false; + const shown = shownObject(project, holderId, objectId); + if (!shown) return false; + const { object, asset } = shown; host.nameField?.setValue(object.name); // A rename of the asset changes no structure, so it arrives here rather than as a rebuild. host.fields.get(ASSET_ROW)?.setValue(asset.name); diff --git a/src/editor/list/InlineList.ts b/src/editor/list/InlineList.ts index 8e1fa269..22d56667 100644 --- a/src/editor/list/InlineList.ts +++ b/src/editor/list/InlineList.ts @@ -25,7 +25,6 @@ import { type Widget, } from '../../ui'; import { - type FieldValue, isList, isStruct, itemTypeOf, @@ -34,7 +33,9 @@ import { type StructValue, textOf, untag, + withFieldRenamed, withItemType, + withoutField, } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes'; @@ -463,7 +464,7 @@ export class InlineList extends VStack { const name = locked ? new Label(field.key, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }) : nameField(field.key, depth, (to) => { - if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to)); + if (to && to !== field.key) this.replaceAt(access, path, withFieldRenamed(now(), field.key, to)); }); line.add(fieldMark); line.add(name); @@ -607,18 +608,6 @@ function addFieldMenu(add: (kind: NestedKind) => void): Dropdown { return button; } -const withoutField = (bundle: PropertyValue, key: string): ListItem => { - const next = { ...structItemOf(bundle) }; - delete next[key]; - return next; -}; - -const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem => { - const next: Record = {}; - for (const [key, value] of Object.entries(structItemOf(bundle))) next[key === from ? to : key] = value; - return next; -}; - /** * An item's kind, and for a struct its type and the shape of every field below it – what its rows * are built from. diff --git a/src/editor/objectGroups.ts b/src/editor/objectGroups.ts index 8a9759f7..5d9a4747 100644 --- a/src/editor/objectGroups.ts +++ b/src/editor/objectGroups.ts @@ -49,7 +49,7 @@ export function groupMenuItems( * Asks for the name of a new group – or, from a dropdown, for an existing group to put the objects * into. A group all of them are in already is not offered. */ -export function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void { +function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void { const title = ids.length > 1 ? `Group ${ids.length} objects` : 'Group the object'; const groups = (source.groups ?? []).filter((g) => { const members = groupMemberIds(source, g.id); diff --git a/src/editor/panels/GroupNameDialog.ts b/src/editor/panels/GroupNameDialog.ts index aee4848d..85286219 100644 --- a/src/editor/panels/GroupNameDialog.ts +++ b/src/editor/panels/GroupNameDialog.ts @@ -5,7 +5,7 @@ import { dialogSlot, FormDialog } from '../FormDialog'; const FIELD_WIDTH = 220; /** The dropdown entry that makes a group instead of joining one. */ -export const NEW_GROUP = 'New group'; +const NEW_GROUP = 'New group'; const slot = dialogSlot(); diff --git a/src/editor/panels/markIcons.ts b/src/editor/panels/markIcons.ts index 81180fe0..d86bdcfc 100644 --- a/src/editor/panels/markIcons.ts +++ b/src/editor/panels/markIcons.ts @@ -1,6 +1,6 @@ /** * The small symbols in inspector rows and lists (#214): kind marks, row actions, transition arrows. - * They used to be characters, which the bundled Latin font does not have. + * Drawn, not typed: the bundled Latin font has none of these characters. */ import type { Kind } from '../../core/properties/kinds'; import apply from './icons/apply.svg?raw'; diff --git a/src/editor/panels/toolbarIcons.ts b/src/editor/panels/toolbarIcons.ts index 864803ec..8aa35265 100644 --- a/src/editor/panels/toolbarIcons.ts +++ b/src/editor/panels/toolbarIcons.ts @@ -1,6 +1,6 @@ /** - * The top bar's icons (#183). It carries no captions any more: menu, undo and redo on the left, - * play and pause in the middle, so each button is its picture and nothing else. + * The top bar's icons (#183). It carries no captions: menu, undo and redo on the left, play and + * pause in the middle, so each button is its picture and nothing else. */ import type { VisibilityPart } from '../../core/model/sceneVisibility'; import eye from './icons/eye.svg?raw'; diff --git a/src/editor/plugins/PluginsPane.ts b/src/editor/plugins/PluginsPane.ts index b7f4a9c9..7b34f74e 100644 --- a/src/editor/plugins/PluginsPane.ts +++ b/src/editor/plugins/PluginsPane.ts @@ -57,9 +57,10 @@ class PluginRow extends HStack { } /** - * The plugins (#114, #119), in the user management tab since #179: every file in `data/plugins/` with a switch. Switching one on loads - * and activates it there and then, switching it off takes its tabs and listeners away again – the - * switch is part of the open project, so it travels with it and undo takes it back. + * The plugins (#114, #119) in the user management tab (#179): every file in `data/plugins/` with a + * switch. Switching one on loads and activates it there and then, switching it off takes its tabs + * and listeners away again – the switch is part of the open project, so it travels with it and undo + * takes it back. * * A plugin that is off is never loaded, which is why its row has no name until it has run once: * reading a name out of it would mean running it. diff --git a/src/editor/pointGrips.ts b/src/editor/pointGrips.ts new file mode 100644 index 00000000..15c48b38 --- /dev/null +++ b/src/editor/pointGrips.ts @@ -0,0 +1,38 @@ +import type { Graphics } from 'pixi.js'; +import type { VectorPoint } from '../core/model/Asset'; +import type { Vec2 } from '../core/model/Properties'; +import { handleAt } from '../core/model/vectorart'; +import { Theme } from '../ui'; + +/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */ +export const POINT_SIZE = 8; +/** Radius of a Bezier handle's knob, in screen pixels. */ +export const HANDLE_RADIUS = 4; +/** Pixels the pointer must travel before a press becomes a drag rather than a click. */ +export const DRAG_THRESHOLD = 3; + +/** + * One point of a Vector art ring or a curve with its handles, in widget pixels so they keep their + * size; `toLocal` says where a place in the picture is drawn. + */ +export function drawPointGrip( + g: Graphics, + point: VectorPoint, + picked: boolean, + toLocal: (x: number, y: number) => Vec2, +): void { + const at = toLocal(point.x, point.y); + for (const side of ['in', 'out'] as const) { + const handle = handleAt(point, side); + if (!handle) continue; + const tip = toLocal(handle.x, handle.y); + g.moveTo(at.x, at.y).lineTo(tip.x, tip.y).stroke({ width: 1, color: Theme.colors.marquee }); + g.circle(tip.x, tip.y, HANDLE_RADIUS) + .fill(Theme.colors.viewport) + .stroke({ width: 1, color: Theme.colors.marquee }); + } + const half = POINT_SIZE / 2; + g.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE) + .fill(picked ? Theme.colors.accent : Theme.colors.accentText) + .stroke({ width: 1, color: picked ? Theme.colors.accentText : Theme.colors.accent }); +} diff --git a/src/editor/polygonEdit.ts b/src/editor/polygonEdit.ts index 4c53751e..4861eb67 100644 --- a/src/editor/polygonEdit.ts +++ b/src/editor/polygonEdit.ts @@ -10,7 +10,7 @@ */ import type { VectorPoint, VectorPointMode, VectorRing } from '../core/model/Asset'; import type { Vec2 } from '../core/model/Properties'; -import { closestOnSegment } from '../core/util/vec'; +import { closestOnSegment, cubicAt, splitCubic } from '../core/util/vec'; // Where a handle sits in canvas pixels; it is stored as an offset, and the model says so once. import { cloneVectorPoint, cloneVectorRing, handleAt } from '../core/model/vectorart'; @@ -162,11 +162,7 @@ export function nearestOnSegment( export function pointOnSegment(from: VectorPoint, to: VectorPoint, t: number): Vec2 { const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y }; const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y }; - const u = 1 - t; - return { - x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x, - y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y, - }; + return cubicAt(from, c1, c2, to, t); } /** A ring walked as a polyline – for hit-testing its inside and for drawing it in the editor. */ @@ -318,20 +314,16 @@ export function withPointOnSegment(ring: VectorRing, index: number, t: number): if (from.out || to.in) { const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y }; const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y }; - const a = lerp(from, c1, t); - const b = lerp(c1, c2, t); - const c = lerp(c2, to, t); - const d = lerp(a, b, t); - const e = lerp(b, c, t); + const { left, right } = splitCubic([from, c1, c2, to], t); const next = points[(index + 1) % ring.points.length]!; - setHandle(points[index]!, 'out', a); - setHandle(next, 'in', c); + setHandle(points[index]!, 'out', left[1]!); + setHandle(next, 'in', right[2]!); // The split keeps the directions but shortens one handle of each end: no longer symmetric, // still smooth. The new point sits on a curve, so its handles are in line – smooth as well. for (const end of [points[index]!, next]) if (end.mode === 'symmetric') end.mode = 'smooth'; inserted = { x: at.x, y: at.y, mode: 'smooth' }; - setHandle(inserted, 'in', d); - setHandle(inserted, 'out', e); + setHandle(inserted, 'in', left[2]!); + setHandle(inserted, 'out', right[1]!); if (!inserted.in && !inserted.out) delete inserted.mode; } points.splice(index + 1, 0, inserted); @@ -372,7 +364,3 @@ function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void { if (Math.hypot(offset.x, offset.y) < 1e-6) delete point[side]; else point[side] = offset; } - -function lerp(a: Vec2, b: Vec2, t: number): Vec2 { - return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; -} diff --git a/src/editor/sound/SoundPicker.ts b/src/editor/sound/SoundPicker.ts index b2f6ad63..3230e552 100644 --- a/src/editor/sound/SoundPicker.ts +++ b/src/editor/sound/SoundPicker.ts @@ -81,7 +81,7 @@ class SoundList extends RowList { } } -export class SoundPicker extends FilePicker { +class SoundPicker extends FilePicker { declare protected readonly list: SoundList; constructor(private readonly deps: SoundImportDeps) { diff --git a/src/editor/soundImport.ts b/src/editor/soundImport.ts index 939282e8..7aa54af0 100644 --- a/src/editor/soundImport.ts +++ b/src/editor/soundImport.ts @@ -4,7 +4,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd import type { EditorState } from './state'; /** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */ -export const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a'; +const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a'; const SOUND_EXTENSION = /\.(mp3|ogg|wav|m4a)$/i; diff --git a/src/editor/state.ts b/src/editor/state.ts index cc589aa3..7e0990be 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -308,11 +308,6 @@ export class EditorState { return findScene(this.project, this.project.activeSceneId); } - /** The play-mode copy while a run is on, else null – what a scene swap rebuilds its world from. */ - get runtimeProject(): Project | null { - return this.mode === 'play' ? this.project : null; - } - /** * Which scene Play builds its world from. Two answers rather than one, because the project has * two: where the game begins, and what you are working on this minute. The switch says which diff --git a/src/editor/texture/ImagePicker.ts b/src/editor/texture/ImagePicker.ts index f312caee..1a353bc0 100644 --- a/src/editor/texture/ImagePicker.ts +++ b/src/editor/texture/ImagePicker.ts @@ -97,7 +97,7 @@ class ImageGrid extends PickerList { } } -export class ImagePicker extends FilePicker { +class ImagePicker extends FilePicker { declare protected readonly list: ImageGrid; constructor(private readonly deps: TextureImportDeps) { diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 8441e518..5dadd4dd 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -152,7 +152,7 @@ export class TextureCanvas extends SheetCanvas { super(); this.perFrame = new FramePreview(state, 'texture-drag'); this.marks.addChild(this.sheet, this.overlay); - // Plates under the outlines, as before the split: a corner handle must not sink under a name. + // Plates under the outlines: a corner handle must not sink under a name. this.content.addChild(this.image, this.plates, this.marks, this.labelLayer); state.on('project', () => this.redraw()); diff --git a/src/editor/texture/sheetView.ts b/src/editor/texture/sheetView.ts index faab9f9e..ef426ade 100644 --- a/src/editor/texture/sheetView.ts +++ b/src/editor/texture/sheetView.ts @@ -34,7 +34,7 @@ export function fitView(imageWidth: number, imageHeight: number, w: number, h: n }; } -export function clampZoom(zoom: number): number { +function clampZoom(zoom: number): number { return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom)); } diff --git a/src/editor/textureImport.ts b/src/editor/textureImport.ts index 36c3862f..8bb757f0 100644 --- a/src/editor/textureImport.ts +++ b/src/editor/textureImport.ts @@ -5,7 +5,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd 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'; +const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'; /** Longest side a stored texture may have: a 4000×3000 photo costs 48 MB of video memory. */ export const MAX_TEXTURE_SIDE = 2048; @@ -51,7 +51,7 @@ export interface PreparedImage { } /** The bytes to upload: the file itself when it already fits, otherwise a downscaled WebP. */ -export async function prepareImage(file: File): Promise { +async function prepareImage(file: File): Promise { let bitmap: ImageBitmap; try { bitmap = await createImageBitmap(file); diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index e5e0061e..9065d547 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -316,7 +316,7 @@ export class TileCanvas extends Widget { const regrown = this.board.w !== this.fittedFor.board.w || this.board.h !== this.fittedFor.board.h; if (this.followsFit && regrown) this.needsFit = true; if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) { - // The largest step the board fits at, as in the scene view – no longer a free factor. + // The largest step the board fits at, as in the scene view. const { w, h } = this.board; const fitted = Math.min((this.w - FIT_MARGIN * 2) / w, (this.h - FIT_MARGIN * 2) / h); const zoom = zoomStepBelow(fitted * this.ratio()) / this.ratio(); diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 6dea8e36..8f68c7e9 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -28,7 +28,7 @@ import type { } from '../../core/model/Asset'; import type { Vec2 } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; -import { cloneVectorRing, findShape, handleAt, vectorCanvasSize } from '../../core/model/vectorart'; +import { cloneVectorRing, findShape, vectorCanvasSize } from '../../core/model/vectorart'; import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint'; import { vectorArtDrawings } from '../../core/model/vectorPath'; import { textureLibrary } from '../../viewport/TextureLibrary'; @@ -49,6 +49,7 @@ import { withoutPoint, withSmoothToggled, } from '../polygonEdit'; +import { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips'; import { type EditorState, shapeSelection } from '../state'; import { drawChecker } from '../texture/checker'; import { SheetCanvas } from '../texture/SheetCanvas'; @@ -63,12 +64,6 @@ export type VectorTool = 'select' | 'pen' | 'paint'; /** Whose paint the paint tool shows: the fill's or the outline's. */ export type PaintTarget = 'fill' | 'stroke'; -/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */ -const POINT_SIZE = 8; -/** Radius of a Bezier handle's knob, in screen pixels. */ -const HANDLE_RADIUS = 4; -/** Pixels the pointer must travel before a press becomes a drag rather than a click. */ -const DRAG_THRESHOLD = 3; /** The grid points snap to while snapping is on, in canvas pixels. */ const SNAP_STEP = 8; @@ -771,33 +766,14 @@ export class VectorArtCanvas extends SheetCanvas { /** The selected shape's points and handles, in widget pixels so they keep their size. */ private drawShape(shape: VectorShape | undefined): void { if (!shape) return; - const half = POINT_SIZE / 2; const picked = this.picked?.shapeId === shape.id ? this.picked : null; + const toLocal = (x: number, y: number) => imageToLocal(this.view, x, y); for (const [ringIndex, ring] of shape.rings.entries()) { - const screen = ringOutline(ring).map((p) => imageToLocal(this.view, p.x, p.y)); + const screen = ringOutline(ring).map((p) => toLocal(p.x, p.y)); if (screen.length >= 2) this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent }); - for (const [index, point] of ring.points.entries()) { - const at = imageToLocal(this.view, point.x, point.y); - const isPicked = picked?.ring === ringIndex && picked.index === index; - for (const side of ['in', 'out'] as const) { - const handle = handleAt(point, side); - if (!handle) continue; - const tip = imageToLocal(this.view, handle.x, handle.y); - this.overlay - .moveTo(at.x, at.y) - .lineTo(tip.x, tip.y) - .stroke({ width: 1, color: Theme.colors.marquee }); - this.overlay - .circle(tip.x, tip.y, HANDLE_RADIUS) - .fill(Theme.colors.viewport) - .stroke({ width: 1, color: Theme.colors.marquee }); - } - this.overlay - .rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE) - .fill(isPicked ? Theme.colors.accent : Theme.colors.accentText) - .stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent }); - } + for (const [index, point] of ring.points.entries()) + drawPointGrip(this.overlay, point, picked?.ring === ringIndex && picked.index === index, toLocal); } } diff --git a/src/editor/vectorart/VectorArtEditor.ts b/src/editor/vectorart/VectorArtEditor.ts index c753d5aa..4a76ed1d 100644 --- a/src/editor/vectorart/VectorArtEditor.ts +++ b/src/editor/vectorart/VectorArtEditor.ts @@ -3,31 +3,18 @@ import { vectorCanvasSize } from '../../core/model/vectorart'; import { Button, Dropdown, Menu, type Stack } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; import { SnapToggle } from '../graph/SnapToggle'; -import { POINT_KINDS, type PointKind } from '../polygonEdit'; +import { POINT_BUTTON_WIDTH, PointKindButton } from '../PointKindButton'; import type { EditorState } from '../state'; import { type PaintTarget, VectorArtCanvas, type VectorTool } from './VectorArtCanvas'; /** Room kept for the "256 × 256 px · 3 shapes" readout. */ const INFO_WIDTH = 190; -/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */ -const POINT_WIDTH = 150; - -const POINT_KIND_LABELS: Record = { - straight: 'Straight', - corner: 'Corner', - smooth: 'Smooth', - symmetric: 'Symmetric', -}; - -function pointLabel(kind: PointKind | null): string { - return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`; -} /** * The Paint tool's menu takes the point type's place in the header (#186) – a point is not picked * with the Paint tool, and the header has no room for both next to the size readout. */ -const PAINT_TARGET_WIDTH = POINT_WIDTH; +const PAINT_TARGET_WIDTH = POINT_BUTTON_WIDTH; const PAINT_TARGET_LABELS: Record = { fill: 'Fill', stroke: 'Outline' }; @@ -47,17 +34,16 @@ export class VectorArtEditor extends AssetTabEditor this.canvas); - readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH }); + readonly pointButton = new PointKindButton(() => this.canvas); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') }); this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') }); this.paintButton = new Button('Paint', { toggle: true, onClick: () => this.setTool('paint') }); - this.pointButton.onClick = () => this.openPointMenu(); this.paintTargetButton.onClick = () => this.openPaintTargetMenu(); const canvas = new VectorArtCanvas(state); - canvas.onPointPicked = () => this.syncPoint(); + canvas.onPointPicked = () => this.pointButton.sync(); this.mount(canvas, [ this.selectButton, this.penButton, @@ -69,16 +55,6 @@ export class VectorArtEditor extends AssetTabEditor ({ - label: POINT_KIND_LABELS[kind], - checked: () => this.canvas.pickedKind === kind, - onSelect: () => this.canvas.setPickedKind(kind), - })); - new Menu(items).openBelow(this.pointButton); - } - /** Whose handles the paint tool shows (#186): the fill's or the outline's. */ private openPaintTargetMenu(): void { const items = (['fill', 'stroke'] as const).map((target) => ({ @@ -94,12 +70,6 @@ export class VectorArtEditor extends AssetTabEditor i.id === s.objectId); + const item = selectedItem(this.host); return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null; } diff --git a/src/editor/viewport/sceneViewHost.ts b/src/editor/viewport/sceneViewHost.ts index 3b5dc75f..989dbdd5 100644 --- a/src/editor/viewport/sceneViewHost.ts +++ b/src/editor/viewport/sceneViewHost.ts @@ -1,3 +1,4 @@ +import { objectRef } from '../../core/model/instance'; import type { ObjectHolder } from '../../core/model/Project'; import type { Vec2 } from '../../core/model/Properties'; import type { DrawItem } from '../../viewport/items'; @@ -25,3 +26,15 @@ export interface SceneViewHost { scale(): number; redraw(): void; } + +/** + * What the view drew for the primary selection, if that is an object of the view's holder – a + * placed container's content is edited in its template. + */ +export function selectedItem(host: SceneViewHost): DrawItem | null { + const { state } = host; + const s = state.selection; + const holder = host.holder(); + if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null; + return host.items().find((i) => i.id === s.objectId) ?? null; +} diff --git a/src/editor/viewport/sizeGizmoEditor.ts b/src/editor/viewport/sizeGizmoEditor.ts index eeb9ad72..1ca07c6c 100644 --- a/src/editor/viewport/sizeGizmoEditor.ts +++ b/src/editor/viewport/sizeGizmoEditor.ts @@ -37,7 +37,7 @@ import { swept, turned, } from '../sizeGizmo'; -import type { SceneViewHost } from './sceneViewHost'; +import { type SceneViewHost, selectedItem } from './sceneViewHost'; /** Screen pixels around a handle that grab it. */ const GRIP = 7; @@ -117,11 +117,7 @@ export class SizeGizmoEditor { /** The primary selection when it is an object with a turn and a scale this view can edit. */ private edited(): DrawItem | null { - const state = this.host.state; - const s = state.selection; - const holder = this.host.holder(); - if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null; - const item = this.host.items().find((i) => i.id === s.objectId); + const item = selectedItem(this.host); return item && hasTransformProps(item.type) ? item : null; } diff --git a/src/runtime/physics/bodyGeometry.ts b/src/runtime/physics/bodyGeometry.ts index de9c12be..dee461e7 100644 --- a/src/runtime/physics/bodyGeometry.ts +++ b/src/runtime/physics/bodyGeometry.ts @@ -6,7 +6,7 @@ import type { ColliderShape } from '../../core/properties/collider'; import { toPixels } from './engine'; /** A pose that moved less than this (world units, radians) was not moved by anyone. */ -export const POSE_EPSILON = 1e-6; +const POSE_EPSILON = 1e-6; export interface Pose { x: number; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index fe4c4370..259f1e9a 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -1,19 +1,15 @@ import { adoptDeclarations, - asNumber, buildSharedState, carriedForward, declaredVariables, emptySharedState, - type EntityHandle, type Graph, - type GraphNode, GraphInterpreter, hierarchyRanks, inEventOrder, isHandleEnabled, type NodeContext, - type PhysicsControl, restartedAssets, restoreSaved, type SaveArea, @@ -24,28 +20,38 @@ import { type Value, } from '../../core/graph'; import { GraphCache } from '../../core/graph/graphIndex'; -import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { keyName } from '../../core/keys'; import { graphLookup } from '../../core/model/graphLookup'; -import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties'; +import { type PropertyBag, textOf } from '../../core/model/Properties'; import { treeOrder } from '../../core/model/hierarchy'; import { resolveObjects } from '../../core/model/instance'; import { findAssetOf, findScene, type Project } from '../../core/model/Project'; -import { soundFileOf } from '../../core/model/sound'; -import { parentChain } from '../../core/model/walk'; -import { resolveAssetProps } from '../../core/properties/resolve'; -import { loopsOf, volumeOf } from '../../core/properties/sounds'; import { type AudioSink, SILENT_AUDIO } from './Audio'; import type { Entity } from '../Entity'; import type { World } from '../World'; import type { Rapier } from '../physics/engine'; import { PhysicsSystem } from '../physics/PhysicsSystem'; +import { behaviorContext, type TimeScaled } from './behaviorContext'; +import { + animationFinishedPulses, + collisionEnterPulses, + collisionExitPulses, + collisionStayPulses, + type Instance, + keyDownPulses, + type Produced, + type Pulse, + pulsesOf, + timerPulses, +} from './behaviorPulses'; import { CollisionSystem } from './CollisionSystem'; -import { bodyOf, type CollisionPair, type CollisionSource, type Contact, NOT_BLOCKED } from './solids'; +import type { CollisionSource } from './solids'; import { AnimationSystem } from './AnimationSystem'; import type { Input } from './Input'; import type { System } from './System'; +export type { TimeScaled } from './behaviorContext'; + /** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */ export const TRACE_FADE = 0.35; @@ -56,11 +62,6 @@ export interface Trace { values: Map>; } -interface Produced { - time: number; - values: Record; -} - /** * A move one graph made, waiting for its events. `source` is the instance that caused it, and who * hears it when the machine is local; a `global` machine is heard by every instance, so the move it @@ -73,42 +74,9 @@ interface StateChange { source: Instance | null; } -const NO_DYNAMIC_BODY = - 'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.'; - /** How often state events may cause the next move before the run is called endless. */ const MAX_STATE_ROUNDS = 100; -/** A collision pair seen from one behavior's body: its own collider and the one across. */ -interface Side { - pair: CollisionPair; - own: Entity; - other: Entity; -} - -/** One event node about to run for one instance: what the event hands it, and the output it leaves by. */ -interface Pulse { - instance: Instance; - node: GraphNode; - outputs?: Record; - port?: string; -} - -interface Instance { - entity: Entity; - interpreter: GraphInterpreter; - trace: Omit; - values: Map>; - /** Current state per state machine – this instance's alone, like its variables. */ - states: Record; - /** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */ - active: boolean; -} - -/** - * Runs the graph of every behavior entity. Each instance has its own interpreter (and - * therefore its own timers and variable state); the project is only read to find graphs. - */ const ERROR_PREFIX = 'Error in behavior'; export interface GameSystemOptions { @@ -130,10 +98,6 @@ export interface GameSystemOptions { saves?: SaveArea | null; } -export interface TimeScaled { - scale: number; -} - /** Collisions and animations are ticked first, so behaviors see this frame's events. */ export function addGameSystems( world: World, @@ -179,6 +143,10 @@ export function behaviorError(world: World): string | undefined { return world.log.find((line) => line.startsWith(ERROR_PREFIX)); } +/** + * Runs the graph of every behavior entity. Each instance has its own interpreter (and + * therefore its own timers and variable state); the project is only read to find graphs. + */ export class BehaviorSystem implements System { /** A paused frame still ticks the behaviors that run while paused (see `update`). */ readonly runsWhilePaused = true; @@ -511,17 +479,20 @@ export class BehaviorSystem implements System { const owner = i.entity.parent; return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside; }; + const { input, collisions, animations } = this; const kinds: (() => Pulse[])[] = [ () => - ticking.flatMap((i) => i.interpreter.eventNodes('OnKeyDown').flatMap((n) => this.keyDown(i, n, dt))), - () => pulsesOf(ticking, 'OnKeyUp', {}, (n) => this.input.justReleased.has(keyOf(n))), - () => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => this.input.heldKeys.has(keyOf(n))), - () => ticking.flatMap((i) => this.timers(i, dt)), + ticking.flatMap((i) => + i.interpreter.eventNodes('OnKeyDown').flatMap((n) => keyDownPulses(i, n, input, dt)), + ), + () => pulsesOf(ticking, 'OnKeyUp', {}, (n) => input.justReleased.has(keyOf(n))), + () => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => input.heldKeys.has(keyOf(n))), + () => ticking.flatMap((i) => timerPulses(i, dt)), // Neither system ticked in a paused frame: what they hold is last frame's, already told. - () => (frozen ? [] : ticking.flatMap((i) => this.collisionEnters(i))), - () => (frozen ? [] : ticking.flatMap((i) => this.collisionStays(i, dt))), - () => (frozen ? [] : ticking.flatMap((i) => this.collisionExits(i))), - () => (frozen ? [] : ticking.flatMap((i) => this.animationsFinished(i))), + () => (frozen ? [] : ticking.flatMap((i) => collisionEnterPulses(i, collisions))), + () => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt))), + () => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions))), + () => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))), () => pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [], () => @@ -545,129 +516,6 @@ export class BehaviorSystem implements System { this.persist(); } - /** - * The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock - * in `nodeState`, so two nodes on the same key repeat on their own schedules. - */ - private keyDown(instance: Instance, node: GraphNode, dt: number): Pulse[] { - const i = instance.interpreter; - const key = keyName(textOf(node.params.key ?? '')); - const stored = i.nodeState.get(node.id); - const { state, fires } = stepKeyRepeat( - stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null, - { - pressed: this.input.justPressed.has(key), - down: this.input.heldKeys.has(key), - dt, - // The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds. - after: asNumber(node.params.repeatAfter ?? 0) / 1000, - interval: asNumber(node.params.repeatInterval ?? 0) / 1000, - }, - ); - if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats }); - else i.nodeState.delete(node.id); - return Array.from({ length: fires }, () => ({ instance, node })); - } - - private timers(instance: Instance, dt: number): Pulse[] { - const i = instance.interpreter; - const pulses: Pulse[] = []; - for (const node of i.eventNodes('OnTimer')) { - const state = i.nodeState.get(node.id) ?? { elapsed: 0 }; - i.nodeState.set(node.id, state); - const interval = Math.max(0.01, Number(node.params.interval ?? 1)); - state.elapsed = (state.elapsed ?? 0) + dt; - // What is left after taking one interval is how long ago this tick was due. When one frame - // catches up several ticks, the first one was due longest ago and is the latest. - while (state.elapsed >= interval) { - state.elapsed -= interval; - pulses.push({ instance, node, outputs: { late: state.elapsed } }); - } - } - return pulses; - } - - /** - * The collision pairs of one kind that concern this behavior: every pair in which one collider - * belongs to the behavior's body (its owner, or itself when it has none), with `other` being the - * body on the far side. - */ - private sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] { - const body = instance.entity.parent ?? instance.entity; - return pairs.flatMap((pair) => { - const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null; - return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : []; - }); - } - - private collisionEnters(instance: Instance): Pulse[] { - const sides = this.sides(instance, this.collisions?.enters ?? []); - return instance.interpreter - .eventNodes('OnCollisionEnter') - .flatMap((node) => sides.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) }))); - } - - /** - * Stay fires every frame or, with an interval, once each time that long has passed since the pair - * met (#149). Each stay node keeps a clock per pair in its node state, and a pair that is gone - * takes its clock with it. - */ - private collisionStays(instance: Instance, dt: number): Pulse[] { - const collisions = this.collisions; - if (!collisions) return []; - const interpreter = instance.interpreter; - const stays = this.sides(instance, collisions.stays); - const contacts = new Map(); - const contactOf = (pair: CollisionPair) => { - if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair)); - return contacts.get(pair)!; - }; - const pulses: Pulse[] = []; - for (const node of interpreter.eventNodes('OnCollisionStay')) { - const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0; - const clocks = interpreter.nodeState.get(node.id) ?? {}; - const running: Record = {}; - for (const side of stays) { - let fires = 1; - if (interval > 0) { - const key = `${side.pair.a.id}|${side.pair.b.id}`; - let elapsed = (clocks[key] ?? 0) + dt; - for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval; - running[key] = elapsed; - } - for (let i = 0; i < fires; i++) - pulses.push({ instance, node, outputs: sideOutputs(side, contactOf(side.pair)) }); - } - if (interval > 0) interpreter.nodeState.set(node.id, running); - else interpreter.nodeState.delete(node.id); - } - return pulses; - } - - private collisionExits(instance: Instance): Pulse[] { - const sides = this.sides(instance, this.collisions?.exits ?? []); - return instance.interpreter - .eventNodes('OnCollisionExit') - .flatMap((node) => sides.map((side) => ({ instance, node, outputs: sideOutputs(side) }))); - } - - /** - * A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below - * it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view. - */ - private animationsFinished(instance: Instance): Pulse[] { - const owner = instance.entity.parent; - const finished = (this.animations?.finished ?? []).filter( - ({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner), - ); - return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => { - const wanted = textOf(node.params.animation ?? ''); - return finished - .filter(({ animationId }) => wanted === '' || wanted === animationId) - .map(({ entity }) => ({ instance, node, outputs: { object: entity } })); - }); - } - runningIds(): string[] { return this.instances.map((i) => i.entity.objectId); } @@ -696,19 +544,6 @@ export class BehaviorSystem implements System { return merged; } - /** - * What the graph asked for, as the sink understands it: the asset's own volume scaled by the - * node's, and its loop flag unless the node overrules it either way. The asset is what a sound - * *is* – a node that says nothing therefore plays it as the project describes it. - */ - private playSound(assetId: string, volume: number, loop: boolean | undefined, owner: string): void { - const asset = findAssetOf(this.project, assetId, 'sound'); - if (!asset?.file) return; - const props = resolveAssetProps(this.project, asset); - const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); - this.audio.play(asset.file, level, loop ?? loopsOf(props), owner); - } - /** * Runs the pulses of one event kind in their order (see `core/graph/eventOrder.ts`). An exception * stops play and is logged, never thrown. `living`: a pulse whose object an earlier one destroyed @@ -741,201 +576,14 @@ export class BehaviorSystem implements System { } private context(instance: Instance, world: World, dt: number): NodeContext { - const { entity, states } = instance; - // Handles arriving from the graph are the entities themselves, or test doubles with the same id. - const resolve = (handle: EntityHandle | null): Entity | undefined => - handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined; - return { - self: entity, - owner: entity.parent, - variables: entity.props, - states, + return behaviorContext(instance, world, dt, { + project: this.project, + input: this.input, + collisions: this.collisions, + audio: this.audio, + clock: this.clock, shared: this.sharedState, stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }), - time: world.time, - dt, - findByName: (name) => world.findByName(name) ?? null, - // By identity, not by id: a restarted scene builds new entities under the same ids. - isAlive: (target) => world.entities.some((e) => e === target), - findReferenced: (from, ref) => { - const found = resolve(from); - return (found && world.findReferenced(found, ref)) ?? null; - }, - childByName: (parent, name) => { - const found = resolve(parent); - return (found && world.childByName(found, name)) ?? null; - }, - objects: () => world.entities, - isKeyDown: (key) => this.input.isDown(key), - pointer: world.pointer, - hitTest: (target, x, y) => { - const found = resolve(target); - return Boolean(found && world.hitTest(found, x, y)); - }, - isTouching: (a, b) => { - const ea = resolve(a); - const eb = resolve(b); - return Boolean(this.collisions && ea && eb && this.collisions.touching(world, ea, eb)); - }, - moveAndSlide: (target, dx, dy) => { - const found = resolve(target); - if (!found) return NOT_BLOCKED; - if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy); - found.x += dx; - found.y += dy; - return NOT_BLOCKED; - }, - raycast: (from, direction, length, { ignore, triggers, layers }) => { - const hit = this.collisions?.raycast(world, from, direction, length, { - ignore: resolve(ignore) ?? null, - triggers, - layers, - }); - world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit)); - return hit ? { ...hit, object: bodyOf(hit.collider) } : null; - }, - raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => { - const hits = - this.collisions?.raycastAll( - world, - from, - direction, - length, - { ignore: resolve(ignore) ?? null, triggers, layers }, - maxHits, - ) ?? []; - // Drawn to the last thing it met, not to the first: that is how far this ray looked. - const last = hits[hits.length - 1]; - const end = last ? last.point : pointAlong(from, direction, length); - world.noteRay(from, end, hits.length > 0); - return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) })); - }, - ignoreOneWay: (target, seconds) => { - const found = resolve(target); - if (found) world.ignoreOneWay(found, seconds); - }, - isTilemap: (target) => { - const found = resolve(target); - return found ? world.isTilemap(found) : false; - }, - tileAt: (target, layer, cell) => { - const found = resolve(target); - return found ? world.tileAt(found, layer, cell) : null; - }, - setTile: (target, layer, cell, tileId) => { - const found = resolve(target); - return found ? world.setTile(found, layer, cell, tileId) : false; - }, - physics: - this.collisions instanceof PhysicsSystem - ? physicsControl(world, this.collisions, resolve) - : undefined, - log: (message) => { - world.log.push(message); - // The `Log` node's whole purpose: the author wants this in the browser console. - // eslint-disable-next-line no-console - console.log(`[behavior ${entity.name}]`, message); - }, - playSound: (assetId, volume, loop, detached) => - this.playSound(assetId, volume, loop, detached ? '' : entity.id), - // An id nothing resolves stops nothing, rather than silencing the whole run by accident; - // only the empty id means "everything", which is what the node promises. - stopSound: (assetId) => { - const file = assetId === '' ? '' : soundFileOf(this.project, assetId); - if (assetId === '' || file) this.audio.stop(file); - }, - spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), - destroy: (target, keepChildren) => { - const found = resolve(target); - if (!found) return []; - if (keepChildren) return world.removeKeepingChildren(found); - world.remove(found); - return []; - }, - loadScene: (sceneId) => { - // An id naming no scene is dropped here rather than half way through the swap, where the - // run would already have torn down the world it is still standing in. - if (findScene(this.project, sceneId)) world.loadSceneRequested = sceneId; - else world.log.push(`Load scene: no scene ${sceneId}`); - }, - stop: () => { - world.stopRequested = true; - }, - restart: () => { - world.restartRequested = true; - }, - timeScale: () => this.clock?.scale ?? 1, - setTimeScale: (scale) => { - if (this.clock) this.clock.scale = scale; - else world.log.push(`Set time scale is ignored in the editor run (${scale})`); - }, - isPaused: () => world.paused, - setPaused: (paused) => { - world.paused = paused; - }, - }; + }); } } - -/** Every node of one event type in these instances, in hierarchy and then node order. */ -function pulsesOf( - instances: readonly Instance[], - type: string, - outputs: Record = {}, - filter?: (node: GraphNode) => boolean, -): Pulse[] { - return instances.flatMap((instance) => - instance.interpreter - .eventNodes(type) - .filter((node) => !filter || filter(node)) - .map((node) => ({ instance, node, outputs })), - ); -} - -/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */ -function sideOutputs({ own, other, pair }: Side, contact?: Partial | null): Record { - const outputs: Record = { other: bodyOf(other), otherCollider: other, collider: own }; - if (contact !== undefined) { - // The pair's normal points towards `a`; seen from `b` it points the other way. - const sign = own === pair.a ? 1 : -1; - outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0; - outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0; - outputs.impulse = contact?.impulse ?? 0; - outputs.pointX = contact?.point?.x ?? 0; - outputs.pointY = contact?.point?.y ?? 0; - } - return outputs; -} - -/** Where a ray of that length ends; where it has no direction, where it starts. */ -function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 { - const d = Math.hypot(direction.x, direction.y); - if (!(d > 0) || !(length > 0)) return from; - return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length }; -} - -/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */ -function physicsControl( - world: World, - physics: PhysicsSystem, - resolve: (handle: EntityHandle | null) => Entity | undefined, -): PhysicsControl { - const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => { - const found = resolve(handle); - if (!found) return false; - if (action(found)) return true; - physics.hint(world, found, NO_DYNAMIC_BODY); - return false; - }; - return { - velocityOf: (handle) => { - const found = resolve(handle); - const v = found ? physics.velocityOf(found) : null; - return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null; - }, - setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })), - setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)), - applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })), - setGravity: (x, y) => physics.setGravity(world, { x, y }), - }; -} diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts new file mode 100644 index 00000000..3b0cd38d --- /dev/null +++ b/src/runtime/systems/behaviorContext.ts @@ -0,0 +1,228 @@ +/** + * What a behavior's graph reaches of the running game, as the `NodeContext` its nodes are handed: + * the world, the keyboard, collisions and rays, physics, sound, the clock and the shared values. + * Handles arriving from a graph are resolved to the world's entities here, so no node holds one. + */ +import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph'; +import type { Vec2 } from '../../core/model/Properties'; +import { findAssetOf, findScene, type Project } from '../../core/model/Project'; +import { soundFileOf } from '../../core/model/sound'; +import { resolveAssetProps } from '../../core/properties/resolve'; +import { loopsOf, volumeOf } from '../../core/properties/sounds'; +import type { Entity } from '../Entity'; +import { PhysicsSystem } from '../physics/PhysicsSystem'; +import type { World } from '../World'; +import type { AudioSink } from './Audio'; +import type { Instance } from './behaviorPulses'; +import type { Input } from './Input'; +import { bodyOf, type CollisionSource, NOT_BLOCKED } from './solids'; + +export interface TimeScaled { + scale: number; +} + +/** What the running `BehaviorSystem` lends every context it makes. */ +export interface ContextSources { + project: Project; + input: Input; + collisions: CollisionSource | null; + audio: AudioSink; + clock: TimeScaled | null; + shared: SharedState; + /** A graph moved one of this instance's machines; its events follow between exec flows. */ + stateChanged(machineId: string, from: string, to: string): void; +} + +const NO_DYNAMIC_BODY = + 'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.'; + +export function behaviorContext( + instance: Instance, + world: World, + dt: number, + run: ContextSources, +): NodeContext { + const { entity, states } = instance; + const { project, collisions, clock } = run; + // Handles arriving from the graph are the entities themselves, or test doubles with the same id. + const resolve = (handle: EntityHandle | null): Entity | undefined => + handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined; + return { + self: entity, + owner: entity.parent, + variables: entity.props, + states, + shared: run.shared, + stateChanged: (machineId, from, to) => run.stateChanged(machineId, from, to), + time: world.time, + dt, + findByName: (name) => world.findByName(name) ?? null, + // By identity, not by id: a restarted scene builds new entities under the same ids. + isAlive: (target) => world.entities.some((e) => e === target), + findReferenced: (from, ref) => { + const found = resolve(from); + return (found && world.findReferenced(found, ref)) ?? null; + }, + childByName: (parent, name) => { + const found = resolve(parent); + return (found && world.childByName(found, name)) ?? null; + }, + objects: () => world.entities, + isKeyDown: (key) => run.input.isDown(key), + pointer: world.pointer, + hitTest: (target, x, y) => { + const found = resolve(target); + return Boolean(found && world.hitTest(found, x, y)); + }, + isTouching: (a, b) => { + const ea = resolve(a); + const eb = resolve(b); + return Boolean(collisions && ea && eb && collisions.touching(world, ea, eb)); + }, + moveAndSlide: (target, dx, dy) => { + const found = resolve(target); + if (!found) return NOT_BLOCKED; + if (collisions) return collisions.moveAndSlide(world, found, dx, dy); + found.x += dx; + found.y += dy; + return NOT_BLOCKED; + }, + raycast: (from, direction, length, { ignore, triggers, layers }) => { + const hit = collisions?.raycast(world, from, direction, length, { + ignore: resolve(ignore) ?? null, + triggers, + layers, + }); + world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit)); + return hit ? { ...hit, object: bodyOf(hit.collider) } : null; + }, + raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => { + const hits = + collisions?.raycastAll( + world, + from, + direction, + length, + { ignore: resolve(ignore) ?? null, triggers, layers }, + maxHits, + ) ?? []; + // Drawn to the last thing it met, not to the first: that is how far this ray looked. + const last = hits[hits.length - 1]; + const end = last ? last.point : pointAlong(from, direction, length); + world.noteRay(from, end, hits.length > 0); + return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) })); + }, + ignoreOneWay: (target, seconds) => { + const found = resolve(target); + if (found) world.ignoreOneWay(found, seconds); + }, + isTilemap: (target) => { + const found = resolve(target); + return found ? world.isTilemap(found) : false; + }, + tileAt: (target, layer, cell) => { + const found = resolve(target); + return found ? world.tileAt(found, layer, cell) : null; + }, + setTile: (target, layer, cell, tileId) => { + const found = resolve(target); + return found ? world.setTile(found, layer, cell, tileId) : false; + }, + physics: collisions instanceof PhysicsSystem ? physicsControl(world, collisions, resolve) : undefined, + log: (message) => { + world.log.push(message); + // The `Log` node's whole purpose: the author wants this in the browser console. + // eslint-disable-next-line no-console + console.log(`[behavior ${entity.name}]`, message); + }, + playSound: (assetId, volume, loop, detached) => + playSound(run, assetId, volume, loop, detached ? '' : entity.id), + // An id nothing resolves stops nothing, rather than silencing the whole run by accident; + // only the empty id means "everything", which is what the node promises. + stopSound: (assetId) => { + const file = assetId === '' ? '' : soundFileOf(project, assetId); + if (assetId === '' || file) run.audio.stop(file); + }, + spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), + destroy: (target, keepChildren) => { + const found = resolve(target); + if (!found) return []; + if (keepChildren) return world.removeKeepingChildren(found); + world.remove(found); + return []; + }, + loadScene: (sceneId) => { + // An id naming no scene is dropped here rather than half way through the swap, where the + // run would already have torn down the world it is still standing in. + if (findScene(project, sceneId)) world.loadSceneRequested = sceneId; + else world.log.push(`Load scene: no scene ${sceneId}`); + }, + stop: () => { + world.stopRequested = true; + }, + restart: () => { + world.restartRequested = true; + }, + timeScale: () => clock?.scale ?? 1, + setTimeScale: (scale) => { + if (clock) clock.scale = scale; + else world.log.push(`Set time scale is ignored in the editor run (${scale})`); + }, + isPaused: () => world.paused, + setPaused: (paused) => { + world.paused = paused; + }, + }; +} + +/** + * What the graph asked for, as the sink understands it: the asset's own volume scaled by the + * node's, and its loop flag unless the node overrules it either way. The asset is what a sound + * *is* – a node that says nothing therefore plays it as the project describes it. + */ +function playSound( + { project, audio }: ContextSources, + assetId: string, + volume: number, + loop: boolean | undefined, + owner: string, +): void { + const asset = findAssetOf(project, assetId, 'sound'); + if (!asset?.file) return; + const props = resolveAssetProps(project, asset); + const level = Math.max(0, Math.min(1, volumeOf(props) * volume)); + audio.play(asset.file, level, loop ?? loopsOf(props), owner); +} + +/** Where a ray of that length ends; where it has no direction, where it starts. */ +function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 { + const d = Math.hypot(direction.x, direction.y); + if (!(d > 0) || !(length > 0)) return from; + return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length }; +} + +/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */ +function physicsControl( + world: World, + physics: PhysicsSystem, + resolve: (handle: EntityHandle | null) => Entity | undefined, +): PhysicsControl { + const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => { + const found = resolve(handle); + if (!found) return false; + if (action(found)) return true; + physics.hint(world, found, NO_DYNAMIC_BODY); + return false; + }; + return { + velocityOf: (handle) => { + const found = resolve(handle); + const v = found ? physics.velocityOf(found) : null; + return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null; + }, + setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })), + setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)), + applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })), + setGravity: (x, y) => physics.setGravity(world, { x, y }), + }; +} diff --git a/src/runtime/systems/behaviorPulses.ts b/src/runtime/systems/behaviorPulses.ts new file mode 100644 index 00000000..9611fc82 --- /dev/null +++ b/src/runtime/systems/behaviorPulses.ts @@ -0,0 +1,203 @@ +/** + * A running behavior, and the pulses the events of one frame send into it: one per event node that + * fires, with what the event hands on. `BehaviorSystem` decides when each kind is gathered and runs + * them in their order. + */ +import { asNumber, type Graph, type GraphInterpreter, type GraphNode, type Value } from '../../core/graph'; +import { stepKeyRepeat } from '../../core/graph/keyRepeat'; +import { keyName } from '../../core/keys'; +import { textOf } from '../../core/model/Properties'; +import { parentChain } from '../../core/model/walk'; +import type { Entity } from '../Entity'; +import type { AnimationSystem } from './AnimationSystem'; +import type { Input } from './Input'; +import { bodyOf, type CollisionPair, type CollisionSource, type Contact } from './solids'; + +/** What one node last handed on, and when – the graph tab shows the newest of every instance. */ +export interface Produced { + time: number; + values: Record; +} + +export interface Instance { + entity: Entity; + interpreter: GraphInterpreter; + /** Recency of exec edges and nodes, keyed by id, in world time. */ + trace: { edges: Map; nodes: Map }; + values: Map>; + /** Current state per state machine – this instance's alone, like its variables. */ + states: Record; + /** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */ + active: boolean; +} + +/** One event node about to run for one instance: what the event hands it, and the output it leaves by. */ +export interface Pulse { + instance: Instance; + node: GraphNode; + outputs?: Record; + port?: string; +} + +/** A collision pair seen from one behavior's body: its own collider and the one across. */ +interface Side { + pair: CollisionPair; + own: Entity; + other: Entity; +} + +/** Every node of one event type in these instances, in hierarchy and then node order. */ +export function pulsesOf( + instances: readonly Instance[], + type: string, + outputs: Record = {}, + filter?: (node: GraphNode) => boolean, +): Pulse[] { + return instances.flatMap((instance) => + instance.interpreter + .eventNodes(type) + .filter((node) => !filter || filter(node)) + .map((node) => ({ instance, node, outputs })), + ); +} + +/** + * The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock + * in `nodeState`, so two nodes on the same key repeat on their own schedules. + */ +export function keyDownPulses(instance: Instance, node: GraphNode, input: Input, dt: number): Pulse[] { + const i = instance.interpreter; + const key = keyName(textOf(node.params.key ?? '')); + const stored = i.nodeState.get(node.id); + const { state, fires } = stepKeyRepeat( + stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null, + { + pressed: input.justPressed.has(key), + down: input.heldKeys.has(key), + dt, + // The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds. + after: asNumber(node.params.repeatAfter ?? 0) / 1000, + interval: asNumber(node.params.repeatInterval ?? 0) / 1000, + }, + ); + if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats }); + else i.nodeState.delete(node.id); + return Array.from({ length: fires }, () => ({ instance, node })); +} + +export function timerPulses(instance: Instance, dt: number): Pulse[] { + const i = instance.interpreter; + const pulses: Pulse[] = []; + for (const node of i.eventNodes('OnTimer')) { + const state = i.nodeState.get(node.id) ?? { elapsed: 0 }; + i.nodeState.set(node.id, state); + const interval = Math.max(0.01, Number(node.params.interval ?? 1)); + state.elapsed = (state.elapsed ?? 0) + dt; + // What is left after taking one interval is how long ago this tick was due. When one frame + // catches up several ticks, the first one was due longest ago and is the latest. + while (state.elapsed >= interval) { + state.elapsed -= interval; + pulses.push({ instance, node, outputs: { late: state.elapsed } }); + } + } + return pulses; +} + +/** + * The collision pairs of one kind that concern this behavior: every pair in which one collider + * belongs to the behavior's body (its owner, or itself when it has none), with `other` being the + * body on the far side. + */ +function sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] { + const body = instance.entity.parent ?? instance.entity; + return pairs.flatMap((pair) => { + const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null; + return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : []; + }); +} + +export function collisionEnterPulses(instance: Instance, collisions: CollisionSource | null): Pulse[] { + const entered = sides(instance, collisions?.enters ?? []); + return instance.interpreter + .eventNodes('OnCollisionEnter') + .flatMap((node) => entered.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) }))); +} + +/** + * Stay fires every frame or, with an interval, once each time that long has passed since the pair + * met (#149). Each stay node keeps a clock per pair in its node state, and a pair that is gone + * takes its clock with it. + */ +export function collisionStayPulses( + instance: Instance, + collisions: CollisionSource | null, + dt: number, +): Pulse[] { + if (!collisions) return []; + const interpreter = instance.interpreter; + const stays = sides(instance, collisions.stays); + const contacts = new Map(); + const contactOf = (pair: CollisionPair) => { + if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair)); + return contacts.get(pair)!; + }; + const pulses: Pulse[] = []; + for (const node of interpreter.eventNodes('OnCollisionStay')) { + const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0; + const clocks = interpreter.nodeState.get(node.id) ?? {}; + const running: Record = {}; + for (const side of stays) { + let fires = 1; + if (interval > 0) { + const key = `${side.pair.a.id}|${side.pair.b.id}`; + let elapsed = (clocks[key] ?? 0) + dt; + for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval; + running[key] = elapsed; + } + for (let i = 0; i < fires; i++) + pulses.push({ instance, node, outputs: sideOutputs(side, contactOf(side.pair)) }); + } + if (interval > 0) interpreter.nodeState.set(node.id, running); + else interpreter.nodeState.delete(node.id); + } + return pulses; +} + +export function collisionExitPulses(instance: Instance, collisions: CollisionSource | null): Pulse[] { + const left = sides(instance, collisions?.exits ?? []); + return instance.interpreter + .eventNodes('OnCollisionExit') + .flatMap((node) => left.map((side) => ({ instance, node, outputs: sideOutputs(side) }))); +} + +/** + * A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below + * it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view. + */ +export function animationFinishedPulses(instance: Instance, animations: AnimationSystem | null): Pulse[] { + const owner = instance.entity.parent; + const finished = (animations?.finished ?? []).filter( + ({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner), + ); + return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => { + const wanted = textOf(node.params.animation ?? ''); + return finished + .filter(({ animationId }) => wanted === '' || wanted === animationId) + .map(({ entity }) => ({ instance, node, outputs: { object: entity } })); + }); +} + +/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */ +function sideOutputs({ own, other, pair }: Side, contact?: Partial | null): Record { + const outputs: Record = { other: bodyOf(other), otherCollider: other, collider: own }; + if (contact !== undefined) { + // The pair's normal points towards `a`; seen from `b` it points the other way. + const sign = own === pair.a ? 1 : -1; + outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0; + outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0; + outputs.impulse = contact?.impulse ?? 0; + outputs.pointX = contact?.point?.x ?? 0; + outputs.pointY = contact?.point?.y ?? 0; + } + return outputs; +} diff --git a/src/viewport/FontLibrary.ts b/src/viewport/FontLibrary.ts index eaae22a9..4b22a4a5 100644 --- a/src/viewport/FontLibrary.ts +++ b/src/viewport/FontLibrary.ts @@ -5,7 +5,7 @@ import { FileLibrary } from './FileLibrary'; * Registers uploaded typefaces with the browser. Text appears in the default typeface for a frame * or two and then reflows when the face lands. */ -export class FontLibrary extends FileLibrary { +class FontLibrary extends FileLibrary { private readonly ready = new Set(); constructor() { diff --git a/src/viewport/ParticleLibrary.ts b/src/viewport/ParticleLibrary.ts index 1a87ec96..d35ac3f0 100644 --- a/src/viewport/ParticleLibrary.ts +++ b/src/viewport/ParticleLibrary.ts @@ -8,7 +8,7 @@ import { gameClock } from '../core/time'; * contract the `ShaderLibrary` keeps, including its own freeze so a test can hold particle time * still without freezing the shaders too. */ -export class ParticleLibrary { +class ParticleLibrary { private readonly builds = new Map(); private frozenAt: number | null = null; diff --git a/src/viewport/ShaderLibrary.ts b/src/viewport/ShaderLibrary.ts index a2e9e9a1..487ee1fb 100644 --- a/src/viewport/ShaderLibrary.ts +++ b/src/viewport/ShaderLibrary.ts @@ -9,7 +9,7 @@ import type { ShaderSource } from './shaders'; * Compiled shader graphs, one per asset, recompiled when the graph's content changes. A graph is * edited in place, so the cache keys on a fingerprint of what the fragment depends on. */ -export class ShaderLibrary { +class ShaderLibrary { private readonly builds = new Map(); /** How often a graph went through the compiler – what the checks read to prove a move did not. */ compiles = 0; diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index 6f8e8a26..050b0572 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -24,7 +24,7 @@ import { gameClock } from '../core/time'; import { type View, worldBounds, worldToScreen } from './view'; /** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */ -export const CLIP_GHOST_ALPHA = 0.3; +const CLIP_GHOST_ALPHA = 0.3; /** * What a view draws from: the project and word of when it changed. The editor's state is one; a diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index 633f1ce7..9b8c4f09 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -21,7 +21,7 @@ import { disposeParticleShader, type ShaderSource, syncParticleShader } from './ const DEG = Math.PI / 180; -export function emitterSettings(props: PropertyBag): EmitterSettings { +function emitterSettings(props: PropertyBag): EmitterSettings { return { mode: emitModeOf(props), rate: numberOf(props[RATE_KEY], 20), diff --git a/src/viewport/textPicture.ts b/src/viewport/textPicture.ts index 606e62fd..2b192fd0 100644 --- a/src/viewport/textPicture.ts +++ b/src/viewport/textPicture.ts @@ -30,7 +30,7 @@ export function textResolution(scale: number, smooth: boolean): number { * Chrome on Windows smooths canvas glyphs sideways (DirectWrite) whatever the filter is afterwards; * at this factor the smoothing is an eighth of a font pixel and drowns in the block around it. */ -export const SHARP_SUPERSAMPLE = 8; +const SHARP_SUPERSAMPLE = 8; /** * Glyphs rasterised `factor` times too fine, brought down to one texel per `factor`×`factor` block: diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 258d7834..8675c31b 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -60,7 +60,7 @@ export function wheelNotches(deltaY: number, deltaMode: number): number { } /** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */ -export const WHEEL_STEP = 50; +const WHEEL_STEP = 50; /** * Gathers wheel travel into whole zoom steps: 1 up, -1 down, 0 while it is not a step yet. Travel diff --git a/tests/core/struct-types.test.ts b/tests/core/struct-types.test.ts index 2b7ae626..7ddc8dde 100644 --- a/tests/core/struct-types.test.ts +++ b/tests/core/struct-types.test.ts @@ -271,18 +271,17 @@ describe('a struct typed by a Struct asset', () => { f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'Name', kind: 'text' })); f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'enabled', kind: 'boolean' })); f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { Name: 'Gem', enabled: true })); - const make = new MakeStructTypeCommand(chest.id, 'drop', []); - f.stack.execute(make); - const type = f.project.assets.find((a) => a.id === make.assetId) as StructAsset; + f.stack.execute(new MakeStructTypeCommand(chest.id, 'drop', [])); + const type = f.project.assets.at(-1) as StructAsset; expect(type).toMatchObject({ type: 'struct', name: 'drop', props: { Name: 'Gem', enabled: true } }); expect(type.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['Name:text', 'enabled:boolean']); expect(drop(chest).structType).toBe(type.id); expect(chest.props.drop).toEqual({ Name: 'Gem', enabled: true }); f.stack.undo(); - expect(f.project.assets.some((a) => a.id === make.assetId)).toBe(false); + expect(f.project.assets.some((a) => a.id === type.id)).toBe(false); expect(drop(chest).structType).toBeUndefined(); f.stack.redo(); - expect(drop(chest).structType).toBe(make.assetId); + expect(drop(chest).structType).toBe(type.id); }); }); diff --git a/tests/core/surface.test.ts b/tests/core/surface.test.ts index 1364757b..674da140 100644 --- a/tests/core/surface.test.ts +++ b/tests/core/surface.test.ts @@ -8,9 +8,15 @@ import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model import { translation } from '../../src/core/model/matrix'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; -import { TILE_BOUNCINESS_KEY, TILE_FRICTION_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; +import { + TILE_BOUNCINESS_KEY, + TILE_FRICTION_KEY, + TILE_ONE_WAY_ANGLE_KEY, + TILE_ONE_WAY_KEY, + TILE_SOLID_KEY, +} from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; -import { solidRects, tileMaterial, tileMaterials } from '../../src/core/model/tilemapCollision'; +import { oneWayAngles, solidRects, tileMaterial, tileMaterials } from '../../src/core/model/tilemapCollision'; import { colliderFrame, colliderShape, @@ -255,6 +261,18 @@ describe('the surface of a solid tile (#193)', () => { { x: 0, y: 0, w: 2, h: 1 }, ]); }); + + it('a tile that does not block brings no surface and no one-way angle of its own', () => { + const s = tileScene(); + s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_SOLID_KEY, false)); + s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_ONE_WAY_KEY, true)); + s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_ONE_WAY_ANGLE_KEY, 30)); + s.paint([[0, 0]], s.wallId); + s.paint([[1, 0]], s.iceId); + const asset = s.mapAsset(); + expect(tileMaterials(s.project, asset)).toEqual([{ friction: 0.5, bounciness: 0 }]); + expect(oneWayAngles(s.project, asset)).toEqual([]); + }); }); describe.each(COLLISION_SYSTEMS)('$system: Move slides by the tile surface (#193)', ({ physics }) => { diff --git a/tests/core/tilemap.test.ts b/tests/core/tilemap.test.ts index 39a948ca..3866a014 100644 --- a/tests/core/tilemap.test.ts +++ b/tests/core/tilemap.test.ts @@ -34,10 +34,8 @@ import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY, - tilemapTileIds, tilemapSize, } from '../../src/core/model/tilemap'; -import { tilemapsUsing } from '../../src/core/model/usages'; import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap'; import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh'; import { makeProject, roundTrip } from '../helpers'; @@ -445,14 +443,6 @@ describe('a tilemap in the document', () => { expect(back.layers).toHaveLength(1); expect(back.layers[0]!.cells).toEqual({ '3,3': f.tileId }); }); - - it('counts as a use of the tiles painted on it', () => { - const f = makeTilemap(); - expect(tilemapsUsing(f.project, [f.tileId])).toHaveLength(0); - f.paint([{ x: 0, y: 0 }]); - expect(tilemapsUsing(f.project, [f.tileId]).map((a) => a.id)).toEqual([f.tilemapId]); - expect(tilemapTileIds(f.assetOf())).toEqual([f.tileId]); - }); }); describe('the mesh a layer becomes', () => { diff --git a/tests/core/typed-lists.test.ts b/tests/core/typed-lists.test.ts index 82f548ae..4897fdaf 100644 --- a/tests/core/typed-lists.test.ts +++ b/tests/core/typed-lists.test.ts @@ -164,9 +164,8 @@ describe('a list typed by a Struct asset', () => { { Name: 'Gem', Amount: 2 }, { Name: 'Axe', Rare: true }, ]); - const make = new MakeListItemTypeCommand(target(chest)); - f.stack.execute(make); - const type = f.project.assets.find((a) => a.id === make.assetId) as StructAsset; + f.stack.execute(new MakeListItemTypeCommand(target(chest))); + const type = f.project.assets.at(-1) as StructAsset; expect(type).toMatchObject({ type: 'struct', name: 'loots', @@ -182,7 +181,7 @@ describe('a list typed by a Struct asset', () => { { Name: 'Axe', Amount: 2, Rare: true, [ITEM_TYPE_KEY]: type.id }, ]); f.stack.undo(); - expect(f.project.assets.some((a) => a.id === make.assetId)).toBe(false); + expect(f.project.assets.some((a) => a.id === type.id)).toBe(false); expect(chest.props.loots).toEqual([ { Name: 'Gem', Amount: 2 }, { Name: 'Axe', Rare: true },