Skip to content

Commit bf596c9

Browse files
authored
Merge pull request #70 from decentraland/feat/playback-completion-signals
feat: playback completion signals
2 parents 568c164 + 5ec93e2 commit bf596c9

5 files changed

Lines changed: 87 additions & 3 deletions

File tree

animations-tweens/SKILL.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,27 @@ The short-circuit avoids calling `getMutableOrNull()` on unchanged frames, elimi
156156

157157
**When to prefer `Animator.playSingleAnimation`.** It's the canonical write: pauses all other states in one pass and accepts a `resetCursor` argument (defaults to `true`). It's not idempotent — but it's the right call site when the trigger is a one-shot event. The custom-helper pattern only exists when porting SDK6 code that needs lazy clip registration (see the previous PITFALL) or `noLoop + revertToIdle` semantics.
158158

159+
## Detecting Animation Completion
160+
161+
When a non-looping clip finishes, the engine flips that state's `playing` back to `false` — no `setTimeout(clipLength)` guessing needed. Poll with the READ-ONLY `Animator.get()` and edge-detect the `true → false` transition:
162+
163+
```typescript
164+
let wasPlaying = false
165+
engine.addSystem(() => {
166+
const state = Animator.get(entity).states.find((s) => s.clip === 'Bite')
167+
const isPlaying = state?.playing ?? false
168+
if (wasPlaying && !isPlaying) {
169+
console.log('Bite finished')
170+
Animator.playSingleAnimation(entity, 'Walk') // chain the next clip
171+
}
172+
wasPlaying = isPlaying
173+
})
174+
```
175+
176+
- **Never poll with `Animator.getClip()` or `getMutable()`** — both return a mutable ref and mark the component dirty every frame (same serialization overhead as the PITFALL above). `Animator.get()` is the only safe per-frame read.
177+
- The engine only flips the flag when the clip ends by itself. Looping clips never flip it, `speed: 0` states never finish, and a scene-side `stopAllAnimations()` is your own write (track it yourself if you need to tell them apart).
178+
- Requires a DCL 2.0 desktop client with playback-completion support; on older clients the flag never flips — keep a `timers.setTimeout` fallback only if you must support them.
179+
159180
## Tweens (Code-Based Animation)
160181

