Files
feuerwerk/src/editor/animation/AnimationEditor.ts
T
adminandClaude Opus 5.5 c9e8a3f6ac
game-maker / gate (push) Successful in 4m16s
game-maker / suite (push) Failing after 16m12s
Let an animation drive a sprite's video picture by picture (#404)
A video used to run on its own clock as soon as a sprite wore it. An
animation could switch the sprite on and off, but never say which
picture shows at which frame. Cutting a video in an animation needs
the opposite: the animation sets the time and the video follows.

- Sprites wearing a video or a clip get two rows, Video clock and
  Video time. They show only while the texture holds a video.
- With Video clock on Driven the video does not play by itself. It
  shows the picture at Video time, counted from the clip's start.
  Past the end it goes round with Loop and holds the last picture
  without.
- Video time is a number like any other, so keys, step, easing and
  curves give stills, slow motion and ramps.
- A driven sprite gets a player of its own, keyed by the wearer's
  name. The same clip can run twice at different times.
- Following has two ways (`videoFollow.ts`). Running forward at 0.25x
  to 4x the video plays at that rate and is nudged. A still, a scrub
  or a step backwards seeks, and only the newest seek counts.
- Sound is heard only near 1x. The animation tab now plays it too, so
  a cut can follow the sound. Scrubbing is silent.
- Scene, game view, the inspector's PREVIEW and the shader preview
  show the driven picture. In a running game Play animation sets it.

Found on the way:

- After a scrub the video in the animation tab kept running. The
  preview redrew only while the playhead moved, so nobody asked for
  the video once it stood. `AnimationEditor.tick` now redraws it every
  frame while it shows a video.
- Every sprite has two more defaults (`videoClock: 'free'`,
  `videoTime: 0`). Eight tests in five files pinned the old list and
  are updated.

Tests:
- Unit: video-clock and video-follow new, 4 files with 42 tests green.
- e2e 66 (melektaus-atx): ALL PASSED, 22 checks, 3 new. 66i: two
  sprites on one clip stand at 0.2 s red and 0.7 s green. 66j: a
  scrub to frames 2, 7, 12 and 3 shows red, green, blue, red, and
  each colour still stands 0.7 s later. 66k: playing runs 10-30 ms
  behind at rate 1.00-1.03 and is not muted.
- Counterchecks: a seek that keeps the rate turns the seek test red.
  Not seeing Driven turns 66i, 66j and 66k red.
- Gate (melektaus-atx): exit 0, 297 files, 4546 tests.
- Not tested in the browser: Play animation in a running game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:23:07 +02:00

