Animation rows can target behavior variables and shader parameters (#104)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m38s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 09:04:29 +02:00
1 parent b278605c53
commit beeea355d9
9 files changed
+273 -77

No files matched your search

+87 -22
View File
@@ -1,11 +1,12 @@
import { isKindWith } from '../properties/kinds';
import { type PropertyDef, schemaFor } from '../properties/PropertySchema';
import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema';
import { shaderOf } from '../properties/shaders';
import type { AnimationAsset, AnimationKey, AnimationTrack, Asset, KeyInterpolation } from './Asset';
import type { AssetType } from './AssetTypes';
import { treeOrder } from './hierarchy';
import { instanceChildId, resolveObjects } from './instance';
import { findAsset, type Project } from './Project';
import { colorOf, type PropertyValue, textOf } from './Properties';
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
import type { SceneObject } from './SceneObject';
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
@@ -209,14 +210,27 @@ export function animatedKind(type: AssetType, key: string): string | undefined {
return animatableDefs(type).find((d) => d.key === key)?.kind;
}
function hasPosition(type: AssetType): boolean {
return type !== 'scene' && type !== 'behavior';
}
/** Whether a row sets the object's position rather than a value in its bag – never a behavior's `x`. */
export function isPositionRow(type: AssetType, key: string): key is (typeof POSITION_KEYS)[number] {
return hasPosition(type) && isPositionKey(key);
}
function isFlat(def: PropertyDef): boolean {
return def.kind !== 'list' && def.kind !== 'struct';
}
/**
* The rows the timeline offers for an asset type: position, then the schema's flat properties. A
* list or struct has no single value to key; a shader's own parameters come later (#97). A scene is
* placed nowhere, so it offers only its own properties.
* list or struct has no single value to key. A scene is placed nowhere and a behavior drawn nowhere,
* so neither offers a position – a behavior's variable `x` is the variable.
*/
export function animatableDefs(type: AssetType): PropertyDef[] {
const own = schemaFor(type).filter((d) => d.kind !== 'list' && d.kind !== 'struct');
if (type === 'scene') return own;
const own = schemaFor(type).filter(isFlat);
if (!hasPosition(type)) return own;
const position: PropertyDef[] = POSITION_KEYS.map((key) => ({
key,
label: key.toUpperCase(),
@@ -226,6 +240,28 @@ export function animatableDefs(type: AssetType): PropertyDef[] {
return [...position, ...own];
}
/**
* The rows one asset offers when it holds `bag`: its type's, then what the asset itself declares (#104)
* – a behavior's variables, a sprite's parameters of the shader the bag names. Which shader that is
* can differ per object, and at runtime per frame, hence the bag. A declared row never shadows a row
* of the type.
*/
export function animatableDefsOf(
project: Project,
asset: Asset,
bag: PropertyBag = asset.props,
): PropertyDef[] {
const own = animatableDefs(asset.type);
const declared =
asset.type === 'behavior'
? asset.variables
: asset.type === 'sprite'
? shaderParamRows(project, shaderOf(bag))
: [];
const taken = new Set(own.map((d) => d.key));
return [...own, ...declared.filter((d) => isFlat(d) && !taken.has(d.key))];
}
/**
* The id of the one object a placed root stands on – the preview's holder, and the instance the
* targets inside a container are resolved below.
@@ -273,39 +309,68 @@ export function animationTargets(project: Project, root: Asset | undefined): Ani
.map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 }));
}
/** What a row aims at: the asset, the object placing it (none for the root itself) and the bag it holds. */
interface Aimed {
asset: Asset;
object?: SceneObject;
bag: PropertyBag;
}
function aimedAt(project: Project, animation: AnimationAsset, target: string): Aimed | undefined {
const root = findAsset(project, animation.root);
if (target === '') return root && { asset: root, bag: root.props };
const object = animationTargets(project, root).find((t) => t.id === target)?.object;
const asset = object && findAsset(project, object.assetId);
return object && asset && { asset, object, bag: { ...asset.props, ...object.overrides } };
}
/** The asset a row aims at: the root itself, or what the object inside it places. Undefined once gone. */
export function targetAssetOf(
project: Project,
animation: AnimationAsset,
target: string,
): Asset | undefined {
const root = findAsset(project, animation.root);
if (target === '') return root;
const aimed = animationTargets(project, root).find((t) => t.id === target);
return aimed ? findAsset(project, aimed.object.assetId) : undefined;
return aimedAt(project, animation, target)?.asset;
}
/** The rows the object a row may aim at offers; undefined once that object is gone. */
export function targetDefs(
project: Project,
animation: AnimationAsset,
target: string,
): PropertyDef[] | undefined {
const aimed = aimedAt(project, animation, target);
return aimed && animatableDefsOf(project, aimed.asset, aimed.bag);
}
/**
* The property a row sets. Undefined when its object is gone – or has lost the property: a behavior
* taken off, a variable renamed, a shader swapped for one without the parameter all read as missing,
* and the keys stay.
*/
export function trackDef(
project: Project,
animation: AnimationAsset,
track: AnimationTrack,
): PropertyDef | undefined {
return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property);
}
/** How the timeline names what a row aims at: the object (or the root itself) and the property. */
export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string {
const root = findAsset(project, animation.root);
const object =
track.target === ''
? undefined
: animationTargets(project, root).find((t) => t.id === track.target)?.object;
const aimed = object ? findAsset(project, object.assetId) : track.target === '' ? root : undefined;
const who =
track.target === '' ? (root?.name ?? '?') : object ? object.name || aimed?.name || '?' : '(missing)';
const def = aimed && animatableDefs(aimed.type).find((d) => d.key === track.property);
return `${who} · ${def?.label ?? track.property}`;
const aimed = aimedAt(project, animation, track.target);
const who = !aimed ? '(missing)' : aimed.object ? aimed.object.name || aimed.asset.name : aimed.asset.name;
const def =
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property);
return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`;
}
/** Whether a value names something in the project – what deleting that something has to count. */
export function referencesAny(
track: AnimationTrack,
type: AssetType | undefined,
kind: string | undefined,
ids: ReadonlySet<string>,
): boolean {
const kind = type ? animatedKind(type, track.property) : undefined;
if (!kind || !isKindWith('reference', kind)) return false;
return track.keys.some((k) => typeof k.value === 'string' && ids.has(k.value));
}
+15 -17
View File
@@ -5,9 +5,9 @@
*/
import {
animatableDefs,
animatedKind,
animatableDefsOf,
frameCount,
isPositionKey,
isPositionRow,
PLACED_ROOT_ID,
placedRoot,
playbackOf,
@@ -16,9 +16,7 @@ import {
import type { AnimationAsset, AnimationTrack, Asset } from './Asset';
import { instanceChildId, objectRef } from './instance';
import { findAsset, type ObjectHolder, type Project } from './Project';
import type { PropertyValue } from './Properties';
import { resolveSceneObjectProps } from '../properties/resolve';
import type { SceneObject } from './SceneObject';
/** 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;
@@ -91,17 +89,17 @@ function writeObject(
): void {
const ref = objectRef(project, holder, id);
const asset = ref && findAsset(project, ref.object.assetId);
const kind = asset && animatedKind(asset.type, track.property);
if (!ref || !asset || !kind) return;
const value = trackWriteAt(track, kind, frame, baseOf(project, asset, ref.object, track.property), reverse);
if (value === undefined) return;
if (isPositionKey(track.property))
ref.setPosition({ ...ref.object.position, [track.property]: Number(value) });
else ref.setOverride(track.property, value);
}
/** What the target holds before the animation writes it: the zero of a relative row. */
function baseOf(project: Project, asset: Asset, object: SceneObject, key: string): PropertyValue | undefined {
if (isPositionKey(key)) return object.position[key];
return resolveSceneObjectProps(project, asset, object)[key];
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;
if (!kind) return;
const key = track.property;
if (isPositionRow(asset.type, key)) {
const value = trackWriteAt(track, kind, frame, ref.object.position[key], reverse);
if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) });
return;
}
const value = trackWriteAt(track, kind, frame, props[key], reverse);
if (value !== undefined) ref.setOverride(key, value);
}
+2 -3
View File
@@ -5,7 +5,7 @@
*/
import { isKindWith } from '../properties/kinds';
import { schemaForAsset } from '../properties/PropertySchema';
import { referencesAny, targetAssetOf } from './animation';
import { referencesAny, trackDef } from './animation';
import type { AnimationAsset, Asset } from './Asset';
import { overrideSites } from './instance';
import { type ObjectHolder, objectHolders, type Project } from './Project';
@@ -68,7 +68,6 @@ export function animationsUsing(project: Project, ids: readonly string[]): Anima
const wanted = new Set(ids);
return project.assets.filter(
(a): a is AnimationAsset =>
a.type === 'animation' &&
a.tracks.some((t) => referencesAny(t, targetAssetOf(project, a, t.target)?.type, wanted)),
a.type === 'animation' && a.tracks.some((t) => referencesAny(t, trackDef(project, a, t)?.kind, wanted)),
);
}