161182
Animate entity properties smoothly over time. Create with `Tween.create(entity, { mode: Tween.Mode.Move/Rotate/Scale({start, end}), duration, easingFunction })`. Duration is in **milliseconds** (1000 = 1 second). An entity can only have one Tween component at a time.
@@ -223,6 +244,7 @@ For complex animations, create a system with `engine.addSystem((dt) => { ... })`
223244
| Door/grave/lid spawns OPEN instead of closed | The renderer auto-plays the clip and holds its final (open) frame; the model's closed pose is frame 0 of that clip | Hold frame 0 with a `playing: true, speed: 0, loop: false` state — see "Resting an animated model at its FIRST frame" above |
224245
| Unnecessary per-frame serialization overhead | Clip-switch helper calls `getMutableOrNull` every tick with identical values (custom helper or `playSingleAnimation` called from a system / per-frame input callback) | Track the last-applied clip and short-circuit when unchanged, or only call the helper on state transitions — see "Short-circuit clip-switch helpers" best practice above |
225246
| Animator has no effect | Missing `GltfContainer` | `Animator` only works on entities with a loaded GLTF model |
247+
| Need to know when a non-looping clip ends | Guessing with `timers.setTimeout(clipLength)` | Edge-detect `playing` flipping to `false` via read-only `Animator.get()` — see "Detecting Animation Completion" above |
226248
| Tween doesn't move | Same start and end | Verify values differ in `Tween.Mode.Move()` |
227249
| Entity teleports when a Move tween starts | `start` doesn't match the entity's current position — omitted `start` = `(0,0,0)` (scene origin), hardcoded/stale `start` = wherever you wrote | Pass `Transform.get(entity).position` as `start` — it's the live position even while a previous tween is mid-flight |
228250
| Tween plays once then stops | No loop | Add `TweenSequence` with `loop: TweenLoop.TL_YOYO` |
@@ -235,6 +257,7 @@ For full code examples (Animator setup, all tween types, sequences, helpers, tex
235257
Engine-team test scenes (ground-truth API usage):
236258

237259
- `https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-8-animator-tests-shark``Animator` with two states + `weight`; clicking cycles `playSingleAnimation('swim')` / `('bite')` and demonstrates concurrent blend by also setting `getClip('bite').playing = true`.
260+
- `https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-9-animation-finish` — animation-completion detection: non-looping `bite` finish flips `playing` to `false` (observed via read-only `Animator.get()`), chains into `swim`; proves looping clips never report finish.
238261
- `https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/78,-4-tweens` — every finite mode (`setMove`/`setRotate`/`setScale`/`setTextureMove`/`setMoveRotateScale`), continuous modes, `createOrReplace`, `deleteFrom`, pause-toggle via `getMutableOrNull().playing`, and mixed-mode `TweenSequence`.
239262
- `https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/77,-5-tweens-moving-platforms``Tween.setMove` + empty-sequence `TL_YOYO` for bobbing platforms, multi-waypoint path via `sequence[]`, and `createOrReplace` retrigger from a `TriggerArea`.
240263
- `https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/79,-4-tween-following-cube` — a cube that chases the player, with a pad that switches live between `setMoveContinuous` (smooth, the default) and re-creating a `setMove` tween from `Transform.get(entity).position` on every re-aim (visibly jittery). Both modes share the same re-aim trigger and speed, so the difference is attributable to the tween mode alone. Also shows `Billboard` with `BM_Y` on floating labels.

audio-video/SKILL.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ AudioSource.stopSound(entity) // stops, resets cursor
5353

5454
Both helpers return `false` if the entity has no `AudioSource`, so create the component first (e.g. `AudioSource.create(entity, { audioClipUrl, playing: false })` at init).
5555

56+
**Detecting when a sound finishes:** when a non-looping clip ends on its own, the engine flips `AudioSource.playing` back to `false`. Poll it with the READ-ONLY getter and edge-detect the `true → false` transition — never poll with `getMutable` (dirties the component every frame):
57+
58+
```typescript
59+
let wasPlaying = false
60+
engine.addSystem(() => {
61+
const isPlaying = AudioSource.get(entity).playing ?? false
62+
if (wasPlaying && !isPlaying) console.log('sound finished') // chain next sound/action here
63+
wasPlaying = isPlaying
64+
})
65+
```
66+
67+
The engine only flips the flag on natural completion — a scene-initiated `stopSound()` is your own write, and looping clips never flip it. Alternatively use `audioEventsSystem` (callback per `MediaState` change, works for `AudioSource` AND `AudioStream` entities; `MS_PLAYING → MS_READY` = stopped, `MS_ERROR` = file failed to load): `audioEventsSystem.registerAudioEventsEntity(entity, (e) => {...})`, plus `getAudioState(entity)` / `removeAudioEventsEntity(entity)` — same shape as `videoEventsSystem`. Both features require a DCL 2.0 desktop client with playback-completion support; on older clients the flag never flips and no finish signal arrives — don't build logic that hard-blocks on it without a timeout fallback.
68+
5669
Players must interact with the scene (click) before audio can play (browser autoplay policy). If an audio file needs to be ready to play the instant the player interacts, use the `AssetLoad` component to pre-load the asset.
5770

5871
> **Before adding audio**: Confirm with the user before fetching audio from external sources.
@@ -61,7 +74,7 @@ Players must interact with the scene (click) before audio can play (browser auto
6174

6275
Stream audio from a URL (radio, live streams). Key fields: `url` (streaming URL), `playing`, `volume`. Non-spatial by default — plays at same volume everywhere. Set `spatial: true` with `spatialMinDistance`/`spatialMaxDistance` for distance-based volume.
6376

64-
Query state with `AudioStream.getAudioState(entity)` which returns a `PBAudioEvent | undefined` — an object with a `state` field (a `MediaState` enum: `MS_PLAYING`, `MS_ERROR`, etc.) and a `timestamp` field, not a bare enum. Read the state as `AudioStream.getAudioState(entity)?.state`.
77+
Query state with `AudioStream.getAudioState(entity)` which returns a `PBAudioEvent | undefined` — an object with a `state` field (a `MediaState` enum: `MS_PLAYING`, `MS_ERROR`, etc.) and a `timestamp` field, not a bare enum. Read the state as `AudioStream.getAudioState(entity)?.state`. For callback-style state changes instead of polling, `audioEventsSystem.registerAudioEventsEntity` works on AudioStream entities too (see the AudioSource finish-detection note above).
6578

6679
> **Before adding a streaming URL**: If not provided by the user, confirm the source first.
6780
@@ -115,5 +128,6 @@ Engine-team test scenes exercised against the real explorer:
115128

116129
- [audio-source-retrigger-test](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/89,-10-audio-source-retrigger-test)`AudioSource.playSound`/`stopSound`, same-URL retrigger, URL-swap on one entity, `resetCursor` semantics, volume/pitch/loop variations, and why `playSound` beats hand-mutating `getMutable` (LWW dedup).
117130
- [audio-visualization](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/88,-10-audio-visualization)`AudioAnalysis` music visualizer (see the `audio-analysis` skill).
131+
- [audio-finish](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/89,-11-audio-finish) — natural-finish detection via the `playing` flip + `audioEventsSystem` callback, and how a scene-initiated stop is distinguished from a natural finish.
118132

119133
For full code examples and implementation patterns, see `{baseDir}/references/media-patterns.md`. For component field details, see `{baseDir}/references/media-reference.md`.

audio-video/references/media-reference.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,26 @@ audio.playing = true
5656
audio.currentTime = 0 // if playing/currentTime already had these values, LWW may dedup the PUT
5757
```
5858

59+
### Audio Events (playback state changes, incl. finish detection)
60+
61+
Mirrors `videoEventsSystem`, but for `AudioSource` and `AudioStream` entities. The event's `state` is a `MediaState` enum value (`MS_NONE`, `MS_ERROR`, `MS_LOADING`, `MS_READY`, `MS_PLAYING`, `MS_BUFFERING`, `MS_SEEKING`, `MS_PAUSED`).
62+
63+
```typescript
64+
import { audioEventsSystem, MediaState } from '@dcl/sdk/ecs'
65+
66+
audioEventsSystem.registerAudioEventsEntity(entity, (event) => {
67+
// MS_PLAYING -> MS_READY = the sound stopped (natural finish for AudioSource clips)
68+
// MS_ERROR = the file failed to load
69+
console.log('audio state:', event.state, 'at', event.timestamp)
70+
})
71+
72+
const latest = audioEventsSystem.getAudioState(entity) // last reported PBAudioEvent | undefined
73+
audioEventsSystem.hasAudioEventsEntity(entity) // is a callback registered
74+
audioEventsSystem.removeAudioEventsEntity(entity) // unregister
75+
```
76+
77+
For AudioSource clips the engine also flips the component's `playing` field back to `false` on natural finish — pollable with the read-only `AudioSource.get(entity).playing` (see SKILL.md). Requires a DCL 2.0 desktop client with playback-completion support.
78+
5979
## AudioStream — Full Fields
6080

6181
```typescript

player-avatar/SKILL.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,30 @@ import { stopEmote } from '~system/RestrictedActions'
302302
stopEmote({})
303303
```
304304

305+
### Detecting when an emote finishes
306+
307+
Every emote lifecycle event is appended to the `AvatarEmoteCommand` grow-only set on the player entity, with a `state` field (`EmoteState` enum). Works for scene-triggered emotes (`triggerEmote`/`triggerSceneEmote`), emotes the player plays via the emote wheel, AND other players' emotes (pass their entity instead of `engine.PlayerEntity`).
308+
309+
```typescript
310+
import { AvatarEmoteCommand, EmoteState } from '@dcl/sdk/ecs'
311+
312+
AvatarEmoteCommand.onChange(engine.PlayerEntity, (cmd) => {
313+
if (!cmd) return
314+
switch (cmd.state ?? EmoteState.ES_STARTED) {
315+
case EmoteState.ES_STARTED: // emote started (also the value when `state` is absent — older clients)
316+
break
317+
case EmoteState.ES_FINISHED: // non-looping emote played to its natural end
318+
break
319+
case EmoteState.ES_INTERRUPTED: // cut short: movement/jump, teleport, another emote, stopEmote(), or scene exit
320+
break
321+
}
322+
})
323+
```
324+
325+
- Always default absent `state` to `ES_STARTED` (`cmd.state ?? EmoteState.ES_STARTED`) — entries from older clients omit the field, and older clients never send FINISHED/INTERRUPTED at all, so don't hard-block gameplay on a finish signal without a fallback.
326+
- **Masked (partial-body) emotes on the local player report no lifecycle events** — known limitation.
327+
- Requires a DCL 2.0 desktop client with playback-completion support.
328+
305329
### Emote masks (upper-body only)
306330

307331
`triggerEmote` and `triggerSceneEmote` both accept an optional `mask` (enum `AvatarMask`, imported from `@dcl/sdk/ecs`) that limits which bones the animation drives. Use it to restrict a looping emote to the upper body so the player can keep walking around while the upper body animates (e.g. carrying/juggling an object).

player-avatar/references/avatar-apis.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,14 @@ import {
177177
AvatarEmoteCommand,
178178
AvatarBase,
179179
AvatarEquippedData,
180+
EmoteState,
180181
} from '@dcl/sdk/ecs'
181182

182-
// Emote played
183+
// Emote lifecycle: each entry carries state ES_STARTED / ES_FINISHED / ES_INTERRUPTED.
184+
// Absent state (older clients) means STARTED — always apply the ?? default.
185+
// See SKILL.md "Detecting when an emote finishes".
183186
AvatarEmoteCommand.onChange(engine.PlayerEntity, (cmd) => {
184-
if (cmd) console.log('Emote:', cmd.emoteUrn)
187+
if (cmd) console.log('Emote:', cmd.emoteUrn, 'state:', cmd.state ?? EmoteState.ES_STARTED)
185188
})
186189

187190
// Appearance changed

0 commit comments

Comments
 (0)