144 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SetAnimationRootCommand } from '../../core/commands/animationCommands';
import { ANIMATION_ROOT_TYPES, animationDuration, fpsOf, frameCount } from '../../core/model/animation';
import type { AnimationAsset, Asset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { Button, Dropdown, formatNumber, Menu, type MenuItem, VStack, type Widget } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { PreviewCanvas } from './PreviewCanvas';
import { openRowDialog, RowDialog } from './RowDialog';
import { TimelineCanvas } from './TimelineCanvas';
/** Room kept for the "3 rows · 24 frames · 10 fps · 2.4 s" readout. */
const INFO_WIDTH = 220;
const ROOT_WIDTH = 160;
/**
* The Animation tab (#97): the header picks what the animation animates and adds rows, below it
* the preview shows the root at the playhead (#99) and the timeline under that edits the rows.
* `fps` and `playback` are properties of the asset, edited in the inspector like any other.
*/
export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanvas> {
/** Public for the check scripts. */
readonly rootPicker: Dropdown;
readonly addRowButton: Button;
readonly playButton: Button;
readonly preview: PreviewCanvas;
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
this.rootPicker = new Dropdown('—', { width: ROOT_WIDTH, onClick: () => this.openRootPicker() });
this.addRowButton = new Button('+ Row', { onClick: () => this.openRowDialog() });
this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() });
const timeline = new TimelineCanvas(state);
this.preview = new PreviewCanvas(state, () => ({
frame: timeline.playhead,
reverse: timeline.playheadReverse,
}));
timeline.onPlayhead = () => this.preview.redraw();
this.mount(timeline, [this.rootPicker, this.addRowButton, this.playButton]);
}
/** Fit frames the preview; the timeline keeps its own zoom and scroll. */
protected override fit(): void {
this.preview.fit();
}
protected override saveView(): unknown {
return { timeline: this.canvas.saveView(), preview: this.preview.saveView() };
}
protected override restoreView(view: unknown): void {
const saved = view as { timeline: unknown; preview: unknown };
this.canvas.restoreView(saved.timeline);
// Pointed at the document first: `syncExtras` would do it later and fit it over the restored view.
this.preview.show(this.currentAssetId);
this.preview.restoreView(saved.preview);
}
/** The preview above, the timeline below, each taking half. */
protected override body(canvas: TimelineCanvas): Widget {
const stack = new VStack();
stack.add(this.preview, 1);
stack.add(canvas, 1);
return stack;
}
/** Only called while the tab is visible. */
tick(dt: number): void {
const drawn = this.preview.redrawCount;
if (this.canvas.isPlaying) {
this.canvas.tick(dt);
// A finished `once` pass pauses the canvas; the button has to say so.
this.syncPlayButton();
}
// A video moves by itself, and a driven one (#404) has to be told every frame that the
// playhead stands; the playhead redraws only when it moves.
this.preview.animate(drawn);
}
/** Whether the preview is running – a moving picture needs a frame without an event. */
get isPlaying(): boolean {
return this.canvas.isPlaying;
}
override selectedId(): string {
return this.state.selectedAnimationId ?? '';
}
override isMine(asset: Asset): asset is AnimationAsset {
return asset.type === 'animation';
}
protected override infoText(asset: AnimationAsset): string {
const rows = asset.tracks.length;
const frames = Math.max(0, frameCount(asset));
const seconds = formatNumber(animationDuration(asset), 1);
const fps = Number(fpsOf(asset).toFixed(2));
return `${rows} ${rows === 1 ? 'row' : 'rows'} · ${frames} ${frames === 1 ? 'frame' : 'frames'} · ${fps} fps · ${seconds} s`;
}
protected override syncExtras(asset: AnimationAsset | null): void {
const root = asset ? findAsset(this.state.project, asset.root) : undefined;
this.preview.show(asset?.id ?? '');
this.fitButton.setEnabled(Boolean(root));
this.rootPicker.setText(root ? root.name : asset ? 'Animates…' : '—');
this.rootPicker.setEnabled(asset !== null);
// A row needs something to aim at.
this.addRowButton.setEnabled(Boolean(root));
this.playButton.setEnabled(Boolean(asset && frameCount(asset) > 0));
this.syncPlayButton();
}
private syncPlayButton(): void {
this.playButton.setText(this.canvas.isPlaying ? 'Pause' : 'Play');
}
private togglePlay(): void {
if (this.canvas.isPlaying) this.canvas.pause();
else this.canvas.play();
this.syncPlayButton();
}
/** What may be animated: a sprite, text, container or scene – never another animation. */
private openRootPicker(): void {
const asset = this.asset();
if (!asset) return;
const items: MenuItem[] = this.state.project.assets
.filter((a) => ANIMATION_ROOT_TYPES.includes(a.type))
.sort((a, b) => a.name.localeCompare(b.name))
.map((candidate) => ({
label: `${candidate.name} (${candidate.type})`,
current: candidate.id === asset.root,
onSelect: () => this.state.execute(new SetAnimationRootCommand(asset.id, candidate.id)),
}));
if (items.length === 0) items.push({ label: 'Nothing to animate yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.rootPicker);
}
private openRowDialog(): void {
const asset = this.asset();
if (asset) openRowDialog(new RowDialog(this.state, asset));
}
}