Let a behavior play, pause and seek a video and hear it end (#410)
A game should be able to stop a video on a sprite, jump to a spot in it and start it again, and react when it has run out. Until now a video on a sprite either ran free on its own or followed a composition, and no behavior could touch it. - Five new nodes: Play video, Pause video and Seek video (time in s from the clip start), On video finished with a Video field, and the data node Video time with time, length and playing. - Play, Pause and Seek act on the owner when object is not linked. - On video finished with an empty field hears every video. A chosen video also hears its clips. - A new VideoSystem keeps a clock for every sprite with a video. It counts from the moment the sprite appears, as long as it is on. For a Driven sprite the clock is its Video time. A paused game holds all clocks. - A free sprite that one of the nodes touches gets the clock "steered" at run time. It then has its own player, and other sprites with the same clip play on. Pause of the game and sound go the existing Driven way. - On a Driven sprite and in the editor run the nodes do nothing and write a line to the log. - Until its own player has a frame, a steered sprite shows the frame of the shared player. Without that it would blink out on the first Pause video. - Play at the end of a clip without loop starts it from the start. The issue does not say, this is my choice. Found, not fixed: with a Driven sprite in the scene the run waits up to 3 s for its frame at the start. The world does not tick then, but the free videos already play. Their clocks lag behind the picture by that wait, so On video finished comes that much late. Tests: - Unit: 23 new (video-steer 16, node catalog 7), affected groups 4820/4820. - Counter-probes: 16 bends, all red. - e2e 66 on atx: ALL PASSED, 39 checks. New 66s: Pause at 0.15 s held its own player at 0.2 s, the picture stayed red for 1 s. Seek landed on 0.5 s exactly, green, held. Play showed green, blue, white, and white stayed at the end. On video finished came once. Without the clock "steered" all three 66s checks are red. - Gate on atx: CHECK EXIT 0, 336 files, 5199 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9428c5bc2
commit
b17a924157
19 files changed
+885
-16
No files matched your search
@@ -5,6 +5,7 @@ import type { Position, PropertyBag } from '../model/Properties';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
import type { HeardPosition, Quantize } from '../music/beats';
|
||||
import type { RecordingSettings } from '../recording/recording';
|
||||
import type { VideoReading, VideoSteer } from '../model/videoClock';
|
||||
import { type ChainTransform, composeAlongChain } from '../model/walk';
|
||||
import { enabledAlongChain } from '../properties/enabled';
|
||||
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
|
||||
@@ -242,6 +243,13 @@ export interface NodeContext {
|
||||
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
|
||||
physics?: PhysicsControl;
|
||||
log(message: string): void;
|
||||
/**
|
||||
* Plays, pauses or seeks the video the sprite `target` wears (#410). A free one gets a player of its
|
||||
* own from then on; a driven one, and the editor run, are left alone and say so in the log.
|
||||
*/
|
||||
steerVideo(target: EntityHandle, steer: VideoSteer): void;
|
||||
/** Where the video the sprite `target` wears stands (#410); null for none. */
|
||||
videoReading(target: EntityHandle): VideoReading | null;
|
||||
/**
|
||||
* Starts a sound asset, named by its id. `volume` multiplies the asset's own (1 leaves it alone);
|
||||
* `loop` is the caller's answer to whether it repeats, and `undefined` leaves that to the asset,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { VideoSteer } from '../../../model/videoClock';
|
||||
import type { EntityHandle, NodeContext } from '../../NodeContext';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asEntity, asNumber, type Value } from '../../values';
|
||||
import { exec, num, obj } from '../shared';
|
||||
|
||||
const STEERS =
|
||||
'A free video gets a player of its own from then on, so other sprites wearing the same clip play ' +
|
||||
'on. A driven one and the editor run are left alone and say so in the log. Unconnected, object is the owner.';
|
||||
|
||||
/** The sprite a video node works on: the connected object, or the owner. */
|
||||
export function videoTarget(ctx: NodeContext, value: Value | undefined): EntityHandle | null {
|
||||
return asEntity(value) ?? ctx.owner;
|
||||
}
|
||||
|
||||
function steering(steer: (inputs: Record<string, Value>) => VideoSteer): NodeDef['run'] {
|
||||
return (ctx, inputs) => {
|
||||
const target = videoTarget(ctx, inputs.object);
|
||||
if (target) ctx.steerVideo(target, steer(inputs));
|
||||
return { next: 'exec' };
|
||||
};
|
||||
}
|
||||
|
||||
export const PlayVideo: NodeDef = {
|
||||
type: 'PlayVideo',
|
||||
label: 'Play video',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays on the video a sprite wears from where it stands, heard as before. At the end of a clip ' +
|
||||
`without Loop it starts over. ${STEERS}`,
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: steering(() => ({ kind: 'play' })),
|
||||
};
|
||||
|
||||
export const PauseVideo: NodeDef = {
|
||||
type: 'PauseVideo',
|
||||
label: 'Pause video',
|
||||
category: 'action',
|
||||
description: `Holds the video a sprite wears at the picture it shows, without sound. ${STEERS}`,
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: steering(() => ({ kind: 'pause' })),
|
||||
};
|
||||
|
||||
export const SeekVideo: NodeDef = {
|
||||
type: 'SeekVideo',
|
||||
label: 'Seek video',
|
||||
category: 'action',
|
||||
description:
|
||||
'Sends the video a sprite wears to time, in seconds from the start of its clip; playing or held, ' +
|
||||
'it stays so. Past the end it goes round with Loop and holds the last picture without, as Video ' +
|
||||
`time does. ${STEERS}`,
|
||||
inputs: [exec(), obj('object'), num('time', 'time (s)')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: steering((inputs) => ({ kind: 'seek', time: asNumber(inputs.time) })),
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { videoTarget } from '../action/videoNodes';
|
||||
import { bool, num, obj } from '../shared';
|
||||
|
||||
export const VideoTime: NodeDef = {
|
||||
type: 'VideoTime',
|
||||
label: 'Video time',
|
||||
category: 'data',
|
||||
description:
|
||||
'Where the video a sprite wears stands: "time" in seconds from the start of its clip, "length" ' +
|
||||
'how long the clip lasts, "playing" whether it moves. A free video counts from when the sprite ' +
|
||||
'appeared, while it is switched on; a driven one stands where its Video time says. All 0 for a ' +
|
||||
'sprite without a video. Unconnected, object is the owner.',
|
||||
inputs: [obj('object')],
|
||||
outputs: [num('time', 'time (s)'), num('length', 'length (s)'), bool('playing')],
|
||||
params: [],
|
||||
read: (ctx, _params, input) => {
|
||||
const target = videoTarget(ctx, input('object'));
|
||||
const reading = target ? ctx.videoReading(target) : null;
|
||||
return reading ? { ...reading } : { time: 0, length: 0, playing: false };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { exec, obj } from '../shared';
|
||||
|
||||
export const OnVideoFinished: NodeDef = {
|
||||
type: 'OnVideoFinished',
|
||||
label: 'On video finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a video without Loop reaches its last picture on the owner, or on a sprite below ' +
|
||||
'it – and when one this behavior played, paused or sought does, on whatever sprite. A free video ' +
|
||||
'counts from when its sprite appeared, while it is switched on; a driven one ends when its Video ' +
|
||||
'time passes the end. Leave Video at None to hear every video; a video hears its clips as well.',
|
||||
inputs: [],
|
||||
outputs: [exec(), obj('object')],
|
||||
params: [{ key: 'video', label: 'Video', kind: 'asset', default: '', assetTypes: ['video'] }, orderParam],
|
||||
hiddenParams: (params) => [...(textOf(params.video ?? '') ? [] : ['video']), ...hiddenOrder(params)],
|
||||
};
|
||||
@@ -36,6 +36,7 @@ import { SetZ } from './action/SetZ';
|
||||
import { Spawn } from './action/Spawn';
|
||||
import { StopGame } from './action/StopGame';
|
||||
import { StartRecording, StopRecording } from './action/recordingNodes';
|
||||
import { PauseVideo, PlayVideo, SeekVideo } from './action/videoNodes';
|
||||
import { StopSound } from './action/StopSound';
|
||||
import { CellToWorld, SetTile, TileAt, TileLiteral, WorldToCell } from './action/tileNodes';
|
||||
import { Abs } from './data/Abs';
|
||||
@@ -126,8 +127,10 @@ import { SubList } from './data/SubList';
|
||||
import { TextureSize } from './data/TextureSize';
|
||||
import { OnAnimationFinished } from './events/OnAnimationFinished';
|
||||
import { OnCompositionFinished } from './events/OnCompositionFinished';
|
||||
import { OnVideoFinished } from './events/OnVideoFinished';
|
||||
import { OnBar, OnBeat, OnSongMarker } from './events/musicEvents';
|
||||
import { SongPosition } from './data/SongPosition';
|
||||
import { VideoTime } from './data/VideoTime';
|
||||
import { OnBehavior } from './events/OnBehavior';
|
||||
import { OnClick } from './events/OnClick';
|
||||
import { OnMouseWheel } from './events/OnMouseWheel';
|
||||
@@ -189,6 +192,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
OnMouseWheel,
|
||||
OnAnimationFinished,
|
||||
OnCompositionFinished,
|
||||
OnVideoFinished,
|
||||
OnBeat,
|
||||
OnBar,
|
||||
OnSongMarker,
|
||||
@@ -319,6 +323,9 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
StopAnimation,
|
||||
PlayComposition,
|
||||
StopComposition,
|
||||
PlayVideo,
|
||||
PauseVideo,
|
||||
SeekVideo,
|
||||
PlaySound,
|
||||
StopSound,
|
||||
SetSoundFxParameters,
|
||||
@@ -334,6 +341,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
ReleaseSongLane,
|
||||
SwitchSong,
|
||||
SongPosition,
|
||||
VideoTime,
|
||||
SetBusVolume,
|
||||
BusVolume,
|
||||
LoadScene,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The time of the video a sprite wears, as a run counts it (#410): what `On video finished` and
|
||||
* `Video time` go by, and what `Play video`, `Pause video` and `Seek video` set.
|
||||
*
|
||||
* A free video counts from when its sprite appears, while it is switched on; a behavior that steers
|
||||
* it takes it over from there. A driven one has the time its `Video time` says.
|
||||
*/
|
||||
export interface VideoClock {
|
||||
/** The texture it counts for, a video or a clip of one; another one starts it over. */
|
||||
ref: string;
|
||||
/** Seconds into the clip: between 0 and its length, once that is known. */
|
||||
time: number;
|
||||
playing: boolean;
|
||||
/** Set once a behavior steered it: the run drives the sprite's video by this time from then on. */
|
||||
steered: boolean;
|
||||
/** The behaviors that steered it: they hear its end wherever it stands. */
|
||||
steeredBy: object[];
|
||||
}
|
||||
|
||||
export type VideoSteer = { kind: 'play' } | { kind: 'pause' } | { kind: 'seek'; time: number };
|
||||
|
||||
/** What `Video time` reads. */
|
||||
export interface VideoReading {
|
||||
time: number;
|
||||
length: number;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
export function newVideoClock(ref: string): VideoClock {
|
||||
return { ref, time: 0, playing: true, steered: false, steeredBy: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `time` seconds into a clip `length` long stand, by the rule of `Video time`: round again with
|
||||
* Loop, held at the end without. An unknown length (0) bounds only at the start.
|
||||
*/
|
||||
export function clipTimeOf(time: number, length: number, loop: boolean): number {
|
||||
const run = Number.isFinite(time) ? Math.max(0, time) : 0;
|
||||
if (!(length > 0)) return run;
|
||||
return loop ? run % length : Math.min(run, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a playing clock on by `dt`. Returns whether it reached the end of a clip without Loop: it
|
||||
* stops there, so that happens once a pass.
|
||||
*/
|
||||
export function advanceVideoClock(clock: VideoClock, dt: number, length: number, loop: boolean): boolean {
|
||||
if (!clock.playing) return false;
|
||||
const time = clock.time + dt;
|
||||
if (!(length > 0) || loop || time < length) {
|
||||
clock.time = clipTimeOf(time, length, loop);
|
||||
return false;
|
||||
}
|
||||
clock.time = length;
|
||||
clock.playing = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What a steering node does to the clock; Play at the end of a clip without Loop starts it over. */
|
||||
export function steerVideoClock(clock: VideoClock, steer: VideoSteer, length: number, loop: boolean): void {
|
||||
if (steer.kind === 'pause') clock.playing = false;
|
||||
else if (steer.kind === 'seek') clock.time = clipTimeOf(steer.time, length, loop);
|
||||
else {
|
||||
if (!loop && length > 0 && clock.time >= length) clock.time = 0;
|
||||
clock.playing = true;
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,19 @@ export function videoLoopsOf(bag: PropertyBag): boolean {
|
||||
return bag[VIDEO_LOOP_KEY] === undefined ? true : booleanOf(bag[VIDEO_LOOP_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The clock a run gives a free sprite once a behavior plays, pauses or seeks its video (#410): driven
|
||||
* like `driven`, by the run's own count of its time. No row offers it, and nothing saves it.
|
||||
*/
|
||||
export const VIDEO_STEERED = 'steered';
|
||||
|
||||
/** Whether something other than the video sets its time: an animation, a composition, a behavior. */
|
||||
export function videoDrivenOf(bag: PropertyBag): boolean {
|
||||
return bag[VIDEO_CLOCK_KEY] === 'driven';
|
||||
return bag[VIDEO_CLOCK_KEY] === 'driven' || bag[VIDEO_CLOCK_KEY] === VIDEO_STEERED;
|
||||
}
|
||||
|
||||
export function videoSteeredOf(bag: PropertyBag): boolean {
|
||||
return bag[VIDEO_CLOCK_KEY] === VIDEO_STEERED;
|
||||
}
|
||||
|
||||
export function videoTimeOf(bag: PropertyBag): number {
|
||||
|
||||
@@ -137,6 +137,7 @@ export class EditorRun {
|
||||
audio: soundLibrary,
|
||||
saves: saveArea(project.id, 'editor'),
|
||||
playsCompositions: false,
|
||||
steersVideos: false,
|
||||
});
|
||||
this.world = fresh;
|
||||
this.hadFocus = this.hooks.focused();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CompositionPlayer } from '../core/model/composition';
|
||||
import type { FrameOutline } from '../core/model/frameOutline';
|
||||
import type { TileEdits } from '../core/model/liveTilemap';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import type { VideoClock } from '../core/model/videoClock';
|
||||
|
||||
/** A live instance of a scene object; its resolved props are mutable runtime state the world mirrors back. */
|
||||
export interface Entity {
|
||||
@@ -29,6 +30,8 @@ export interface Entity {
|
||||
animations?: AnimationPlayer[];
|
||||
/** For a placed composition: what `Play composition` started (#417), out of `props` like `animations`. */
|
||||
composition?: CompositionPlayer;
|
||||
/** For a sprite wearing a video: the time a run counts for it (#410), out of `props` like `animations`. */
|
||||
video?: VideoClock;
|
||||
/** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */
|
||||
emits?: number;
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type Pulse,
|
||||
pulsesOf,
|
||||
timerPulses,
|
||||
videoFinishedPulses,
|
||||
} from './behaviorPulses';
|
||||
import { CollisionSystem } from './CollisionSystem';
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ import { CompositionSystem, type VideoPictures } from './CompositionSystem';
|
||||
import type { Input } from './Input';
|
||||
import type { RecordingSink } from '../../core/recording/recording';
|
||||
import type { System } from './System';
|
||||
import { VideoSystem } from './VideoSystem';
|
||||
|
||||
export type { Breakpoint, TimeScaled } from './behaviorContext';
|
||||
|
||||
@@ -131,13 +133,15 @@ export interface GameSystemOptions {
|
||||
recorder?: RecordingSink | null;
|
||||
/** Whether `Play composition` plays (#417); the editor run ignores it. */
|
||||
playsCompositions?: boolean;
|
||||
/** Whether `Play video`, `Pause video` and `Seek video` steer (#410); the editor run ignores them. */
|
||||
steersVideos?: boolean;
|
||||
/** The view a played game is shown in: a composition waits for its videos there (#417). */
|
||||
pictures?: VideoPictures | null;
|
||||
/** Where a `Break` node pauses: only the editor's Play has one. */
|
||||
breakpoint?: Breakpoint | null;
|
||||
}
|
||||
|
||||
/** Collisions, compositions and animations are ticked first, so behaviors see this frame's events. */
|
||||
/** Collisions, compositions, animations and videos are ticked first, so behaviors see this frame's events. */
|
||||
export function addGameSystems(
|
||||
world: World,
|
||||
project: Project,
|
||||
@@ -152,6 +156,7 @@ export function addGameSystems(
|
||||
saves = null,
|
||||
recorder = null,
|
||||
playsCompositions = true,
|
||||
steersVideos = true,
|
||||
pictures = null,
|
||||
breakpoint = null,
|
||||
}: GameSystemOptions = {},
|
||||
@@ -159,7 +164,8 @@ export function addGameSystems(
|
||||
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
|
||||
const compositions = new CompositionSystem(playsCompositions, pictures, audio);
|
||||
const animations = new AnimationSystem();
|
||||
const sources = { collisions, animations, compositions, audio, clock, saves, recorder, breakpoint };
|
||||
const videos = new VideoSystem(steersVideos);
|
||||
const sources = { collisions, animations, compositions, videos, audio, clock, saves, recorder, breakpoint };
|
||||
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
|
||||
if (carried) behaviors.carryOver(carried);
|
||||
if (kept) {
|
||||
@@ -169,6 +175,7 @@ export function addGameSystems(
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(compositions);
|
||||
world.addSystem(animations);
|
||||
world.addSystem(videos);
|
||||
world.addSystem(behaviors);
|
||||
return behaviors;
|
||||
}
|
||||
@@ -178,6 +185,7 @@ export interface EventSources {
|
||||
collisions?: (CollisionSource & System) | null;
|
||||
animations?: AnimationSystem | null;
|
||||
compositions?: CompositionSystem | null;
|
||||
videos?: VideoSystem | null;
|
||||
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
|
||||
audio?: AudioSink | null;
|
||||
clock?: TimeScaled | null;
|
||||
@@ -225,6 +233,7 @@ export class BehaviorSystem implements System {
|
||||
private readonly collisions: CollisionSource | null;
|
||||
private readonly animations: AnimationSystem | null;
|
||||
private readonly compositions: CompositionSystem | null;
|
||||
private readonly videos: VideoSystem | null;
|
||||
private readonly audio: AudioSink;
|
||||
private readonly clock: TimeScaled | null;
|
||||
private readonly saves: SaveArea | null;
|
||||
@@ -248,6 +257,7 @@ export class BehaviorSystem implements System {
|
||||
this.collisions = sources.collisions ?? null;
|
||||
this.animations = sources.animations ?? null;
|
||||
this.compositions = sources.compositions ?? null;
|
||||
this.videos = sources.videos ?? null;
|
||||
this.audio = sources.audio ?? SILENT_AUDIO;
|
||||
this.clock = sources.clock ?? null;
|
||||
this.saves = sources.saves ?? null;
|
||||
@@ -644,7 +654,7 @@ export class BehaviorSystem implements System {
|
||||
const owner = i.entity.parent;
|
||||
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
|
||||
};
|
||||
const { input, collisions, animations, compositions } = this;
|
||||
const { input, collisions, animations, compositions, videos } = this;
|
||||
const perCell: PerCell = (i, node) =>
|
||||
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
|
||||
// Every frame, paused or not: the music goes on, and what a paused one passed is not told after.
|
||||
@@ -664,6 +674,7 @@ export class BehaviorSystem implements System {
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => compositionFinishedPulses(i, compositions))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => videoFinishedPulses(i, videos))),
|
||||
() => ticking.flatMap((i) => beatPulses(i, passed, playbackOf)),
|
||||
() => ticking.flatMap((i) => barPulses(i, passed, playbackOf)),
|
||||
() => ticking.flatMap((i) => markerPulses(i, passed, playbackOf)),
|
||||
@@ -795,6 +806,7 @@ export class BehaviorSystem implements System {
|
||||
project: this.project,
|
||||
input: this.input,
|
||||
collisions: this.collisions,
|
||||
videos: this.videos,
|
||||
audio: this.audio,
|
||||
clock: this.clock,
|
||||
recorder: this.recorder,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { isHandleEnabled } from '../../core/graph';
|
||||
import { findVideo, videoLengthOf, videoPlayOf, type VideoRef } from '../../core/model/video';
|
||||
import {
|
||||
advanceVideoClock,
|
||||
clipTimeOf,
|
||||
newVideoClock,
|
||||
steerVideoClock,
|
||||
type VideoClock,
|
||||
type VideoReading,
|
||||
type VideoSteer,
|
||||
} from '../../core/model/videoClock';
|
||||
import {
|
||||
VIDEO_CLOCK_KEY,
|
||||
VIDEO_STEERED,
|
||||
VIDEO_TIME_KEY,
|
||||
videoDrivenOf,
|
||||
videoTimeOf,
|
||||
} from '../../core/properties/videos';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import type { System } from './System';
|
||||
|
||||
export interface VideoFinish {
|
||||
/** The sprite. */
|
||||
entity: Entity;
|
||||
videoId: string;
|
||||
/** The video or clip it wears. */
|
||||
ref: string;
|
||||
steeredBy: readonly object[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the time of every video a sprite wears in the run (#410) and tells when one without Loop
|
||||
* reaches its end. Runs after the compositions and animations, which set a driven one's time, and
|
||||
* before the behaviors, so they hear this frame's `finished`. A paused game does not tick it: every
|
||||
* clock holds.
|
||||
*
|
||||
* A sprite a behavior steered is driven by its clock from then on: a player of its own shows the
|
||||
* picture at that time, as for a driven one. The editor run steers none: it writes into the saved
|
||||
* scene, where the clock would stay behind.
|
||||
*/
|
||||
export class VideoSystem implements System {
|
||||
finished: VideoFinish[] = [];
|
||||
|
||||
constructor(private readonly steers = true) {}
|
||||
|
||||
update(world: World, dt: number): void {
|
||||
this.finished = [];
|
||||
for (const entity of world.entities) {
|
||||
if (entity.type !== 'sprite') continue;
|
||||
const ref = wornVideo(world, entity);
|
||||
if (!ref) {
|
||||
letGo(entity);
|
||||
continue;
|
||||
}
|
||||
const known = entity.video?.ref === entity.props.texture;
|
||||
const clock = clockOf(entity);
|
||||
const length = videoLengthOf(ref);
|
||||
const loop = videoPlayOf(ref).loop;
|
||||
let ended: boolean;
|
||||
if (!clock.steered && videoDrivenOf(entity.props)) {
|
||||
const time = videoTimeOf(entity.props);
|
||||
const was = known ? clock.time : time;
|
||||
ended = !loop && length > 0 && was < length && time >= length;
|
||||
clock.playing = time !== was;
|
||||
clock.time = time;
|
||||
} else {
|
||||
ended = isHandleEnabled(entity) && advanceVideoClock(clock, dt, length, loop);
|
||||
if (clock.steered) drive(entity, clock);
|
||||
}
|
||||
if (ended)
|
||||
this.finished.push({ entity, videoId: ref.asset.id, ref: clock.ref, steeredBy: clock.steeredBy });
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.finished = [];
|
||||
}
|
||||
|
||||
/** What a steering node does (#410); the line for the log where it does nothing. */
|
||||
steer(world: World, entity: Entity, steer: VideoSteer, by: object): string | null {
|
||||
const ref = entity.type === 'sprite' ? wornVideo(world, entity) : null;
|
||||
if (!ref) return `${entity.name} wears no video, so it cannot be played, paused or sought`;
|
||||
if (!this.steers) return 'Play, Pause and Seek video are ignored in the editor run';
|
||||
const clock = clockOf(entity);
|
||||
if (!clock.steered && videoDrivenOf(entity.props))
|
||||
return `${entity.name} has a driven Video clock: its Video time says where the video stands`;
|
||||
clock.steered = true;
|
||||
if (!clock.steeredBy.includes(by)) clock.steeredBy.push(by);
|
||||
steerVideoClock(clock, steer, videoLengthOf(ref), videoPlayOf(ref).loop);
|
||||
drive(entity, clock);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Where the video `entity` wears stands; null for none. */
|
||||
reading(world: World, entity: Entity): VideoReading | null {
|
||||
const ref = entity.type === 'sprite' ? wornVideo(world, entity) : null;
|
||||
if (!ref) return null;
|
||||
const clock = clockOf(entity);
|
||||
const length = videoLengthOf(ref);
|
||||
return { time: clipTimeOf(clock.time, length, videoPlayOf(ref).loop), length, playing: clock.playing };
|
||||
}
|
||||
}
|
||||
|
||||
function wornVideo(world: World, entity: Entity): VideoRef | null {
|
||||
const texture = entity.props.texture;
|
||||
return typeof texture === 'string' ? findVideo(world.project, texture) : null;
|
||||
}
|
||||
|
||||
/** The sprite's clock, started over when it wears another video; a steered one stays steered. */
|
||||
function clockOf(entity: Entity): VideoClock {
|
||||
const texture = entity.props.texture as string;
|
||||
const clock = entity.video;
|
||||
if (clock?.ref === texture) return clock;
|
||||
const fresh = newVideoClock(texture);
|
||||
if (clock?.steered) {
|
||||
fresh.steered = true;
|
||||
fresh.steeredBy = clock.steeredBy;
|
||||
}
|
||||
return (entity.video = fresh);
|
||||
}
|
||||
|
||||
function drive(entity: Entity, clock: VideoClock): void {
|
||||
entity.props[VIDEO_CLOCK_KEY] = VIDEO_STEERED;
|
||||
entity.props[VIDEO_TIME_KEY] = clock.time;
|
||||
}
|
||||
|
||||
/** A sprite that wears no video any more has no clock; one a behavior steered is free again. */
|
||||
function letGo(entity: Entity): void {
|
||||
if (entity.video?.steered) entity.props[VIDEO_CLOCK_KEY] = 'free';
|
||||
delete entity.video;
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import type { Input } from './Input';
|
||||
import { type HeardPlaying, heardPlayings, positionOf } from './musicPulses';
|
||||
import type { RecordingSink } from '../../core/recording/recording';
|
||||
import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids';
|
||||
import type { VideoSystem } from './VideoSystem';
|
||||
|
||||
export interface TimeScaled {
|
||||
scale: number;
|
||||
@@ -65,6 +66,8 @@ export interface ContextSources {
|
||||
project: Project;
|
||||
input: Input;
|
||||
collisions: CollisionSource | null;
|
||||
/** What counts and steers the videos the sprites wear (#410); absent, nothing does. */
|
||||
videos?: VideoSystem | null;
|
||||
audio: AudioSink;
|
||||
clock: TimeScaled | null;
|
||||
/** Where a recording of the game view goes (#405); only a played game has one. */
|
||||
@@ -199,6 +202,15 @@ export function behaviorContext(
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[behavior ${entity.name}]`, message);
|
||||
},
|
||||
steerVideo: (target, steer) => {
|
||||
const found = resolve(target);
|
||||
const said = found && run.videos ? run.videos.steer(world, found, steer, entity) : null;
|
||||
if (said) world.log.push(said);
|
||||
},
|
||||
videoReading: (target) => {
|
||||
const found = resolve(target);
|
||||
return (found && run.videos?.reading(world, found)) ?? null;
|
||||
},
|
||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) =>
|
||||
playSound(
|
||||
run,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { parentChain } from '../../core/model/walk';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { AnimationSystem } from './AnimationSystem';
|
||||
import type { CompositionSystem } from './CompositionSystem';
|
||||
import type { VideoSystem } from './VideoSystem';
|
||||
import type { Input } from './Input';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids';
|
||||
|
||||
@@ -250,6 +251,26 @@ export function compositionFinishedPulses(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A behavior hears the videos of the sprites at its owner or below it, and those it steered itself
|
||||
* wherever they stand – the rule of `On animation finished`. Video names the video, or one clip of it.
|
||||
*/
|
||||
export function videoFinishedPulses(instance: Instance, videos: VideoSystem | null): Pulse[] {
|
||||
const owner = instance.entity.parent;
|
||||
const finished = (videos?.finished ?? []).filter(
|
||||
({ entity, steeredBy }) =>
|
||||
!owner ||
|
||||
steeredBy.includes(instance.entity) ||
|
||||
[...parentChain(entity, (e) => e.parent)].includes(owner),
|
||||
);
|
||||
return instance.interpreter.eventNodes('OnVideoFinished').flatMap((node) => {
|
||||
const wanted = textOf(node.params.video ?? '');
|
||||
return finished
|
||||
.filter(({ videoId, ref }) => wanted === '' || wanted === videoId || wanted === ref)
|
||||
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
|
||||
});
|
||||
}
|
||||
|
||||
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
|
||||
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
|
||||
const outputs: Record<string, Value> = {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { CameraShade, shadedPicture } from './cameraShade';
|
||||
import { snapPlacement } from './pixelGrid';
|
||||
import { cameraSees } from '../core/model/renderLayers';
|
||||
import { drivenVideoKey, isVideoKey } from '../core/model/video';
|
||||
import { videoDrivenOf } from '../core/properties/videos';
|
||||
import { videoDrivenOf, videoSteeredOf } from '../core/properties/videos';
|
||||
import type { HeardVideo } from './SteadySound';
|
||||
import { type VideoHearing, videoLibrary } from './VideoLibrary';
|
||||
|
||||
@@ -881,19 +881,25 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
||||
// A video (#402) moves by itself like a Time shader does.
|
||||
if (wornShader(item.props, shaders)?.time || (item.texture && isVideoKey(item.texture.file)))
|
||||
this.animated = true;
|
||||
const drawn = this.wearVideo(item);
|
||||
let drawn = this.wearVideo(item);
|
||||
let ghost = this.ghostSprites.get(item.id);
|
||||
// A driven video (#404) is left out until it has its first picture: its plain colour flashed
|
||||
// white at the start of a run and at a cut. So is one brought in while it still seeks, once the
|
||||
// view has held its last frame long enough (`holdsForVideo`).
|
||||
const key = drawn !== item ? (drawn.texture?.file ?? '') : null;
|
||||
if (
|
||||
let key = drawn !== item ? (drawn.texture?.file ?? '') : null;
|
||||
const ready = (at: string) =>
|
||||
videoLibrary.hasPicture(at) && (this.videosShown.get(item.id) === at || videoLibrary.showsAsked(at));
|
||||
// A free video a behavior just took over (#410) shows the shared picture until its own player has one.
|
||||
const bridged =
|
||||
key !== null &&
|
||||
!(
|
||||
videoLibrary.hasPicture(key) &&
|
||||
(this.videosShown.get(item.id) === key || videoLibrary.showsAsked(key))
|
||||
)
|
||||
) {
|
||||
!ready(key) &&
|
||||
videoSteeredOf(item.props) &&
|
||||
videoLibrary.hasPicture(item.texture!.file);
|
||||
if (bridged) {
|
||||
drawn = item;
|
||||
key = null;
|
||||
}
|
||||
if (key !== null && !ready(key)) {
|
||||
sprite.visible = false;
|
||||
if (ghost) disposeSpriteMesh(ghost);
|
||||
this.ghostSprites.delete(item.id);
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('the order rule', () => {
|
||||
const events = BUILT_IN_NODES.filter(
|
||||
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
|
||||
);
|
||||
expect(events).toHaveLength(22);
|
||||
expect(events).toHaveLength(23);
|
||||
for (const def of events) {
|
||||
expect(
|
||||
def.params.map((p) => p.key),
|
||||
|
||||
@@ -2007,6 +2007,44 @@ const CASES: Record<string, Case[]> = {
|
||||
after: () => expect(director.composition?.stopped).toBe(true),
|
||||
},
|
||||
],
|
||||
PlayVideo: [
|
||||
{
|
||||
name: 'plays on the video of the object it is given (#410)',
|
||||
inputs: { object: director },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.steered).toEqual([{ target: director.id, steer: { kind: 'play' } }]),
|
||||
},
|
||||
{
|
||||
name: 'unconnected, it plays the video of the owner',
|
||||
inputs: {},
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.steered).toEqual([{ target: ctx.owner!.id, steer: { kind: 'play' } }]),
|
||||
},
|
||||
],
|
||||
PauseVideo: [
|
||||
{
|
||||
name: 'holds the video of the object it is given (#410)',
|
||||
inputs: { object: director },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.steered).toEqual([{ target: director.id, steer: { kind: 'pause' } }]),
|
||||
},
|
||||
{
|
||||
name: 'without an owner and no object it asks nothing',
|
||||
inputs: {},
|
||||
context: { owner: null },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.steered).toEqual([]),
|
||||
},
|
||||
],
|
||||
SeekVideo: [
|
||||
{
|
||||
name: 'sends the video of the object to the time it is given (#410)',
|
||||
inputs: { object: director, time: 0.5 },
|
||||
next: 'exec',
|
||||
after: (ctx) =>
|
||||
expect(ctx.steered).toEqual([{ target: director.id, steer: { kind: 'seek', time: 0.5 } }]),
|
||||
},
|
||||
],
|
||||
PlaySound: [
|
||||
{
|
||||
name: 'no sound is a no-op that still continues the flow',
|
||||
@@ -2548,6 +2586,23 @@ const CASES: Record<string, Case[]> = {
|
||||
outputs: { bar: 0, beat: 0, fraction: 0, seconds: 0, playing: false },
|
||||
},
|
||||
],
|
||||
VideoTime: [
|
||||
{
|
||||
name: 'answers where the video of the object stands (#410)',
|
||||
context: {
|
||||
videoReading: (target: { id: string }) =>
|
||||
target.id === director.id ? { time: 0.75, length: 2, playing: true } : null,
|
||||
},
|
||||
inputs: { object: director },
|
||||
outputs: { time: 0.75, length: 2, playing: true },
|
||||
},
|
||||
{
|
||||
name: 'all 0 for a sprite without a video, the owner where unconnected',
|
||||
context: { videoReading: () => null },
|
||||
inputs: {},
|
||||
outputs: { time: 0, length: 0, playing: false },
|
||||
},
|
||||
],
|
||||
SetSongTrack: [
|
||||
{
|
||||
name: 'turns the track of the playing it is given, on the next bar where nothing else is picked (#338)',
|
||||
@@ -2629,6 +2684,7 @@ const EVENT_NODES: Record<string, string> = {
|
||||
OnMouseWheel: 'clip-areas.test.ts',
|
||||
OnAnimationFinished: 'animation.test.ts',
|
||||
OnCompositionFinished: 'composition-play.test.ts',
|
||||
OnVideoFinished: 'video-steer.test.ts',
|
||||
OnBeat: 'music-events.test.ts',
|
||||
OnBar: 'music-events.test.ts',
|
||||
OnSongMarker: 'music-events.test.ts',
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* The time of the video a sprite wears, as a run counts it (#410): `On video finished`, `Video time`
|
||||
* and the nodes that play, pause and seek it.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateVideoCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import {
|
||||
advanceVideoClock,
|
||||
clipTimeOf,
|
||||
newVideoClock,
|
||||
steerVideoClock,
|
||||
} from '../../src/core/model/videoClock';
|
||||
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY } from '../../src/core/properties/videos';
|
||||
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
|
||||
import { VideoSystem } from '../../src/runtime/systems/VideoSystem';
|
||||
import { Input } from '../../src/runtime/systems/Input';
|
||||
import { World } from '../../src/runtime/World';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
|
||||
|
||||
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
|
||||
|
||||
describe('the clock of a video', () => {
|
||||
it('goes round with Loop and holds at the end without, past the end and before the start', () => {
|
||||
expect(clipTimeOf(2.5, 2, true)).toBeCloseTo(0.5, 9);
|
||||
expect(clipTimeOf(2.5, 2, false)).toBe(2);
|
||||
expect(clipTimeOf(-1, 2, true)).toBe(0);
|
||||
expect(clipTimeOf(Number.NaN, 2, false)).toBe(0);
|
||||
// A length not known yet bounds only at the start.
|
||||
expect(clipTimeOf(7, 0, false)).toBe(7);
|
||||
});
|
||||
|
||||
it('reaches the end of a clip without Loop once a pass and stops there', () => {
|
||||
const clock = newVideoClock('v');
|
||||
const ends = [];
|
||||
for (let i = 0; i < 30; i++) ends.push(advanceVideoClock(clock, 0.1, 2, false));
|
||||
expect(ends.filter(Boolean)).toHaveLength(1);
|
||||
expect(ends.indexOf(true)).toBe(19);
|
||||
expect(clock).toMatchObject({ time: 2, playing: false });
|
||||
});
|
||||
|
||||
it('never ends with Loop: it goes round', () => {
|
||||
const clock = newVideoClock('v');
|
||||
const ends = [];
|
||||
for (let i = 0; i < 50; i++) ends.push(advanceVideoClock(clock, 0.1, 2, true));
|
||||
expect(ends.some(Boolean)).toBe(false);
|
||||
expect(clock.time).toBeCloseTo(1, 6);
|
||||
expect(clock.playing).toBe(true);
|
||||
});
|
||||
|
||||
it('a held clock does not move and does not end', () => {
|
||||
const clock = { ...newVideoClock('v'), time: 1.95 };
|
||||
steerVideoClock(clock, { kind: 'pause' }, 2, false);
|
||||
expect(advanceVideoClock(clock, 0.1, 2, false)).toBe(false);
|
||||
expect(clock.time).toBe(1.95);
|
||||
});
|
||||
|
||||
it('a seek past the end goes round with Loop and holds the last picture without', () => {
|
||||
const looping = newVideoClock('v');
|
||||
steerVideoClock(looping, { kind: 'seek', time: 4.5 }, 2, true);
|
||||
expect(looping.time).toBeCloseTo(0.5, 9);
|
||||
const once = newVideoClock('v');
|
||||
steerVideoClock(once, { kind: 'seek', time: 4.5 }, 2, false);
|
||||
expect(once.time).toBe(2);
|
||||
// Still playing, it is at its end: the next step tells so, once.
|
||||
expect(advanceVideoClock(once, 0.1, 2, false)).toBe(true);
|
||||
expect(advanceVideoClock(once, 0.1, 2, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('Play at the end of a clip without Loop starts it over, and it ends again', () => {
|
||||
const clock = newVideoClock('v');
|
||||
for (let i = 0; i < 25; i++) advanceVideoClock(clock, 0.1, 2, false);
|
||||
steerVideoClock(clock, { kind: 'play' }, 2, false);
|
||||
expect(clock).toMatchObject({ time: 0, playing: true });
|
||||
const ends = [];
|
||||
for (let i = 0; i < 25; i++) ends.push(advanceVideoClock(clock, 0.1, 2, false));
|
||||
expect(ends.filter(Boolean)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
interface Stage extends SceneFixture {
|
||||
videoId: string;
|
||||
/** The placed sprite wearing the video, and a second one wearing the same. */
|
||||
screen: string;
|
||||
other: string;
|
||||
}
|
||||
|
||||
/** A sprite "Screen" wearing a video two seconds long, placed twice; Loop as asked. */
|
||||
function stage(loop: boolean): Stage {
|
||||
const f = makeSpriteScene('Screen');
|
||||
const video = new CreateVideoCommand('Intro', FILE, {
|
||||
width: 320,
|
||||
height: 180,
|
||||
duration: 2,
|
||||
fps: 30,
|
||||
audio: false,
|
||||
});
|
||||
f.stack.execute(video);
|
||||
f.stack.execute(new SetAssetPropertyCommand(video.asset.id, 'loop', loop));
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'texture', video.asset.id));
|
||||
return {
|
||||
...f,
|
||||
videoId: video.asset.id,
|
||||
screen: place(f, f.spriteId, { x: 0, y: 0 }),
|
||||
other: place(f, f.spriteId, { x: 400, y: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
/** A behavior below `parent`: `On video finished` (Video as given) logs "done". */
|
||||
function listener(s: Stage, parent: string, video = ''): ReturnType<typeof graphEditor> {
|
||||
const behavior = makeBehavior(s, 'Watch');
|
||||
place(s, behavior.id, { x: 0, y: 0 }, parent);
|
||||
const edit = graphEditor(s, behavior);
|
||||
const finished = edit.addNode('OnVideoFinished', { video });
|
||||
const log = edit.addNode('Log', { message: 'done' });
|
||||
edit.connect(finished, 'exec', log, 'exec');
|
||||
return edit;
|
||||
}
|
||||
|
||||
function play(s: Stage, steersVideos = true) {
|
||||
const world = World.instantiate(s.project, s.sceneId);
|
||||
const input = new Input();
|
||||
addGameSystems(world, s.project, input, { steersVideos });
|
||||
return {
|
||||
world,
|
||||
input,
|
||||
at: (objectId: string) => world.findByObjectId(objectId)!,
|
||||
done: () => world.log.filter((line) => line === 'done').length,
|
||||
frame(times = 1) {
|
||||
for (let i = 0; i < times; i++) {
|
||||
world.tick(0.1);
|
||||
input.endFrame();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('a video played in a game', () => {
|
||||
it('tells once when a free video without Loop reaches its end, counted from when its sprite appeared', () => {
|
||||
const s = stage(false);
|
||||
listener(s, s.screen);
|
||||
const run = play(s);
|
||||
run.frame(19);
|
||||
expect(run.done()).toBe(0);
|
||||
run.frame();
|
||||
expect(run.done()).toBe(1);
|
||||
run.frame(30);
|
||||
expect(run.done()).toBe(1);
|
||||
// Counted only: a free sprite stays free.
|
||||
expect(run.at(s.screen).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
|
||||
});
|
||||
|
||||
it('never tells with Loop', () => {
|
||||
const s = stage(true);
|
||||
listener(s, s.screen);
|
||||
const run = play(s);
|
||||
run.frame(60);
|
||||
expect(run.done()).toBe(0);
|
||||
});
|
||||
|
||||
it('hears only the sprites below its owner, and the video it names', () => {
|
||||
const s = stage(false);
|
||||
listener(s, s.screen, 'some-other-video');
|
||||
const run = play(s);
|
||||
run.frame(25);
|
||||
expect(run.done()).toBe(0);
|
||||
const t = stage(false);
|
||||
listener(t, t.screen, t.videoId);
|
||||
const again = play(t);
|
||||
again.frame(25);
|
||||
// Two sprites wear it; the owner's is the one heard.
|
||||
expect(again.done()).toBe(1);
|
||||
});
|
||||
|
||||
it('a paused game holds the clock', () => {
|
||||
const s = stage(false);
|
||||
listener(s, s.screen);
|
||||
const run = play(s);
|
||||
run.frame(10);
|
||||
run.world.paused = true;
|
||||
run.frame(20);
|
||||
expect(run.done()).toBe(0);
|
||||
run.world.paused = false;
|
||||
run.frame(10);
|
||||
expect(run.done()).toBe(1);
|
||||
});
|
||||
|
||||
it('pause, seek and play steer the owner’s video on a player of its own and leave the other one free', () => {
|
||||
const s = stage(false);
|
||||
const edit = listener(s, s.screen);
|
||||
const pause = edit.addNode('PauseVideo');
|
||||
const seek = edit.addNode('SeekVideo', { time: 0.5 });
|
||||
const resume = edit.addNode('PlayVideo');
|
||||
const p = edit.addNode('OnKeyDown', { key: 'p' });
|
||||
const k = edit.addNode('OnKeyDown', { key: 'k' });
|
||||
const g = edit.addNode('OnKeyDown', { key: 'g' });
|
||||
edit.connect(p, 'exec', pause, 'exec');
|
||||
edit.connect(k, 'exec', seek, 'exec');
|
||||
edit.connect(g, 'exec', resume, 'exec');
|
||||
const run = play(s);
|
||||
run.frame(10);
|
||||
run.input.press('p');
|
||||
run.frame();
|
||||
const screen = run.at(s.screen);
|
||||
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('steered');
|
||||
const held = screen.props[VIDEO_TIME_KEY] as number;
|
||||
// The clock moved on with this frame before the behavior held it.
|
||||
expect(held).toBeCloseTo(1.1, 6);
|
||||
run.input.release('p');
|
||||
run.frame(30);
|
||||
expect(screen.props[VIDEO_TIME_KEY]).toBe(held);
|
||||
expect(run.done()).toBe(0);
|
||||
expect(run.at(s.other).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
|
||||
|
||||
run.input.press('k');
|
||||
run.frame();
|
||||
run.input.release('k');
|
||||
expect(screen.props[VIDEO_TIME_KEY]).toBe(0.5);
|
||||
run.input.press('g');
|
||||
run.frame();
|
||||
run.input.release('g');
|
||||
run.frame(14);
|
||||
expect(run.done()).toBe(0);
|
||||
run.frame();
|
||||
expect(run.done()).toBe(1);
|
||||
expect(screen.props[VIDEO_TIME_KEY]).toBe(2);
|
||||
});
|
||||
|
||||
it('a driven video is left alone, says so, and ends when its Video time passes the end', () => {
|
||||
const s = stage(false);
|
||||
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_CLOCK_KEY, 'driven'));
|
||||
const edit = listener(s, s.screen);
|
||||
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseVideo'), 'exec');
|
||||
const run = play(s);
|
||||
run.frame();
|
||||
expect(run.world.log.filter((l) => l.includes('driven Video clock'))).toHaveLength(1);
|
||||
const screen = run.at(s.screen);
|
||||
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('driven');
|
||||
screen.props[VIDEO_TIME_KEY] = 1.9;
|
||||
run.frame();
|
||||
expect(run.done()).toBe(0);
|
||||
screen.props[VIDEO_TIME_KEY] = 2.05;
|
||||
run.frame();
|
||||
screen.props[VIDEO_TIME_KEY] = 2.3;
|
||||
run.frame();
|
||||
expect(run.done()).toBe(1);
|
||||
});
|
||||
|
||||
it('a driven video already past its end when its sprite appears does not tell so', () => {
|
||||
const s = stage(false);
|
||||
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_CLOCK_KEY, 'driven'));
|
||||
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_TIME_KEY, 5));
|
||||
listener(s, s.screen);
|
||||
const run = play(s);
|
||||
run.frame(5);
|
||||
expect(run.done()).toBe(0);
|
||||
});
|
||||
|
||||
it('the editor run steers nothing and says so', () => {
|
||||
const s = stage(false);
|
||||
const edit = listener(s, s.screen);
|
||||
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseVideo'), 'exec');
|
||||
const run = play(s, false);
|
||||
run.frame(3);
|
||||
expect(run.world.log.filter((l) => l.includes('ignored in the editor run'))).toHaveLength(1);
|
||||
expect(run.at(s.screen).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
|
||||
});
|
||||
|
||||
it('Video time reads the clock: where it stands in the clip, its length, whether it moves', () => {
|
||||
const s = stage(true);
|
||||
const world = World.instantiate(s.project, s.sceneId);
|
||||
const videos = new VideoSystem();
|
||||
world.addSystem(videos);
|
||||
for (let i = 0; i < 25; i++) world.tick(0.1);
|
||||
const screen = world.findByObjectId(s.screen)!;
|
||||
const reading = videos.reading(world, screen)!;
|
||||
expect(reading.time).toBeCloseTo(0.5, 6);
|
||||
expect(reading).toMatchObject({ length: 2, playing: true });
|
||||
videos.steer(world, screen, { kind: 'pause' }, {});
|
||||
world.tick(0.1);
|
||||
expect(videos.reading(world, screen)!.playing).toBe(false);
|
||||
screen.props.texture = '';
|
||||
expect(videos.reading(world, screen)).toBeNull();
|
||||
});
|
||||
|
||||
it('another video on a steered sprite starts its clock over, still steered; none at all frees it', () => {
|
||||
const s = stage(false);
|
||||
const world = World.instantiate(s.project, s.sceneId);
|
||||
const videos = new VideoSystem();
|
||||
world.addSystem(videos);
|
||||
const screen = world.findByObjectId(s.screen)!;
|
||||
world.tick(0.1);
|
||||
videos.steer(world, screen, { kind: 'seek', time: 1.5 }, {});
|
||||
const second = new CreateVideoCommand('Outro', FILE, {
|
||||
width: 320,
|
||||
height: 180,
|
||||
duration: 3,
|
||||
fps: 30,
|
||||
audio: false,
|
||||
});
|
||||
s.stack.execute(second);
|
||||
screen.props.texture = second.asset.id;
|
||||
world.tick(0.1);
|
||||
expect(screen.video).toMatchObject({ ref: second.asset.id, steered: true, playing: true });
|
||||
expect(screen.props[VIDEO_TIME_KEY]).toBeCloseTo(0.1, 9);
|
||||
screen.props.texture = '';
|
||||
world.tick(0.1);
|
||||
expect(screen.video).toBeUndefined();
|
||||
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('free');
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import { createEmptyProject, findScene, type Project, ROUTING_ID } from '../src/
|
||||
import { settleRouting } from '../src/core/music/projectRouting';
|
||||
import { MASTER_BUS } from '../src/core/properties/buses';
|
||||
import type { RecordingSettings } from '../src/core/recording/recording';
|
||||
import type { VideoSteer } from '../src/core/model/videoClock';
|
||||
import type { SceneObject } from '../src/core/model/SceneObject';
|
||||
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
|
||||
import type { AudioSink } from '../src/runtime/systems/Audio';
|
||||
@@ -304,6 +305,8 @@ export type FakeContext = NodeContext & {
|
||||
recordings: (RecordingSettings | 'stop')[];
|
||||
/** How often a `Break` node asked to pause. */
|
||||
breaks: number;
|
||||
/** What the video nodes asked of which sprite (#410), in order. */
|
||||
steered: { target: string; steer: VideoSteer }[];
|
||||
};
|
||||
|
||||
/** A NodeContext double: self/owner handles, `speed = 100`, dt 0.5, moves applied directly, `log` and `stop` recorded. */
|
||||
@@ -347,6 +350,9 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
return { blockedX: false, blockedY: false, hit: false, normal: { x: 0, y: 0 } };
|
||||
},
|
||||
log: (message) => logs.push(message),
|
||||
steered: [],
|
||||
steerVideo: (target, steer) => ctx.steered.push({ target: target.id, steer }),
|
||||
videoReading: () => null,
|
||||
played: [],
|
||||
silenced: [],
|
||||
loaded: [],
|
||||
|
||||
+117
-2
@@ -25,6 +25,10 @@ const EARLY = 'vd66-early';
|
||||
const LATE = 'vd66-late';
|
||||
const CUT = 'vd66-cut';
|
||||
const BUS = 'vd66-bus';
|
||||
const STEER = 'vd66-steer';
|
||||
const STEERED = 'vd66-steered';
|
||||
const STEER_LOGIC = 'vd66-steer-logic';
|
||||
const STEER_BRAIN = 'vd66-steer-brain';
|
||||
const LANE = LANES.video;
|
||||
const COLORS = { red: '#fe0000', green: '#00ff01', blue: '#0000ff', white: '#ffffff' };
|
||||
|
||||
@@ -702,11 +706,122 @@ export async function video(t) {
|
||||
JSON.stringify({ settledColor, settled }),
|
||||
);
|
||||
|
||||
// 66s. Steered by a behavior (#410): Pause video holds the picture on a player of its own, Seek
|
||||
// video sends it to 0.5 s (green) and it stays held there, Play video plays on from there:
|
||||
// blue, then white held at the end of a video without Loop, and On video finished is heard
|
||||
// once. Read from the Game tab, the frames after Play in the page. No driven sprite is left
|
||||
// in the scene: the run would wait for its picture, free videos playing meanwhile, keys lost.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const gone = ['${OBJECT}', '${EARLY}', '${LATE}'];
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => o.id !== '${LATE}' && o.id !== '${EARLY}');
|
||||
p.assets.find((a) => a.id === ${JSON.stringify(assetId)}).props.loop = false;
|
||||
const logic = structuredClone(p.assets.find((a) => a.id === 'mover'));
|
||||
Object.assign(logic, {
|
||||
id: '${STEER_LOGIC}',
|
||||
name: '${STEER_LOGIC}',
|
||||
props: { enabled: true, runInEditor: false },
|
||||
variables: [],
|
||||
graph: ${JSON.stringify({
|
||||
nodes: [
|
||||
node('vd66s-p', 'OnKeyDown', 0, 0, { key: 'p' }),
|
||||
node('vd66s-pause', 'PauseVideo', 260, 0),
|
||||
node('vd66s-s', 'OnKeyDown', 0, 160, { key: 's' }),
|
||||
node('vd66s-seek', 'SeekVideo', 260, 160, { time: 0.5 }),
|
||||
node('vd66s-g', 'OnKeyDown', 0, 320, { key: 'g' }),
|
||||
node('vd66s-play', 'PlayVideo', 260, 320),
|
||||
node('vd66s-end', 'OnVideoFinished', 0, 480),
|
||||
node('vd66s-log', 'Log', 260, 480, { message: 'vd66 finished' }),
|
||||
],
|
||||
edges: [
|
||||
edge('vd66s-e1', 'vd66s-p', 'exec', 'vd66s-pause', 'exec'),
|
||||
edge('vd66s-e2', 'vd66s-s', 'exec', 'vd66s-seek', 'exec'),
|
||||
edge('vd66s-e3', 'vd66s-g', 'exec', 'vd66s-play', 'exec'),
|
||||
edge('vd66s-e4', 'vd66s-end', 'exec', 'vd66s-log', 'exec'),
|
||||
],
|
||||
})},
|
||||
});
|
||||
p.assets.unshift(logic);
|
||||
p.assets.unshift({ id: '${STEER}', type: 'sprite', name: '${STEER}', props: { width: 320, height: 180, color: 0xffffff, texture: ${JSON.stringify(assetId)}, shader: '' } });
|
||||
${MAKE_OBJECT}
|
||||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||||
const screen = make('${STEERED}', '${STEER}', '', 0, '${STEERED}');
|
||||
Object.assign(screen.position, { x: cam.x + 120, y: cam.y + 80, z: 1000 });
|
||||
scene.objects.push(screen);
|
||||
scene.objects.push(make('${STEER_BRAIN}', '${STEER_LOGIC}', '${STEERED}', 0, '${STEER_BRAIN}'));`);
|
||||
const steeredColor = () =>
|
||||
json(`(() => {
|
||||
const r = __gm.gameRect('${STEERED}');
|
||||
return r ? __gm.pixelAt(Math.round(r.x), Math.round(r.y)) : null;
|
||||
})()`);
|
||||
const steeredPlayers = async () =>
|
||||
(await json('__gm.videos()')).filter((v) => v.key.includes('@') && v.key.includes(STEERED));
|
||||
const finishedFrom = t.consoleLog.length;
|
||||
await play();
|
||||
const steerRun = await waitFor(
|
||||
() => json('__gm.playInfo()'),
|
||||
(info) => info.playing && !info.waiting && info.time > 0,
|
||||
);
|
||||
const startColor = await steeredColor();
|
||||
await press('p');
|
||||
await sleep(900);
|
||||
const heldColors = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
heldColors.push(await steeredColor());
|
||||
await sleep(120);
|
||||
}
|
||||
const heldPlayers = await steeredPlayers();
|
||||
check(
|
||||
'Pause video holds the picture of a free video, on a player of its own',
|
||||
!steerRun.waiting &&
|
||||
startColor === COLORS.red &&
|
||||
heldColors.every((color) => color === COLORS.red) &&
|
||||
heldPlayers.length === 1 &&
|
||||
heldPlayers[0].paused &&
|
||||
heldPlayers[0].muted,
|
||||
JSON.stringify({ steerRun, startColor, heldColors, heldPlayers }),
|
||||
);
|
||||
await press('s');
|
||||
const sought = await waitFor(steeredColor, (color) => color === COLORS.green);
|
||||
await sleep(700);
|
||||
const soughtHeld = await steeredColor();
|
||||
const soughtPlayer = (await steeredPlayers())[0];
|
||||
check(
|
||||
'Seek video sends it to 0.5 s, green, and it stays held there',
|
||||
sought === COLORS.green &&
|
||||
soughtHeld === COLORS.green &&
|
||||
soughtPlayer?.paused === true &&
|
||||
Math.abs(soughtPlayer.time - 0.5) < 0.05,
|
||||
JSON.stringify({ sought, soughtHeld, soughtPlayer }),
|
||||
);
|
||||
await t.evaluate(`window.__vd66s = [];
|
||||
(function read() {
|
||||
const r = __gm.gameRect('${STEERED}');
|
||||
if (r) window.__vd66s.push(__gm.pixelAt(Math.round(r.x), Math.round(r.y)));
|
||||
if (window.__vd66s.length < 150) requestAnimationFrame(read);
|
||||
})();`);
|
||||
await press('g');
|
||||
await sleep(2200);
|
||||
const playedFrames = await json('window.__vd66s.splice(0)');
|
||||
const finished = t.consoleLog.slice(finishedFrom).filter((line) => line.includes('vd66 finished'));
|
||||
await stop();
|
||||
const firstBlue = playedFrames.indexOf(COLORS.blue);
|
||||
const firstWhite = playedFrames.indexOf(COLORS.white);
|
||||
check(
|
||||
'Play video plays on from there to the last picture, and On video finished is heard once',
|
||||
!playedFrames.includes(COLORS.red) &&
|
||||
firstBlue > 0 &&
|
||||
firstWhite > firstBlue &&
|
||||
playedFrames.slice(firstWhite).every((color) => color === COLORS.white) &&
|
||||
finished.length === 1,
|
||||
JSON.stringify({ finished, frames: playedFrames.join(' ') }),
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const gone = ['${OBJECT}', '${EARLY}', '${LATE}', '${STEERED}', '${STEER_BRAIN}'];
|
||||
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => !gone.includes(o.id));
|
||||
p.assets = p.assets.filter((a) => ![ '${SPRITE}', '${DRIVEN}', '${CUT}', '${BUS}', ${JSON.stringify(assetId)} ].includes(a.id));
|
||||
p.assets = p.assets.filter((a) => ![ '${SPRITE}', '${DRIVEN}', '${CUT}', '${BUS}', '${STEER}', '${STEER_LOGIC}', ${JSON.stringify(assetId)} ].includes(a.id));
|
||||
__gm.state.select(null);
|
||||
__gm.lookAt(0, 0);`);
|
||||
}
|
||||
Reference in new issue
Block a user