A shader so far worked only on its own wearer. Effects over the whole game picture, such as a vignette, grey, scanlines or a red flash on a hit, had no place to live. A camera now wears one, drawn the way a field shader of a particle source is (#170). - The camera gets a Shader row and, while one is picked, a Shader resolution row. The shader's properties follow as camera rows. They load, animate and appear in a Properties node under Type Camera. - Every view keeps masks and objects in one container. CameraShade renders it into the camera's picture, cleared to the background, and draws the shader over the frame. Game tab and play.html take the container off the stage while the picture stands in for it. - Screen pixels gives the picture as many pixels as it covers on screen. Camera pixels uses View width x View height and scales up without smoothing. - A pixel counts as a camera pixel in both (the user's choice). The uniform uPixelScale makes Texture size read View width x View height, so a Blur keeps its reach at any game scale. - Each camera wears its own shader, overlay cameras included. A red flash on the game camera leaves the HUD of an overlay camera alone. - The scene view's eye has a Shader switch on the Camera row, off by default. With it on, the main camera's shader lies over its frame, below symbols and gizmos, and the world around it stays drawn. - New tool tools/perf-camera-shader.mjs measures the cost. Measured on stein2 (HD 630, Game tab at Fit 1310x679): a camera shader that hands the picture through stays inside the scene's own spread of 3 to 5 fps between runs. A Blur of 4 cost 4 to 8 fps. Tests: - Gate on melektaus-atx: 227 files, 3497 tests (3487 + 10). - e2e 63 alone: 11 checks (6 + 5), e2e sum 1725 counted. - 63e-63i: pass-through is byte-equal and upright at 100%, flat red reaches the corners while the overlay keeps its green, Texture size reads 640 x 360 in both resolutions at Fit, no shader draws as before, and the scene view shows it only with the switch on. - Counter-checks: a camera left out of the shader wearers fails 1 unit test. Counting screen pixels fails 63g with #ab6000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
778 lines
30 KiB
TypeScript
778 lines
30 KiB
TypeScript
import { isKindWith } from '../properties/kinds';
|
||
import {
|
||
KEEP_ON_SCENE_LOAD_KEY,
|
||
type PropertyDef,
|
||
schemaFor,
|
||
shaderParamRows,
|
||
} from '../properties/PropertySchema';
|
||
import { shaderOf } from '../properties/shaders';
|
||
import {
|
||
type AnimationAsset,
|
||
type AnimationKey,
|
||
type AnimationTrack,
|
||
type Asset,
|
||
type EasingCurve,
|
||
type EasingDirection,
|
||
isPrivateVariable,
|
||
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';
|
||
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
||
import { objectName, type SceneObject } from './SceneObject';
|
||
import { mixColor } from '../util/color';
|
||
|
||
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
|
||
const PLAYBACKS = ['loop', 'once', 'once-reset', 'pingpong'] as const;
|
||
export type Playback = (typeof PLAYBACKS)[number];
|
||
|
||
/** What an animation can be built over: something placed, or a whole scene. */
|
||
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. `z` is
|
||
* the draw order, which is sorted on every render, so a keyed `z` moves the object in front or behind.
|
||
*/
|
||
const POSITION_KEYS = ['x', 'y', 'z'] as const;
|
||
|
||
function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
|
||
return (POSITION_KEYS as readonly string[]).includes(key);
|
||
}
|
||
|
||
export interface PlayheadAt {
|
||
/** Frames since the start, fractional: a value between two keys is blended at this point. */
|
||
frame: number;
|
||
/** Only the two `once` modes ever finish. */
|
||
finished: boolean;
|
||
/** On ping-pong's way back: a value that jumps holds the key the playhead passed last – the one above. */
|
||
reverse: boolean;
|
||
}
|
||
|
||
/** Slack for `time * fps`: six ticks of 1/60 s sum to 0.09999999999999999, which at 10 fps is frame 1. */
|
||
const STEP_EPSILON = 1e-6;
|
||
|
||
export function playbackOf(asset: AnimationAsset): Playback {
|
||
const value = textOf(asset.props.playback ?? 'loop');
|
||
return (PLAYBACKS as readonly string[]).includes(value) ? (value as Playback) : 'loop';
|
||
}
|
||
|
||
export function fpsOf(asset: AnimationAsset): number {
|
||
const fps = Number(asset.props.fps);
|
||
return Number.isFinite(fps) && fps > 0 ? fps : 0;
|
||
}
|
||
|
||
/** The frame of the last key over all rows; -1 for an animation without keys. */
|
||
function lastKeyFrame(asset: AnimationAsset): number {
|
||
let last = -1;
|
||
for (const track of asset.tracks) {
|
||
const key = track.keys[track.keys.length - 1];
|
||
if (key && key.frame > last) last = key.frame;
|
||
}
|
||
return last;
|
||
}
|
||
|
||
/**
|
||
* How many frames one pass lasts: the last key's frame is a frame of its own, so keys on 0…9 are
|
||
* ten frames – at 10 fps one second. A pause at the end is a last key that repeats the value.
|
||
*/
|
||
export function frameCount(asset: AnimationAsset): number {
|
||
return lastKeyFrame(asset) + 1;
|
||
}
|
||
|
||
export function animationDuration(asset: AnimationAsset): number {
|
||
const fps = fpsOf(asset);
|
||
return fps > 0 ? frameCount(asset) / fps : 0;
|
||
}
|
||
|
||
/** Where the playhead stands `time` seconds after the start, for a pass of `count` frames. */
|
||
export function playheadAt(count: number, fps: number, playback: Playback, time: number): PlayheadAt {
|
||
if (count <= 0)
|
||
return { frame: 0, finished: playback === 'once' || playback === 'once-reset', reverse: false };
|
||
if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false, reverse: false };
|
||
const at = time * fps + STEP_EPSILON;
|
||
switch (playback) {
|
||
case 'loop':
|
||
return { frame: at % count, finished: false, reverse: false };
|
||
case 'once':
|
||
case 'once-reset':
|
||
return at >= count
|
||
? { frame: count, finished: true, reverse: false }
|
||
: { frame: at, finished: false, reverse: false };
|
||
case 'pingpong': {
|
||
// Turns on the last key, so neither end is shown twice: keys on 0…9 play 0…9…1 in 18 frames (#112).
|
||
const last = count - 1;
|
||
if (last <= 0) return { frame: 0, finished: false, reverse: false };
|
||
const phase = at % (2 * last);
|
||
return phase < last
|
||
? { frame: phase, finished: false, reverse: false }
|
||
: { frame: 2 * last - phase, finished: false, reverse: true };
|
||
}
|
||
}
|
||
}
|
||
|
||
export function animationPlayheadAt(asset: AnimationAsset, time: number): PlayheadAt {
|
||
return playheadAt(frameCount(asset), fpsOf(asset), playbackOf(asset), time);
|
||
}
|
||
|
||
/** Only these blend between keys; every other kind holds its value until the next key. */
|
||
export function blends(kind: string): boolean {
|
||
return kind === 'number' || kind === 'color';
|
||
}
|
||
|
||
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 }[] = [
|
||
{ value: 'linear', label: 'Linear' },
|
||
{ value: 'step', label: 'Step' },
|
||
{ value: 'sine', label: 'Sine' },
|
||
{ value: 'quad', label: 'Quad' },
|
||
{ value: 'cubic', label: 'Cubic' },
|
||
{ value: 'quart', label: 'Quart' },
|
||
{ value: 'quint', label: 'Quint' },
|
||
{ value: 'expo', label: 'Expo' },
|
||
{ value: 'circ', label: 'Circ' },
|
||
{ value: 'back', label: 'Back' },
|
||
{ value: 'elastic', label: 'Elastic' },
|
||
{ value: 'bounce', label: 'Bounce' },
|
||
{ value: 'curve', label: 'Curve' },
|
||
];
|
||
|
||
/** The second field, for a curve only. */
|
||
export const EASING_DIRECTIONS: readonly { value: EasingDirection; label: string }[] = [
|
||
{ value: 'in', label: 'In' },
|
||
{ value: 'out', label: 'Out' },
|
||
{ value: 'in-out', label: 'In-out' },
|
||
];
|
||
|
||
const BACK = 1.70158;
|
||
const ELASTIC = (2 * Math.PI) / 3;
|
||
|
||
function bounceOut(t: number): number {
|
||
const n = 7.5625;
|
||
const d = 2.75;
|
||
if (t < 1 / d) return n * t * t;
|
||
if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75;
|
||
if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
|
||
return n * (t -= 2.625 / d) * t + 0.984375;
|
||
}
|
||
|
||
/** Each curve's `in` form (Penner's); `out` is it mirrored, `in-out` both halves. Back and elastic leave 0…1. */
|
||
const EASE_IN: Record<EasingCurve, (t: number) => number> = {
|
||
sine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
||
quad: (t) => t * t,
|
||
cubic: (t) => t ** 3,
|
||
quart: (t) => t ** 4,
|
||
quint: (t) => t ** 5,
|
||
expo: (t) => (t <= 0 ? 0 : 2 ** (10 * t - 10)),
|
||
circ: (t) => 1 - Math.sqrt(1 - t * t),
|
||
back: (t) => (BACK + 1) * t ** 3 - BACK * t * t,
|
||
elastic: (t) => (t <= 0 ? 0 : t >= 1 ? 1 : -(2 ** (10 * t - 10)) * Math.sin((10 * t - 10.75) * ELASTIC)),
|
||
bounce: (t) => 1 - bounceOut(1 - t),
|
||
};
|
||
|
||
function isCurve(value: string): value is EasingCurve {
|
||
return Object.hasOwn(EASE_IN, value);
|
||
}
|
||
|
||
/**
|
||
* 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]!;
|
||
const direction = match[2] as EasingDirection;
|
||
if (curve === 'ease') return { shape: 'quad', direction };
|
||
return isCurve(curve) && curve !== 'quad' ? { shape: curve, direction } : { shape: 'linear' };
|
||
}
|
||
|
||
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, curve } = easingOf(value as KeyInterpolation);
|
||
return easingValue(shape, direction, curve) === value;
|
||
}
|
||
|
||
export function interpolationOf(key: AnimationKey): KeyInterpolation {
|
||
return key.interpolation ?? 'linear';
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
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':
|
||
return easeIn(t);
|
||
case 'out':
|
||
return 1 - easeIn(1 - t);
|
||
case 'in-out':
|
||
return t < 0.5 ? easeIn(2 * t) / 2 : 1 - easeIn(2 - 2 * t) / 2;
|
||
}
|
||
}
|
||
|
||
/** The value between two keys, `t` from 0 (at `a`) to 1 (at `b`). */
|
||
export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: number): PropertyValue {
|
||
if (kind === 'number') {
|
||
const from = Number(a);
|
||
const to = Number(b);
|
||
return Number.isFinite(from) && Number.isFinite(to) ? from + (to - from) * t : a;
|
||
}
|
||
if (kind === 'color') return mixColor(colorOf(a, 0), colorOf(b, 0), t);
|
||
return a;
|
||
}
|
||
|
||
/**
|
||
* 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. `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;
|
||
if (frame <= first.frame) return first.value;
|
||
for (let i = 1; i < keys.length; i++) {
|
||
const next = keys[i]!;
|
||
if (frame >= next.frame) continue;
|
||
const prev = keys[i - 1]!;
|
||
const interpolation = interpolationOf(prev);
|
||
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, curves));
|
||
}
|
||
return keys[keys.length - 1]!.value;
|
||
}
|
||
|
||
/**
|
||
* What a row writes at a frame: its value, or for a relative number row that value added to `base`,
|
||
* what the target held when the animation started. Shared by the runtime player and the tab's
|
||
* preview, so both show the same picture. A number is held to the property's `min`/`max`, which a
|
||
* back or elastic curve overshoots. `undefined` for a row without keys.
|
||
*/
|
||
export function trackWriteAt(
|
||
track: AnimationTrack,
|
||
def: RowDef,
|
||
frame: number,
|
||
base: PropertyValue | undefined,
|
||
reverse = false,
|
||
curves: CurveLookup = NO_CURVES,
|
||
): PropertyValue | undefined {
|
||
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';
|
||
return heldTo(def, offset ? base + Number(value) : value);
|
||
}
|
||
|
||
/** What `trackWriteAt` needs of a property; a `PropertyDef` has it. */
|
||
export interface RowDef {
|
||
kind: string;
|
||
min?: number;
|
||
max?: number;
|
||
}
|
||
|
||
function heldTo(def: RowDef, value: PropertyValue): PropertyValue {
|
||
if (typeof value !== 'number') return value;
|
||
if (def.min !== undefined && value < def.min) return def.min;
|
||
if (def.max !== undefined && value > def.max) return def.max;
|
||
return value;
|
||
}
|
||
|
||
/** A row's property on an asset of that type; undefined for one the timeline cannot animate. */
|
||
export function animatedDef(type: AssetType, key: string): PropertyDef | undefined {
|
||
return animatableDefs(type).find((d) => d.key === key);
|
||
}
|
||
|
||
/** The property `key` is on an asset holding `bag` – see `animatableDefsOf`. */
|
||
export function animatedDefOf(
|
||
project: Project,
|
||
asset: Asset,
|
||
bag: PropertyBag,
|
||
key: string,
|
||
): PropertyDef | undefined {
|
||
return animatableDefsOf(project, asset, bag).find((d) => d.key === key);
|
||
}
|
||
|
||
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 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[] {
|
||
// Whether an object is kept through a scene swap is nothing a timeline moves (#165).
|
||
const own = schemaFor(type).filter((def) => isFlat(def) && def.key !== KEEP_ON_SCENE_LOAD_KEY);
|
||
if (!hasPosition(type)) return own;
|
||
const position: PropertyDef[] = POSITION_KEYS.map((key) => ({
|
||
key,
|
||
label: key.toUpperCase(),
|
||
kind: 'number',
|
||
default: 0,
|
||
}));
|
||
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' || asset.type === 'text' || asset.type === 'camera'
|
||
? 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.
|
||
*/
|
||
export const PLACED_ROOT_ID = 'preview-root';
|
||
|
||
/** The root placed at the origin, its content following the template like any placed container's. */
|
||
export function placedRoot(root: Asset): SceneObject {
|
||
return {
|
||
id: PLACED_ROOT_ID,
|
||
assetId: root.id,
|
||
name: root.name,
|
||
position: { x: 0, y: 0, z: 0 },
|
||
editorOffset: { x: 0, y: 0 },
|
||
overrides: {},
|
||
parentId: '',
|
||
instanceEdits: {},
|
||
};
|
||
}
|
||
|
||
/** One object a row may aim at below the root, under the id the row stores. */
|
||
export interface AnimationTarget {
|
||
id: string;
|
||
object: SceneObject;
|
||
depth: number;
|
||
}
|
||
|
||
/**
|
||
* Everything a row may aim at below the root, in tree order – into placed containers, too (#109).
|
||
* A scene's rows name its objects by the ids the running scene gives them, which a baked instance
|
||
* keeps. A container's rows name a path of template ids, what the runtime appends to whichever
|
||
* object the animation plays on. A sprite or text has nothing below it.
|
||
*/
|
||
export function animationTargets(project: Project, root: Asset | undefined): AnimationTarget[] {
|
||
if (root?.type === 'scene')
|
||
return treeOrder({ objects: resolveObjects(project, root) }).map(({ object, depth }) => ({
|
||
id: object.id,
|
||
object,
|
||
depth,
|
||
}));
|
||
if (root?.type !== 'container') return [];
|
||
const prefix = instanceChildId(PLACED_ROOT_ID, '');
|
||
return treeOrder({ objects: resolveObjects(project, { objects: [placedRoot(root)] }) })
|
||
.filter(({ object }) => object.id.startsWith(prefix))
|
||
.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 {
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* The rows the dialog offers for the object `target` names: what it can animate, less a behavior's
|
||
* private variables (#270) and what another row animates already. A row on a private variable keeps
|
||
* playing – only a new one is not offered. `changing` is the row being aimed elsewhere: its keys
|
||
* stay, so only properties of its kind are offered, and its own property is, private or not.
|
||
*/
|
||
export function offeredDefs(
|
||
project: Project,
|
||
animation: AnimationAsset,
|
||
target: string,
|
||
changing: AnimationTrack | null = null,
|
||
): PropertyDef[] {
|
||
const aimed = aimedAt(project, animation, target);
|
||
if (!aimed) return [];
|
||
const kind = changing ? (trackDef(project, animation, changing)?.kind ?? null) : null;
|
||
return animatableDefsOf(project, aimed.asset, aimed.bag).filter((def) => {
|
||
if (kind !== null && def.kind !== kind) return false;
|
||
const own = changing?.target === target && changing.property === def.key;
|
||
if (isPrivateVariable(aimed.asset, def.key) && !own) return false;
|
||
return !animation.tracks.some(
|
||
(t) => t.id !== changing?.id && t.target === target && t.property === def.key,
|
||
);
|
||
});
|
||
}
|
||
|
||
/** 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 aimed = aimedAt(project, animation, track.target);
|
||
const who = !aimed ? '(missing)' : aimed.object ? objectName(aimed.object, aimed.asset) : 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,
|
||
kind: string | undefined,
|
||
ids: ReadonlySet<string>,
|
||
): boolean {
|
||
if (!kind || !isKindWith('reference', kind)) return false;
|
||
return track.keys.some((k) => typeof k.value === 'string' && ids.has(k.value));
|
||
}
|
||
|
||
/**
|
||
* One animation playing on one object. It lives on the object, off its property bag, like the
|
||
* emit counter: what the world writes back must not grow an override per frame. `bases` is what
|
||
* each row's target held when the first tick after the start reached it – the zero of a relative
|
||
* row and what `once-reset` puts back – keyed `<entity id> <property>`.
|
||
*/
|
||
export interface AnimationPlayer {
|
||
animationId: string;
|
||
time: number;
|
||
playing: boolean;
|
||
/** Start order across the whole run: of two players setting one property, the later start wins. */
|
||
order: number;
|
||
bases?: Record<string, PropertyValue>;
|
||
blend?: AnimationBlend;
|
||
/** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */
|
||
blendOut?: number;
|
||
fade?: AnimationFade;
|
||
}
|
||
|
||
/**
|
||
* Who asked for a player – the behaviors that played it, which hear its end wherever the object
|
||
* stands. Kept beside the player, not on it: a player is plain data, read as JSON by the check
|
||
* scripts, and what asks is an entity of the run with its parents and children.
|
||
*/
|
||
const starters = new WeakMap<AnimationPlayer, object[]>();
|
||
|
||
export function startersOf(player: AnimationPlayer): readonly object[] {
|
||
return starters.get(player) ?? [];
|
||
}
|
||
|
||
function noteStarter(player: AnimationPlayer, startedBy: object | undefined): void {
|
||
if (!startedBy || startersOf(player).includes(startedBy)) return;
|
||
starters.set(player, [...startersOf(player), startedBy]);
|
||
}
|
||
|
||
/**
|
||
* A crossfade into a player that just took over (#106): over `seconds`, whatever the animation's frame
|
||
* rate, a row that blends mixes from what the property held into the player's value. `from` is taken
|
||
* on the first tick of the blend; a property an earlier player writes in the same tick is mixed from
|
||
* that value instead, live.
|
||
*/
|
||
export interface AnimationBlend {
|
||
seconds: number;
|
||
elapsed: number;
|
||
from?: Record<string, PropertyValue>;
|
||
}
|
||
|
||
/**
|
||
* A player on its way out (#106): over `seconds` a row that blends mixes from the player's value back
|
||
* to what lies below it – what an earlier player writes in the same tick, live, or else the value the
|
||
* row found at the start. Everything else goes back at once. Then the player is gone. `ended` marks
|
||
* the fade of a `once-reset` pass whose frames ran out: playing it again starts it over.
|
||
*/
|
||
export interface AnimationFade {
|
||
seconds: number;
|
||
elapsed: number;
|
||
ended?: boolean;
|
||
}
|
||
|
||
/** How far a blend or a fade has come, 0…1. */
|
||
export function blendShare(blend: { seconds: number; elapsed: number }): number {
|
||
if (!(blend.seconds > 0)) return 1;
|
||
return Math.min(1, blend.elapsed / blend.seconds);
|
||
}
|
||
|
||
interface PlayerHolder {
|
||
animations?: AnimationPlayer[];
|
||
}
|
||
|
||
let startCount = 0;
|
||
|
||
/**
|
||
* Starts the animation on the object, or lets a paused one run on. One already running keeps its
|
||
* frame, so a graph may ask for it every frame without holding it at its first frame – but it counts
|
||
* as started last again: turning back to a walk that still runs has to show that walk.
|
||
* `blendSeconds` crossfades into it – but only when it takes over: asked for again while it already
|
||
* leads on the object, it keeps the blend it has, so a graph calling every frame does not restart it.
|
||
* One fading out turns round: it blends back in from the value it has reached, without a jump – or,
|
||
* when a `once-reset` pass already ran out, starts over. `blendOutSeconds` is the fade at the end of
|
||
* a `once-reset` pass. `restart` starts a known one over from its first frame in any case – blended in
|
||
* from where it stands when `blendSeconds` is set – and keeps its starting values, so a relative row
|
||
* does not add up. `startedBy` is noted for the player next to whoever asked before, so each of them
|
||
* hears it finish.
|
||
*/
|
||
export function playAnimation(
|
||
holder: PlayerHolder,
|
||
animationId: string,
|
||
blendSeconds = 0,
|
||
blendOutSeconds = 0,
|
||
restart = false,
|
||
startedBy?: object,
|
||
): void {
|
||
const players = (holder.animations ??= []);
|
||
const known = players.find((p) => p.animationId === animationId);
|
||
const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined;
|
||
const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined;
|
||
if (known) {
|
||
noteStarter(known, startedBy);
|
||
const leads = players.every((p) => p.order <= known.order);
|
||
const fade = known.fade;
|
||
known.playing = true;
|
||
known.order = ++startCount;
|
||
known.blendOut = blendOut;
|
||
delete known.fade;
|
||
if (restart || fade?.ended) {
|
||
known.time = 0;
|
||
known.blend = blend;
|
||
} else if (fade && blend) {
|
||
// Mixed back from the starting values it stands where the fade left it: `share` = 1 - fade share.
|
||
blend.elapsed = blendSeconds * (1 - blendShare(fade));
|
||
known.blend = { ...blend, ...(known.bases && { from: { ...known.bases } }) };
|
||
} else if (fade || !leads) known.blend = blend;
|
||
return;
|
||
}
|
||
const player: AnimationPlayer = {
|
||
animationId,
|
||
time: 0,
|
||
playing: true,
|
||
order: ++startCount,
|
||
...(blend && { blend }),
|
||
...(blendOut && { blendOut }),
|
||
};
|
||
noteStarter(player, startedBy);
|
||
players.push(player);
|
||
}
|
||
|
||
/**
|
||
* Ends it where it stands – the values stay; `''` ends every animation on the object. With
|
||
* `blendSeconds` it fades out instead, back to what lies below it (see `AnimationFade`). A player that
|
||
* has not reached anything yet ends at once either way.
|
||
*/
|
||
export function stopAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void {
|
||
if (!holder.animations) return;
|
||
const hit = (p: AnimationPlayer) => animationId === '' || p.animationId === animationId;
|
||
const fades = blendSeconds > 0;
|
||
holder.animations = holder.animations.filter((p) => !hit(p) || (fades && p.bases !== undefined));
|
||
if (fades)
|
||
for (const player of holder.animations)
|
||
if (hit(player) && !player.fade) player.fade = { seconds: blendSeconds, elapsed: 0 };
|
||
}
|
||
|
||
/** Holds it at its frame until `Play animation` lets it run on; `''` holds every one. */
|
||
export function pauseAnimation(holder: PlayerHolder, animationId: string): void {
|
||
for (const player of holder.animations ?? [])
|
||
if (animationId === '' || player.animationId === animationId) player.playing = false;
|
||
}
|
||
|
||
/** What a run needs of an animation to say where a player stands in it (#233). */
|
||
export interface AnimationClip {
|
||
frames: number;
|
||
fps: number;
|
||
playback: Playback;
|
||
}
|
||
|
||
export function clipOf(asset: AnimationAsset): AnimationClip {
|
||
return { frames: frameCount(asset), fps: fpsOf(asset), playback: playbackOf(asset) };
|
||
}
|
||
|
||
export function clipDuration(clip: AnimationClip): number {
|
||
return clip.fps > 0 ? clip.frames / clip.fps : 0;
|
||
}
|
||
|
||
/** What `Animation playing` answers (#233). */
|
||
export interface PlayerStatus {
|
||
playing: boolean;
|
||
/** Where the playhead stands on the timeline, 0…1 – on ping-pong's way back it falls again. */
|
||
progress: number;
|
||
/** Seconds until the playhead reaches the end of its pass, and a `once-reset` has blended out. */
|
||
timeLeft: number;
|
||
}
|
||
|
||
export const NOT_PLAYING: PlayerStatus = { playing: false, progress: 0, timeLeft: 0 };
|
||
|
||
/**
|
||
* A paused player is not running, and neither is one `Stop animation` fades out. A `once-reset` pass
|
||
* blending back is: it still shows.
|
||
*/
|
||
function isRunning(player: AnimationPlayer): boolean {
|
||
return player.fade ? player.fade.ended === true : player.playing;
|
||
}
|
||
|
||
/**
|
||
* The player `Animation playing` answers for: the one of that animation, or for `''` the one started
|
||
* last among those running – and when none runs, the one started last at all.
|
||
*/
|
||
export function leadingPlayer(holder: PlayerHolder, animationId: string): AnimationPlayer | undefined {
|
||
const players = (holder.animations ?? []).filter(
|
||
(p) => animationId === '' || p.animationId === animationId,
|
||
);
|
||
const running = players.filter(isRunning);
|
||
return (running.length > 0 ? running : players).sort((a, b) => b.order - a.order)[0];
|
||
}
|
||
|
||
export function playerStatus(clip: AnimationClip, player: AnimationPlayer): PlayerStatus {
|
||
const playing = isRunning(player);
|
||
const fade = player.fade;
|
||
if (fade?.ended) return { playing, progress: 1, timeLeft: Math.max(0, fade.seconds - fade.elapsed) };
|
||
if (clip.frames <= 0 || clip.fps <= 0) return { playing, progress: 0, timeLeft: 0 };
|
||
const head = playheadAt(clip.frames, clip.fps, clip.playback, player.time);
|
||
if (clip.playback === 'pingpong') {
|
||
const last = clip.frames - 1;
|
||
if (last <= 0) return { playing, progress: 0, timeLeft: 0 };
|
||
const ahead = head.reverse ? head.frame : last - head.frame;
|
||
return { playing, progress: head.frame / last, timeLeft: fade ? 0 : ahead / clip.fps };
|
||
}
|
||
const blendOut = clip.playback === 'once-reset' ? (player.blendOut ?? 0) : 0;
|
||
return {
|
||
playing,
|
||
progress: Math.min(1, head.frame / clip.frames),
|
||
timeLeft: fade ? 0 : Math.max(0, clip.frames - head.frame) / clip.fps + blendOut,
|
||
};
|
||
}
|
||
|
||
/** 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>();
|
||
for (const key of keys) {
|
||
const frame = Math.max(0, Math.round(key.frame));
|
||
byFrame.set(frame, { ...key, frame });
|
||
}
|
||
return [...byFrame.values()].sort((a, b) => a.frame - b.frame);
|
||
}
|