Build your own easing curve and use it on keys and Lerp (#247)
The built-in eases cover the usual shapes, but a game often wants its own: a coin that bounces twice, a door that overshoots and settles. A new asset type holds such a curve, and keys and the Lerp node can pick it instead of a built-in ease. - New asset type "curve": points like in Vector art, x is t and y is the value. A new curve is the straight line from (0, 0) to (1, 1). - One pure evaluation in src/core/model/curve.ts serves the timeline preview, the running game and the Lerp node. - The curve always has one value per t. The end points stay at t 0 and 1, points cannot pass each other, and a handle that reaches past its neighbour is shortened along its direction. Loading a hand-edited file applies the same rules. - Curve tab: click on the line to insert a point that keeps the shape, click on free space to put one there, drag points and handles, double-click or Delete removes, Alt-click toggles round and sharp. Every step is one undo step. - Presets: all 30 built-in eases plus Linear, fitted with adaptive subdivision. Each stays within 0.4 % of the built-in ease. - Preview: a dot runs along the curve once a second, a second dot shows only the value. The Preview button stops it. - Animation keys get "Curve" under Easing with a curve picker. Lerp gets "Curve" under Ease, and its result may overshoot b. - A missing or unpicked curve runs linear. The inspector says so, the node shows it as a problem, and deleting a used curve warns with the number of keys and Lerp nodes. Tests: - ./check.sh: 180 files, 2612 tests, CHECK EXIT 0 - GM_STEPS=1-6,25,53: ALL PASSED, 309 checks - New e2e module 53 with 13 checks, all with the real mouse. Expected values come from its own Bezier evaluation. - Counter-checks bite: without Bezier evaluation 4 unit tests fail, without handle clamping 2. In the browser, a key that cannot find its curve, a click that sets no point and a handle drag that moves the point all turn checks red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
320d3e27e1
commit
393b8ec7f9
41 files changed
+1974
-60
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, (t: number) => 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';
|
||||
}
|
||||
+19
-1
@@ -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 `<curve>-<direction>`, except quad, which keeps its first name `ease-<direction>`.
|
||||
* `curve:<id>` blends along a Curve asset (#247); an empty id is "Curve" with none picked yet.
|
||||
*/
|
||||
export type KeyInterpolation =
|
||||
'linear' | 'step' | `ease-${EasingDirection}` | `${Exclude<EasingCurve, 'quad'>}-${EasingDirection}`;
|
||||
| 'linear'
|
||||
| 'step'
|
||||
| `ease-${EasingDirection}`
|
||||
| `${Exclude<EasingCurve, 'quad'>}-${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
|
||||
|
||||
@@ -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<AssetType, string> = {
|
||||
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<AssetType, string> = {
|
||||
* 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<AssetType> = new Set([
|
||||
'pixelart',
|
||||
'palette',
|
||||
'animation',
|
||||
'curve',
|
||||
'tile',
|
||||
'statemachine',
|
||||
'shader',
|
||||
|
||||
+38
-11
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -29,5 +29,6 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
start: startStateOf(asset.graph, asset.start),
|
||||
};
|
||||
},
|
||||
curve: (assetId) => findAssetOf(project, assetId, 'curve')?.points ?? null,
|
||||
};
|
||||
}
|
||||
@@ -405,6 +405,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
],
|
||||
// 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 },
|
||||
|
||||
+23
-15
@@ -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.
|
||||
|
||||
+11
-1
@@ -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<CentreTab, string> = {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +132,12 @@ 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.`
|
||||
: 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,6 +12,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
vectorart: 'vectorart',
|
||||
pixelart: 'pixelart',
|
||||
animation: 'animation',
|
||||
curve: 'curve',
|
||||
tile: 'tile',
|
||||
container: 'container',
|
||||
};
|
||||
|
||||
@@ -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<CurveAsset> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<PointKind, string> = {
|
||||
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<CurveAsset, CurveCanvas> {
|
||||
/** 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
|
||||
pixelart: 'Pixel drawings',
|
||||
palette: 'Palettes',
|
||||
animation: 'Animations',
|
||||
curve: 'Curves',
|
||||
tile: 'Tiles',
|
||||
tilemap: 'Tilemaps',
|
||||
statemachine: 'State machines',
|
||||
|
||||
@@ -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<AssetType, string> = {
|
||||
camera,
|
||||
collider,
|
||||
container,
|
||||
curve,
|
||||
font,
|
||||
palette,
|
||||
particles,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- An ease-in-out rising from the bottom left to the top right, over the two axes it is drawn on. -->
|
||||
<path d="M-6.5 -7 L-6.5 6.5 L7 6.5" fill="none" stroke="currentColor" stroke-width="1" />
|
||||
<path d="M-6.5 6.5 C0 6.5 0 -6.5 6.5 -6.5" fill="none" stroke="currentColor" stroke-width="1.75" />
|
||||
<circle cx="0" cy="0" r="1.75" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 449 B |
@@ -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;
|
||||
|
||||
@@ -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<string>();
|
||||
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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, Case[]> = {
|
||||
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 } },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -159,6 +159,7 @@ export async function animation(t) {
|
||||
'Back',
|
||||
'Elastic',
|
||||
'Bounce',
|
||||
'Curve',
|
||||
]) &&
|
||||
(await easingOf()) === 'cubic-in-out' &&
|
||||
Boolean(cubicDirection),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user