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>
192 lines
7.3 KiB
TypeScript
192 lines
7.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
CreateAssetCommand,
|
||
SetAssetPropertyCommand,
|
||
SetMachineScopeCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { pluginsOf } from '../../src/core/store/projectPlugins';
|
||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||
|
||
describe('serialize', () => {
|
||
it('round-trips a project', () => {
|
||
const f = makeSpriteScene();
|
||
const box = place(f, f.spriteId, { x: 7, y: 8 });
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'width', 33));
|
||
expect(roundTrip(f.project)).toEqual(f.project);
|
||
});
|
||
|
||
/**
|
||
* Which plugins the project switches on (#119). The editor reads them off the raw file before it
|
||
* deserializes anything, so `pluginsOf` is what both sides use and both have to agree.
|
||
*/
|
||
it('reads the plugins of a project the same way before and after deserializing', () => {
|
||
const raw = { id: 'p', name: 'P', plugins: ['b.js', 'a.js', 'a.js', '', 7] };
|
||
expect(pluginsOf(raw)).toEqual(['a.js', 'b.js']);
|
||
expect(deserializeProject(raw).plugins).toEqual(['a.js', 'b.js']);
|
||
// A project written before there was such a field, and anything that is not a list of names.
|
||
expect(pluginsOf({ id: 'p' })).toEqual([]);
|
||
expect(pluginsOf({ plugins: 'demo.js' })).toEqual([]);
|
||
expect(pluginsOf(null)).toEqual([]);
|
||
expect(deserializeProject({ id: 'p' }).plugins).toEqual([]);
|
||
});
|
||
|
||
/** A behavior's own rows are not the common ones: coercing against those alone dropped the value. */
|
||
it('keeps a behavior running in the editor across a save', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = makeBehavior(f, 'B');
|
||
f.stack.execute(new SetAssetPropertyCommand(behavior.id, 'runInEditor', true));
|
||
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id)!;
|
||
expect(loaded.props.runInEditor).toBe(true);
|
||
});
|
||
|
||
/** The declarations of a Variables asset and their starting values are the whole asset. */
|
||
it('round-trips a Variables asset and the scope of a state machine', () => {
|
||
const f = makeSpriteScene();
|
||
const shared = new CreateAssetCommand('variables', 'Score');
|
||
f.stack.execute(shared);
|
||
f.stack.execute(new AddVariableCommand(shared.asset.id, 'score', 'number'));
|
||
f.stack.execute(new SetAssetPropertyCommand(shared.asset.id, 'score', 7));
|
||
const machine = new CreateAssetCommand('statemachine', 'M');
|
||
f.stack.execute(machine);
|
||
f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global'));
|
||
expect(roundTrip(f.project)).toEqual(f.project);
|
||
});
|
||
|
||
/** `local` is the absence of the field, so a project made before scopes existed saves back byte-alike. */
|
||
it('writes no scope for a local machine and reads an unknown one as local', () => {
|
||
const f = makeSpriteScene();
|
||
makeBehavior(f, 'B', ['score']);
|
||
f.stack.execute(new CreateAssetCommand('statemachine', 'M'));
|
||
expect(JSON.stringify(serializeProject(f.project))).not.toContain('scope');
|
||
|
||
const loaded = deserializeProject({
|
||
assets: [{ id: 'm1', type: 'statemachine', name: 'M', scope: 'planet' }],
|
||
});
|
||
const machine = loaded.assets[0]!;
|
||
if (machine.type !== 'statemachine') throw new Error('wrong types');
|
||
expect(machine.scope).toBeUndefined();
|
||
});
|
||
|
||
/** #41: the borders are part of the frame, and a frame nobody sliced still saves without them. */
|
||
it('round-trips the nine-slice of a frame and repairs an impossible one', () => {
|
||
const file = `${'ab'.repeat(32)}.png`;
|
||
const loaded = deserializeProject({
|
||
assets: [
|
||
{
|
||
id: 't1',
|
||
type: 'texture',
|
||
name: 'Sheet',
|
||
file,
|
||
width: 64,
|
||
height: 64,
|
||
frames: [
|
||
{
|
||
id: 'f1',
|
||
name: 'Button',
|
||
x: 0,
|
||
y: 0,
|
||
w: 24,
|
||
h: 24,
|
||
border: { left: 8, right: 8 },
|
||
fill: 'stretch',
|
||
},
|
||
{ id: 'f2', name: 'Plain', x: 24, y: 0, w: 24, h: 24 },
|
||
{
|
||
id: 'f3',
|
||
name: 'Greedy',
|
||
x: 0,
|
||
y: 24,
|
||
w: 8,
|
||
h: 8,
|
||
border: { top: 99, bottom: 99 },
|
||
fill: 'wat',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
const texture = loaded.assets[0]!;
|
||
if (texture.type !== 'texture') throw new Error('wrong types');
|
||
const [sliced, plain, greedy] = texture.frames;
|
||
expect(sliced?.border).toEqual({ left: 8, top: 0, right: 8, bottom: 0 });
|
||
// Only the fill that is not the default is written; an unknown one reads as the default.
|
||
expect(sliced?.fill).toBe('stretch');
|
||
expect(plain?.border).toBeUndefined();
|
||
expect(plain?.fill).toBeUndefined();
|
||
// Measured against the frame, not the image, and never leaving it without a middle.
|
||
expect(greedy?.border).toEqual({ left: 0, top: 4, right: 0, bottom: 3 });
|
||
expect(greedy?.fill).toBeUndefined();
|
||
expect(roundTrip(loaded)).toEqual(loaded);
|
||
});
|
||
|
||
it('repairs broken documents', () => {
|
||
const project = deserializeProject(
|
||
{
|
||
name: 'Broken',
|
||
activeSceneId: 'missing',
|
||
assets: [
|
||
{
|
||
id: 's1',
|
||
type: 'scene',
|
||
name: 'Scene',
|
||
props: { backgroundColor: '#000000', junk: 1 },
|
||
objects: [
|
||
{
|
||
id: 'o1',
|
||
assetId: 'sp1',
|
||
position: { x: '5', y: null },
|
||
overrides: { color: '#00ff00', width: -3, foo: 1 },
|
||
},
|
||
{ id: 'o2', assetId: 'nope', position: {} },
|
||
{ id: 'o3', assetId: 's1', position: {} },
|
||
],
|
||
},
|
||
{ id: 'sp1', type: 'sprite', props: { width: 10 } },
|
||
// An asset type this build does not know (a newer document, a renamed type). It used to
|
||
// be `sound` (known since #45), then `video` (known since #402) – hence a made-up type.
|
||
{ id: 'x', type: 'hologram' },
|
||
{ type: 'camera' },
|
||
],
|
||
},
|
||
'fallback-id',
|
||
);
|
||
expect(project.id).toBe('fallback-id');
|
||
expect(project.activeSceneId).toBe('s1');
|
||
expect(project.assets.map((a) => a.id)).toEqual(['s1', 'sp1']);
|
||
const scene = project.assets[0]!;
|
||
expect(scene.type === 'scene' && scene.props).toEqual({ backgroundColor: 0, gravityX: 0, gravityY: 980 });
|
||
const objects = scene.type === 'scene' ? scene.objects : [];
|
||
expect(objects).toHaveLength(1);
|
||
expect(objects[0]).toEqual({
|
||
id: 'o1',
|
||
assetId: 'sp1',
|
||
name: '',
|
||
position: { x: 5, y: 0, z: 0 },
|
||
editorOffset: { x: 0, y: 0 },
|
||
overrides: { color: 0x00ff00, width: 1 },
|
||
parentId: '',
|
||
instanceEdits: {},
|
||
});
|
||
expect(project.assets[1]!.name).toBe('sprite');
|
||
expect(project.assets[1]!.props).toEqual({
|
||
enabled: true,
|
||
keepOnSceneLoad: false,
|
||
scale: 1,
|
||
rotation: 0,
|
||
mirrorX: false,
|
||
mirrorY: false,
|
||
renderLayer: -1,
|
||
width: 10,
|
||
height: 100,
|
||
color: 0xffffff,
|
||
texture: '',
|
||
videoClock: 'free',
|
||
videoTime: 0,
|
||
shader: '',
|
||
});
|
||
});
|
||
});
|