diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index edffd30e..b480ddeb 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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, diff --git a/src/core/graph/nodes/action/videoNodes.ts b/src/core/graph/nodes/action/videoNodes.ts new file mode 100644 index 00000000..74739164 --- /dev/null +++ b/src/core/graph/nodes/action/videoNodes.ts @@ -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) => 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) })), +}; diff --git a/src/core/graph/nodes/data/VideoTime.ts b/src/core/graph/nodes/data/VideoTime.ts new file mode 100644 index 00000000..ef2e256c --- /dev/null +++ b/src/core/graph/nodes/data/VideoTime.ts @@ -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 }; + }, +}; diff --git a/src/core/graph/nodes/events/OnVideoFinished.ts b/src/core/graph/nodes/events/OnVideoFinished.ts new file mode 100644 index 00000000..2a411fb3 --- /dev/null +++ b/src/core/graph/nodes/events/OnVideoFinished.ts @@ -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)], +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 5a0c1329..91def37a 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/model/videoClock.ts b/src/core/model/videoClock.ts new file mode 100644 index 00000000..9d67a2dd --- /dev/null +++ b/src/core/model/videoClock.ts @@ -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; + } +} diff --git a/src/core/properties/videos.ts b/src/core/properties/videos.ts index d17938ee..1687403b 100644 --- a/src/core/properties/videos.ts +++ b/src/core/properties/videos.ts @@ -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 { diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index cf52efdd..6fbac315 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -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(); diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index b1e916c9..8cc1f874 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -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; /** diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index e41f79c7..efcb9eed 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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, diff --git a/src/runtime/systems/VideoSystem.ts b/src/runtime/systems/VideoSystem.ts new file mode 100644 index 00000000..2642d4b4 --- /dev/null +++ b/src/runtime/systems/VideoSystem.ts @@ -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; +} diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 6da3c12a..c213cac8 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -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, diff --git a/src/runtime/systems/behaviorPulses.ts b/src/runtime/systems/behaviorPulses.ts index b0f2473e..c1997768 100644 --- a/src/runtime/systems/behaviorPulses.ts +++ b/src/runtime/systems/behaviorPulses.ts @@ -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 | null): Record { const outputs: Record = { diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index f759ef68..45d9818f 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -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 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); diff --git a/tests/core/event-order.test.ts b/tests/core/event-order.test.ts index e450828a..79f29fa5 100644 --- a/tests/core/event-order.test.ts +++ b/tests/core/event-order.test.ts @@ -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), diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 64c441d6..1a8713b7 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2007,6 +2007,44 @@ const CASES: Record = { 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 = { 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 = { 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', diff --git a/tests/core/video-steer.test.ts b/tests/core/video-steer.test.ts new file mode 100644 index 00000000..5271996d --- /dev/null +++ b/tests/core/video-steer.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 { + 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'); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 791f9e66..0d5c9f32 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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 = {}): 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: [], diff --git a/tools/e2e/66-video.mjs b/tools/e2e/66-video.mjs index 68bbe610..fdd223e1 100644 --- a/tools/e2e/66-video.mjs +++ b/tools/e2e/66-video.mjs @@ -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);`); }