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
+1981
-67
No files matched your search
+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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-7
@@ -15,6 +15,7 @@ import { CreateContainerCommand } from '../core/commands/containerCommands';
|
||||
import { RemoveNodeCommand } from '../core/commands/graphCommands';
|
||||
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { curveUsages } from '../core/model/curve';
|
||||
import { generatesPictures } from '../core/model/generated';
|
||||
import { isTemplateContent } from '../core/model/instance';
|
||||
import type { Command } from '../core/commands/Command';
|
||||
@@ -131,16 +132,19 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const picture = asset.type === 'texture' || generatesPictures(asset.type);
|
||||
const animations = picture ? animationsUsing(state.project, ids).length : 0;
|
||||
const tiles = picture ? tilesUsing(state.project, ids).length : 0;
|
||||
const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 };
|
||||
const detail =
|
||||
asset.type === 'scene'
|
||||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||||
: references > 0 || animations > 0 || tiles > 0
|
||||
? wornDetail(references, animations, tiles, asset.type)
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
? `${usages} placed container(s) are removed together with their content.`
|
||||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||||
: eased.keys > 0 || eased.nodes > 0
|
||||
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
|
||||
: references > 0 || animations > 0 || tiles > 0
|
||||
? wornDetail(references, animations, tiles, asset.type)
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
? `${usages} placed container(s) are removed together with their content.`
|
||||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||||
new Dialog(`Delete "${asset.name}"?`, detail, [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user