diff --git a/README.md b/README.md index 0e668cfe..eed0d62a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1459 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1472 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs @@ -72,6 +72,7 @@ CI. Details, the GPU requirement and the detached runner are in | [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to | | [Sounds](https://git.nockal.com/meleki/game-maker/wiki/Sounds) | Sound assets and `Play sound` | | [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab | +| [Curves](https://git.nockal.com/meleki/game-maker/wiki/Curves) | Easings drawn by hand, the Curve tab, using one on a key or a `Lerp` | | [Shaders](https://git.nockal.com/meleki/game-maker/wiki/Shaders) | Shader graphs a sprite wears, their node set, the properties they declare | | [Particles](https://git.nockal.com/meleki/game-maker/wiki/Particles) | Particle graphs: a particle as a function of its age | | [State machines](https://git.nockal.com/meleki/game-maker/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` | diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 6db583b1..9c73cddd 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -13,6 +13,7 @@ import { trackValueAt, } from '../model/animation'; import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset'; +import { curveLookup } from '../model/curve'; import { findAsset, type Project } from '../model/Project'; import type { PropertyValue } from '../model/Properties'; import { coerceValue } from '../properties/PropertySchema'; @@ -233,7 +234,7 @@ export class SetAnimationKeyCommand extends TimelineCommand { requireFrame(this.frame, 'A key sits on a whole frame from 0'); const def = trackDef(project, asset, track); if (!def) throw new CommandError('The row aims at something that is gone'); - const current = trackValueAt(track.keys, def.kind, this.frame); + const current = trackValueAt(track.keys, def.kind, this.frame, false, curveLookup(project)); const raw = this.value ?? current ?? (track.relative ? 0 : def.default); const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) }; const interpolation = track.keys.find((k) => k.frame === this.frame)?.interpolation; diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index e5b51085..77837b14 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -9,6 +9,7 @@ import type { VariablesAsset, } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; +import { linearCurve } from '../model/curve'; import { isFontFile } from '../model/font'; import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; import { isSoundFile } from '../model/sound'; @@ -71,6 +72,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'palette', colors: [...DEFAULT_PALETTE] }; case 'animation': return { ...base, type: 'animation', root: '', tracks: [] }; + // Linear until someone bends it: a key that picks a new curve still moves as it did. + case 'curve': + return { ...base, type: 'curve', points: linearCurve() }; case 'tile': return { ...base, type: 'tile', pictures: [] }; // A tilemap without a layer could not be painted on, so it starts with one. diff --git a/src/core/commands/curveCommands.ts b/src/core/commands/curveCommands.ts new file mode 100644 index 00000000..fbe07d9f --- /dev/null +++ b/src/core/commands/curveCommands.ts @@ -0,0 +1,39 @@ +/** + * Editing the points of a Curve asset (#247). One gesture is one command, as with a vector art ring: + * the whole point list is written once when the pointer lets go, so an undo takes the drag back. + * Whatever is written is held to the curve's rules first (`sanitizedCurve`), so no command – and no + * hand-made list from a preset – can leave a curve that runs backwards in `t`. + */ +import type { CurveAsset, VectorPoint } from '../model/Asset'; +import { sanitizedCurve } from '../model/curve'; +import type { Project } from '../model/Project'; +import type { Command } from './Command'; +import { requireCurve } from './require'; + +export class SetCurvePointsCommand implements Command { + readonly label: string; + private previous: VectorPoint[] = []; + + constructor( + private readonly assetId: string, + private readonly points: readonly VectorPoint[], + label = 'Edit curve', + ) { + this.label = label; + } + + /** Nothing to do when the curve already reads exactly like this – no empty undo step. */ + static changes(asset: CurveAsset, points: readonly VectorPoint[]): boolean { + return JSON.stringify(asset.points) !== JSON.stringify(sanitizedCurve(points)); + } + + execute(project: Project): void { + const asset = requireCurve(project, this.assetId); + this.previous = asset.points; + asset.points = sanitizedCurve(this.points); + } + + undo(project: Project): void { + requireCurve(project, this.assetId).points = this.previous; + } +} diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 71537141..a1d0fa4a 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -3,6 +3,7 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/G import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; import type { Asset, + CurveAsset, TileAsset, BehaviorAsset, StateMachineAsset, @@ -121,6 +122,12 @@ export function requireVectorShape(project: Project, assetId: string, shapeId: s return shape; } +export function requireCurve(project: Project, assetId: string): CurveAsset { + const asset = findAssetOf(project, assetId, 'curve'); + if (!asset) throw new CommandError(`Curve ${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/graph/lookup.ts b/src/core/graph/lookup.ts index 673053ad..47ee82dc 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -1,3 +1,4 @@ +import type { VectorPoint } from '../model/Asset'; import type { PropertyBag } from '../model/Properties'; import type { Graph } from './Graph'; @@ -35,12 +36,14 @@ export interface StateMachine { } /** - * What a node may look up in the project around its graph: the behaviors it can run and the - * state machines it can read. One handle, so no caller can resolve a node with half of it. + * What a node may look up in the project around its graph: the behaviors it can run, the state + * machines it can read and the curves a `Lerp` eases along (#247). One handle, so no caller can + * resolve a node with half of it. */ export interface GraphLookup { behavior(assetId: string): BehaviorProgram | null; machine(assetId: string): StateMachine | null; + curve(assetId: string): readonly VectorPoint[] | null; } -export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null }; +export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null, curve: () => null }; diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 22ff7e51..c292b7d2 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -13,6 +13,7 @@ import { } from './Graph'; import type { GraphLookup } from './lookup'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; +import { lerpProblem } from './nodes/data/Lerp'; import { agreedType, type SharedPortGroup } from './portTypes'; export const BEHAVIOR_START = 'BehaviorStart'; @@ -273,10 +274,11 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin /** * Why this call cannot do anything, or null if it can. A call is the one node that depends on a - * *different* graph, so it is the one node that can look perfectly healthy and still be dead. + * *different* graph, so it is the one node that can look perfectly healthy and still be dead. A + * `Lerp` depends on another asset as well, the curve it eases along (#247), and says so here too. */ export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null { - if (node.type !== RUN_BEHAVIOR) return null; + if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup); const id = textOf(node.params[BEHAVIOR_PARAM] ?? ''); if (!id) return 'No behavior picked'; if (id === ownerId) return 'A behavior cannot run itself'; diff --git a/src/core/graph/nodes/data/Lerp.ts b/src/core/graph/nodes/data/Lerp.ts index 7423a5b9..6afd05a0 100644 --- a/src/core/graph/nodes/data/Lerp.ts +++ b/src/core/graph/nodes/data/Lerp.ts @@ -1,7 +1,10 @@ +import type { GraphNode } from '../../Graph'; +import type { GraphLookup } from '../../lookup'; import type { NodeDef } from '../../NodeRegistry'; import { asNumber } from '../../values'; import { num, withDefault } from '../shared'; +import { curveValue, LERP, LERP_CURVE } from '../../../model/curve'; import { textOf } from '../../../model/Properties'; /** Quadratic curves over t in 0…1, each running from 0 to 1. */ @@ -13,10 +16,12 @@ const EASES: Record number> = { }; export const Lerp: NodeDef = { - type: 'Lerp', + type: LERP, label: 'Lerp', category: 'data', - description: 'Blends from a to b as t runs from 0 to 1, along the chosen ease. t is held in 0…1.', + description: + 'Blends from a to b as t runs from 0 to 1, along the chosen ease. t is held in 0…1. With Ease ' + + 'set to Curve it follows the Curve asset picked below, and may overshoot b.', inputs: [withDefault(num('a'), 0), withDefault(num('b'), 1), withDefault(num('t'), 0)], outputs: [num('result')], params: [ @@ -30,13 +35,26 @@ export const Lerp: NodeDef = { { value: 'in', label: 'In' }, { value: 'out', label: 'Out' }, { value: 'inOut', label: 'In-out' }, + { value: 'curve', label: 'Curve' }, ], }, + { key: LERP_CURVE, label: 'Curve', kind: 'asset', default: '', assetTypes: ['curve'] }, ], - run: (_ctx, inputs, params) => { + hiddenParams: (params) => (textOf(params.ease) === 'curve' ? [] : [LERP_CURVE]), + run: (_ctx, inputs, params, _def, _entry, lookup) => { const a = asNumber(inputs.a); const t = Math.min(Math.max(asNumber(inputs.t), 0), 1); - const ease = EASES[textOf(params.ease)] ?? EASES.linear!; - return { outputs: { result: a + (asNumber(inputs.b) - a) * ease(t) } }; + const ease = textOf(params.ease); + const points = ease === 'curve' ? lookup.curve(textOf(params[LERP_CURVE] ?? '')) : null; + const share = points ? curveValue(points, t) : (EASES[ease] ?? EASES.linear!)(t); + return { outputs: { result: a + (asNumber(inputs.b) - a) * share } }; }, }; + +/** Why a Lerp set to Curve runs linear instead, or null when it eases as it says. */ +export function lerpProblem(node: GraphNode, lookup: GraphLookup): string | null { + if (node.type !== LERP || textOf(node.params.ease) !== 'curve') return null; + const id = textOf(node.params[LERP_CURVE] ?? ''); + if (!id) return 'No curve picked – runs linear'; + return lookup.curve(id) ? null : 'That curve is gone – runs linear'; +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 17a6aa4b..32575314 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -160,9 +160,14 @@ export type EasingDirection = 'in' | 'out' | 'in-out'; /** * How a key blends into the next one (#101); absent is `linear`. `step` holds until the next key. A * curve is stored as `-`, except quad, which keeps its first name `ease-`. + * `curve:` blends along a Curve asset (#247); an empty id is "Curve" with none picked yet. */ export type KeyInterpolation = - 'linear' | 'step' | `ease-${EasingDirection}` | `${Exclude}-${EasingDirection}`; + | 'linear' + | 'step' + | `ease-${EasingDirection}` + | `${Exclude}-${EasingDirection}` + | `curve:${string}`; /** * A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot @@ -424,6 +429,18 @@ export interface PaletteAsset extends AssetBase { colors: number[]; } +/** + * An easing drawn by hand (#247). The points are those of an open vector art line, read as a graph: + * `x` is the share of the time (`t`, 0…1) and `y` the share of the way (`v`), which may leave 0…1 to + * overshoot. The first point sits at `t` 0 and the last at 1, the points never overtake each other + * in `t` and a handle never reaches past a neighbour – so the curve has one value for every `t`. + * `sanitizedCurve` holds every stored curve to that. + */ +export interface CurveAsset extends AssetBase { + type: 'curve'; + points: VectorPoint[]; +} + /** * The graph is the motion of one particle: every output of its `Output` node is a function of the * particle's age and its own random number (see `core/particles/compile.ts`). A placed object is @@ -449,6 +466,7 @@ export type Asset = | PixelArtAsset | PaletteAsset | AnimationAsset + | CurveAsset | TileAsset | TilemapAsset | StateMachineAsset diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 8b4fc28d..0100218c 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -13,6 +13,7 @@ export const ASSET_TYPES = [ 'pixelart', 'palette', 'animation', + 'curve', 'tile', 'tilemap', 'statemachine', @@ -39,6 +40,7 @@ export const ASSET_TYPE_LABELS: Record = { pixelart: 'Pixel art', palette: 'Palette', animation: 'Animation', + curve: 'Curve', tile: 'Tile', tilemap: 'Tilemap', statemachine: 'State machine', @@ -54,7 +56,7 @@ export const ASSET_TYPE_LABELS: Record = { * a behavior refers to it by id, and a Variables asset is declarations a graph reads by id. A * sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is * what a tilemap draws with, so it is placed no more directly than a texture is – what is placed - * is the tilemap. + * is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine. */ export type PlaceableAssetType = Exclude< AssetType, @@ -64,6 +66,7 @@ export type PlaceableAssetType = Exclude< | 'pixelart' | 'palette' | 'animation' + | 'curve' | 'tile' | 'statemachine' | 'shader' @@ -79,6 +82,7 @@ const NOT_PLACEABLE: ReadonlySet = new Set([ 'pixelart', 'palette', 'animation', + 'curve', 'tile', 'statemachine', 'shader', diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 15f1b89b..8472b65a 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -11,6 +11,7 @@ import type { KeyInterpolation, } from './Asset'; import type { AssetType } from './AssetTypes'; +import { type CurveLookup, curveValue, NO_CURVES } from './curve'; import { treeOrder } from './hierarchy'; import { instanceChildId, resolveObjects } from './instance'; import { findAsset, type Project } from './Project'; @@ -115,7 +116,7 @@ export function blends(kind: string): boolean { return kind === 'number' || kind === 'color'; } -export type EasingShape = 'linear' | 'step' | EasingCurve; +export type EasingShape = 'linear' | 'step' | EasingCurve | 'curve'; /** The inspector's first easing field, in the order it offers them; `linear` is what an absent field means. */ export const EASING_SHAPES: readonly { value: EasingShape; label: string }[] = [ @@ -131,6 +132,7 @@ export const EASING_SHAPES: readonly { value: EasingShape; label: string }[] = [ { value: 'back', label: 'Back' }, { value: 'elastic', label: 'Elastic' }, { value: 'bounce', label: 'Bounce' }, + { value: 'curve', label: 'Curve' }, ]; /** The second field, for a curve only. */ @@ -170,12 +172,18 @@ function isCurve(value: string): value is EasingCurve { return Object.hasOwn(EASE_IN, value); } -/** A stored interpolation split into the inspector's two fields; unknown text reads as `linear`. */ +/** + * A stored interpolation split into the inspector's fields; unknown text reads as `linear`. `curve` + * is the id of the Curve asset (#247), `''` while none is picked. + */ export function easingOf(interpolation: KeyInterpolation): { shape: EasingShape; direction?: EasingDirection; + curve?: string; } { if (interpolation === 'linear' || interpolation === 'step') return { shape: interpolation }; + if (interpolation.startsWith(CURVE_PREFIX)) + return { shape: 'curve', curve: interpolation.slice(CURVE_PREFIX.length) }; const match = /^([a-z]+)-(in|out|in-out)$/.exec(interpolation); if (!match) return { shape: 'linear' }; const curve = match[1]!; @@ -184,16 +192,23 @@ export function easingOf(interpolation: KeyInterpolation): { return isCurve(curve) && curve !== 'quad' ? { shape: curve, direction } : { shape: 'linear' }; } -/** The stored form of the two fields; a curve without a direction eases both ways. */ -export function easingValue(shape: EasingShape, direction: EasingDirection = 'in-out'): KeyInterpolation { +const CURVE_PREFIX = 'curve:'; + +/** The stored form of the fields; a curve without a direction eases both ways. */ +export function easingValue( + shape: EasingShape, + direction: EasingDirection = 'in-out', + curve = '', +): KeyInterpolation { if (shape === 'linear' || shape === 'step') return shape; + if (shape === 'curve') return `${CURVE_PREFIX}${curve}`; return shape === 'quad' ? `ease-${direction}` : `${shape}-${direction}`; } export function isKeyInterpolation(value: unknown): value is KeyInterpolation { if (typeof value !== 'string') return false; - const { shape, direction } = easingOf(value as KeyInterpolation); - return easingValue(shape, direction) === value; + const { shape, direction, curve } = easingOf(value as KeyInterpolation); + return easingValue(shape, direction, curve) === value; } export function interpolationOf(key: AnimationKey): KeyInterpolation { @@ -203,11 +218,20 @@ export function interpolationOf(key: AnimationKey): KeyInterpolation { /** * How far along the way to the next key a value is, `t` the share of the time passed. Quad, the eases * of old, reaches 0.25 at half the time going in, 0.75 going out, and 0.125 at a quarter going both. + * A Curve asset that is gone, or none picked, blends linearly. */ -export function easedShare(interpolation: KeyInterpolation, t: number): number { - const { shape, direction } = easingOf(interpolation); +export function easedShare( + interpolation: KeyInterpolation, + t: number, + curves: CurveLookup = NO_CURVES, +): number { + const { shape, direction, curve } = easingOf(interpolation); if (shape === 'linear') return t; if (shape === 'step') return 0; + if (shape === 'curve') { + const points = curves(curve ?? ''); + return points ? curveValue(points, t) : t; + } const easeIn = EASE_IN[shape]; switch (direction ?? 'in-out') { case 'in': @@ -234,13 +258,15 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: * A row's value at a (fractional) frame: the first value before the first key, the last after the * last one. Between two keys the earlier one's interpolation decides the way. A value that jumps – * one that cannot blend, or a `step` key – holds until the next key in the direction of play: played - * in `reverse`, that is the key above. `undefined` only for a row without keys. + * in `reverse`, that is the key above. `undefined` only for a row without keys. `curves` finds the + * Curve assets a key may blend along. */ export function trackValueAt( keys: readonly AnimationKey[], kind: string, frame: number, reverse = false, + curves: CurveLookup = NO_CURVES, ): PropertyValue | undefined { const first = keys[0]; if (!first) return undefined; @@ -253,7 +279,7 @@ export function trackValueAt( if (!blends(kind) || interpolation === 'step') return reverse && frame > prev.frame ? next.value : prev.value; const t = (frame - prev.frame) / (next.frame - prev.frame); - return blendValue(kind, prev.value, next.value, easedShare(interpolation, t)); + return blendValue(kind, prev.value, next.value, easedShare(interpolation, t, curves)); } return keys[keys.length - 1]!.value; } @@ -270,8 +296,9 @@ export function trackWriteAt( frame: number, base: PropertyValue | undefined, reverse = false, + curves: CurveLookup = NO_CURVES, ): PropertyValue | undefined { - const value = trackValueAt(track.keys, def.kind, frame, reverse); + const value = trackValueAt(track.keys, def.kind, frame, reverse, curves); if (value === undefined) return undefined; if (def.kind !== 'number') return value; const offset = track.relative && typeof base === 'number'; diff --git a/src/core/model/animationPreview.ts b/src/core/model/animationPreview.ts index c1b83c26..1e5448ba 100644 --- a/src/core/model/animationPreview.ts +++ b/src/core/model/animationPreview.ts @@ -14,6 +14,7 @@ import { trackWriteAt, } from './animation'; import type { AnimationAsset, AnimationTrack, Asset } from './Asset'; +import { type CurveLookup, curveLookup } from './curve'; import { instanceChildId, objectRef } from './instance'; import { findAsset, type ObjectHolder, type Project } from './Project'; import { resolveSceneObjectProps } from '../properties/resolve'; @@ -37,9 +38,10 @@ export function previewHolder( if (!root) return undefined; const reset = playbackOf(animation) === 'once-reset' && frame >= frameCount(animation); const holder = root.type === 'scene' ? sceneCopy(root) : placedHolder(root); + const at: ReadAt = { frame, reverse, curves: curveLookup(project) }; for (const track of reset ? [] : animation.tracks) { - if (root.type === 'scene' && track.target === '') writeScene(holder, track, frame, reverse); - else writeObject(project, holder, previewObjectId(root, track.target), track, frame, reverse); + if (root.type === 'scene' && track.target === '') writeScene(holder, track, at); + else writeObject(project, holder, previewObjectId(root, track.target), track, at); } return holder; } @@ -68,11 +70,19 @@ function placedHolder(root: Asset): ObjectHolder { return { id: PLACED_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] }; } +/** Where on the timeline a row is read, and the Curve assets its keys may blend along. */ +interface ReadAt { + frame: number; + reverse: boolean; + curves: CurveLookup; +} + /** A row on the scene itself: its value goes into the copy's own properties. */ -function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void { +function writeScene(holder: ObjectHolder, track: AnimationTrack, at: ReadAt): void { const def = animatableDefs('scene').find((d) => d.key === track.property); if (!def) return; - const value = trackWriteAt(track, def, frame, holder.props[track.property] ?? def.default, reverse); + const base = holder.props[track.property] ?? def.default; + const value = trackWriteAt(track, def, at.frame, base, at.reverse, at.curves); if (value !== undefined) holder.props[track.property] = value; } @@ -81,8 +91,7 @@ function writeObject( holder: ObjectHolder, id: string, track: AnimationTrack, - frame: number, - reverse: boolean, + at: ReadAt, ): void { const ref = objectRef(project, holder, id); const asset = ref && findAsset(project, ref.object.assetId); @@ -93,10 +102,10 @@ function writeObject( if (!def) return; const key = track.property; if (isPositionRow(asset.type, key)) { - const value = trackWriteAt(track, def, frame, ref.object.position[key], reverse); + const value = trackWriteAt(track, def, at.frame, ref.object.position[key], at.reverse, at.curves); if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) }); return; } - const value = trackWriteAt(track, def, frame, props[key], reverse); + const value = trackWriteAt(track, def, at.frame, props[key], at.reverse, at.curves); if (value !== undefined) ref.setOverride(key, value); } diff --git a/src/core/model/curve.ts b/src/core/model/curve.ts new file mode 100644 index 00000000..461c3c65 --- /dev/null +++ b/src/core/model/curve.ts @@ -0,0 +1,151 @@ +/** + * Curve assets (#247): an easing drawn by hand, read wherever a key or a `Lerp` node picks one. + * One pure function evaluates it for the Curve tab, the animation player and the graph alike. + * + * A segment is a cubic Bezier from one point to the next, a line where neither end has a handle – + * the same rule vector art draws by. It is solved for `t` like CSS `cubic-bezier`: with both inner + * control points between the ends in `t`, `t` rises monotonically along the segment, so bisection + * finds the one place a `t` is reached. + */ +import { isGraphAsset, type VectorPoint } from './Asset'; +import { findAssetOf, type Project } from './Project'; +import type { Vec2 } from './Properties'; +import { cloneVectorPoint } from './vectorart'; + +/** The node type that eases along a curve, and its setting naming the curve. */ +export const LERP = 'Lerp'; +export const LERP_CURVE = 'curve'; + +/** How many animation keys and `Lerp` nodes ease along a curve – what deleting it has to warn about. */ +export function curveUsages(project: Project, curveId: string): { keys: number; nodes: number } { + let keys = 0; + let nodes = 0; + for (const asset of project.assets) { + if (asset.type === 'animation') + for (const track of asset.tracks) + keys += track.keys.filter((key) => key.interpolation === `curve:${curveId}`).length; + if (isGraphAsset(asset)) + nodes += asset.graph.nodes.filter((n) => n.type === LERP && n.params[LERP_CURVE] === curveId).length; + } + return { keys, nodes }; +} + +/** The points of the curve an id names; undefined once it is gone. */ +export type CurveLookup = (curveId: string) => readonly VectorPoint[] | undefined; + +export const NO_CURVES: CurveLookup = () => undefined; + +export function curveLookup(project: Project): CurveLookup { + return (curveId) => (curveId ? findAssetOf(project, curveId, 'curve')?.points : undefined); +} + +/** What a new curve is: a straight line from (0, 0) to (1, 1), the same as `linear`. */ +export function linearCurve(): VectorPoint[] { + return [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + ]; +} + +/** Halvings of the segment; 2^-40 of a segment is far below anything a value could show. */ +const BISECTIONS = 40; + +/** + * The value at `t`, held to 0…1. Between two points the segment decides; a segment without width – + * two points on one `t` – is a jump, and the later point's value wins from there on. + */ +export function curveValue(points: readonly VectorPoint[], t: number): number { + const first = points[0]; + if (!first) return t; + const at = Math.min(Math.max(Number.isFinite(t) ? t : 0, 0), 1); + if (at <= first.x) return first.y; + for (let i = 1; i < points.length; i++) { + const to = points[i]!; + if (at > to.x) continue; + return segmentValue(points[i - 1]!, to, at); + } + return points[points.length - 1]!.y; +} + +function segmentValue(from: VectorPoint, to: VectorPoint, t: number): number { + const span = to.x - from.x; + if (span <= 0) return to.y; + if (!from.out && !to.in) return from.y + ((to.y - from.y) * (t - from.x)) / span; + // Held between the ends in `t`, whatever is stored: that is what keeps the solve unique. + const c1x = clamp(from.x + (from.out?.x ?? 0), from.x, to.x); + const c2x = clamp(to.x + (to.in?.x ?? 0), from.x, to.x); + const c1y = from.y + (from.out?.y ?? 0); + const c2y = to.y + (to.in?.y ?? 0); + let lo = 0; + let hi = 1; + for (let step = 0; step < BISECTIONS; step++) { + const mid = (lo + hi) / 2; + if (cubic(from.x, c1x, c2x, to.x, mid) < t) lo = mid; + else hi = mid; + } + return cubic(from.y, c1y, c2y, to.y, (lo + hi) / 2); +} + +function cubic(a: number, b: number, c: number, d: number, s: number): number { + const u = 1 - s; + return u * u * u * a + 3 * u * u * s * b + 3 * u * s * s * c + s * s * s * d; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * The curve as the rules want it, whatever was handed in. At least two points; sorted by `t`, `t` + * held to 0…1, the first at 0 and the last at 1. The first point has no handle in and the last none + * out. A handle pointing backwards in `t` is turned upright, and one reaching past its neighbour is + * shortened along its own direction, so a smooth point stays smooth. What does not come out a number + * is dropped; with fewer than two points left the curve is linear. + */ +export function sanitizedCurve(points: readonly VectorPoint[]): VectorPoint[] { + const kept = points + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) + .map((p) => ({ ...cloneVectorPoint(p), x: clamp(p.x, 0, 1) })) + .sort((a, b) => a.x - b.x); + if (kept.length < 2) return linearCurve(); + kept[0]!.x = 0; + kept[kept.length - 1]!.x = 1; + for (const [i, point] of kept.entries()) { + const before = kept[i - 1]; + const after = kept[i + 1]; + const handleIn = before && point.in ? heldHandle(point.in, before.x - point.x) : undefined; + const handleOut = after && point.out ? heldHandle(point.out, after.x - point.x) : undefined; + if (handleIn) point.in = handleIn; + else delete point.in; + if (handleOut) point.out = handleOut; + else delete point.out; + if (!point.in && !point.out) delete point.mode; + } + return kept; +} + +/** A handle kept on its own side of the point, reaching `reach` in `t` at most (negative for `in`). */ +function heldHandle(handle: Vec2, reach: number): Vec2 | undefined { + const finite = Number.isFinite(handle.x) && Number.isFinite(handle.y); + if (!finite) return undefined; + let { x, y } = handle; + if (x * reach < 0) x = 0; + if (Math.abs(x) > Math.abs(reach)) { + const scale = Math.abs(reach) / Math.abs(x); + x *= scale; + y *= scale; + } + return Math.hypot(x, y) < 1e-9 ? undefined : { x, y }; +} + +/** The lowest and highest value the curve can reach – its points and handles – and never less than 0…1. */ +export function curveExtent(points: readonly VectorPoint[]): { min: number; max: number } { + let min = 0; + let max = 1; + for (const point of points) + for (const y of [point.y, point.y + (point.in?.y ?? 0), point.y + (point.out?.y ?? 0)]) { + min = Math.min(min, y); + max = Math.max(max, y); + } + return { min, max }; +} diff --git a/src/core/model/curvePresets.ts b/src/core/model/curvePresets.ts new file mode 100644 index 00000000..e811cbdd --- /dev/null +++ b/src/core/model/curvePresets.ts @@ -0,0 +1,111 @@ +/** + * The built-in eases as starting points for a Curve asset (#247). Each is traced by a few Bezier + * segments: the points sit on the ease, and each handle points along the ease's slope on its side, a + * third of the way to the neighbour (a cubic Hermite piece). A segment that strays more than + * `TOLERANCE` from the ease is halved, and only that one – so a quad keeps its single segment, and + * circ gets ever shorter ones only towards its upright end. + */ +import type { EasingDirection, VectorPoint } from './Asset'; +import type { Vec2 } from './Properties'; +import { EASING_DIRECTIONS, EASING_SHAPES, easedShare, type EasingShape, easingValue } from './animation'; +import { curveValue, linearCurve } from './curve'; + +export interface CurvePreset { + label: string; + /** The ease it traces; presets of one shape share it, so a menu can group them. */ + shape: EasingShape; + build(): VectorPoint[]; +} + +const TOLERANCE = 0.004; +/** Enough for a bounce; a curve with more points than this is no longer one to edit by hand. */ +const MAX_POINTS = 64; +const MAX_ROUNDS = 16; +/** Where each segment is compared with the ease. */ +const SAMPLES = 24; +/** The step of the one-sided slopes: small, and never onto the point itself, where expo jumps to 0. */ +const H = 1e-6; + +export function curvePresets(): CurvePreset[] { + const presets: CurvePreset[] = [{ label: 'Linear', shape: 'linear', build: linearCurve }]; + for (const { value: shape, label } of EASING_SHAPES) { + if (shape === 'linear' || shape === 'step' || shape === 'curve') continue; + for (const direction of EASING_DIRECTIONS) + presets.push({ + label: `${label} ${direction.label.toLowerCase()}`, + shape, + build: () => easeCurve(shape, direction.value), + }); + } + return presets; +} + +export function easeCurve(shape: EasingShape, direction: EasingDirection): VectorPoint[] { + const interpolation = easingValue(shape, direction); + return curveFromEase((t) => easedShare(interpolation, t)); +} + +export function curveFromEase(ease: (t: number) => number): VectorPoint[] { + let at = [0, 1]; + let points = traced(ease, at); + for (let round = 0; round < MAX_ROUNDS; round++) { + const next = [0]; + for (let i = 1; i < at.length; i++) { + const strays = segmentGap(points[i - 1]!, points[i]!, ease) > TOLERANCE; + if (strays && next.length + at.length - i < MAX_POINTS) next.push((at[i - 1]! + at[i]!) / 2); + next.push(at[i]!); + } + if (next.length === at.length) break; + at = next; + points = traced(ease, at); + } + return points; +} + +function traced(ease: (t: number) => number, at: readonly number[]): VectorPoint[] { + const value = (x: number) => clean(ease(x)); + return at.map((x, i) => { + const point: VectorPoint = { x, y: value(x) }; + const before = at[i - 1]; + const after = at[i + 1]; + const slopeIn = before === undefined ? null : (ease(x - H) - ease(x - 2 * H)) / H; + const slopeOut = after === undefined ? null : (ease(x + 2 * H) - ease(x + H)) / H; + if (before !== undefined && slopeIn !== null) + point.in = handle((before - x) / 3, slopeIn, point.y - value(before)); + if (after !== undefined && slopeOut !== null) + point.out = handle((after - x) / 3, slopeOut, value(after) - point.y); + if ( + slopeIn !== null && + slopeOut !== null && + Math.abs(slopeIn - slopeOut) < 1e-3 * Math.max(1, Math.abs(slopeOut)) + ) + point.mode = 'smooth'; + return point; + }); +} + +/** + * A handle along `slope`, reaching `reach` in `t`. Where the ease stands upright – circ at its steep + * end – the slope is huge, so the handle is held to the rise of its segment plus one step, keeping + * its direction: nearly vertical, as the ease is there, instead of shooting off the graph. + */ +function handle(reach: number, slope: number, rise: number): Vec2 { + const y = slope * reach; + const most = Math.abs(rise) + Math.abs(reach) * 3; + const scale = Math.abs(y) > most ? most / Math.abs(y) : 1; + return { x: reach * scale, y: y * scale }; +} + +/** Rounding noise off, so an ease that starts at 0 starts at 0 and not at 2e-16. */ +function clean(value: number): number { + return Math.round(value * 1e12) / 1e12; +} + +function segmentGap(from: VectorPoint, to: VectorPoint, ease: (t: number) => number): number { + let gap = 0; + for (let i = 1; i < SAMPLES; i++) { + const t = from.x + ((to.x - from.x) * i) / SAMPLES; + gap = Math.max(gap, Math.abs(curveValue([from, to], t) - ease(t))); + } + return gap; +} diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index 7741475f..b2482cfd 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -29,5 +29,6 @@ export function graphLookup(project: Project): GraphLookup { start: startStateOf(asset.graph, asset.start), }; }, + curve: (assetId) => findAssetOf(project, assetId, 'curve')?.points ?? null, }; } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index dfebca0d..6fe0d8e9 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -405,6 +405,8 @@ const ASSET_SCHEMAS: Record = { ], // The colours are intrinsic data on the asset (see PaletteAsset). palette: [], + // The points are intrinsic data on the asset (see CurveAsset). + curve: [], // The root and the rows are intrinsic data on the asset (see AnimationAsset). animation: [ { key: 'fps', label: 'Frames per second', kind: 'number', default: 10, min: 0.1, max: 120, step: 1 }, diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 742eb16f..c58906d3 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -68,6 +68,7 @@ import { canvasSide, PALETTE_MAX } from '../model/pixelart'; import { isCelFile } from '../model/pixelCel'; import { newId } from '../ids'; import { SHAPE_FILL_DEFAULT } from '../model/vectorart'; +import { sanitizedCurve } from '../model/curve'; import { hasParentCycle } from '../model/walk'; import { DECLARED_PORT_KINDS, @@ -230,6 +231,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { assets.push({ id, type, name, props, ...normalizePixelArt(a, takenIds) }); } else if (type === 'palette') { assets.push({ id, type, name, props, colors: normalizePalette(a.colors) }); + } else if (type === 'curve') { + assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) }); } else if (type === 'animation') { // A root or row aiming at something gone is kept: it reads as missing and keeps its keys. const root = typeof a.root === 'string' ? a.root : ''; @@ -702,22 +705,8 @@ function normalizeShapes(raw: unknown): VectorShape[] { const rings: VectorRing[] = []; for (const r of Array.isArray(s.rings) ? s.rings : []) { const ring = asRecord(r); - const points: VectorPoint[] = []; - for (const p of Array.isArray(ring.points) ? ring.points : []) { - const point = asRecord(p); - const x = asFinite(point.x); - const y = asFinite(point.y); - if (x === null || y === null) continue; - const kept: VectorPoint = { x, y }; - for (const side of ['in', 'out'] as const) { - const handle = normalizeHandle(point[side]); - if (handle) kept[side] = handle; - } - if (point.mode === 'smooth' || point.mode === 'symmetric') kept.mode = point.mode; - points.push(kept); - } // Closed is what a shape usually is, and what every ring written by the editor says. - rings.push({ points, closed: ring.closed !== false }); + rings.push({ points: normalizePoints(ring.points), closed: ring.closed !== false }); } const shape: VectorShape = { id: s.id, @@ -740,6 +729,25 @@ function normalizeShapes(raw: unknown): VectorShape[] { return shapes; } +/** The points of a ring or of a curve (#247); the rules are those of `normalizeShapes`. */ +function normalizePoints(raw: unknown): VectorPoint[] { + const points: VectorPoint[] = []; + for (const p of Array.isArray(raw) ? raw : []) { + const point = asRecord(p); + const x = asFinite(point.x); + const y = asFinite(point.y); + if (x === null || y === null) continue; + const kept: VectorPoint = { x, y }; + for (const side of ['in', 'out'] as const) { + const handle = normalizeHandle(point[side]); + if (handle) kept[side] = handle; + } + if (point.mode === 'smooth' || point.mode === 'symmetric') kept.mode = point.mode; + points.push(kept); + } + return points; +} + /** * A number as written, or null – unlike `toNumber`, which reads anything unusable as 0. A coordinate * of 0 is a place on the canvas, so "broken" and "the left edge" must not come out the same. diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index c9734412..16b15ab2 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -70,6 +70,7 @@ import type { EditorState } from './state'; import { copySelection } from './clipboard'; import { PixelArtEditor } from './pixelart/PixelArtEditor'; import { VectorArtEditor } from './vectorart/VectorArtEditor'; +import { CurveEditor } from './curve/CurveEditor'; import { TextureEditor } from './texture/TextureEditor'; import { installFileImport } from './fileImport'; import type { PluginManager } from './plugins/loader'; @@ -88,6 +89,7 @@ export type CentreTab = | 'vectorart' | 'pixelart' | 'animation' + | 'curve' | 'tile' | 'container' | 'list' @@ -134,6 +136,7 @@ const PANE_ORDER = [ 'vectorart', 'pixelart', 'animation', + 'curve', 'tile', 'container', 'list', @@ -152,6 +155,7 @@ const TAB_LABELS: Record = { vectorart: 'Vector art', pixelart: 'Pixel art', animation: 'Animation', + curve: 'Curve', tile: 'Tile', container: 'Container', list: 'List', @@ -197,6 +201,7 @@ export class Editor implements PlayHost { readonly vectorArtEditor: VectorArtEditor; readonly pixelArtEditor: PixelArtEditor; readonly animationEditor: AnimationEditor; + readonly curveEditor: CurveEditor; readonly tileEditor: TileEditor; readonly containerEditor: ContainerEditor; readonly listEditor: ListEditor; @@ -276,6 +281,7 @@ export class Editor implements PlayHost { this.vectorArtEditor = new VectorArtEditor(state); this.pixelArtEditor = new PixelArtEditor(state); this.animationEditor = new AnimationEditor(state); + this.curveEditor = new CurveEditor(state); this.tileEditor = new TileEditor(state); this.containerEditor = new ContainerEditor(state); this.listEditor = new ListEditor(state); @@ -380,6 +386,7 @@ export class Editor implements PlayHost { vectorart: this.vectorArtEditor, pixelart: this.pixelArtEditor, animation: this.animationEditor, + curve: this.curveEditor, tile: this.tileEditor, container: this.containerEditor, }; @@ -814,6 +821,8 @@ export class Editor implements PlayHost { this.console.refresh(); if (this.tab === 'animation') this.animationEditor.tick(step); if (this.tab === 'tile') this.tileEditor.tick(step); + // The preview is the editor's own, so it runs on while the game is paused. + if (this.tab === 'curve') this.curveEditor.tick(dt); // Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session // to do it, and its states move just the same. if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); @@ -837,7 +846,8 @@ export class Editor implements PlayHost { this.tab === 'pixelart' || (this.tab === 'shader' && this.shaderEditor.animates) || (this.tab === 'animation' && this.animationEditor.isPlaying) || - (this.tab === 'tile' && this.tileEditor.isPlaying) + (this.tab === 'tile' && this.tileEditor.isPlaying) || + (this.tab === 'curve' && this.curveEditor.isPlaying) ); } diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 71fc1117..4c7e2bad 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -15,6 +15,7 @@ import { CreateContainerCommand } from '../core/commands/containerCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import type { AssetType } from '../core/model/AssetTypes'; +import { curveUsages } from '../core/model/curve'; import { generatesPictures } from '../core/model/generated'; import { isTemplateContent } from '../core/model/instance'; import type { Command } from '../core/commands/Command'; @@ -131,16 +132,19 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const picture = asset.type === 'texture' || generatesPictures(asset.type); const animations = picture ? animationsUsing(state.project, ids).length : 0; const tiles = picture ? tilesUsing(state.project, ids).length : 0; + const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 }; const detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` - : references > 0 || animations > 0 || tiles > 0 - ? wornDetail(references, animations, tiles, asset.type) - : usages === 0 - ? 'This cannot be undone with Ctrl+Z after reload.' - : asset.type === 'container' - ? `${usages} placed container(s) are removed together with their content.` - : `${usages} placed instance(s) are removed as well (scenes and container templates).`; + : eased.keys > 0 || eased.nodes > 0 + ? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.` + : references > 0 || animations > 0 || tiles > 0 + ? wornDetail(references, animations, tiles, asset.type) + : usages === 0 + ? 'This cannot be undone with Ctrl+Z after reload.' + : asset.type === 'container' + ? `${usages} placed container(s) are removed together with their content.` + : `${usages} placed instance(s) are removed as well (scenes and container templates).`; new Dialog(`Delete "${asset.name}"?`, detail, [ { label: 'Cancel' }, { label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) }, diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 372c0f66..5f6b4798 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -702,7 +702,7 @@ export class TimelineCanvas extends Widget { /** * A key's outline tells its easing: the right half is the start of the way to the next key, the left * half its end, and a round half is a slow one. Linear is the diamond, any curve in-out the circle, a - * step – which holds – the square. + * step – which holds – the square. A Curve asset (#247) may do anything, so it is drawn round both ways. */ function drawKeyShape( g: Graphics, @@ -712,9 +712,9 @@ function drawKeyShape( half: number, ): Graphics { if (interpolation === 'step') return g.rect(x - half * 0.75, y - half * 0.75, half * 1.5, half * 1.5); - const { direction } = easingOf(interpolation); - const slowStart = direction === 'in' || direction === 'in-out'; - const slowEnd = direction === 'out' || direction === 'in-out'; + const { shape, direction } = easingOf(interpolation); + const slowStart = shape === 'curve' || direction === 'in' || direction === 'in-out'; + const slowEnd = shape === 'curve' || direction === 'out' || direction === 'in-out'; g.moveTo(x, y - half); if (slowStart) g.arc(x, y, half, -Math.PI / 2, Math.PI / 2); else g.lineTo(x + half, y).lineTo(x, y + half); diff --git a/src/editor/assetTabs.ts b/src/editor/assetTabs.ts index 4cda524b..daeda244 100644 --- a/src/editor/assetTabs.ts +++ b/src/editor/assetTabs.ts @@ -12,6 +12,7 @@ export const TAB_OF_ASSET: Partial> = { vectorart: 'vectorart', pixelart: 'pixelart', animation: 'animation', + curve: 'curve', tile: 'tile', container: 'container', }; diff --git a/src/editor/curve/CurveCanvas.ts b/src/editor/curve/CurveCanvas.ts new file mode 100644 index 00000000..5935a3a1 --- /dev/null +++ b/src/editor/curve/CurveCanvas.ts @@ -0,0 +1,424 @@ +/** + * The Curve tab's canvas (#247): the curve as a graph, `t` to the right and `v` up, with guides at + * 0 and 1. Points and handles are edited with the vector art gestures (see `curveView.ts` for how + * the curve is laid onto a picture for them), and a gesture writes the model as a preview and puts + * it back on release, so one drag is one undo step. + * + * The line is drawn from `curveValue`, not from the Bezier segments: what the tab shows is exactly + * what a key or a `Lerp` reads. A dot runs along it as a preview, and a second one beside the graph + * moves only up and down – the motion the curve gives a value. + */ +import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { SetCurvePointsCommand } from '../../core/commands/curveCommands'; +import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset'; +import { 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 { + type PointKind, + pointKind, + ringGripAt, + withHandleMoved, + withoutPoint, + withPointKind, + withPointOnSegment, + withSmoothToggled, +} from '../polygonEdit'; +import type { EditorState } from '../state'; +import { SheetCanvas } from '../texture/SheetCanvas'; +import { fitView, imageToLocal, localToImage } from '../texture/sheetView'; +import { + curveFrame, + curveToImage, + imageToCurve, + isCurveEnd, + previewShare, + toImage, + withCurvePointAt, + withCurvePointMoved, +} 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. */ +const BAR_AT = 1.08; +const DOT_RADIUS = 5; + +/** A gesture in progress. `before` is what the curve read when it began – the undo step's other side. */ +interface CurveDrag { + kind: 'point' | 'handle'; + before: VectorPoint[]; + ring: VectorRing; + points: VectorPoint[]; + index: number; + side: 'in' | 'out'; + pointer: Vec2; + moved: boolean; + label: string; +} + +let instances = 0; + +export class CurveCanvas extends SheetCanvas { + private readonly guides = new Graphics(); + private readonly line = new Graphics(); + private readonly overlay = new Graphics(); + private readonly labels = ['0', '1', '0', '1'].map((text) => new Text({ text, style: textStyle('muted') })); + private drag: CurveDrag | null = null; + /** The point last pressed or put down – the one the header's point type is about; -1 for none. */ + private picked = -1; + onPointPicked: () => void = () => {}; + /** Seconds the preview has run; it only moves while `previewing` is on. */ + private previewTime = 0; + private previewOn = true; + private readonly frameKey = `curve:${(instances += 1)}`; + + constructor(private readonly state: EditorState) { + super(); + this.content.addChild(this.guides, this.line, this.overlay, ...this.labels); + for (const label of this.labels) label.eventMode = 'none'; + state.on('project', () => this.redraw()); + state.on('load', () => { + this.needsFit = true; + this.pick(-1); + this.redraw(); + }); + } + + show(assetId: string): void { + if (this.assetId === assetId) return; + this.assetId = assetId; + this.needsFit = true; + this.pick(-1); + this.redraw(); + } + + /** Moves the preview on; only called while the tab is visible. */ + tick(dt: number): void { + if (!this.previewOn || !this.asset()) return; + this.previewTime += dt; + this.redraw(); + } + + get previewing(): boolean { + return this.previewOn; + } + + setPreviewing(on: boolean): void { + this.previewOn = on; + this.redraw(); + } + + /** Where the preview stands: the share of time, the value the curve gives it, and its two dots. */ + previewState(): { t: number; v: number; dot: Vec2; bar: Vec2 } | null { + const asset = this.asset(); + if (!asset) return null; + const t = previewShare(this.previewTime); + const v = curveValue(asset.points, t); + return { t, v, dot: this.graphToScreen(t, v), bar: this.graphToScreen(BAR_AT, v) }; + } + + /** For the check scripts: stops the preview at `seconds` into its pass. */ + setPreviewTime(seconds: number): void { + this.previewTime = seconds; + this.redraw(); + } + + /** Where a place on the graph sits on screen – the check scripts aim with it. */ + graphToScreen(t: number, v: number): Vec2 { + const at = toImage({ x: t, y: v }); + return this.imageToScreen(at.x, at.y); + } + + get pickedIndex(): number { + return this.picked; + } + + /** The type of the picked point; null while none is picked or it has gone since (an undo). */ + get pickedKind(): PointKind | null { + const point = this.asset()?.points[this.picked]; + return point ? pointKind(point) : null; + } + + setPickedKind(kind: PointKind): void { + const asset = this.asset(); + if (!asset || !asset.points[this.picked]) return; + const points = imageToCurve(withPointKind(curveToImage(asset.points), this.picked, kind)); + if (SetCurvePointsCommand.changes(asset, points)) + this.state.execute(new SetCurvePointsCommand(asset.id, points, 'Point type')); + } + + /** Replaces the whole curve – what a preset does. */ + replace(points: readonly VectorPoint[], label: string): void { + const asset = this.asset(); + if (!asset || !SetCurvePointsCommand.changes(asset, points)) return; + this.pick(-1); + this.state.execute(new SetCurvePointsCommand(asset.id, points, label)); + } + + private pick(index: number): void { + if (this.picked === index) return; + this.picked = index; + this.onPointPicked(); + } + + private asset(): CurveAsset | null { + return (this.assetId && findAssetOf(this.state.project, this.assetId, 'curve')) || null; + } + + /** Delete takes the picked point away – never an end, which holds the curve at 0 or 1. */ + override onKeyDown(event: KeyboardEvent): boolean { + const asset = this.asset(); + if ((event.key !== 'Delete' && event.key !== 'Backspace') || !asset || this.picked < 0) + return super.onKeyDown(event); + this.removePoint(asset, this.picked); + return true; + } + + private removePoint(asset: CurveAsset, index: number): void { + const ring = curveToImage(asset.points); + const next = isCurveEnd(ring, index) ? null : withoutPoint(ring, index); + if (!next) return; + this.pick(-1); + this.state.execute(new SetCurvePointsCommand(asset.id, imageToCurve(next), 'Remove point')); + } + + protected pressTarget(): CurveAsset | null { + return this.asset(); + } + + /** + * A press: on a handle it drags the handle, on a point the point – a double-click takes the point + * away, Alt makes it round or straight again. On the line it puts a point there that keeps the + * curve as it was, on free space a plain one at the pointer. Either way the new point follows the + * pointer until it lets go. + */ + protected press(asset: CurveAsset, e: FederatedPointerEvent, local: Vec2): void { + e.stopPropagation(); + const ring = curveToImage(asset.points); + const at = localToImage(this.view, local.x, local.y); + const grip = ringGripAt(ring, at, POINT_SIZE / this.view.zoom); + if (grip?.kind === 'point') { + this.pick(grip.index); + if (pressCount(e, `curve point ${asset.id} ${grip.index}`) === 2) { + this.removePoint(asset, grip.index); + return; + } + if (e.altKey) { + const points = imageToCurve(withSmoothToggled(ring, grip.index)); + this.state.execute(new SetCurvePointsCommand(asset.id, points, 'Smooth point')); + return; + } + this.startDrag(asset, ring, grip.index, 'point', 'out', local, false, 'Move point'); + return; + } + if (grip?.kind === 'handle') { + this.pick(grip.index); + this.startDrag(asset, ring, grip.index, 'handle', grip.side, local, false, 'Bend curve'); + return; + } + const put = + grip?.kind === 'segment' + ? { ring: withPointOnSegment(ring, grip.index, grip.t), index: grip.index + 1 } + : withCurvePointAt(ring, at); + if (!put) { + this.pick(-1); + this.redraw(); + return; + } + this.pick(put.index); + this.startDrag(asset, put.ring, put.index, 'point', 'out', local, true, 'Add point'); + this.preview(); + } + + private startDrag( + asset: CurveAsset, + ring: VectorRing, + index: number, + kind: CurveDrag['kind'], + side: 'in' | 'out', + pointer: Vec2, + moved: boolean, + label: string, + ): void { + this.drag = { + kind, + before: asset.points, + ring, + points: sanitizedCurve(imageToCurve(ring)), + index, + side, + pointer: { x: pointer.x, y: pointer.y }, + moved, + label, + }; + this.redraw(); + } + + protected move(local: Vec2): void { + const drag = this.drag; + if (!drag) { + this.updateCursor(local); + return; + } + if (!drag.moved && Math.hypot(local.x - drag.pointer.x, local.y - drag.pointer.y) < DRAG_THRESHOLD) + return; + drag.moved = true; + const to = localToImage(this.view, local.x, local.y); + drag.ring = + drag.kind === 'handle' + ? withHandleMoved(drag.ring, drag.index, drag.side, to) + : withCurvePointMoved(drag.ring, drag.index, to); + drag.points = sanitizedCurve(imageToCurve(drag.ring)); + this.preview(); + } + + /** Once per frame, not once per pointer move – the reason is the one `VectorArtCanvas.preview` gives. */ + private preview(): void { + onFrame(this.frameKey, () => { + const asset = this.asset(); + if (this.destroyed || !this.drag || !asset) return; + asset.points = this.drag.points; + this.state.preview(); + }); + } + + protected release(): void { + const drag = this.drag; + this.drag = null; + cancelFrameWork(this.frameKey); + const asset = this.asset(); + if (!drag || !asset) return; + asset.points = drag.before; + if (drag.moved && SetCurvePointsCommand.changes(asset, drag.points)) + this.state.execute(new SetCurvePointsCommand(asset.id, drag.points, drag.label)); + else this.state.preview(); + } + + private updateCursor(local: Vec2): void { + const asset = this.asset(); + if (!asset) { + this.cursor = 'default'; + return; + } + const at = localToImage(this.view, local.x, local.y); + const grip = ringGripAt(curveToImage(asset.points), at, POINT_SIZE / this.view.zoom); + this.cursor = grip ? 'pointer' : 'crosshair'; + } + + override redraw(): void { + this.drawGround(); + const asset = this.asset(); + if (asset && this.needsFit && this.w > 0 && this.h > 0) { + const frame = curveFrame(asset.points); + const view = fitView(frame.w, frame.h, this.w, this.h); + this.view = { ...view, panX: view.panX - frame.x * view.zoom, panY: view.panY - frame.y * view.zoom }; + this.needsFit = false; + } + this.checker.clear(); + this.guides.clear(); + this.line.clear(); + this.overlay.clear(); + for (const label of this.labels) label.visible = asset !== null; + if (asset) { + this.drawGuides(); + this.drawLine(asset.points); + // Under the points: resting at the end, the dot would hide the last one. + this.drawPreview(asset.points); + this.drawPoints(asset.points); + } + this.syncHint(asset); + } + + private local(t: number, v: number): Vec2 { + const at = toImage({ x: t, y: v }); + return imageToLocal(this.view, at.x, at.y); + } + + /** The unit square, its sides at 0 and 1 carried across the whole view, and their numbers. */ + private drawGuides(): void { + const origin = this.local(0, 0); + const corner = this.local(1, 1); + this.guides + .rect(origin.x, corner.y, corner.x - origin.x, origin.y - corner.y) + .fill({ color: Theme.colors.panel, alpha: 0.5 }); + for (const y of [origin.y, corner.y]) this.guides.moveTo(0, y).lineTo(this.w, y); + for (const x of [origin.x, corner.x]) this.guides.moveTo(x, 0).lineTo(x, this.h); + this.guides.stroke({ width: 1, color: Theme.colors.border }); + const [v0, v1, t0, t1] = this.labels as [Text, Text, Text, Text]; + const gap = Theme.gap; + v0.position.set(origin.x - v0.width - gap, origin.y - v0.height / 2); + v1.position.set(origin.x - v1.width - gap, corner.y - v1.height / 2); + t0.position.set(origin.x - t0.width / 2, origin.y + gap); + t1.position.set(corner.x - t1.width / 2, origin.y + gap); + } + + private drawLine(points: readonly VectorPoint[]): void { + const walked: number[] = []; + for (let step = 0; step <= LINE_STEPS; step++) { + const t = step / LINE_STEPS; + const at = this.local(t, curveValue(points, t)); + walked.push(at.x, at.y); + } + this.line.poly(walked, false).stroke({ width: 2, color: Theme.colors.accent }); + } + + /** 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 }); + } + } + + /** A dot on the line at the preview's `t`, and its twin on a rail beside the graph. */ + private drawPreview(points: readonly VectorPoint[]): void { + const t = previewShare(this.previewTime); + const v = curveValue(points, t); + const dot = this.local(t, v); + const bar = this.local(BAR_AT, v); + const top = this.local(BAR_AT, 1); + const bottom = this.local(BAR_AT, 0); + this.overlay + .moveTo(dot.x, dot.y) + .lineTo(bar.x, bar.y) + .stroke({ width: 1, color: Theme.colors.hot, alpha: 0.4 }); + this.overlay + .moveTo(top.x, top.y) + .lineTo(bottom.x, bottom.y) + .stroke({ width: 2, color: Theme.colors.border }); + for (const at of [dot, bar]) this.overlay.circle(at.x, at.y, DOT_RADIUS).fill(Theme.colors.hot); + } + + private syncHint(asset: CurveAsset | null): void { + this.hint.text = asset + ? 'Click the line or free space to add a point, drag handles to bend, double-click a point to remove it.' + : 'No curve selected – pick one in the Assets panel.'; + this.hint.visible = true; + this.hint.position.set(Theme.padding * 2, Theme.padding * 2); + } +} diff --git a/src/editor/curve/CurveEditor.ts b/src/editor/curve/CurveEditor.ts new file mode 100644 index 00000000..a934c4a6 --- /dev/null +++ b/src/editor/curve/CurveEditor.ts @@ -0,0 +1,112 @@ +import type { Asset, CurveAsset } from '../../core/model/Asset'; +import { curvePresets } from '../../core/model/curvePresets'; +import { Button, Dropdown, Menu, type MenuEntry } from '../../ui'; +import { AssetTabEditor } from '../AssetTabEditor'; +import { POINT_KINDS, type PointKind } from '../polygonEdit'; +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 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 runs the preview or holds it. + */ +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 previewButton: Button; + + constructor(state: EditorState) { + super(state, { infoWidth: INFO_WIDTH }); + this.presetButton.onClick = () => this.openPresetMenu(); + this.pointButton.onClick = () => this.openPointMenu(); + this.previewButton = new Button('Preview', { + toggle: true, + onClick: () => this.setPreviewing(!this.canvas.previewing), + }); + const canvas = new CurveCanvas(state); + canvas.onPointPicked = () => this.syncPoint(); + this.mount(canvas, [this.presetButton, this.pointButton, this.previewButton]); + this.setPreviewing(true); + } + + /** Only called while the tab is visible. */ + tick(dt: number): void { + this.canvas.tick(dt); + } + + /** Whether the preview moves by itself – nothing else would wake the tab for it. */ + get isPlaying(): boolean { + return this.canvas.previewing && this.asset() !== null; + } + + setPreviewing(on: boolean): void { + this.canvas.setPreviewing(on); + this.previewButton.setToggled(on); + } + + /** The built-in eases, one group per shape; picking one replaces the curve in one undo step. */ + private openPresetMenu(): void { + const entries: MenuEntry[] = []; + let shape = ''; + for (const preset of curvePresets()) { + if (entries.length > 0 && preset.shape !== shape) entries.push({ separator: true }); + shape = preset.shape; + entries.push({ + label: preset.label, + onSelect: () => this.canvas.replace(preset.build(), `Curve preset "${preset.label}"`), + }); + } + 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); + } + + override selectedId(): string { + return this.state.selectedCurveId ?? ''; + } + + override isMine(asset: Asset): asset is CurveAsset { + return asset.type === 'curve'; + } + + protected override infoText(asset: CurveAsset): string { + const count = asset.points.length; + return `${count} ${count === 1 ? 'point' : 'points'}`; + } + + protected override syncExtras(asset: CurveAsset | null): void { + this.fitButton.setEnabled(asset !== null); + this.presetButton.setEnabled(asset !== null); + this.previewButton.setEnabled(asset !== null); + this.syncPoint(); + } +} diff --git a/src/editor/curve/curveView.ts b/src/editor/curve/curveView.ts new file mode 100644 index 00000000..cc6d7a9c --- /dev/null +++ b/src/editor/curve/curveView.ts @@ -0,0 +1,109 @@ +/** + * The rules of the Curve tab (#247), apart from its widget so they are testable without a view. + * + * The tab edits a curve with the vector art gestures (`polygonEdit.ts`), which work on a ring in + * picture pixels with `y` pointing down. So the curve is laid onto such a picture: `t` 0…1 runs over + * `CURVE_SCALE` pixels to the right and `v` 0…1 as many pixels up. The gestures move points there, + * and `imageToCurve` reads the result back. + */ +import type { VectorPoint, VectorRing } from '../../core/model/Asset'; +import { curveExtent } from '../../core/model/curve'; +import type { Vec2 } from '../../core/model/Properties'; +import { cloneVectorPoint } from '../../core/model/vectorart'; + +/** Picture pixels per unit of `t` and of `v`. */ +export const CURVE_SCALE = 256; + +/** Room around the curve when it is fitted, in units of `t`. */ +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_REST = 0.5; + +export function curveToImage(points: readonly VectorPoint[]): VectorRing { + return { closed: false, points: points.map((p) => mapPoint(p, toImage, toImageOffset)) }; +} + +export function imageToCurve(ring: VectorRing): VectorPoint[] { + return ring.points.map((p) => mapPoint(p, fromImage, fromImageOffset)); +} + +/** A place on the graph, `t` right and `v` up, in picture pixels. */ +export function toImage(at: Vec2): Vec2 { + return { x: at.x * CURVE_SCALE, y: (1 - at.y) * CURVE_SCALE }; +} + +export function fromImage(at: Vec2): Vec2 { + return { x: at.x / CURVE_SCALE, y: 1 - at.y / CURVE_SCALE }; +} + +function toImageOffset(offset: Vec2): Vec2 { + return { x: offset.x * CURVE_SCALE, y: -offset.y * CURVE_SCALE }; +} + +function fromImageOffset(offset: Vec2): Vec2 { + return { x: offset.x / CURVE_SCALE, y: -offset.y / CURVE_SCALE }; +} + +function mapPoint(point: VectorPoint, place: (at: Vec2) => Vec2, offset: (at: Vec2) => Vec2): VectorPoint { + const copy = cloneVectorPoint(point); + const at = place(point); + copy.x = at.x; + copy.y = at.y; + if (copy.in) copy.in = offset(copy.in); + if (copy.out) copy.out = offset(copy.out); + return copy; +} + +/** The part of the picture Fit frames: 0…1 in `t`, and in `v` whatever the curve reaches, plus room. */ +export function curveFrame(points: readonly VectorPoint[]): { x: number; y: number; w: number; h: number } { + const { min, max } = curveExtent(points); + const top = toImage({ x: -FIT_MARGIN, y: max + FIT_MARGIN }); + const bottom = toImage({ x: 1 + FIT_MARGIN, y: min - FIT_MARGIN }); + return { x: top.x, y: top.y, w: bottom.x - top.x, h: bottom.y - top.y }; +} + +/** + * Point `index` of a picture ring moved to `to`. It stays between its neighbours in `t`, so points + * never overtake one another, and the two ends move only up and down. + */ +export function withCurvePointMoved(ring: VectorRing, index: number, to: Vec2): VectorRing { + const last = ring.points.length - 1; + const points = ring.points.map((point, i) => { + const copy = cloneVectorPoint(point); + if (i !== index) return copy; + const before = ring.points[i - 1]; + const after = ring.points[i + 1]; + const x = i === 0 || i === last ? point.x : Math.min(Math.max(to.x, before?.x ?? to.x), after?.x ?? to.x); + return { ...copy, x, y: to.y }; + }); + return { closed: false, points }; +} + +/** + * A plain point put down at `at`, between the points it falls between in `t`; null outside the + * curve's `t` range or on an end. It carries no handles: a press on free space sets a corner, and + * pulling handles out is for the handles. + */ +export function withCurvePointAt(ring: VectorRing, at: Vec2): { ring: VectorRing; index: number } | null { + const first = ring.points[0]; + const last = ring.points[ring.points.length - 1]; + if (!first || !last || at.x <= first.x || at.x >= last.x) return null; + const index = ring.points.findIndex((p) => p.x > at.x); + const points = ring.points.map(cloneVectorPoint); + points.splice(index, 0, { x: at.x, y: at.y }); + return { ring: { closed: false, points }, index }; +} + +/** Whether a point may be taken away: the two ends hold the curve at `t` 0 and 1. */ +export function isCurveEnd(ring: VectorRing, index: number): boolean { + return index === 0 || index === ring.points.length - 1; +} + +/** Where the preview's dot is after `seconds` of running: `t` climbs for a second, then rests at 1. */ +export function previewShare(seconds: number): number { + const cycle = PREVIEW_RUN + PREVIEW_REST; + const into = ((seconds % cycle) + cycle) % cycle; + return Math.min(into / PREVIEW_RUN, 1); +} diff --git a/src/editor/debug/curveHooks.ts b/src/editor/debug/curveHooks.ts new file mode 100644 index 00000000..f22ef54b --- /dev/null +++ b/src/editor/debug/curveHooks.ts @@ -0,0 +1,36 @@ +import { findAssetOf } from '../../core/model/Project'; +import { centreOf, type HookContext } from './hookHelpers'; + +/** The Curve tab (#247). */ +export function curveHooks({ state, editor }: HookContext) { + const { curveEditor } = editor; + return { + curve: { + current: () => curveEditor.currentAssetId, + info: () => curveEditor.shownInfo, + rect: () => curveEditor.canvasRect(), + /** Where a place on the graph (`t` right, `v` up) sits on screen. */ + at: (t: number, v: number) => curveEditor.canvas.graphToScreen(t, v), + /** The points as the model holds them, handles as offsets in `t` and `v`. */ + points: (assetId: string) => { + const asset = findAssetOf(state.project, assetId, 'curve'); + return asset ? (JSON.parse(JSON.stringify(asset.points)) as unknown) : null; + }, + picked: () => curveEditor.canvas.pickedIndex, + pointButton: () => ({ + ...centreOf(curveEditor.pointButton), + text: curveEditor.pointButton.text, + enabled: curveEditor.pointButton.enabled, + }), + presetButton: () => centreOf(curveEditor.presetButton), + fitAt: () => centreOf(curveEditor.fitButton), + previewButton: () => ({ + ...centreOf(curveEditor.previewButton), + toggled: curveEditor.previewButton.toggled, + }), + /** The preview as drawn: the share of time, the value, and where its two dots are. */ + preview: () => curveEditor.canvas.previewState(), + setPreviewTime: (seconds: number) => curveEditor.canvas.setPreviewTime(seconds), + }, + }; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 1411e22a..bd8842be 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -1,6 +1,7 @@ import type { Application } from 'pixi.js'; import { physicsStatus } from '../runtime/physics/engine'; import { soundLibrary } from '../viewport/SoundLibrary'; +import { curveHooks } from './debug/curveHooks'; import { documentHooks } from './debug/documentHooks'; import { graphHooks } from './debug/graphHooks'; import { centreOf, type HookContext } from './debug/hookHelpers'; @@ -30,6 +31,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E ...documentHooks(context), ...pixelArtHooks(context), ...vectorArtHooks(context), + ...curveHooks(context), ...renderHooks(context), /** * Voices started, stopped and in flight. CI runs the browser with `--mute-audio`, so nothing diff --git a/src/editor/inspector/trackSection.ts b/src/editor/inspector/trackSection.ts index 0211c273..6cb29cdd 100644 --- a/src/editor/inspector/trackSection.ts +++ b/src/editor/inspector/trackSection.ts @@ -26,6 +26,8 @@ import type { KeyInterpolation, } from '../../core/model/Asset'; import { findAssetOf, type Project } from '../../core/model/Project'; +import { textOf } from '../../core/model/Properties'; +import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button } from '../../ui'; import { openRowDialog, RowDialog } from '../animation/RowDialog'; import { createFieldRow } from './fieldFactory'; @@ -45,9 +47,33 @@ export function trackSignature( if (!asset || !track) return 'missing'; const kind = kindOf(project, asset, track) ?? 'missing'; const key = keys.length === 1 ? `${keys[0]!.trackId}@${keys[0]!.frame}` : String(keys.length); - return `${track.id}|${track.target}|${track.property}|${kind}|${track.relative ? 'rel' : 'abs'}|${key}`; + return `${track.id}|${track.target}|${track.property}|${kind}|${track.relative ? 'rel' : 'abs'}|${key}|${curveState( + project, + asset, + keys, + )}`; } +/** + * Which curve the shown easing picks and whether it is there (#247): the Curve row and its warning + * come and go with it, so a change here has to rebuild the section. + */ +function curveState(project: Project, asset: AnimationAsset, keys: readonly KeyRef[]): string { + const key = easingKey(project, asset, keys); + const easing = key && easingOf(interpolationOf(key)); + if (easing?.shape !== 'curve') return ''; + const found = findAssetOf(project, easing.curve ?? '', 'curve'); + return `curve:${easing.curve ?? ''}:${found ? 'here' : 'gone'}`; +} + +const CURVE_ROW: PropertyDef = { + key: 'curve', + label: 'Curve', + kind: 'asset', + default: '', + assetTypes: ['curve'], +}; + /** * A row of the timeline: what it aims at, whether it is relative, and – with one key picked – the * key's value. Several keys show their count and the way to delete them at once. @@ -160,11 +186,17 @@ function buildEasing(host: InspectorHost, asset: AnimationAsset, keys: readonly easing.shape, { onInput: () => {}, - onChange: (value) => set(easingValue(value as EasingShape, current().direction)), + onChange: (value) => { + const now = current(); + // Switched to Curve, a key takes the curve it had, else the first one there is. + const curve = now.curve || firstCurveId(host.state.project); + set(easingValue(value as EasingShape, now.direction, curve)); + }, }, ); host.fields.set('easing', shape); host.body.add(shape.row); + if (easing.shape === 'curve') buildCurveRow(host, easing.curve ?? '', set); if (easing.direction) { const direction = createFieldRow( host, @@ -187,6 +219,23 @@ function buildEasing(host: InspectorHost, asset: AnimationAsset, keys: readonly addNote(host, 'Easing: the way from this key to the next one.', 'small'); } +/** The Curve asset the keys blend along, and why they blend linearly when there is none. */ +function buildCurveRow(host: InspectorHost, curveId: string, set: (value: KeyInterpolation) => void): void { + const row = createFieldRow(host, CURVE_ROW, curveId, { + onInput: () => {}, + onChange: (value) => set(easingValue('curve', undefined, textOf(value))), + }); + host.fields.set('curve', row); + host.body.add(row.row); + if (!curveId) addNote(host, 'No curve picked – the key blends linearly.', 'warning'); + else if (!findAssetOf(host.state.project, curveId, 'curve')) + addNote(host, 'That curve is gone – the key blends linearly.', 'warning'); +} + +function firstCurveId(project: Project): string { + return project.assets.find((a) => a.type === 'curve')?.id ?? ''; +} + /** The last picked key on a row that blends; undefined when none of them can ease. */ function easingKey( project: Project, @@ -226,6 +275,7 @@ export function refreshTrackSection( if (Boolean(easing.direction) !== host.fields.has('direction')) return false; host.fields.get('easing')?.setValue(easing.shape); if (easing.direction) host.fields.get('direction')?.setValue(easing.direction); + if (easing.shape === 'curve') host.fields.get('curve')?.setValue(easing.curve ?? ''); return true; } diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index 919f9f4a..963d71f0 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -23,6 +23,7 @@ export const ASSET_GROUP_LABELS: Record = { pixelart: 'Pixel drawings', palette: 'Palettes', animation: 'Animations', + curve: 'Curves', tile: 'Tiles', tilemap: 'Tilemaps', statemachine: 'State machines', diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 4759882d..cdd33f5c 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -8,6 +8,7 @@ import behavior from './icons/behavior.svg?raw'; import camera from './icons/camera.svg?raw'; import collider from './icons/collider.svg?raw'; import container from './icons/container.svg?raw'; +import curve from './icons/curve.svg?raw'; import font from './icons/font.svg?raw'; import game from './icons/game.svg?raw'; import group from './icons/group.svg?raw'; @@ -36,6 +37,7 @@ export const ASSET_ICON_SVG: Record = { camera, collider, container, + curve, font, palette, particles, diff --git a/src/editor/panels/icons/curve.svg b/src/editor/panels/icons/curve.svg new file mode 100644 index 00000000..858b61c1 --- /dev/null +++ b/src/editor/panels/icons/curve.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/editor/state.ts b/src/editor/state.ts index 145f3a8e..24d2abfa 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -645,6 +645,13 @@ export class EditorState { return findAssetOf(this.project, s.assetId, 'tile')?.id ?? null; } + /** The curve the selection refers to (#247); only the asset itself, its points are picked in the tab. */ + get selectedCurveId(): string | null { + const s = this.selection; + if (s?.kind !== 'asset') return null; + return findAssetOf(this.project, s.assetId, 'curve')?.id ?? null; + } + /** The timeline row the selection is about; undefined when it is about something else. */ get selectedTrackId(): string | undefined { return this.selection?.kind === 'track' ? this.selection.trackId : undefined; diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 538fb426..3dc48369 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -12,6 +12,7 @@ import { trackWriteAt, } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; +import { curveLookup } from '../../core/model/curve'; import { instanceChildId, splitInstanceId } from '../../core/model/instance'; import { findAsset, findAssetOf } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; @@ -52,6 +53,7 @@ export class AnimationSystem implements System { running.sort((a, b) => a.player.order - b.player.order); // What the players before this one wrote this tick – a blend mixes from that value, live. const written = new Set(); + const curves = curveLookup(world.project); for (const { entity, player } of running) { const asset = findAssetOf(world.project, player.animationId, 'animation'); if (!asset) { @@ -81,7 +83,7 @@ export class AnimationSystem implements System { const fade = player.fade; const fadeShare = fade ? blendShare(fade) : 0; for (const b of bindings) { - let value = trackWriteAt(b.track, b.def, head.frame, player.bases[b.base], head.reverse); + let value = trackWriteAt(b.track, b.def, head.frame, player.bases[b.base], head.reverse, curves); if (value === undefined) continue; const below = written.has(b.base) ? b.read() : undefined; if (share < 1 && blends(b.def.kind)) { diff --git a/tests/core/curve.test.ts b/tests/core/curve.test.ts new file mode 100644 index 00000000..7070ae90 --- /dev/null +++ b/tests/core/curve.test.ts @@ -0,0 +1,290 @@ +/** + * Curve assets (#247): an easing drawn by hand. One function evaluates it for the tab, the animation + * player and the `Lerp` node; every stored curve is held to the rules first, so it has exactly one + * value for every `t`. + */ +import { describe, expect, it } from 'vitest'; +import { + AddAnimationTrackCommand, + SetAnimationKeyCommand, + SetAnimationRootCommand, + SetKeyInterpolationCommand, +} from '../../src/core/commands/animationCommands'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { SetCurvePointsCommand } from '../../src/core/commands/curveCommands'; +import { + easedShare, + easingOf, + easingValue, + isKeyInterpolation, + playAnimation, + trackValueAt, +} from '../../src/core/model/animation'; +import type { VectorPoint } from '../../src/core/model/Asset'; +import { + curveLookup, + curveUsages, + curveValue, + linearCurve, + sanitizedCurve, +} from '../../src/core/model/curve'; +import { curvePresets, easeCurve } from '../../src/core/model/curvePresets'; +import { findAssetOf } from '../../src/core/model/Project'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem'; +import { World } from '../../src/runtime/World'; +import { makeProject, makeSpriteScene, place, roundTrip } from '../helpers'; + +/** Quad in, exactly: a cubic whose `t` runs evenly and whose value is s². */ +const QUAD_IN: VectorPoint[] = [ + { x: 0, y: 0, out: { x: 1 / 3, y: 0 } }, + { x: 1, y: 1, in: { x: -1 / 3, y: -2 / 3 } }, +]; + +const steps = (count: number) => Array.from({ length: count + 1 }, (_, i) => i / count); + +describe('evaluating a curve', () => { + it('reads a new curve as linear', () => { + for (const t of steps(20)) expect(curveValue(linearCurve(), t)).toBeCloseTo(t, 12); + }); + + it('solves a Bezier segment for t', () => { + for (const t of steps(16)) expect(curveValue(QUAD_IN, t)).toBeCloseTo(t * t, 9); + // CSS `ease-in-out`: symmetric about the middle, and rising all the way. + const cssEaseInOut: VectorPoint[] = [ + { x: 0, y: 0, out: { x: 0.42, y: 0 } }, + { x: 1, y: 1, in: { x: -0.42, y: 0 } }, + ]; + expect(curveValue(cssEaseInOut, 0.5)).toBeCloseTo(0.5, 9); + expect(curveValue(cssEaseInOut, 0.25) + curveValue(cssEaseInOut, 0.75)).toBeCloseTo(1, 9); + const values = steps(100).map((t) => curveValue(cssEaseInOut, t)); + for (let i = 1; i < values.length; i++) expect(values[i]!).toBeGreaterThanOrEqual(values[i - 1]!); + }); + + it('hits the ends exactly and holds t to 0…1', () => { + const back = easeCurve('back', 'out'); + expect(curveValue(back, 0)).toBe(0); + expect(curveValue(back, 1)).toBe(1); + expect(curveValue(back, -3)).toBe(0); + expect(curveValue(back, 7)).toBe(1); + }); + + it('overshoots where the points go past 1', () => { + const overshoot: VectorPoint[] = [ + { x: 0, y: 0 }, + { x: 0.5, y: 1.5 }, + { x: 1, y: 1 }, + ]; + expect(curveValue(overshoot, 0.25)).toBeCloseTo(0.75, 12); + expect(curveValue(overshoot, 0.5)).toBeCloseTo(1.5, 12); + expect(curveValue(overshoot, 0.75)).toBeCloseTo(1.25, 12); + expect(Math.max(...steps(100).map((t) => curveValue(easeCurve('back', 'out'), t)))).toBeGreaterThan(1.09); + }); + + it('keeps one value for every t even when the stored handles reach past a neighbour', () => { + const wild: VectorPoint[] = [ + { x: 0, y: 0, out: { x: 5, y: 1 } }, + { x: 1, y: 1, in: { x: -5, y: -1 } }, + ]; + const values = steps(200).map((t) => curveValue(wild, t)); + for (let i = 1; i < values.length; i++) expect(values[i]!).toBeGreaterThanOrEqual(values[i - 1]! - 1e-12); + }); +}); + +describe('the rules a stored curve is held to', () => { + it('sorts the points, pins the ends to t 0 and 1 and keeps t in 0…1', () => { + const held = sanitizedCurve([ + { x: 0.9, y: 0.5 }, + { x: 0.2, y: 2 }, + { x: 1.4, y: 1 }, + { x: -0.3, y: 0.1 }, + ]); + expect(held.map((p) => [p.x, p.y])).toEqual([ + [0, 0.1], + [0.2, 2], + [0.9, 0.5], + [1, 1], + ]); + }); + + it('drops what is no number, and falls back to linear below two points', () => { + expect( + sanitizedCurve([ + { x: Number.NaN, y: 0 }, + { x: 0.5, y: 0.5 }, + ]), + ).toEqual(linearCurve()); + expect(sanitizedCurve([])).toEqual(linearCurve()); + }); + + it('shortens a handle reaching past its neighbour along its own direction', () => { + const [first, last] = sanitizedCurve([ + { x: 0, y: 0, out: { x: 2, y: 1 } }, + { x: 1, y: 1, in: { x: -4, y: -2 } }, + ]); + expect(first!.out).toEqual({ x: 1, y: 0.5 }); + expect(last!.in).toEqual({ x: -1, y: -0.5 }); + }); + + it('turns a handle pointing backwards in t upright, and drops the ends outer handles', () => { + const [first, middle, last] = sanitizedCurve([ + { x: 0, y: 0, in: { x: -0.1, y: 0 }, out: { x: 0.1, y: 0.2 } }, + { x: 0.5, y: 0.5, in: { x: 0.2, y: 0.3 }, out: { x: -0.1, y: 0.4 }, mode: 'smooth' }, + { x: 1, y: 1, out: { x: 0.2, y: 0 } }, + ]); + expect(first!.in).toBeUndefined(); + expect(middle!.in).toEqual({ x: 0, y: 0.3 }); + expect(middle!.out).toEqual({ x: 0, y: 0.4 }); + expect(middle!.mode).toBe('smooth'); + expect(last!.out).toBeUndefined(); + }); +}); + +describe('a key and a Lerp that ease along a curve', () => { + it('stores the curve on the key and splits it back', () => { + expect(easingValue('curve', undefined, 'abc')).toBe('curve:abc'); + expect(easingOf('curve:abc')).toEqual({ shape: 'curve', curve: 'abc' }); + expect(isKeyInterpolation('curve:abc')).toBe(true); + expect(isKeyInterpolation('curve:')).toBe(true); + expect(easingOf('curve:')).toEqual({ shape: 'curve', curve: '' }); + }); + + it('blends a row along the curve, and linearly once it is gone', () => { + const { project, stack } = makeProject(); + const create = new CreateAssetCommand('curve', 'Quad'); + stack.execute(create); + stack.execute(new SetCurvePointsCommand(create.asset.id, QUAD_IN)); + const row = [ + { frame: 0, value: 0, interpolation: easingValue('curve', undefined, create.asset.id) }, + { frame: 10, value: 100 }, + ]; + expect(trackValueAt(row, 'number', 5, false, curveLookup(project))).toBeCloseTo(25, 6); + expect(trackValueAt(row, 'number', 5)).toBeCloseTo(50, 9); + expect(easedShare('curve:gone', 0.3, curveLookup(project))).toBe(0.3); + expect(easedShare('curve:', 0.3, curveLookup(project))).toBe(0.3); + }); + + it('plays a key along the curve in the running game', () => { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const curve = new CreateAssetCommand('curve', 'Quad'); + f.stack.execute(curve); + f.stack.execute(new SetCurvePointsCommand(curve.asset.id, QUAD_IN)); + const create = new CreateAssetCommand('animation', 'Slide', { fps: 10, playback: 'once' }); + f.stack.execute(create); + f.stack.execute(new SetAnimationRootCommand(create.asset.id, f.spriteId)); + const row = new AddAnimationTrackCommand(create.asset.id, '', 'x'); + f.stack.execute(row); + f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0)); + f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 10, 100)); + const at = [{ trackId: row.trackId, frame: 0 }]; + f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, `curve:${curve.asset.id}`)); + + const world = World.instantiate(f.project, f.sceneId); + world.addSystem(new AnimationSystem()); + const sprite = world.findByObjectId(box)!; + playAnimation(sprite, create.asset.id); + // The first tick takes the starting values and shows frame 0; five more reach frame 5, t = 0.5. + world.tick(0.1); + for (let i = 0; i < 5; i++) world.tick(0.1); + expect(sprite.x).toBeCloseTo(25, 4); + }); + + it('counts the keys and Lerp nodes using a curve, for the warning before deleting it', () => { + const f = makeSpriteScene(); + const curve = new CreateAssetCommand('curve', 'Pop'); + f.stack.execute(curve); + const create = new CreateAssetCommand('animation', 'Slide', {}); + f.stack.execute(create); + f.stack.execute(new SetAnimationRootCommand(create.asset.id, f.spriteId)); + const row = new AddAnimationTrackCommand(create.asset.id, '', 'x'); + f.stack.execute(row); + for (const frame of [0, 5, 10]) + f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, 0)); + const at = [0, 5].map((frame) => ({ trackId: row.trackId, frame })); + f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, `curve:${curve.asset.id}`)); + const behavior = new CreateAssetCommand('behavior', 'Mover'); + f.stack.execute(behavior); + findAssetOf(f.project, behavior.asset.id, 'behavior')!.graph.nodes.push( + { id: 'l1', type: 'Lerp', x: 0, y: 0, params: { ease: 'curve', curve: curve.asset.id } }, + { id: 'l2', type: 'Lerp', x: 0, y: 0, params: { ease: 'linear' } }, + ); + expect(curveUsages(f.project, curve.asset.id)).toEqual({ keys: 2, nodes: 1 }); + }); +}); + +describe('storing and editing a curve', () => { + it('starts as a straight line and keeps its points through a round trip', () => { + const { project, stack } = makeProject(); + const create = new CreateAssetCommand('curve', 'Pop'); + stack.execute(create); + expect(findAssetOf(project, create.asset.id, 'curve')!.points).toEqual(linearCurve()); + stack.execute(new SetCurvePointsCommand(create.asset.id, easeCurve('elastic', 'out'))); + const curve = findAssetOf(project, create.asset.id, 'curve')!; + expect(findAssetOf(roundTrip(project), create.asset.id, 'curve')).toEqual(curve); + }); + + it('holds a hand-written file to the rules on loading', () => { + const { project, stack } = makeProject(); + const create = new CreateAssetCommand('curve', 'Pop'); + stack.execute(create); + const raw = serializeProject(project) as { assets: { id: string; points?: unknown }[] }; + raw.assets.find((a) => a.id === create.asset.id)!.points = [ + { x: 1, y: 1 }, + { x: 'far', y: 0 }, + { x: 0.4, y: 0.8, out: { x: 3, y: 0 } }, + { x: 0.2, y: 0 }, + ]; + const loaded = findAssetOf(deserializeProject(raw), create.asset.id, 'curve')!; + expect(loaded.points).toEqual([ + { x: 0, y: 0 }, + { x: 0.4, y: 0.8, out: { x: 0.6, y: 0 } }, + { x: 1, y: 1 }, + ]); + }); + + it('writes the curve in one undo step, held to the rules, and takes it back', () => { + const { project, stack } = makeProject(); + const create = new CreateAssetCommand('curve', 'Pop'); + stack.execute(create); + const curve = () => findAssetOf(project, create.asset.id, 'curve')!; + stack.execute( + new SetCurvePointsCommand(create.asset.id, [ + { x: 0.3, y: 0 }, + { x: 0.6, y: 1.2 }, + { x: 0.8, y: 1 }, + ]), + ); + expect(curve().points.map((p) => p.x)).toEqual([0, 0.6, 1]); + expect(SetCurvePointsCommand.changes(curve(), curve().points)).toBe(false); + stack.undo(); + expect(curve().points).toEqual(linearCurve()); + }); +}); + +describe('the built-in eases as presets', () => { + it('traces every ease to within half a percent, and each is a legal curve as it is', () => { + const presets = curvePresets(); + expect(presets).toHaveLength(31); + expect(presets[0]!.build()).toEqual(linearCurve()); + for (const preset of presets.slice(1)) { + const points = preset.build(); + expect(sanitizedCurve(points), preset.label).toEqual(points); + const [shape, direction] = preset.label.toLowerCase().split(' ') as [string, string]; + const interpolation = easingValue( + shape === 'quad' ? 'quad' : (shape as never), + direction as 'in' | 'out' | 'in-out', + ); + for (const t of steps(97)) + expect( + Math.abs(curveValue(points, t) - easedShare(interpolation, t)), + `${preset.label} at ${t}`, + ).toBeLessThan(0.005); + } + }); + + it('needs one segment for a quad, and more for what bounces', () => { + expect(easeCurve('quad', 'in')).toHaveLength(2); + expect(easeCurve('bounce', 'out').length).toBeGreaterThan(8); + }); +}); diff --git a/tests/core/easing-curves.test.ts b/tests/core/easing-curves.test.ts index 3271738f..12f7f3d6 100644 --- a/tests/core/easing-curves.test.ts +++ b/tests/core/easing-curves.test.ts @@ -30,7 +30,10 @@ import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem'; import { World } from '../../src/runtime/World'; import { makeSpriteScene, place, roundTrip } from '../helpers'; -const CURVES = EASING_SHAPES.filter((s) => s.value !== 'linear' && s.value !== 'step').map((s) => s.value); +// A Curve asset (#247) is the last entry: drawn by hand, not one of the ten, and without directions. +const CURVES = EASING_SHAPES.filter((s) => !['linear', 'step', 'curve'].includes(s.value)).map( + (s) => s.value, +); const ALL = CURVES.flatMap((c) => EASING_DIRECTIONS.map((d) => easingValue(c, d.value))); describe('easing curves', () => { diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index f32e9e24..c3807f1c 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -52,6 +52,19 @@ const machineWithStates = (states: { id: string; name: string }[]): GraphLookup machine: (id) => (id === 'm' ? { name: 'M', states, start: '', transitions: [] } : null), }); +/** A lookup holding one curve "c" (#247): straight up to 1.5 at the middle, back down to 1 at the end. */ +const overshootingCurve: GraphLookup = { + ...NO_LOOKUP, + curve: (id) => + id === 'c' + ? [ + { x: 0, y: 0 }, + { x: 0.5, y: 1.5 }, + { x: 1, y: 1 }, + ] + : null, +}; + /** A lookup holding one machine "m" with the given transitions – what `State transition` reads. */ const machineLookup = (transitions: { from: string; to: string; name?: string }[]): GraphLookup => ({ ...NO_LOOKUP, @@ -579,6 +592,20 @@ const CASES: Record = { params: { ease: 'out' }, outputs: { result: 3 }, }, + { + name: 'along a Curve asset, overshooting b', + inputs: { a: 0, b: 8, t: 0.5 }, + params: { ease: 'curve', curve: 'c' }, + lookup: overshootingCurve, + outputs: { result: 12 }, + }, + { + name: 'along a Curve asset that is gone: linear', + inputs: { a: 0, b: 8, t: 0.25 }, + params: { ease: 'curve', curve: 'gone' }, + lookup: overshootingCurve, + outputs: { result: 2 }, + }, ], Floor: [ { name: 'rounds down', inputs: { value: 2.9 }, outputs: { result: 2 } }, diff --git a/tests/editor/curve-view.test.ts b/tests/editor/curve-view.test.ts new file mode 100644 index 00000000..5a358fd0 --- /dev/null +++ b/tests/editor/curve-view.test.ts @@ -0,0 +1,89 @@ +/** + * The Curve tab's rules (#247): the curve is laid onto a picture for the vector art gestures and read + * back, a moved point stays between its neighbours, the ends move only up and down, and a press on + * free space puts a point where it falls in `t`. + */ +import { describe, expect, it } from 'vitest'; +import type { VectorPoint } from '../../src/core/model/Asset'; +import { + CURVE_SCALE, + curveFrame, + curveToImage, + imageToCurve, + isCurveEnd, + previewShare, + withCurvePointAt, + withCurvePointMoved, +} from '../../src/editor/curve/curveView'; + +const CURVE: VectorPoint[] = [ + { x: 0, y: 0, out: { x: 0.1, y: 0.2 } }, + { x: 0.5, y: 1.25, in: { x: -0.2, y: 0 }, out: { x: 0.2, y: 0 }, mode: 'smooth' }, + { x: 1, y: 1 }, +]; + +describe('the curve on the picture', () => { + it('lays t to the right and v upwards, and reads it back unchanged', () => { + const ring = curveToImage(CURVE); + expect(ring.closed).toBe(false); + expect(ring.points[0]).toMatchObject({ + x: 0, + y: CURVE_SCALE, + out: { x: 0.1 * CURVE_SCALE, y: -0.2 * CURVE_SCALE }, + }); + expect(ring.points[1]).toMatchObject({ x: CURVE_SCALE / 2, y: -0.25 * CURVE_SCALE, mode: 'smooth' }); + expect(imageToCurve(ring)).toEqual(CURVE); + }); + + it('frames 0…1 in t and everything the curve reaches in v', () => { + const frame = curveFrame(CURVE); + expect(frame.x).toBeLessThan(0); + expect(frame.x + frame.w).toBeGreaterThan(CURVE_SCALE); + // The top of the frame lies above v = 1.25, the bottom below v = 0. + expect(frame.y).toBeLessThan(-0.25 * CURVE_SCALE); + expect(frame.y + frame.h).toBeGreaterThan(CURVE_SCALE); + }); +}); + +describe('moving and putting down points', () => { + const ring = curveToImage(CURVE); + + it('keeps a moved point between its neighbours in t', () => { + const right = withCurvePointMoved(ring, 1, { x: 900, y: 10 }); + expect(right.points[1]).toMatchObject({ x: CURVE_SCALE, y: 10 }); + const left = withCurvePointMoved(ring, 1, { x: -50, y: 10 }); + expect(left.points[1]!.x).toBe(0); + expect(withCurvePointMoved(ring, 1, { x: 64, y: 20 }).points[1]).toMatchObject({ x: 64, y: 20 }); + // Handles ride along: they are offsets. + expect(right.points[1]!.out).toEqual(ring.points[1]!.out); + }); + + it('moves the two ends only up and down', () => { + expect(withCurvePointMoved(ring, 0, { x: 40, y: 30 }).points[0]).toMatchObject({ x: 0, y: 30 }); + expect(withCurvePointMoved(ring, 2, { x: 10, y: 30 }).points[2]).toMatchObject({ x: CURVE_SCALE, y: 30 }); + }); + + it('puts a point on free space where it falls in t, and none outside the curve', () => { + const put = withCurvePointAt(ring, { x: 200, y: 40 })!; + expect(put.index).toBe(2); + expect(put.ring.points.map((p) => p.x)).toEqual([0, CURVE_SCALE / 2, 200, CURVE_SCALE]); + expect(put.ring.points[2]).toEqual({ x: 200, y: 40 }); + expect(withCurvePointAt(ring, { x: 20, y: 0 })!.index).toBe(1); + expect(withCurvePointAt(ring, { x: -1, y: 0 })).toBeNull(); + expect(withCurvePointAt(ring, { x: CURVE_SCALE + 1, y: 0 })).toBeNull(); + expect(withCurvePointAt(ring, { x: 0, y: 0 })).toBeNull(); + }); + + it('knows the ends', () => { + expect([0, 1, 2].map((i) => isCurveEnd(ring, i))).toEqual([true, false, true]); + }); +}); + +describe('the preview', () => { + it('climbs through t for a second, rests at the end, then starts over', () => { + expect(previewShare(0)).toBe(0); + expect(previewShare(0.25)).toBeCloseTo(0.25, 12); + expect(previewShare(1.2)).toBe(1); + expect(previewShare(1.75)).toBeCloseTo(0.25, 12); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index db1d5744..6dcb8fb1 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -81,7 +81,7 @@ export async function assets(t) { // 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups – // what can be placed in a scene, a line, then what only lives in the library – and it is - // tall enough to show all twenty-one types without scrolling. + // tall enough to show all twenty-two types without scrolling. await clickText('+'); const rows = await json('__gm.menuRows()'); const cut = rows.indexOf(null); @@ -94,7 +94,7 @@ export async function assets(t) { ); check( 'every asset type is offered once, each group sorted by label', - placeable.length + library.length === 21 && + placeable.length + library.length === 22 && String(placeable) === String([...placeable].sort()) && String(library) === String([...library].sort()), JSON.stringify({ placeable, library }), @@ -453,7 +453,7 @@ export async function assets(t) { async function addMenuOnRightClick(t) { const { check, json, shot, click, clickText, press, undo, showTab } = t; const addRows = (rows) => - Array.isArray(rows) && rows.filter(Boolean).length === 21 && rows.includes('Sprite'); + Array.isArray(rows) && rows.filter(Boolean).length === 22 && rows.includes('Sprite'); const heading = await json("__gm.assetRowShown('group:scene')"); const sceneFolded = async () => !(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded; diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index 21b28f1f..ba2c0f53 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -159,6 +159,7 @@ export async function animation(t) { 'Back', 'Elastic', 'Bounce', + 'Curve', ]) && (await easingOf()) === 'cubic-in-out' && Boolean(cubicDirection), diff --git a/tools/e2e/53-curves.mjs b/tools/e2e/53-curves.mjs new file mode 100644 index 00000000..4b5767c4 --- /dev/null +++ b/tools/e2e/53-curves.mjs @@ -0,0 +1,328 @@ +import { edge, node } from '../lib/builders.mjs'; + +/** + * Curve assets (#247): made from the + menu and opened by its row, bent in the Curve tab with the + * real mouse – a point put down on free space, turned smooth through the header, a handle pulled – + * and then picked where a built-in ease is picked, on an animation key and on a `Lerp` node. The + * values those two produce are compared with the curve worked out here, by walking its Bezier + * segments, not with the editor's own solver. + * + * Everything it adds carries a `cv-` id or is the curve it made, and goes again at the end. + */ + +/** The curve's value at `t`, from its segments walked finely and read off in between. */ +function expectedAt(points, t) { + for (let i = 1; i < points.length; i++) { + const from = points[i - 1]; + const to = points[i]; + if (t > to.x) continue; + const c1 = { x: from.x + (from.out?.x ?? 0), y: from.y + (from.out?.y ?? 0) }; + const c2 = { x: to.x + (to.in?.x ?? 0), y: to.y + (to.in?.y ?? 0) }; + const at = (s, a, b, c, d) => + (1 - s) ** 3 * a + 3 * (1 - s) ** 2 * s * b + 3 * (1 - s) * s * s * c + s ** 3 * d; + let previous = { x: from.x, y: from.y }; + for (let step = 1; step <= 20000; step++) { + const s = step / 20000; + const here = { x: at(s, from.x, c1.x, c2.x, to.x), y: at(s, from.y, c1.y, c2.y, to.y) }; + if (here.x >= t) { + const share = here.x > previous.x ? (t - previous.x) / (here.x - previous.x) : 1; + return previous.y + (here.y - previous.y) * share; + } + previous = here; + } + return to.y; + } + return points.at(-1).y; +} + +export async function curves(t) { + const { check, json, evaluate, mutate, click, doubleClick, drag, settle, sleep, shot, showTab, undo } = t; + const inOverlay = async (text) => { + const box = await json('__gm.overlayRect()'); + const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []; + return ( + hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null + ); + }; + const menuRow = async (label) => { + const rows = (await json('__gm.menuRows()')) ?? []; + const row = rows.find((r) => typeof r === 'string' && (r === label || r.endsWith(` ${label}`))); + return row ? inOverlay(row) : null; + }; + const pointsOf = (id) => json(`__gm.curve.points(${JSON.stringify(id)})`); + + // 53a. The + menu makes one; its row opens the Curve tab on it, a straight line of two points. + await showTab('scene'); + await t.clickText('+'); + const offered = (await json('__gm.menuRows()')) ?? []; + const entry = await inOverlay('Curve'); + check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered)); + if (!entry) return; + await click(entry.x, entry.y); + await settle(); + const curveId = ( + await json("__gm.state.project.assets.filter((a) => a.type === 'curve').map((a) => a.id)") + ).at(-1); + // "Curve" is the new asset's name, the Easing value and the row label at once – a name of its own + // keeps `inspectorField('Curve')` on the row. + await mutate( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name = 'Swoop 53'`, + ); + const row = await json(`__gm.assetRow(${JSON.stringify(curveId)})`); + await doubleClick(row.x, row.y); + await settle(); + const fresh = await pointsOf(curveId); + check( + 'a double-click on the new curve opens the Curve tab on a straight line from (0, 0) to (1, 1)', + (await json('__gm.curve.current()')) === curveId && + JSON.stringify(fresh) === + JSON.stringify([ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + ]) && + (await json('__gm.curve.info()')) === '2 points', + JSON.stringify({ current: await json('__gm.curve.current()'), fresh }), + ); + + // 53b. A click on free space puts a point there, and it is the picked one. + const spot = await json('__gm.curve.at(0.5, 0.9)'); + await click(spot.x, spot.y); + await settle(); + const put = await pointsOf(curveId); + const middle = put?.[1]; + check( + 'a click on free space puts a point where it was clicked, between the ends, and picks it', + put?.length === 3 && + Math.abs(middle.x - 0.5) < 0.01 && + Math.abs(middle.y - 0.9) < 0.01 && + !middle.in && + !middle.out && + (await json('__gm.curve.picked()')) === 1, + JSON.stringify({ put, picked: await json('__gm.curve.picked()') }), + ); + + // 53c. Point: Smooth grows its handles, and pulling the right one up bends the curve – the left + // handle turns with it, the other way round. + const pointButton = await json('__gm.curve.pointButton()'); + await click(pointButton.x, pointButton.y); + await sleep(100); + const smooth = await menuRow('Smooth'); + if (smooth) await click(smooth.x, smooth.y); + await settle(); + const rounded = await pointsOf(curveId); + const grown = rounded?.[1]; + check( + 'Point: Smooth grows both handles of the picked point, in line', + grown?.mode === 'smooth' && + Boolean(grown.in && grown.out) && + Math.abs(grown.in.x * grown.out.y - grown.in.y * grown.out.x) < 1e-9, + JSON.stringify({ grown, button: pointButton }), + ); + if (!grown?.out) return; + // The handle reaches above what the straight line was fitted to; Fit frames it again. + const fit = await json('__gm.curve.fitAt()'); + await click(fit.x, fit.y); + await settle(); + const tip = await json(`__gm.curve.at(${grown.x + grown.out.x}, ${grown.y + grown.out.y})`); + await drag(tip.x, tip.y, tip.x, tip.y - 60, { steps: 8 }); + await settle(); + const bent = await pointsOf(curveId); + const pulled = bent?.[1]; + const across = pulled && pulled.in.x * pulled.out.y - pulled.in.y * pulled.out.x; + check( + 'dragging the right handle up raises it, and the smooth point turns its left handle to match', + pulled?.out.y > grown.out.y + 0.1 && Math.abs(across) < 1e-6 && pulled.in.x < 0 && pulled.in.y < 0, + JSON.stringify({ before: grown, after: pulled }), + ); + await shot('53-curve-tab'); + + // The preview runs by itself until its button holds it; held, it stands still. + // Three samples over 0.6 s: longer than the half second the dot rests at the end of each pass. + const running = []; + for (let i = 0; i < 3; i++) { + if (i > 0) await sleep(300); + running.push((await json('__gm.curve.preview()'))?.t); + } + const previewButton = await json('__gm.curve.previewButton()'); + await click(previewButton.x, previewButton.y); + await settle(); + const held = [(await json('__gm.curve.preview()'))?.t]; + await sleep(250); + held.push((await json('__gm.curve.preview()'))?.t); + check( + 'the preview runs by itself, and the Preview button holds it', + previewButton.toggled && + new Set(running).size > 1 && + !(await json('__gm.curve.previewButton()')).toggled && + held[0] === held[1], + JSON.stringify({ previewButton, running, held }), + ); + + // The preview's dot sits on the line at its t, and its twin beside the graph at the same height. + await evaluate('__gm.curve.setPreviewTime(0.35)'); + const preview = await json('__gm.curve.preview()'); + const onLine = await json(`__gm.curve.at(${preview?.t}, ${expectedAt(bent, preview?.t ?? 0)})`); + check( + 'the preview dot runs along the drawn curve, and its twin beside the graph at the same height', + Math.abs(preview?.t - 0.35) < 1e-9 && + Math.abs(preview.v - expectedAt(bent, 0.35)) < 0.002 && + Math.abs(preview.dot.x - onLine.x) < 1 && + Math.abs(preview.dot.y - onLine.y) < 1 && + Math.abs(preview.bar.y - preview.dot.y) < 1e-6 && + preview.bar.x > preview.dot.x, + JSON.stringify({ preview, onLine }), + ); + + // 53d. An animation key picks the curve in the inspector, and the preview draws the value it gives. + await mutate(` + const p = __gm.state.project; + const sprite = p.assets.find((a) => a.type === 'sprite'); + p.assets.push({ id: 'cv-anim', type: 'animation', name: 'Curve slide', props: { fps: 10, playback: 'once' }, + root: sprite.id, + tracks: [{ id: 'cv-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] }] });`); + await evaluate("__gm.openDocument('asset:cv-anim')"); + await settle(); + const cell = await json("__gm.animation.cellAt('cv-row', 0)"); + await click(cell.x, cell.y); + await settle(); + const easing = await json("__gm.inspectorField('Easing')"); + if (easing) { + await click(easing.x, easing.y); + await sleep(100); + const curveEntry = await menuRow('Curve'); + if (curveEntry) await click(curveEntry.x, curveEntry.y); + await settle(); + } + const key = async () => + (await json("__gm.state.project.assets.find((a) => a.id === 'cv-anim').tracks[0].keys[0]")) + ?.interpolation; + const curveRow = await json("__gm.inspectorField('Curve', true)"); + check( + 'Easing: Curve on the key takes the one curve there is and shows it in a Curve row', + (await key()) === `curve:${curveId}` && Boolean(curveRow), + JSON.stringify({ interpolation: await key(), curveRow, easing }), + ); + const rootId = await json("__gm.animation.preview.objectId('')"); + const drawnAt = async (frame) => { + await evaluate(`__gm.animation.scrub(${frame})`); + await sleep(100); + return (await json(`__gm.animation.preview.item(${JSON.stringify(rootId)})`))?.x; + }; + const drawn = [await drawnAt(3), await drawnAt(5), await drawnAt(8)]; + const wanted = [0.3, 0.5, 0.8].map((share) => 100 * expectedAt(bent, share)); + check( + 'the preview puts the sprite where the curve says, not where a straight line would', + drawn.every((x, i) => Math.abs(x - wanted[i]) < 0.05) && Math.abs(drawn[1] - 50) > 5, + JSON.stringify({ drawn, wanted }), + ); + await shot('53-curve-key'); + + // 53e. A Lerp picks it too – Ease: Curve, then the curve in its row – and the game logs its value. + await mutate(` + const p = __gm.state.project; + p.assets.push({ id: 'cv-lerpy', type: 'behavior', name: 'Curve lerp', props: { enabled: true, runInEditor: false }, + variables: [], graph: ${JSON.stringify({ + nodes: [ + node('cv-start', 'OnStart', 0, 0), + node('cv-lerp', 'Lerp', 0, 160, { a: 0, b: 100, t: 0.3 }), + node('cv-log', 'Log', 300, 0), + ], + edges: [ + edge('cv-e1', 'cv-start', 'exec', 'cv-log', 'exec'), + edge('cv-e2', 'cv-lerp', 'result', 'cv-log', 'message'), + ], + })} }); + const scene = __gm.state.activeScene; + const holder = structuredClone(scene.objects[0]); + Object.assign(holder, { id: 'cv-holder', assetId: 'cv-lerpy', parentId: '', name: 'Curve holder', + position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); + delete holder.groupId; + scene.objects.push(holder); + __gm.state.select({ kind: 'node', assetId: 'cv-lerpy', nodeId: 'cv-lerp', nodeIds: ['cv-lerp'], annotationIds: [] });`); + await showTab('graph'); + await settle(); + const pick = async (field, label) => { + const at = await json(`__gm.inspectorField(${JSON.stringify(field)}, true)`); + if (!at) return null; + await click(at.x, at.y); + await sleep(100); + const option = await menuRow(label); + if (option) await click(option.x, option.y); + else await t.press('Escape'); + await settle(); + return option; + }; + await pick('Ease', 'Curve'); + const unpicked = await json("__gm.graph.nodeSettings('cv-lerp')"); + const name = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name`); + const picked = await pick('Curve', `${name} (curve)`); + const params = await json( + "__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params", + ); + const settings = await json("__gm.graph.nodeSettings('cv-lerp')"); + check( + 'Ease: Curve brings the Curve row onto the node, and picking the curve there sets it', + params?.ease === 'curve' && + params.curve === curveId && + Boolean(picked) && + unpicked?.length === 2 && + settings?.some((s) => s.includes(name)), + JSON.stringify({ params, unpicked, settings, picked }), + ); + await shot('53-curve-lerp'); + const logFrom = t.consoleLog.length; + await t.play(); + await sleep(600); + await t.stop(); + const logged = t.consoleLog + .slice(logFrom) + .filter((line) => line.startsWith('[behavior Curve holder]')) + .map((line) => Number(line.split(']').pop())); + const lerpWanted = 100 * expectedAt(bent, 0.3); + check( + 'the running Lerp gives what the curve says at t 0.3', + logged.length === 1 && Math.abs(logged[0] - lerpWanted) < 0.05 && Math.abs(logged[0] - 30) > 1, + JSON.stringify({ logged, lerpWanted }), + ); + + // 53f. Undo takes the pick back to the plain Lerp; then everything made here goes. + await showTab('graph'); + await undo(); + await settle(); + const undone = await json( + "__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params", + ); + check('undo takes the picked curve back off the Lerp', !undone?.curve, JSON.stringify(undone)); + + // 53g. A preset starts the curve over from a built-in ease, and the key follows it at once. + await evaluate(`__gm.openDocument('asset:${curveId}')`); + await settle(); + const presetButton = await json('__gm.curve.presetButton()'); + await click(presetButton.x, presetButton.y); + await sleep(100); + const presets = (await json('__gm.menuRows()')) ?? []; + // Thirty-one entries run past the bottom of the popup; its search box is the way to the last ones. + await t.typeText('bounce out'); + await t.press('Enter'); + await settle(); + const bounced = await pointsOf(curveId); + await evaluate("__gm.openDocument('asset:cv-anim')"); + await settle(); + const bouncedAt = await drawnAt(5); + check( + 'Preset → Bounce out replaces the curve with the traced ease, and the key follows it (76.6 at the middle)', + presets.filter((r) => typeof r === 'string').length === 31 && + bounced?.length > 8 && + Math.abs(bouncedAt - 76.5625) < 0.4, + JSON.stringify({ presets: presets.length, points: bounced?.length, bouncedAt }), + ); + await shot('53-curve-preset'); + + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('cv-')); + p.assets = p.assets.filter((a) => !a.id.startsWith('cv-') && a.id !== ${JSON.stringify(curveId)}); + __gm.state.select(null)`); + await showTab('scene'); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 18c820fa..f2c1bfff 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -55,6 +55,7 @@ import { users } from './49-users.mjs'; import { snapshots } from './50-snapshots.mjs'; import { clipboard } from './51-clipboard.mjs'; import { hierarchyGroups } from './52-hierarchy-groups.mjs'; +import { curves } from './53-curves.mjs'; export const STEPS = [ hierarchy, @@ -109,4 +110,5 @@ export const STEPS = [ snapshots, clipboard, hierarchyGroups, + curves, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 3ad45708..0629d0a3 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -118,6 +118,7 @@ export const TAB_LABELS = { vectorart: 'Vector art', pixelart: 'Pixel art', animation: 'Animation', + curve: 'Curve', tile: 'Tile', container: 'Container', list: 'List', @@ -166,6 +167,7 @@ const DOCUMENT_KINDS = [ 'vectorart', 'pixelart', 'animation', + 'curve', 'tile', 'container', 'list',