diff --git a/README.md b/README.md index 787abd50..bfce189b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2018 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2023 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index f2f14d3d..54c2f28e 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -1,4 +1,5 @@ import type { AnimationPlayer, EaseShare } from '../model/animation'; +import type { CompositionPlayer } from '../model/composition'; import type { Mat } from '../model/matrix'; import type { Position, PropertyBag } from '../model/Properties'; import type { FxParams } from '../soundfx/nodes'; @@ -36,6 +37,8 @@ export interface EntityHandle { emits?: number; /** What `Play animation` started on this object (see `model/animation.ts`). */ animations?: AnimationPlayer[]; + /** What `Play composition` started on a placed composition (see `model/composition.ts`). */ + composition?: CompositionPlayer; } /** Position, rotation, scale and mirror of every ancestor applied, nearest last. */ diff --git a/src/core/graph/nodes/action/compositionNodes.ts b/src/core/graph/nodes/action/compositionNodes.ts new file mode 100644 index 00000000..05fd3226 --- /dev/null +++ b/src/core/graph/nodes/action/compositionNodes.ts @@ -0,0 +1,40 @@ +import { playComposition, stopComposition } from '../../../model/composition'; +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean, asEntity } from '../../values'; +import { bool, exec, obj, withDefault } from '../shared'; + +export const PlayComposition: NodeDef = { + type: 'PlayComposition', + label: 'Play composition', + category: 'action', + description: + 'Plays a composition placed in the scene from its first frame: each item becomes an object below ' + + 'it when the item begins – its behaviors start then – and is destroyed when it ends. One already ' + + 'playing plays on; Restart starts it over. Without Loop it ends after its last item, and On ' + + 'composition finished hears that. The editor run plays none and says so in the log.', + inputs: [exec(), obj('object'), withDefault(bool('restart'), false)], + outputs: [exec()], + params: [], + run: (ctx, inputs) => { + const target = asEntity(inputs.object); + if (target) playComposition(target, asBoolean(inputs.restart), ctx.self); + return { next: 'exec' }; + }, +}; + +export const StopComposition: NodeDef = { + type: 'StopComposition', + label: 'Stop composition', + category: 'action', + description: + 'Stops a playing composition: the objects of its items are destroyed on the next frame. It does ' + + 'not count as finished, and Play composition starts it from its first frame again.', + inputs: [exec(), obj('object')], + outputs: [exec()], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.object); + if (target) stopComposition(target); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/events/OnCompositionFinished.ts b/src/core/graph/nodes/events/OnCompositionFinished.ts new file mode 100644 index 00000000..0b49bc4e --- /dev/null +++ b/src/core/graph/nodes/events/OnCompositionFinished.ts @@ -0,0 +1,24 @@ +import { textOf } from '../../../model/Properties'; +import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; +import { exec, obj } from '../shared'; + +export const OnCompositionFinished: NodeDef = { + type: 'OnCompositionFinished', + label: 'On composition finished', + category: 'event', + description: + 'Runs once when a composition placed at the owner, or below it, plays past its last item – and when ' + + 'one this behavior played itself does, wherever it stands. A looping one never finishes, and Stop ' + + 'composition does not count. Leave Composition at None to hear every composition.', + inputs: [], + outputs: [exec(), obj('object')], + params: [ + { key: 'composition', label: 'Composition', kind: 'asset', default: '', assetTypes: ['composition'] }, + orderParam, + ], + hiddenParams: (params) => [ + ...(textOf(params.composition ?? '') ? [] : ['composition']), + ...hiddenOrder(params), + ], +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 1fa31105..a311c454 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -2,6 +2,7 @@ import { type NodeDef, registerNode } from '../NodeRegistry'; import { AddJoint } from './action/AddJoint'; import { Destroy } from './action/Destroy'; import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes'; +import { PlayComposition, StopComposition } from './action/compositionNodes'; import { Emit } from './action/Emit'; import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes'; import { LoadScene } from './action/LoadScene'; @@ -123,6 +124,7 @@ import { SetItem } from './data/SetItem'; import { SubList } from './data/SubList'; import { TextureSize } from './data/TextureSize'; import { OnAnimationFinished } from './events/OnAnimationFinished'; +import { OnCompositionFinished } from './events/OnCompositionFinished'; import { OnBehavior } from './events/OnBehavior'; import { OnClick } from './events/OnClick'; import { OnMouseWheel } from './events/OnMouseWheel'; @@ -183,6 +185,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ OnClick, OnMouseWheel, OnAnimationFinished, + OnCompositionFinished, OnBehavior, OnStateEntered, OnStateExited, @@ -308,6 +311,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ PlayAnimation, PauseAnimation, StopAnimation, + PlayComposition, + StopComposition, PlaySound, StopSound, SetSoundFxParameters, diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index fff75d02..8177750e 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -84,8 +84,7 @@ export const ASSET_TYPE_LABELS: Record = { * sound is played through (#311), heard but never seen, an instrument what a note is played * with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334). A bus is * where sounds are mixed on their way out (#325). A video is a moving picture a sprite wears through - * `texture` (#402), as it wears an image. A composition is not placed yet: what a placed one shows - * and plays is still to come (#417). + * `texture` (#402), as it wears an image. */ export type PlaceableAssetType = Exclude< AssetType, @@ -110,7 +109,6 @@ export type PlaceableAssetType = Exclude< | 'variables' | 'struct' | 'video' - | 'composition' >; const NOT_PLACEABLE: ReadonlySet = new Set([ @@ -135,7 +133,6 @@ const NOT_PLACEABLE: ReadonlySet = new Set([ 'variables', 'struct', 'video', - 'composition', ]); export function isPlaceableType(type: AssetType): type is PlaceableAssetType { @@ -155,6 +152,7 @@ export function isSymbolType(type: AssetType): boolean { type === 'rigidbody' || type === 'joint' || type === 'container' || + type === 'composition' || type === 'clip' ); } diff --git a/src/core/model/composition.ts b/src/core/model/composition.ts index dfb1f161..f9634a4b 100644 --- a/src/core/model/composition.ts +++ b/src/core/model/composition.ts @@ -22,7 +22,14 @@ import type { CompositionTrack, } from './Asset'; import { curveLookup } from './curve'; -import { instanceChildId, objectRef, resolveObjects, splitInstanceId } from './instance'; +import { + contentId, + instanceChildId, + objectRef, + placedWithContent, + resolveObjects, + splitInstanceId, +} from './instance'; import { findAsset, findAssetOf, type Project } from './Project'; import { booleanOf, type PropertyBag } from './Properties'; import { objectName, type SceneObject } from './SceneObject'; @@ -104,6 +111,57 @@ export function fittedLength(project: Project, comp: CompositionAsset, item: Com return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps); } +/** + * A composition placed in a running game that `Play composition` started; kept on its entity, out of + * `props`. Its items exist only while it plays. + */ +export interface CompositionPlayer { + /** Tells a start apart from the one before, so a restart builds every item anew. */ + run: number; + /** Seconds since its first frame. */ + time: number; + /** Whether the first frame was shown: the clock runs from the tick after the start. */ + shown: boolean; + /** Set by `Stop composition`: the next tick takes its items away. */ + stopped: boolean; + /** The behaviors that played it; they hear `On composition finished` wherever it stands. */ + startedBy: object[]; +} + +let runCount = 0; + +/** + * Starts the composition placed as `holder` from its first frame. One already playing plays on, unless + * `restart` – or a `Stop composition` this tick – asks to start over. + */ +export function playComposition( + holder: { composition?: CompositionPlayer }, + restart = false, + startedBy?: object, +): void { + const playing = holder.composition; + if (playing && !playing.stopped && !restart) { + if (startedBy && !playing.startedBy.includes(startedBy)) playing.startedBy.push(startedBy); + return; + } + holder.composition = { + run: ++runCount, + time: 0, + shown: false, + stopped: false, + startedBy: startedBy ? [startedBy] : [], + }; +} + +export function stopComposition(holder: { composition?: CompositionPlayer }): void { + if (holder.composition) holder.composition.stopped = true; +} + +/** The frame a player's time stands on; the small guard keeps a time of exactly `n / fps` on frame n. */ +export function compositionFrameAt(comp: CompositionAsset, time: number): number { + return Math.floor(time * compositionFps(comp) + 1e-6); +} + /** A clip playing on an item, at `frame` of its animation – held on the last one once it is through. */ export interface ClipAt { clip: CompositionClip; @@ -187,6 +245,17 @@ export function compositionHolder(project: Project, comp: CompositionAsset, fram return holder; } +/** + * What a composition placed as `placementId` shows in the editor: its first frame, as objects below + * the placement with the ids its items take in a run. Stored nowhere – a placement is sealed, and + * its items are edited in the composition only. + */ +export function placedPicture(project: Project, comp: CompositionAsset, placementId: string): SceneObject[] { + return compositionHolder(project, comp, 0).objects.flatMap((o) => + placedWithContent(project, o, contentId(placementId, o.id), placementId), + ); +} + const record = (value: unknown): Record => typeof value === 'object' && value !== null ? (value as Record) : {}; const text = (value: unknown) => (typeof value === 'string' ? value : ''); diff --git a/src/core/model/instance.ts b/src/core/model/instance.ts index d7b05982..7c31219e 100644 --- a/src/core/model/instance.ts +++ b/src/core/model/instance.ts @@ -436,6 +436,40 @@ export function materializeInstance( })); } +/** + * The id the object at `path` below `ownerId` has – one level deeper when the owner is content + * itself, so the content of `q~a` is `q~a/b`, the id a template path through nested containers gives. + */ +export function contentId(ownerId: string, path: string): string { + const split = splitInstanceId(ownerId); + return split + ? instanceChildId(split.instanceId, `${split.path}${PATH_SEP}${path}`) + : instanceChildId(ownerId, path); +} + +/** + * `object` as `id` below `parentId`, followed by its container content with the ids below that one – + * how a composition puts its objects into a scene (#417). Copies, free to be stored and written to. + */ +export function placedWithContent( + project: Project, + object: SceneObject, + id: string, + parentId: string, +): SceneObject[] { + const rename = (at: string) => (at === object.id ? id : contentId(id, at.slice(object.id.length + 1))); + const { groupId: _, ...top } = object; + return resolveObjects(project, { objects: [{ ...top, parentId: '' }] }).map((o) => ({ + ...o, + id: rename(o.id), + parentId: o.id === object.id ? parentId : rename(o.parentId), + position: { ...o.position }, + editorOffset: { ...o.editorOffset }, + overrides: { ...o.overrides }, + instanceEdits: {}, + })); +} + /** * Spells out every live instance of the holder. The runtime needs it: entities are built from * stored objects, and a behavior may destroy or move one of them – which a resolved copy could diff --git a/src/core/model/sceneVisibility.ts b/src/core/model/sceneVisibility.ts index 3f1abcc8..4b8fb5bf 100644 --- a/src/core/model/sceneVisibility.ts +++ b/src/core/model/sceneVisibility.ts @@ -17,6 +17,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [ 'camera', 'clip', 'collider', + 'composition', 'container', 'joint', 'particles', diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 8d7dc705..33a8cb4c 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -371,6 +371,7 @@ const TRANSFORM_TYPES: ReadonlySet = new Set([ 'text', 'collider', 'container', + 'composition', 'clip', 'particles', 'tilemap', @@ -438,6 +439,7 @@ const RENDER_LAYER_TYPES: ReadonlySet = new Set([ 'text', 'particles', 'container', + 'composition', 'tilemap', ]); diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 30903bfb..8edf8195 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -71,6 +71,7 @@ export function footprintOf( case 'behavior': return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL }; case 'container': + case 'composition': return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL }; case 'rigidbody': return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL }; diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index 42e85873..cf52efdd 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -136,6 +136,7 @@ export class EditorRun { shouldRun: (e) => runsInEditor(e.props), audio: soundLibrary, saves: saveArea(project.id, 'editor'), + playsCompositions: false, }); this.world = fresh; this.hadFocus = this.hooks.focused(); diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 5fe714bb..820aa98b 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -113,7 +113,7 @@ const LABEL_PAD_Y = 1; const LABEL_ROW_GAP = 1; const LABEL_PLATE_ALPHA = 0.7; /** Types whose icon is all there is of them: they are selected and outlined where it stands. */ -const ICON_ONLY = new Set(['behavior', 'container', 'rigidbody', 'joint']); +const ICON_ONLY = new Set(['behavior', 'container', 'composition', 'rigidbody', 'joint']); /** Owner links in the colour of the icon they leave; anything else in the muted default. */ const LINK_COLORS: Partial> = { @@ -268,7 +268,13 @@ export class SceneViewport extends FreeViewport { } protected override collectFrame(): Frame { - return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport, this.editorWorld); + return collectHolderItems( + this.state.project, + this.holder(), + Theme.colors.viewport, + this.editorWorld, + this.state.mode === 'edit', + ); } protected override get baseColor(): number { @@ -725,7 +731,7 @@ export class SceneViewport extends FreeViewport { const minY = Math.min(a.y, b.y); const maxY = Math.max(a.y, b.y); const ids = this.lastItems - .filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY) + .filter((i) => i.enabled && !i.sealedIn && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY) .map((i) => i.id); this.state.select(objectSelection(holder.id, ids)); } diff --git a/src/editor/viewport/sizeGizmoEditor.ts b/src/editor/viewport/sizeGizmoEditor.ts index be1486e4..72d0114a 100644 --- a/src/editor/viewport/sizeGizmoEditor.ts +++ b/src/editor/viewport/sizeGizmoEditor.ts @@ -54,8 +54,18 @@ const DRAG_THRESHOLD = 2; const TURN_SNAP = 15; /** How strongly the frame round a container's content is drawn. */ const FRAME_ALPHA = 0.6; +/** A placed composition is framed like a container, round the picture it shows (#417). */ +const holdsContent = (item: DrawItem) => item.type === 'container' || item.type === 'composition'; + /** Content that is only an icon or a view, and so takes no room in a container's frame. */ -const SHAPELESS = new Set(['behavior', 'rigidbody', 'joint', 'camera', 'container']); +const SHAPELESS = new Set([ + 'behavior', + 'rigidbody', + 'joint', + 'camera', + 'container', + 'composition', +]); /** The keys a drag writes; the preview puts the values straight on the object, the release a command. */ type Writes = Partial>; @@ -129,7 +139,7 @@ export class SizeGizmoEditor { /** A container is framed round its content (#242), everything else round its own footprint. */ private worldBox(item: DrawItem): Vec2[] { - if (item.type === 'container') { + if (holdsContent(item)) { const frame = contentFrame(item.m, this.contentCorners(item.id)); if (frame) return frame; } @@ -303,7 +313,7 @@ export class SizeGizmoEditor { }); const color = Theme.colors.gizmo; // The selection outline sits round the container's icon, so the frame the knob belongs to is drawn here. - if (item.type === 'container') g.poly(corners, true).stroke({ width: 1, color, alpha: FRAME_ALPHA }); + if (holdsContent(item)) g.poly(corners, true).stroke({ width: 1, color, alpha: FRAME_ALPHA }); g.moveTo(base.x, base.y).lineTo(knob.x, knob.y).stroke({ width: 1.5, color }); const busy = this.drag?.objectId === item.id && this.drag.grip.kind === 'knob'; g.circle(knob.x, knob.y, KNOB_RADIUS) diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index d314266a..b1e916c9 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -1,6 +1,7 @@ import type { ENTITY_HANDLE } from '../core/graph/NodeContext'; import type { AnimationPlayer } from '../core/model/animation'; import type { AssetType } from '../core/model/AssetTypes'; +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'; @@ -26,6 +27,8 @@ export interface Entity { parent: Entity | null; /** The animations playing on this object; kept out of `props`, which the world writes back. */ animations?: AnimationPlayer[]; + /** For a placed composition: what `Play composition` started (#417), out of `props` like `animations`. */ + composition?: CompositionPlayer; /** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */ emits?: number; /** diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 2b6df87d..7cae3a88 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -96,6 +96,14 @@ function driftedKeys(live: PropertyBag, model: ModelState): readonly string[] { return keys ?? NO_KEYS; } +/** `order`'s members of `list` in that order, in the slots of `list` they held before. */ +function reorderIn(list: T[], order: readonly T[]): void { + const slots = new Map(list.map((value, index) => [value, index])); + const present = order.filter((value) => slots.has(value)); + const indices = present.map((value) => slots.get(value)!).sort((a, b) => a - b); + indices.forEach((slot, i) => (list[slot] = present[i]!)); +} + /** * The running game: entities of one scene plus the systems ticking them. Built on the play copy * (discarded on stop) or, for the editor run, on the saved model, where its writes stay. @@ -383,6 +391,45 @@ export class World { return entity; } + /** + * Adds objects a composition plays into the scene (#417), each below the parent it names, and + * answers their entities. An object of the same id still in the scene – one taken away this tick – + * gives way, so the scene never holds an id twice. + */ + place(objects: readonly SceneObject[]): Entity[] { + const scene = findScene(this.project, this.sceneId); + if (!scene) return []; + const ids = new Set(objects.map((o) => o.id)); + for (let i = scene.objects.length - 1; i >= 0; i--) + if (ids.has(scene.objects[i]!.id)) scene.objects.splice(i, 1); + scene.objects.push(...objects); + const placed: Entity[] = []; + for (const object of objects) { + const asset = findAsset(this.project, object.assetId); + if (!asset || !isPlaceableType(asset.type)) continue; + const entity = this.materialize(object, asset); + entity.parent = this.findByObjectId(object.parentId) ?? null; + placed.push(entity); + } + this.structureChanged = true; + return placed; + } + + /** + * Puts these entities, and their objects in the scene, in this order among the places they already + * hold – the order that decides between equal `z` (#417). + */ + arrange(order: readonly Entity[]): void { + reorderIn(this.entities, order); + const scene = findScene(this.project, this.sceneId); + if (!scene) return; + const byId = new Map(scene.objects.map((o) => [o.id, o])); + reorderIn( + scene.objects, + order.map((e) => byId.get(e.objectId)).filter((o): o is SceneObject => o !== undefined), + ); + } + /** * Writes the entities' state into the scene: positions, property values that differ from * what the model resolves (stored as overrides, so the inspector marks them) and removed diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index d855dda2..3dd5e488 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -15,7 +15,7 @@ import { } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import { curveLookup } from '../../core/model/curve'; -import { instanceChildId, splitInstanceId } from '../../core/model/instance'; +import { contentId } from '../../core/model/instance'; import { findAsset, findAssetOf } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; import type { PropertyDef } from '../../core/properties/PropertySchema'; @@ -141,7 +141,7 @@ function drop(entity: Entity, player: AnimationPlayer): void { * there, so one animation fits containers built alike. A row whose target is gone, or lacks the * property, is skipped. */ -function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { +export function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { const root = findAsset(world.project, asset.root); if (!root) return []; const bindings: Binding[] = []; @@ -167,7 +167,7 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { else if (track.target === '') entity = owner; else entity = - world.findByObjectId(childId(owner.objectId, track.target)) ?? + world.findByObjectId(contentId(owner.objectId, track.target)) ?? byNames(world, owner, targetNamePath(world.project, root, track.target)); const def = entity && defOn(world, entity, key); if (entity && def) { @@ -193,15 +193,6 @@ function defOn(world: World, entity: Entity, key: string): PropertyDef | undefin return asset && animatedDefOf(world.project, asset, entity.props, key); } -/** - * The id a template object gets below a placed container – nested one deeper when the container is - * content itself. `path` may run through containers placed inside the template (`a/b`). - */ -function childId(ownerId: string, path: string): string { - const split = splitInstanceId(ownerId); - return split ? instanceChildId(split.instanceId, `${split.path}/${path}`) : instanceChildId(ownerId, path); -} - /** The object below `owner` with these names, level by level; none when a name is missing or taken twice. */ function byNames(world: World, owner: Entity, names: string[] | undefined): Entity | undefined { if (!names) return undefined; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index e3d509bb..6c97535d 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -43,6 +43,7 @@ import { collisionEnterPulses, collisionExitPulses, collisionStayPulses, + compositionFinishedPulses, type Instance, keyDownPulses, type PerCell, @@ -54,6 +55,7 @@ import { import { CollisionSystem } from './CollisionSystem'; import type { CollisionSource } from './solids'; import { AnimationSystem } from './AnimationSystem'; +import { CompositionSystem } from './CompositionSystem'; import type { Input } from './Input'; import type { RecordingSink } from '../../core/recording/recording'; import type { System } from './System'; @@ -118,9 +120,11 @@ export interface GameSystemOptions { saves?: SaveArea | null; /** Where `Start recording` records to (#405): only a played game has one. */ recorder?: RecordingSink | null; + /** Whether `Play composition` plays (#417); the editor run ignores it. */ + playsCompositions?: boolean; } -/** Collisions and animations are ticked first, so behaviors see this frame's events. */ +/** Collisions, compositions and animations are ticked first, so behaviors see this frame's events. */ export function addGameSystems( world: World, project: Project, @@ -134,11 +138,13 @@ export function addGameSystems( clock = null, saves = null, recorder = null, + playsCompositions = true, }: GameSystemOptions = {}, ): BehaviorSystem { const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem(); + const compositions = new CompositionSystem(playsCompositions); const animations = new AnimationSystem(); - const sources = { collisions, animations, audio, clock, saves, recorder }; + const sources = { collisions, animations, compositions, audio, clock, saves, recorder }; const behaviors = new BehaviorSystem(project, input, sources, shouldRun); if (carried) behaviors.carryOver(carried); if (kept) { @@ -146,6 +152,7 @@ export function addGameSystems( if (collisions instanceof PhysicsSystem) collisions.resume(kept.motion); } world.addSystem(collisions); + world.addSystem(compositions); world.addSystem(animations); world.addSystem(behaviors); return behaviors; @@ -155,6 +162,7 @@ export function addGameSystems( export interface EventSources { collisions?: (CollisionSource & System) | null; animations?: AnimationSystem | null; + compositions?: CompositionSystem | 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; @@ -200,6 +208,7 @@ export class BehaviorSystem implements System { private adopted: Instance[] = []; private readonly collisions: CollisionSource | null; private readonly animations: AnimationSystem | null; + private readonly compositions: CompositionSystem | null; private readonly audio: AudioSink; private readonly clock: TimeScaled | null; private readonly saves: SaveArea | null; @@ -219,6 +228,7 @@ export class BehaviorSystem implements System { ) { this.collisions = sources.collisions ?? null; this.animations = sources.animations ?? null; + this.compositions = sources.compositions ?? null; this.audio = sources.audio ?? SILENT_AUDIO; this.clock = sources.clock ?? null; this.saves = sources.saves ?? null; @@ -614,7 +624,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 } = this; + const { input, collisions, animations, compositions } = this; const perCell: PerCell = (i, node) => asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i))); const kinds: (() => Pulse[])[] = [ @@ -630,6 +640,7 @@ export class BehaviorSystem implements System { () => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt, perCell))), () => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))), () => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))), + () => (frozen ? [] : ticking.flatMap((i) => compositionFinishedPulses(i, compositions))), () => pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [], () => diff --git a/src/runtime/systems/CompositionSystem.ts b/src/runtime/systems/CompositionSystem.ts new file mode 100644 index 00000000..9ac4c973 --- /dev/null +++ b/src/runtime/systems/CompositionSystem.ts @@ -0,0 +1,161 @@ +import { isHandleEnabled } from '../../core/graph'; +import { trackWriteAt } from '../../core/model/animation'; +import { + compositionAt, + compositionFrameAt, + compositionFps, + compositionLength, + compositionLoops, + type LiveItem, +} from '../../core/model/composition'; +import type { CompositionAsset } from '../../core/model/Asset'; +import { curveLookup } from '../../core/model/curve'; +import { contentId, placedWithContent } from '../../core/model/instance'; +import { findAssetOf } from '../../core/model/Project'; +import type { PropertyValue } from '../../core/model/Properties'; +import { findVideo } from '../../core/model/video'; +import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../../core/properties/videos'; +import type { Entity } from '../Entity'; +import type { World } from '../World'; +import { bind } from './AnimationSystem'; +import type { System } from './System'; + +export interface CompositionFinish { + /** The placed composition. */ + entity: Entity; + compositionId: string; + startedBy: readonly object[]; +} + +/** An item's object in the run, with what its clips started from and the sprites whose video it drives. */ +interface Spawned { + entity: Entity; + bases: Map; + videos: Entity[]; +} + +interface Run { + id: number; + /** By item id. An item a behavior destroyed stays here until it ends, so it is not built again. */ + live: Map; +} + +/** + * Plays what `Play composition` started (#417): builds each item's object below the placed composition + * when the item begins and destroys it when it ends, writes the clips over them and drives their + * videos. Runs before the animations and the behaviors, so a behavior writes last and hears this + * frame's `finished`. The editor run plays none: it writes into the saved scene, where the objects of + * a cut would stay behind. + */ +export class CompositionSystem implements System { + finished: CompositionFinish[] = []; + private readonly runs = new Map(); + + constructor(private readonly plays = true) {} + + update(world: World, dt: number): void { + this.finished = []; + for (const placement of this.runs.keys()) + if (!world.entities.includes(placement)) this.runs.delete(placement); + const started = world.entities.filter((e) => e.type === 'composition' && e.composition); + for (const placement of started) this.advance(world, placement, dt); + } + + stop(): void { + this.finished = []; + this.runs.clear(); + } + + private advance(world: World, placement: Entity, dt: number): void { + const player = placement.composition!; + if (!this.plays) { + world.log.push('Play composition is ignored in the editor run'); + delete placement.composition; + return; + } + let run = this.runs.get(placement); + if (run && run.id !== player.run) this.end(world, placement); + const comp = findAssetOf(world.project, placement.assetId, 'composition'); + if (!comp || player.stopped) { + this.end(world, placement); + delete placement.composition; + return; + } + run = this.runs.get(placement); + if (!run) this.runs.set(placement, (run = { id: player.run, live: new Map() })); + if (player.shown && isHandleEnabled(placement)) player.time += dt; + player.shown = true; + const length = compositionLength(world.project, comp); + let frame = compositionFrameAt(comp, player.time); + if (frame >= length && compositionLoops(comp) && length > 0) { + player.time %= length / compositionFps(comp); + frame = Math.min(compositionFrameAt(comp, player.time), length - 1); + } else if (frame >= length) { + this.end(world, placement); + delete placement.composition; + this.finished.push({ entity: placement, compositionId: comp.id, startedBy: player.startedBy }); + return; + } + this.show(world, comp, placement, run, frame); + } + + /** Takes every item of the placement's run away. */ + private end(world: World, placement: Entity): void { + const run = this.runs.get(placement); + if (!run) return; + for (const { entity } of run.live.values()) if (world.entities.includes(entity)) world.remove(entity); + this.runs.delete(placement); + } + + private show(world: World, comp: CompositionAsset, placement: Entity, run: Run, frame: number): void { + const live = compositionAt(world.project, comp, frame); + const alive = new Set(live.map((l) => l.item.id)); + for (const [id, { entity }] of run.live) { + if (alive.has(id)) continue; + if (world.entities.includes(entity)) world.remove(entity); + run.live.delete(id); + } + let born = false; + for (const item of live) { + if (run.live.has(item.item.id)) continue; + const spawned = spawn(world, placement, item); + if (!spawned) continue; + run.live.set(item.item.id, spawned); + born = true; + } + // In the order the tracks lay them, whenever they began. + if (born) world.arrange(live.flatMap((l) => run.live.get(l.item.id)?.entity ?? [])); + const curves = curveLookup(world.project); + for (const item of live) { + const spawned = run.live.get(item.item.id); + if (spawned && world.entities.includes(spawned.entity)) play(world, spawned, item, curves); + } + } +} + +function spawn(world: World, placement: Entity, item: LiveItem): Spawned | null { + const id = contentId(placement.objectId, item.object.id); + const entities = world.place(placedWithContent(world.project, item.object, id, placement.objectId)); + const entity = entities.find((e) => e.objectId === id); + if (!entity) return null; + // Below the item, a sprite set to be driven by hand keeps its own time. + const videos = entities.filter((e) => e.type === 'sprite' && (e === entity || !videoDrivenOf(e.props))); + return { entity, bases: new Map(), videos }; +} + +function play(world: World, spawned: Spawned, item: LiveItem, curves: ReturnType): void { + for (const sprite of spawned.videos) { + const texture = sprite.props.texture; + if (typeof texture !== 'string' || !findVideo(world.project, texture)) continue; + sprite.props[VIDEO_CLOCK_KEY] = 'driven'; + sprite.props[VIDEO_TIME_KEY] = item.videoTime; + } + for (const clip of item.clips) { + for (const b of bind(world, clip.animation, spawned.entity)) { + let base = spawned.bases.get(b.base); + if (base === undefined) spawned.bases.set(b.base, (base = b.read())); + const value = trackWriteAt(b.track, b.def, clip.frame, base, false, curves); + if (value !== undefined) b.write(value); + } + } +} diff --git a/src/runtime/systems/behaviorPulses.ts b/src/runtime/systems/behaviorPulses.ts index 4be355f6..b0f2473e 100644 --- a/src/runtime/systems/behaviorPulses.ts +++ b/src/runtime/systems/behaviorPulses.ts @@ -10,6 +10,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties'; import { parentChain } from '../../core/model/walk'; import type { Entity } from '../Entity'; import type { AnimationSystem } from './AnimationSystem'; +import type { CompositionSystem } from './CompositionSystem'; import type { Input } from './Input'; import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids'; @@ -226,6 +227,29 @@ export function animationFinishedPulses(instance: Instance, animations: Animatio }); } +/** + * A behavior hears the compositions placed at its owner or below it, and those it played itself + * wherever they stand – the rule of `On animation finished`. + */ +export function compositionFinishedPulses( + instance: Instance, + compositions: CompositionSystem | null, +): Pulse[] { + const owner = instance.entity.parent; + const finished = (compositions?.finished ?? []).filter( + ({ entity, startedBy }) => + !owner || + startedBy.includes(instance.entity) || + [...parentChain(entity, (e) => e.parent)].includes(owner), + ); + return instance.interpreter.eventNodes('OnCompositionFinished').flatMap((node) => { + const wanted = textOf(node.params.composition ?? ''); + return finished + .filter(({ compositionId }) => wanted === '' || wanted === compositionId) + .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/items.ts b/src/viewport/items.ts index 401a5cba..287f19bd 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -1,6 +1,7 @@ import { isHandleEnabled, worldTransformOf } from '../core/graph'; import { type AssetType, isSymbolType } from '../core/model/AssetTypes'; import { treeOrder } from '../core/model/hierarchy'; +import { placedPicture } from '../core/model/composition'; import { resolvedHolder } from '../core/model/instance'; import type { Mat } from '../core/model/matrix'; import type { PropertyBag } from '../core/model/Properties'; @@ -79,6 +80,8 @@ export interface DrawItem { clip: ClipRegion | null; /** The render layer it is on, its parents' choice included (#302): which cameras see it. */ renderLayer: number; + /** Part of what a placed composition shows (#417): the placement, which a click on it selects. */ + sealedIn?: string; } interface DrawList { @@ -200,16 +203,29 @@ function wearsPicture(type: AssetType): boolean { * A scene or a container template, in tree order – the tie-break for equal `z` – at the editor * positions, with the content of placed containers resolved in. A container has no background of * its own, so it keeps the fallback. `run` is the editor run on that scene: the tiles its behaviors - * set are shown on the maps, though they never reach the model (#135). + * set are shown on the maps, though they never reach the model (#135). A placed composition shows + * its first frame (#417) unless `pictures` is off – in play mode, where its items are objects of the + * run itself. */ export function collectHolderItems( project: Project, holder: ObjectHolder | undefined, fallbackBackground: number, run: World | null = null, + pictures = true, ): DrawList { if (!holder) return { items: [], background: fallbackBackground }; - const view = resolvedHolder(project, holder); + const resolved = resolvedHolder(project, holder); + const sealed = new Map(); + const shown = pictures + ? resolved.objects.flatMap((placement) => { + const comp = findAssetOf(project, placement.assetId, 'composition'); + const picture = comp ? placedPicture(project, comp, placement.id) : []; + for (const o of picture) sealed.set(o.id, placement.id); + return picture; + }) + : []; + const view = shown.length > 0 ? { ...resolved, objects: [...resolved.objects, ...shown] } : resolved; const items: DrawItem[] = []; for (const { object } of treeOrder(view)) { const asset = findAsset(project, object.assetId); @@ -240,6 +256,7 @@ export function collectHolderItems( frame: asset.type === 'collider' ? colliderFrame(project, props) : null, clip: null, renderLayer: 0, + ...(sealed.has(object.id) && { sealedIn: sealed.get(object.id) }), }); } const byId = new Map(items.map((i) => [i.id, i])); diff --git a/src/viewport/pick.ts b/src/viewport/pick.ts index 9c4d43aa..0ce8e7ab 100644 --- a/src/viewport/pick.ts +++ b/src/viewport/pick.ts @@ -16,7 +16,8 @@ export interface PointerHit { * What lies under the pointer, front to back as it is drawn (#203). A tilemap counts only where * one of its layers has a tile, and at the height of the highest such layer: an empty cell is * empty space, and a sprite drawn above those layers is in front of the map. Symbols are drawn - * above everything, so they come first. + * above everything, so they come first. What a placed composition shows answers as the placement, + * once, where its frontmost part lies (#417). */ export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] { const rank = new Map(); @@ -35,7 +36,8 @@ export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]) const best = Math.max(-1, ...ranks); if (best >= 0) ranked.push({ id: hit.id, rank: best }); } - return ranked.sort((a, b) => b.rank - a.rank).map((r) => r.id); + const ids = ranked.sort((a, b) => b.rank - a.rank).map((r) => items[index.get(r.id)!]!.sealedIn ?? r.id); + return [...new Set(ids)]; } /** diff --git a/src/viewport/symbolIcons.ts b/src/viewport/symbolIcons.ts index c23635ef..248132b0 100644 --- a/src/viewport/symbolIcons.ts +++ b/src/viewport/symbolIcons.ts @@ -8,18 +8,20 @@ import { drawSvg, haloSvg, Theme } from '../ui'; import behavior from '../editor/panels/icons/behavior.svg?raw'; import camera from '../editor/panels/icons/camera.svg?raw'; import collider from '../editor/panels/icons/collider.svg?raw'; +import composition from '../editor/panels/icons/composition.svg?raw'; import container from '../editor/panels/icons/container.svg?raw'; import joint from '../editor/panels/icons/joint.svg?raw'; import particles from '../editor/panels/icons/particles.svg?raw'; import rigidbody from '../editor/panels/icons/rigidbody.svg?raw'; export type SymbolIcon = - 'behavior' | 'camera' | 'collider' | 'container' | 'joint' | 'particles' | 'rigidbody'; + 'behavior' | 'camera' | 'collider' | 'composition' | 'container' | 'joint' | 'particles' | 'rigidbody'; export const SYMBOL_SVG: Record = { behavior, camera, collider, + composition, container, joint, particles, diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index ecc7456e..3ddd00c1 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -174,6 +174,7 @@ export function drawItem( return; case 'behavior': case 'container': + case 'composition': case 'rigidbody': case 'joint': return; diff --git a/tests/core/composition-play.test.ts b/tests/core/composition-play.test.ts new file mode 100644 index 00000000..faa70bab --- /dev/null +++ b/tests/core/composition-play.test.ts @@ -0,0 +1,344 @@ +/** + * A composition placed in a scene and played (#417): its items become objects below the placement + * while they last, its clips and videos reach them, and `On composition finished` hears the end. + */ +import { describe, expect, it } from 'vitest'; +import { + AddAnimationTrackCommand, + SetAnimationKeyCommand, + SetAnimationRootCommand, +} from '../../src/core/commands/animationCommands'; +import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands'; +import type { CompositionAsset, ContainerAsset } from '../../src/core/model/Asset'; +import { + compositionHolder, + placedPicture, + playComposition, + stopComposition, +} from '../../src/core/model/composition'; +import { contentId, instanceChildId, resolveObjects } from '../../src/core/model/instance'; +import { findAssetOf, findScene } from '../../src/core/model/Project'; +import { createSceneObject } from '../../src/core/model/SceneObject'; +import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY } from '../../src/core/properties/videos'; +import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem'; +import { Input } from '../../src/runtime/systems/Input'; +import { World } from '../../src/runtime/World'; +import { stackAt } from '../../src/viewport/pick'; +import { collectHolderItems } from '../../src/viewport/items'; +import { graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; +import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands'; + +const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`; + +interface Stage extends ProjectFixture { + sceneId: string; + spriteId: string; + /** The composition's object in the scene. */ + placement: string; + comp(): CompositionAsset; + track(): string; + item(track: string, assetId: string, start: number, length: number, offset?: number): string; + /** An animation over `root` moving x (or a property of `target`) from 0 to 90 over frames 0–9 at 10 fps. */ + move(root: string, property?: string, target?: string): string; + clip(track: string, animation: string, start: number): void; +} + +/** A scene "Level" with a composition "Cut" at 100/50, ten frames a second, and a sprite "Box". */ +function makeStage(): Stage { + const f = makeProject(); + const scene = new CreateAssetCommand('scene', 'Level'); + const comp = new CreateAssetCommand('composition', 'Cut', { fps: 10 }); + const sprite = new CreateAssetCommand('sprite', 'Box'); + for (const c of [scene, comp, sprite]) f.stack.execute(c); + const place = new PlaceAssetCommand(scene.asset.id, comp.asset.id, { x: 100, y: 50 }); + f.stack.execute(place); + const current = () => findAssetOf(f.project, comp.asset.id, 'composition')!; + let next = 0; + return { + ...f, + sceneId: scene.asset.id, + spriteId: sprite.asset.id, + placement: place.objectId, + comp: current, + track() { + const id = `t${++next}`; + current().tracks.push({ id, name: id }); + return id; + }, + item(track, assetId, start, length, offset = 0) { + const id = `i${++next}`; + current().objects.push(createSceneObject(id, assetId, { x: 0, y: 0 })); + current().items.push({ id, track, start, length, offset }); + return id; + }, + move(root, property = 'x', target = '') { + const create = new CreateAssetCommand('animation', 'Move', { fps: 10, playback: 'loop' }); + f.stack.execute(create); + f.stack.execute(new SetAnimationRootCommand(create.asset.id, root)); + const row = new AddAnimationTrackCommand(create.asset.id, target, property); + f.stack.execute(row); + f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0)); + f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 9, 90)); + return create.asset.id; + }, + clip(track, animation, start) { + current().clips.push({ id: `c${++next}`, track, animation, start }); + }, + }; +} + +const first = (stage: Stage) => stage.comp().tracks[0]!.id; + +/** A behavior below the placement: On start plays it, On composition finished logs "done". */ +function director(stage: Stage): void { + const behavior = makeBehavior(stage, 'Director'); + stage.stack.execute(new PlaceAssetCommand(stage.sceneId, behavior.id, { x: 0, y: 0 }, stage.placement)); + const edit = graphEditor(stage, behavior); + const start = edit.addNode('OnStart'); + const owner = edit.addNode('Owner'); + const play = edit.addNode('PlayComposition'); + edit.connect(start, 'exec', play, 'exec'); + edit.connect(owner, 'object', play, 'object'); + const finished = edit.addNode('OnCompositionFinished', { composition: stage.comp().id }); + const log = edit.addNode('Log', { message: 'done' }); + edit.connect(finished, 'exec', log, 'exec'); +} + +/** The scene played as a game: `frame()` ticks a tenth of a second and writes back like Play does. */ +function play(stage: Stage, playsCompositions = true) { + const world = World.instantiate(stage.project, stage.sceneId); + addGameSystems(world, stage.project, new Input(), { playsCompositions }); + const placement = () => world.findByObjectId(stage.placement)!; + const at = (item: string) => world.findByObjectId(contentId(stage.placement, item)); + return { + world, + placement, + at, + frame(times = 1) { + for (let i = 0; i < times; i++) { + world.tick(0.1); + world.syncToModel(); + } + }, + stored: () => findScene(stage.project, stage.sceneId)!.objects.map((o) => o.id), + }; +} + +describe('a composition played in a scene', () => { + it('builds an item’s object below the placement when it begins and destroys it when it ends', () => { + const stage = makeStage(); + director(stage); + const a = stage.item(first(stage), stage.spriteId, 0, 3); + const b = stage.item(first(stage), stage.spriteId, 3, 2); + const run = play(stage); + expect(run.at(a)).toBeUndefined(); + run.frame(); + // The first tick shows frame 0, the clock runs from the next. + expect(run.at(a)?.parent).toBe(run.placement()); + expect(run.at(a)?.objectId).toBe(`${stage.placement}~${a}`); + expect(run.at(b)).toBeUndefined(); + run.frame(3); + expect(run.at(a)).toBeUndefined(); + expect(run.stored()).not.toContain(contentId(stage.placement, a)); + expect(run.at(b)?.name).toBe('Box'); + expect(run.stored()).toContain(contentId(stage.placement, b)); + run.frame(2); + expect(run.at(b)).toBeUndefined(); + expect(run.world.log).toEqual(['done']); + expect(run.placement().composition).toBeUndefined(); + run.frame(5); + expect(run.world.log).toEqual(['done']); + }); + + it('lays the first track in front, whatever began first', () => { + const stage = makeStage(); + director(stage); + const back = stage.track(); + // Built after the front one, the item of the back track still has to go behind it. + const front = stage.item(first(stage), stage.spriteId, 0, 5); + const late = stage.item(back, stage.spriteId, 2, 5); + const run = play(stage); + run.frame(3); + const order = (ids: string[]) => + [late, front].map((item) => ids.indexOf(contentId(stage.placement, item))); + const [behind, inFront] = order(run.world.entities.map((e) => e.objectId)); + expect(behind).toBeLessThan(inFront!); + const [storedBehind, storedFront] = order(run.stored()); + expect(storedBehind).toBeLessThan(storedFront!); + }); + + it('writes its clips over the items as the editor shows them, and holds the last value', () => { + const stage = makeStage(); + director(stage); + const item = stage.item(first(stage), stage.spriteId, 0, 30); + stage.clip(first(stage), stage.move(stage.spriteId), 2); + const run = play(stage); + const shown = (frame: number) => + compositionHolder(stage.project, stage.comp(), frame).objects.find((o) => o.id === item)!.position.x; + let at = -1; + for (const frame of [0, 1, 2, 7, 11, 20]) { + run.frame(frame - at); + at = frame; + expect(run.at(item)!.x, `frame ${frame}`).toBe(shown(frame)); + } + expect(run.at(item)!.x).toBe(90); + }); + + it('reaches into a container item by its template path', () => { + const stage = makeStage(); + director(stage); + const rig = new CreateAssetCommand('container', 'Rig'); + const arm = new CreateAssetCommand('sprite', 'Arm'); + stage.stack.execute(rig); + stage.stack.execute(arm); + (findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push( + createSceneObject('arm', arm.asset.id, { x: 5, y: 0 }), + ); + const item = stage.item(first(stage), rig.asset.id, 0, 30); + stage.clip(first(stage), stage.move(rig.asset.id, 'rotation', 'arm'), 0); + const run = play(stage); + run.frame(6); + const armOf = run.world.findByObjectId(contentId(contentId(stage.placement, item), 'arm')); + expect(armOf?.objectId).toBe(`${stage.placement}~${item}/arm`); + expect(armOf?.parent).toBe(run.at(item)); + expect(armOf?.props.rotation).toBe(50); + }); + + it('drives the video an item wears at its video time', () => { + const stage = makeStage(); + director(stage); + const video = new CreateVideoCommand('Intro', FILE, { + width: 320, + height: 180, + duration: 10, + fps: 30, + audio: false, + }); + stage.stack.execute(video); + const screen = new CreateAssetCommand('sprite', 'Screen', { texture: video.asset.id }); + stage.stack.execute(screen); + const item = stage.item(first(stage), screen.asset.id, 0, 50, 1.5); + const run = play(stage); + run.frame(6); + expect(run.at(item)!.props[VIDEO_CLOCK_KEY]).toBe('driven'); + expect(run.at(item)!.props[VIDEO_TIME_KEY]).toBeCloseTo(2, 9); + }); + + it('loops without finishing, building an item anew each time round', () => { + const stage = makeStage(); + director(stage); + stage.comp().props.loop = true; + const a = stage.item(first(stage), stage.spriteId, 0, 2); + stage.item(first(stage), stage.spriteId, 2, 2); + const run = play(stage); + run.frame(); + const firstRound = run.at(a); + run.frame(4); + expect(run.at(a)).toBeDefined(); + expect(run.at(a)).not.toBe(firstRound); + run.frame(20); + expect(run.world.log).toEqual([]); + }); + + it('takes the items away on Stop, without finishing, and starts over on the next play', () => { + const stage = makeStage(); + director(stage); + const a = stage.item(first(stage), stage.spriteId, 0, 30); + const run = play(stage); + run.frame(3); + const before = run.at(a); + stopComposition(run.placement()); + run.frame(); + expect(run.at(a)).toBeUndefined(); + expect(run.placement().composition).toBeUndefined(); + playComposition(run.placement()); + run.frame(); + expect(run.at(a)).toBeDefined(); + expect(run.at(a)).not.toBe(before); + expect(run.world.log).toEqual([]); + }); + + it('builds every item anew on a restart, and plays on when asked to play again', () => { + const stage = makeStage(); + director(stage); + const a = stage.item(first(stage), stage.spriteId, 0, 30); + const run = play(stage); + run.frame(5); + const playing = run.at(a); + playComposition(run.placement()); + run.frame(); + expect(run.at(a)).toBe(playing); + expect(run.placement().composition!.time).toBeCloseTo(0.5, 9); + playComposition(run.placement(), true); + run.frame(); + expect(run.at(a)).not.toBe(playing); + expect(run.placement().composition!.time).toBe(0); + }); + + it('stands still while the game is paused', () => { + const stage = makeStage(); + director(stage); + stage.item(first(stage), stage.spriteId, 0, 30); + const run = play(stage); + run.frame(3); + run.world.paused = true; + run.frame(5); + expect(run.placement().composition!.time).toBeCloseTo(0.2, 9); + }); + + it('plays nothing in the editor run, and says so', () => { + const stage = makeStage(); + director(stage); + const a = stage.item(first(stage), stage.spriteId, 0, 30); + const run = play(stage, false); + run.frame(3); + expect(run.at(a)).toBeUndefined(); + expect(run.world.log).toEqual(['Play composition is ignored in the editor run']); + }); +}); + +describe('a placed composition in the editor', () => { + it('shows its first frame below the placement, with the ids its items take in a run', () => { + const stage = makeStage(); + const a = stage.item(first(stage), stage.spriteId, 0, 3); + stage.item(first(stage), stage.spriteId, 5, 3); + const comp = stage.comp(); + expect(placedPicture(stage.project, comp, stage.placement).map((o) => [o.id, o.parentId])).toEqual([ + [contentId(stage.placement, a), stage.placement], + ]); + // Nothing of it is stored: the placement is sealed. + expect(resolveObjects(stage.project, findScene(stage.project, stage.sceneId)!)).toHaveLength(1); + }); + + it('draws that frame where the placement stands, and a click on it picks the placement', () => { + const stage = makeStage(); + const a = stage.item(first(stage), stage.spriteId, 0, 3); + const scene = findScene(stage.project, stage.sceneId)!; + const { items } = collectHolderItems(stage.project, scene, 0); + const drawn = items.find((i) => i.id === contentId(stage.placement, a))!; + expect([drawn.x, drawn.y]).toEqual([100, 50]); + expect(drawn.sealedIn).toBe(stage.placement); + expect(stackAt(items, [{ id: drawn.id, layers: null }])).toEqual([stage.placement]); + // Play mode draws the run's own objects instead. + expect(collectHolderItems(stage.project, scene, 0, null, false).items.map((i) => i.id)).toEqual([ + stage.placement, + ]); + }); + + it('gives the content of a container item the ids a run gives it', () => { + const stage = makeStage(); + const rig = new CreateAssetCommand('container', 'Rig'); + stage.stack.execute(rig); + (findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push( + createSceneObject('arm', stage.spriteId, { x: 5, y: 0 }), + ); + const item = stage.item(first(stage), rig.asset.id, 0, 3); + const picture = placedPicture(stage.project, stage.comp(), stage.placement); + const top = contentId(stage.placement, item); + expect(picture.map((o) => [o.id, o.parentId])).toEqual([ + [top, stage.placement], + [contentId(top, 'arm'), top], + ]); + expect(contentId(top, 'arm')).toBe(instanceChildId(stage.placement, `${item}/arm`)); + }); +}); diff --git a/tests/core/event-order.test.ts b/tests/core/event-order.test.ts index 5660b71e..d88d3b33 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(18); + expect(events).toHaveLength(19); 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 bd12cf3e..73f9f20b 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { requireNodeDef } from '../../src/core/commands/require'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; +import { playComposition } from '../../src/core/model/composition'; import { createEmptyGraph, type DeclaredPort, @@ -107,6 +108,8 @@ const nestedMover = { const emitTarget = fakeEntity('Sparks'); /** Their own entity each: the animation nodes leave players on the object they are handed. */ const dancer = fakeEntity('Dancer'); +/** A placed composition for Play and Stop composition (#417). */ +const director = fakeEntity('Director'); /** An object whose once-reset "swing" has run out and blends back over half a second (#233). */ const swinging = { ...fakeEntity('Fighter'), @@ -1976,6 +1979,34 @@ const CASES: Record = { after: () => expect(dancer.animations).toEqual([]), }, ], + PlayComposition: [ + { + name: 'no object is a no-op that still continues the flow', + inputs: { object: null, restart: false }, + next: 'exec', + }, + { + name: 'starts a player on the object, and a second play leaves it running', + inputs: { object: director, restart: false }, + next: 'exec', + after: () => { + expect(director.composition).toMatchObject({ time: 0, shown: false, stopped: false }); + const run = director.composition!.run; + playComposition(director); + expect(director.composition!.run).toBe(run); + playComposition(director, true); + expect(director.composition!.run).toBeGreaterThan(run); + }, + }, + ], + StopComposition: [ + { + name: 'marks the player stopped for the next tick to take its items away', + inputs: { object: director }, + next: 'exec', + after: () => expect(director.composition?.stopped).toBe(true), + }, + ], PlaySound: [ { name: 'no sound is a no-op that still continues the flow', @@ -2506,6 +2537,7 @@ const EVENT_NODES: Record = { OnClick: 'nodes.test.ts', OnMouseWheel: 'clip-areas.test.ts', OnAnimationFinished: 'animation.test.ts', + OnCompositionFinished: 'composition-play.test.ts', OnStateEntered: 'state-machine.test.ts', OnStateExited: 'state-machine.test.ts', }; diff --git a/tests/editor/scene-visibility.test.ts b/tests/editor/scene-visibility.test.ts index 4daa5339..5fe9bacf 100644 --- a/tests/editor/scene-visibility.test.ts +++ b/tests/editor/scene-visibility.test.ts @@ -27,6 +27,7 @@ describe('scene visibility', () => { 'camera', 'clip', 'collider', + 'composition', 'container', 'joint', 'particles', diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index c2e4e192..987e4414 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -30,6 +30,7 @@ describe('icon files', () => { 'behavior', 'camera', 'collider', + 'composition', 'container', 'joint', 'particles', diff --git a/tools/e2e/68-composition.mjs b/tools/e2e/68-composition.mjs new file mode 100644 index 00000000..4a9e1843 --- /dev/null +++ b/tools/e2e/68-composition.mjs @@ -0,0 +1,169 @@ +import { MAKE_OBJECT } from './lib/scaffold.mjs'; + +/** + * A composition placed in the scene (#417). In the editor the placement shows the composition's + * first frame, and a click on that picture picks the placement. Played, a behavior below it starts + * it: each item becomes an object below the placement while it lasts, the first track in front, a + * clip moves the item it overlaps, and `On composition finished` hears the end, by which time no + * item is left. Nothing of the run stays in the scene. + * + * Everything it adds carries a `cp68` id and goes again at the end. + */ +const PLACE = 'cp68-place'; +const DONE = 'cut 68 done'; + +const node = (id, type, x, y, params = {}) => ({ id: `cp68-${id}`, type, x, y, params }); +const link = (id, from, fromPort, to, toPort) => ({ + id: `cp68-${id}`, + from: { node: `cp68-${from}`, port: fromPort }, + to: { node: `cp68-${to}`, port: toPort }, +}); + +export async function composition(t) { + const { check, sleep, json, evaluate, mutate, play, stop, showTab } = t; + await showTab('scene'); + + const graph = { + nodes: [ + node('start', 'OnStart', 0, 0), + node('owner', 'Owner', 0, 150), + node('play', 'PlayComposition', 300, 0), + node('finished', 'OnCompositionFinished', 0, 300, { composition: 'cp68-cut' }), + node('log', 'Log', 300, 300, { message: DONE }), + ], + edges: [ + link('e1', 'start', 'exec', 'play', 'exec'), + link('e2', 'owner', 'object', 'play', 'object'), + link('e3', 'finished', 'exec', 'log', 'exec'), + ], + }; + // Red from the start, a larger blue behind it from the next frame – built after red, it still goes + // behind – and green after them, moved by a clip; ten frames a second, so the cut lasts 1.2 s. Placed where the camera looks, in front of what is there. + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const cam = scene.objects.find((o) => o.id === 'o3').position; + ${MAKE_OBJECT} + const sprite = p.assets.find((a) => a.type === 'sprite'); + const look = (o, size, color) => { o.overrides = { width: size, height: size, color, texture: '' }; return o; }; + const red = look(make('cp68-red', sprite.id, '', 0, 'Red 68'), 200, 0xff0000); + const blue = look(make('cp68-blue', sprite.id, '', 0, 'Blue 68'), 300, 0x0000ff); + const green = look(make('cp68-green', sprite.id, '', 0, 'Green 68'), 200, 0x00ff00); + for (const o of [red, blue, green]) delete o.groupId; + p.assets.push( + { + id: 'cp68-cut', + type: 'composition', + name: 'Cut 68', + props: { fps: 10, loop: false }, + objects: [red, blue, green], + groups: [], + tracks: [{ id: 'cp68-front', name: 'Front' }, { id: 'cp68-back', name: 'Back' }], + items: [ + { id: 'cp68-red', track: 'cp68-front', start: 0, length: 6, offset: 0 }, + { id: 'cp68-blue', track: 'cp68-back', start: 1, length: 5, offset: 0 }, + { id: 'cp68-green', track: 'cp68-front', start: 6, length: 6, offset: 0 }, + ], + clips: [{ id: 'cp68-clip', track: 'cp68-front', animation: 'cp68-move', start: 6 }], + }, + { + id: 'cp68-move', + type: 'animation', + name: 'Slide 68', + props: { fps: 10, playback: 'once' }, + root: sprite.id, + tracks: [{ id: 'cp68-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }], + }, + { id: 'cp68-director', type: 'behavior', name: 'Director 68', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graph)} }, + ); + const place = make('${PLACE}', 'cp68-cut', '', 0, 'Cut 68'); + Object.assign(place.position, { x: cam.x, y: cam.y, z: 2000 }); + scene.objects.push(place, make('cp68-logic', 'cp68-director', '${PLACE}', 0, 'Director 68')); + __gm.lookAt(cam.x, cam.y); + `); + const id = (item) => `${PLACE}~cp68-${item}`; + + // 68a. In the editor the placement shows its first frame – red alone – and a click on the picture + // picks the placement, since what it shows is edited in the composition. + const order = await json('__gm.drawOrder()'); + // Off the centre, where the placement's own symbol sits. + const rect = await json(`__gm.objectRect(${JSON.stringify(id('red'))})`); + const stack = rect + ? await json(`__gm.sceneStack(${rect.x + rect.w * 0.35}, ${rect.y + rect.h * 0.35})`) + : null; + check( + 'a placed composition shows its first frame, and a click on it picks the placement', + order.includes(id('red')) && + !order.includes(id('blue')) && + !order.includes(id('green')) && + JSON.stringify(stack) === JSON.stringify([PLACE]), + JSON.stringify({ order: order.filter((o) => o.startsWith('cp68')), stack }), + ); + + // 68b. Played: the items come and go below the placement, sampled every frame of the editor. + await play(); + const seen = await evaluate(`new Promise((resolve) => { + const frames = []; + const sample = () => { + const world = __gm.world(); + if (!world) return resolve(JSON.stringify(frames)); + const items = world.entities.filter((e) => e.objectId.startsWith('${PLACE}~')); + const green = items.find((e) => e.objectId === '${id('green')}'); + frames.push({ + ids: items.map((e) => e.objectId.slice('${PLACE}~cp68-'.length)), + parents: [...new Set(items.map((e) => e.parent))], + greenX: green ? green.x : null, + done: world.log.includes('${DONE}'), + }); + if (frames.length > 400 || frames.at(-1).done) return resolve(JSON.stringify(frames)); + requestAnimationFrame(sample); + }; + sample(); + })`); + const frames = JSON.parse(seen); + const both = frames.find((f) => f.ids.length === 2); + const greenXs = frames.map((f) => f.greenX).filter((x) => x !== null); + check( + 'Play composition builds the items below the placement, the first track in front, one after the other', + both !== undefined && + JSON.stringify(both.ids) === JSON.stringify(['blue', 'red']) && + both.parents.length === 1 && + both.parents[0] === PLACE && + !frames.some((f) => f.ids.join('+') === 'red+blue') && + frames.some((f) => JSON.stringify(f.ids) === JSON.stringify(['green'])) && + !frames.some((f) => f.ids.includes('green') && f.ids.includes('red')), + JSON.stringify(frames.filter((_, i) => i % 5 === 0).map((f) => f.ids.join('+'))), + ); + check( + 'a clip moves the item it overlaps, from its value on', + greenXs.length >= 3 && + greenXs[0] < 40 && + greenXs.at(-1) > greenXs[0] && + greenXs.every((x, i) => i === 0 || x >= greenXs[i - 1]), + JSON.stringify(greenXs), + ); + const last = frames.at(-1); + check( + 'On composition finished hears the end, by which time no item is left', + last?.done === true && last.ids.length === 0, + JSON.stringify(last), + ); + await sleep(300); + await stop(); + + // 68c. The run's objects stay in the run: the scene holds the placement and its behavior only. + const stored = await json( + `__gm.state.activeScene.objects.filter((o) => o.id.startsWith('cp68')).map((o) => o.id).sort()`, + ); + check( + 'nothing of a played composition stays in the scene', + JSON.stringify(stored) === JSON.stringify(['cp68-logic', PLACE].sort()), + JSON.stringify(stored), + ); + + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => !a.id.startsWith('cp68-')); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('cp68-'));`); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 3e0a64d6..8e87ecc6 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -70,6 +70,7 @@ import { pattern } from './64-pattern.mjs'; import { song } from './65-song.mjs'; import { video } from './66-video.mjs'; import { recording } from './67-recording.mjs'; +import { composition } from './68-composition.mjs'; export const STEPS = [ hierarchy, @@ -139,4 +140,5 @@ export const STEPS = [ song, video, recording, + composition, ];