The scene view and the animation preview share one camera, the three form dialogs one base, and keys are found through one set of helpers (#85)
game-maker / gate (push) Successful in 1m36s
game-maker / suite (push) Successful in 5m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 14:19:53 +02:00
1 parent bf02c9e8b3
commit 2a390e52ff
22 files changed
+418 -452

No files matched your search

+36 -30
View File
@@ -2,7 +2,10 @@ import { newId } from '../ids';
import {
ANIMATION_ROOT_TYPES,
blends,
findTrack,
hasKey,
isKeyInterpolation,
type KeyRef,
normalizeKeys,
targetAssetOf,
targetDefs,
@@ -16,12 +19,6 @@ import { coerceValue } from '../properties/PropertySchema';
import { type Command, CommandError } from './Command';
import { requireAsset } from './require';
/** A key on one row, as the timeline selects it. */
export interface KeyRef {
trackId: string;
frame: number;
}
function requireAnimation(project: Project, assetId: string): AnimationAsset {
const asset = requireAsset(project, assetId);
if (asset.type !== 'animation') throw new CommandError(`${asset.name} is not an animation`);
@@ -29,11 +26,27 @@ function requireAnimation(project: Project, assetId: string): AnimationAsset {
}
function requireTrack(asset: AnimationAsset, trackId: string): AnimationTrack {
const track = asset.tracks.find((t) => t.id === trackId);
const track = findTrack(asset, trackId);
if (!track) throw new CommandError(`${asset.name} has no such row`);
return track;
}
function requireKey(asset: AnimationAsset, ref: KeyRef): { track: AnimationTrack; key: AnimationKey } {
const track = requireTrack(asset, ref.trackId);
const key = track.keys.find((k) => k.frame === ref.frame);
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
return { track, key };
}
function requireFrame(frame: number, message: string): void {
if (!Number.isInteger(frame) || frame < 0) throw new CommandError(message);
}
/** `Remove key`, `Remove 3 keys` – how a key command names itself. */
function keysLabel(verb: string, count: number): string {
return count === 1 ? `${verb} key` : `${verb} ${count} keys`;
}
/** The kind of `property` on the object `target` names, refusing what cannot be animated there. */
function requireAnimatable(
project: Project,
@@ -217,8 +230,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
protected apply(project: Project, asset: AnimationAsset): void {
const track = requireTrack(asset, this.trackId);
if (!Number.isInteger(this.frame) || this.frame < 0)
throw new CommandError('A key sits on a whole frame from 0');
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);
@@ -250,9 +262,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand {
if (!isKeyInterpolation(this.interpolation)) throw new CommandError('No such easing');
let changed = 0;
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
const key = track.keys.find((k) => k.frame === ref.frame);
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
const { track, key } = requireKey(asset, ref);
const kind = trackDef(project, asset, track)?.kind;
if (kind !== undefined && !blends(kind)) continue;
if (this.interpolation === 'linear') delete key.interpolation;
@@ -271,16 +281,14 @@ export class RemoveAnimationKeysCommand extends TimelineCommand {
private readonly keys: readonly KeyRef[],
) {
super(assetId);
this.label = keys.length === 1 ? 'Remove key' : `Remove ${keys.length} keys`;
this.label = keysLabel('Remove', keys.length);
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (this.keys.length === 0) throw new CommandError('No keys to remove');
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
const at = track.keys.findIndex((k) => k.frame === ref.frame);
if (at < 0) throw new CommandError(`No key on frame ${ref.frame}`);
track.keys.splice(at, 1);
const { track, key } = requireKey(asset, ref);
track.keys.splice(track.keys.indexOf(key), 1);
}
}
}
@@ -299,8 +307,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
private readonly copy = false,
) {
super(assetId);
const noun = keys.length === 1 ? 'key' : `${keys.length} keys`;
this.label = `${copy ? 'Duplicate' : 'Move'} ${noun}`;
this.label = keysLabel(copy ? 'Duplicate' : 'Move', keys.length);
}
protected apply(_project: Project, asset: AnimationAsset): void {
@@ -308,9 +315,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
if (this.copy && this.delta === 0) throw new CommandError('A duplicate has to land somewhere else');
const byTrack = new Map<string, Set<number>>();
for (const ref of this.keys) {
const track = requireTrack(asset, ref.trackId);
if (!track.keys.some((k) => k.frame === ref.frame))
throw new CommandError(`No key on frame ${ref.frame}`);
requireKey(asset, ref);
if (ref.frame + this.delta < 0) throw new CommandError('A key cannot move before frame 0');
byTrack.set(ref.trackId, (byTrack.get(ref.trackId) ?? new Set()).add(ref.frame));
}
@@ -342,6 +347,10 @@ export interface KeyClipboard {
rows: ClipboardRow[];
}
export function clipboardKeyCount(clipboard: KeyClipboard): number {
return clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
}
/** The picked keys as a clipboard, or null when none of them exists. */
export function copyAnimationKeys(
project: Project,
@@ -351,7 +360,7 @@ export function copyAnimationKeys(
const picked = asset.tracks
.map((track) => ({
track,
keys: track.keys.filter((k) => refs.some((r) => r.trackId === track.id && r.frame === k.frame)),
keys: track.keys.filter((k) => hasKey(refs, { trackId: track.id, frame: k.frame })),
}))
.filter((p) => p.keys.length > 0);
if (picked.length === 0) return null;
@@ -391,13 +400,11 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
private readonly intoTrackId = '',
) {
super(assetId);
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
this.label = count === 1 ? 'Paste key' : `Paste ${count} keys`;
this.label = keysLabel('Paste', clipboardKeyCount(clipboard));
}
protected apply(project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.frame) || this.frame < 0)
throw new CommandError('Keys paste onto a whole frame from 0');
requireFrame(this.frame, 'Keys paste onto a whole frame from 0');
this.pasted = [];
this.skipped = 0;
const single = this.clipboard.rows.length === 1 && this.intoTrackId !== '';
@@ -434,7 +441,7 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
private matchingTrack(asset: AnimationAsset, row: ClipboardRow): AnimationTrack | undefined {
return asset.id === this.clipboard.assetId
? asset.tracks.find((t) => t.id === row.trackId)
? findTrack(asset, row.trackId)
: asset.tracks.find((t) => t.target === row.target && t.property === row.property);
}
}
@@ -467,8 +474,7 @@ export class ShiftAnimationFramesCommand extends TimelineCommand {
}
protected apply(_project: Project, asset: AnimationAsset): void {
if (!Number.isInteger(this.at) || this.at < 0)
throw new CommandError('Frames are inserted at a whole frame from 0');
requireFrame(this.at, 'Frames are inserted at a whole frame from 0');
if (!Number.isInteger(this.count) || this.count === 0)
throw new CommandError('Give a whole number of frames');
let touched = 0;
+36 -4
View File
@@ -17,9 +17,9 @@ export type Playback = (typeof PLAYBACKS)[number];
export const ANIMATION_ROOT_TYPES: readonly AssetType[] = ['sprite', 'text', 'container', 'scene'];
/** Position lives on the object, not in its property bag, so the timeline offers it by name. */
export const POSITION_KEYS = ['x', 'y'] as const;
const POSITION_KEYS = ['x', 'y'] as const;
export function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
return (POSITION_KEYS as readonly string[]).includes(key);
}
@@ -46,7 +46,7 @@ export function fpsOf(asset: AnimationAsset): number {
}
/** The frame of the last key over all rows; -1 for an animation without keys. */
export function lastKeyFrame(asset: AnimationAsset): number {
function lastKeyFrame(asset: AnimationAsset): number {
let last = -1;
for (const track of asset.tracks) {
const key = track.keys[track.keys.length - 1];
@@ -133,7 +133,7 @@ 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. The eases are
* quadratic: ease-in reaches 0.25 at half the time, ease-out 0.75, ease-in-out 0.5 but 0.125 at a quarter.
*/
export function easedShare(interpolation: KeyInterpolation, t: number): number {
function easedShare(interpolation: KeyInterpolation, t: number): number {
switch (interpolation) {
case 'linear':
return t;
@@ -210,6 +210,16 @@ export function animatedKind(type: AssetType, key: string): string | undefined {
return animatableDefs(type).find((d) => d.key === key)?.kind;
}
/** The kind `key` has on an asset holding `bag` – see `animatableDefsOf`. */
export function animatedKindOf(
project: Project,
asset: Asset,
bag: PropertyBag,
key: string,
): string | undefined {
return animatableDefsOf(project, asset, bag).find((d) => d.key === key)?.kind;
}
function hasPosition(type: AssetType): boolean {
return type !== 'scene' && type !== 'behavior';
}
@@ -501,6 +511,28 @@ export function pauseAnimation(holder: PlayerHolder, animationId: string): void
if (animationId === '' || player.animationId === animationId) player.playing = false;
}
/** A key on one row, as the timeline selects it. */
export interface KeyRef {
trackId: string;
frame: number;
}
export function sameKey(a: KeyRef, b: KeyRef): boolean {
return a.trackId === b.trackId && a.frame === b.frame;
}
export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean {
return keys.some((k) => sameKey(k, key));
}
export function findTrack(asset: AnimationAsset, trackId: string): AnimationTrack | undefined {
return asset.tracks.find((t) => t.id === trackId);
}
export function findKey(asset: AnimationAsset, ref: KeyRef): AnimationKey | undefined {
return findTrack(asset, ref.trackId)?.keys.find((k) => k.frame === ref.frame);
}
/** Sorted by frame, at most one per frame (the later wins), frames whole and not negative. */
export function normalizeKeys(keys: readonly AnimationKey[]): AnimationKey[] {
const byFrame = new Map<number, AnimationKey>();
+4 -7
View File
@@ -5,7 +5,7 @@
*/
import {
animatableDefs,
animatableDefsOf,
animatedKindOf,
frameCount,
isPositionRow,
PLACED_ROOT_ID,
@@ -18,9 +18,6 @@ import { instanceChildId, objectRef } from './instance';
import { findAsset, type ObjectHolder, type Project } from './Project';
import { resolveSceneObjectProps } from '../properties/resolve';
/** The one object the preview places the root on – what a row aimed at the root itself writes to. */
export const PREVIEW_ROOT_ID = PLACED_ROOT_ID;
/**
* The holder the preview draws at `frame`: a copy of the scene for a scene root, otherwise a holder
* of one object placing the root at the origin, its content resolved from the template like any
@@ -50,7 +47,7 @@ export function previewHolder(
/** The id the preview draws a row's target under – for a check that wants to look at it. */
export function previewObjectId(root: Asset | undefined, target: string): string {
if (root?.type === 'scene') return target;
return target === '' ? PREVIEW_ROOT_ID : instanceChildId(PREVIEW_ROOT_ID, target);
return target === '' ? PLACED_ROOT_ID : instanceChildId(PLACED_ROOT_ID, target);
}
/** Deep enough that nothing a row writes – a position, an override, an instance edit – is the document's. */
@@ -68,7 +65,7 @@ function sceneCopy(scene: ObjectHolder): ObjectHolder {
}
function placedHolder(root: Asset): ObjectHolder {
return { id: PREVIEW_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
return { id: PLACED_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
}
/** A row on the scene itself: its value goes into the copy's own properties. */
@@ -92,7 +89,7 @@ function writeObject(
if (!ref || !asset) return;
// Resolved, so a shader worn through an override or an instance edit brings its parameters (#104).
const props = resolveSceneObjectProps(project, asset, ref.object);
const kind = animatableDefsOf(project, asset, props).find((d) => d.key === track.property)?.kind;
const kind = animatedKindOf(project, asset, props, track.property);
if (!kind) return;
const key = track.property;
if (isPositionRow(asset.type, key)) {
+3 -14
View File
@@ -1,14 +1,8 @@
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
import { type PropertyBag, textOf } from '../model/Properties';
import { propertyNodeParams } from '../properties/declaredRows';
import { flatDef } from '../properties/PropertySchema';
import {
PARTICLE_PROPERTY,
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
propertyNameOf,
} from '../properties/particles';
import { PARTICLE_PROPERTY, PROPERTY_NAME_PARAM, propertyNameOf } from '../properties/particles';
/** What one particle is, at one moment: everything a graph may read about it. */
export interface ParticleEnv {
@@ -249,12 +243,7 @@ export const ParticleProperty: ParticleNodeDef = {
description: 'A number the source sets: a property row on the placed source, and a value here.',
inputs: [],
outputs: [numberOut('value')],
params: [
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
],
params: propertyNodeParams(NUMBER_STEP),
derivePorts: ({ node }) => ({ label: propertyNameOf(node) || ParticleProperty.label }),
emit: (_inputs, params, ctx) => {
// The slot is bound once, here: reading it per particle would cost a lookup by name each time.
+11 -1
View File
@@ -1,6 +1,6 @@
import type { Graph, GraphNode } from '../graph/Graph';
import type { PropertyBag } from '../model/Properties';
import type { FlatDef } from './PropertySchema';
import { flatDef, type FlatDef, type PropertyDef } from './PropertySchema';
/** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */
export const PROPERTY_NAME_PARAM = 'name';
@@ -8,6 +8,16 @@ export const PROPERTY_DEFAULT_PARAM = 'default';
export const PROPERTY_MIN_PARAM = 'min';
export const PROPERTY_MAX_PARAM = 'max';
/** The params of a `Property` node, the same in both graphs but for the step of their number fields. */
export function propertyNodeParams(step: number): PropertyDef[] {
return [
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step },
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step },
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step },
];
}
export interface Declaration {
isDeclaration(node: GraphNode): boolean;
nameOf(node: GraphNode): string;
+1 -6
View File
@@ -3,12 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
import { type FlatDef, isValidFieldName } from './PropertySchema';
export {
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
} from './declaredRows';
export { PROPERTY_NAME_PARAM } from './declaredRows';
/** The node of a particle graph that declares a parameter: a row on every source placed from the asset. */
export const PARTICLE_PROPERTY = 'ParticleProperty';
+1 -6
View File
@@ -3,12 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
import { type FlatDef, isSpriteProp, isValidFieldName } from './PropertySchema';
export {
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
} from './declaredRows';
export { PROPERTY_NAME_PARAM } from './declaredRows';
/** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */
export const SHADER_KEY = 'shader';
+3 -14
View File
@@ -1,14 +1,8 @@
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
import { type PropertyBag, textOf } from '../model/Properties';
import { propertyNodeParams } from '../properties/declaredRows';
import { flatDef } from '../properties/PropertySchema';
import {
PROPERTY_DEFAULT_PARAM,
PROPERTY_MAX_PARAM,
PROPERTY_MIN_PARAM,
PROPERTY_NAME_PARAM,
propertyName,
SHADER_PROPERTY,
} from '../properties/shaders';
import { PROPERTY_NAME_PARAM, propertyName, SHADER_PROPERTY } from '../properties/shaders';
export type GlslType = 'float' | 'vec3';
@@ -232,12 +226,7 @@ export const ShaderProperty: ShaderNodeDef = {
description: 'A number the sprite sets: a property row below the Shader dropdown, and a value here.',
inputs: [],
outputs: [{ key: 'value', label: 'value', type: 'number' }],
params: [
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
],
params: propertyNodeParams(NUMBER_STEP),
derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderProperty.label }),
emit: (_inputs, params) => ({
value: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'float' },