Skip to content

Commit bcd3a4b

Browse files
committed
fix: tween invalidation mid-movement
1 parent 0f012e0 commit bcd3a4b

3 files changed

Lines changed: 177 additions & 105 deletions

File tree

package-lock.json

Lines changed: 0 additions & 94 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@dcl/ecs/src/systems/tween.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,25 +31,43 @@ export function createTweenSystem(engine: IEngine): TweenSystem {
3131
completed: boolean
3232
// Tween has changed on this frame
3333
changed: boolean
34+
// True right after this tween was (re)introduced (created, replaced, or advanced by the
35+
// sequence/YOYO logic below). Cleared the first time the renderer reports a state other than
36+
// TS_COMPLETED (or reports no state at all) for it. While true, a TS_COMPLETED reading is
37+
// assumed to be a stale leftover from the tween this one replaced - the renderer's PUT for
38+
// the new tween's own state hasn't round-tripped back yet - and is never trusted as its
39+
// completion.
40+
awaitingFreshState: boolean
3441
}
3542
>()
3643
function isCompleted(entity: Entity) {
37-
const tweenState = TweenState.getOrNull(entity)
3844
const tween = Tween.getOrNull(entity)
3945
const tweenCache = cache.get(entity)
40-
if (!tweenState || !tween || !tweenCache) return false
41-
/* istanbul ignore next */
46+
if (!tween || !tweenCache) return false
47+
48+
// A tween that was just replaced (this frame) is never completed: the cache-maintenance
49+
// system below hasn't caught up with the new definition yet, so `tweenCache` still describes
50+
// the tween that was just replaced, not the current one.
51+
if (tweenChanged(entity)) return false
52+
53+
const tweenState = TweenState.getOrNull(entity)
54+
if (!tweenState || tweenState.state !== TweenStateStatus.TS_COMPLETED) {
55+
// The renderer isn't (or is no longer) reporting completion for this tween, so any earlier
56+
// TS_COMPLETED reading is confirmed stale: trust the next TS_COMPLETED we see for it.
57+
tweenCache.awaitingFreshState = false
58+
return false
59+
}
60+
61+
// From here, the renderer reports TS_COMPLETED for the current tween definition.
4262
if (
43-
// Renderer notified that the tween is completed
44-
// Only consider it completed if the tween hasn't changed this frame (to avoid false positives after YOYO/sequence processing)
45-
((tweenState.state === TweenStateStatus.TS_COMPLETED && !tweenCache.changed) ||
46-
(tweenChanged(entity) && !tweenCache.changed)) &&
63+
// Still the stale completion of the tween this one replaced
64+
tweenCache.awaitingFreshState ||
4765
// Avoid sending isCompleted multiple times
48-
!tweenCache.completed
66+
tweenCache.completed
4967
) {
50-
return true
68+
return false
5169
}
52-
return false
70+
return true
5371
}
5472
function tweenChanged(entity: Entity) {
5573
const currentTween = Tween.getOrNull(entity)
@@ -74,7 +92,8 @@ export function createTweenSystem(engine: IEngine): TweenSystem {
7492
cache.set(entity, {
7593
tween: buffer.toBinary(),
7694
completed: false,
77-
changed: true
95+
changed: true,
96+
awaitingFreshState: true
7897
})
7998
continue
8099
}
@@ -144,12 +163,18 @@ export function createTweenSystem(engine: IEngine): TweenSystem {
144163
// Mark as changed so the cache system will detect the change and reset the cache properly
145164
tweenCache.completed = false
146165
tweenCache.changed = true
166+
// The renderer still hasn't reported on this replacement tween; don't trust a
167+
// TS_COMPLETED reading until it reports something else for it first.
168+
tweenCache.awaitingFreshState = true
147169
} else if (tweenSequence.loop === TweenLoop.TL_YOYO) {
148170
Tween.createOrReplace(entity, backwardsTween(tween))
149171
// Reset completed flag for the backwards tween
150172
// Mark as changed so the cache system will detect the change and reset the cache properly
151173
tweenCache.completed = false
152174
tweenCache.changed = true
175+
// The renderer still hasn't reported on this replacement tween; don't trust a
176+
// TS_COMPLETED reading until it reports something else for it first.
177+
tweenCache.awaitingFreshState = true
153178
} else if (tweenSequence.loop === TweenLoop.TL_RESTART) {
154179
Tween.deleteFrom(entity)
155180
cache.delete(entity)

test/ecs/events/tween.spec.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,4 +285,145 @@ describe('Tween System', () => {
285285
)
286286
})
287287
})
288+
289+
// Regression coverage for the "retarget mid-travel" false-positive bug: replacing a tween
290+
// (Tween.createOrReplace / setMove et al.) while a previous one is still running, or right
291+
// after it arrived, must never be reported as completed for the *new* tween.
292+
describe('mid-flight retargeting must never be reported as completed', () => {
293+
it('does not report completed when a tween is replaced while still TS_ACTIVE, even when read in the same frame as the replacement', async () => {
294+
const testEngine = Engine()
295+
const testTweenSystem = createTweenSystem(testEngine)
296+
const testTween = components.Tween(testEngine)
297+
const testTweenState = components.TweenState(testEngine)
298+
const testEntity = testEngine.addEntity()
299+
const testMockTween = mockTweenEngine(testEngine, testTween)
300+
301+
await testMockTween(testEntity, testTween.Mode.Move({ start: Vector3.create(), end: Vector3.create(1, 1, 1) }))
302+
await testEngine.update(1) // let the cache pick up the first tween
303+
304+
testTweenState.createOrReplace(testEntity, { state: TweenStateStatus.TS_ACTIVE, currentTime: 0.5 })
305+
await testEngine.update(1) // now travelling, mid-flight
306+
307+
let resultOnRetargetFrame: boolean | undefined
308+
testEngine.addSystem(() => {
309+
if (resultOnRetargetFrame === undefined) {
310+
// Replace the tween and read tweenCompleted() in the very same tick, before the
311+
// internal cache-maintenance system (registered at -Infinity) gets a chance to run.
312+
testTween.createOrReplace(testEntity, {
313+
duration: 1000,
314+
easingFunction: EasingFunction.EF_EASEBACK,
315+
mode: testTween.Mode.Move({ start: Vector3.create(1, 1, 1), end: Vector3.create(2, 2, 2) })
316+
})
317+
resultOnRetargetFrame = testTweenSystem.tweenCompleted(testEntity)
318+
}
319+
})
320+
321+
await testEngine.update(1)
322+
expect(resultOnRetargetFrame).toBe(false)
323+
324+
// And it must stay false on the following frames too, while genuinely mid-flight
325+
expect(testTweenSystem.tweenCompleted(testEntity)).toBe(false)
326+
await testEngine.update(1)
327+
expect(testTweenSystem.tweenCompleted(testEntity)).toBe(false)
328+
})
329+
330+
it('does not report completed for a tween retargeted right after an arrival (stale TS_COMPLETED), but does report once the new tween genuinely completes', async () => {
331+
const testEngine = Engine()
332+
const testTweenSystem = createTweenSystem(testEngine)
333+
const testTween = components.Tween(testEngine)
334+
const testTweenState = components.TweenState(testEngine)
335+
const testEntity = testEngine.addEntity()
336+
const testMockTween = mockTweenEngine(testEngine, testTween)
337+
const testMockTweenStatus = mockTweenStatusEngine(testEngine, testTweenState)
338+
const testCompleted = jest.fn()
339+
testEngine.addSystem(() => {
340+
if (testTweenSystem.tweenCompleted(testEntity)) testCompleted()
341+
})
342+
343+
// First tween genuinely completes
344+
await testMockTween(testEntity)
345+
await testMockTweenStatus(testEntity)
346+
expect(testCompleted).toBeCalledTimes(1)
347+
348+
// Retarget while TweenState is still (stale) TS_COMPLETED from the tween we just replaced -
349+
// the renderer's PUT acknowledging the new tween hasn't round-tripped back yet.
350+
testCompleted.mockClear()
351+
await testMockTween(testEntity, testTween.Mode.Move({ start: Vector3.create(), end: Vector3.create(5, 5, 5) }))
352+
await testEngine.update(1)
353+
expect(testCompleted).toBeCalledTimes(0)
354+
355+
// Stays uncompleted across several frames of stale TS_COMPLETED (the round trip is "at
356+
// least 2 frames, variable" - a single-frame guard is not enough)
357+
await testEngine.update(1)
358+
expect(testCompleted).toBeCalledTimes(0)
359+
await testEngine.update(1)
360+
expect(testCompleted).toBeCalledTimes(0)
361+
362+
// Renderer catches up and reports the new tween as active
363+
testTweenState.createOrReplace(testEntity, { state: TweenStateStatus.TS_ACTIVE, currentTime: 0.1 })
364+
await testEngine.update(1)
365+
expect(testCompleted).toBeCalledTimes(0)
366+
367+
// The new tween genuinely completes
368+
testTweenState.createOrReplace(testEntity, { state: TweenStateStatus.TS_COMPLETED, currentTime: 1 })
369+
await testEngine.update(1)
370+
expect(testCompleted).toBeCalledTimes(1)
371+
372+
// And is not reported again on subsequent frames
373+
testCompleted.mockClear()
374+
await testEngine.update(1)
375+
expect(testCompleted).toBeCalledTimes(0)
376+
})
377+
378+
it('does not double-report a sequence-advanced step while the renderer still echoes the previous step TS_COMPLETED', async () => {
379+
const testEngine = Engine()
380+
const testTweenSystem = createTweenSystem(testEngine)
381+
const testTween = components.Tween(testEngine)
382+
const testTweenState = components.TweenState(testEngine)
383+
const testTweenSequence = components.TweenSequence(testEngine)
384+
const testEntity = testEngine.addEntity()
385+
const testMockTween = mockTweenEngine(testEngine, testTween)
386+
const testCompleted = jest.fn()
387+
testEngine.addSystem(() => {
388+
if (testTweenSystem.tweenCompleted(testEntity)) testCompleted()
389+
})
390+
391+
const moveTween = await testMockTween(
392+
testEntity,
393+
testTween.Mode.Move({ start: Vector3.create(), end: Vector3.create(1, 1, 1) })
394+
)
395+
const rotateTween = {
396+
...moveTween,
397+
mode: testTween.Mode.Rotate({ start: Quaternion.Zero(), end: Quaternion.Identity() })
398+
}
399+
testTweenSequence.createOrReplace(testEntity, { sequence: [rotateTween], loop: TweenLoop.TL_RESTART })
400+
401+
// Settle the cache on the move tween
402+
await testEngine.update(1)
403+
await testEngine.update(1)
404+
405+
// Move tween genuinely completes -> sequence system advances to the rotate tween this same frame
406+
testTweenState.createOrReplace(testEntity, { state: TweenStateStatus.TS_COMPLETED, currentTime: 1 })
407+
await testEngine.update(1)
408+
expect(testCompleted).toBeCalledTimes(1)
409+
expect(testTween.get(testEntity).mode).toMatchCloseTo(rotateTween.mode)
410+
411+
// TweenState is still (stale) TS_COMPLETED, echoing the move tween that just finished. The
412+
// renderer hasn't acknowledged the rotate tween yet - it must not be reported as completed.
413+
testCompleted.mockClear()
414+
await testEngine.update(1)
415+
expect(testCompleted).toBeCalledTimes(0)
416+
await testEngine.update(1)
417+
expect(testCompleted).toBeCalledTimes(0)
418+
419+
// Renderer catches up: rotate tween is active, then genuinely completes
420+
testTweenState.createOrReplace(testEntity, { state: TweenStateStatus.TS_ACTIVE, currentTime: 0.2 })
421+
await testEngine.update(1)
422+
expect(testCompleted).toBeCalledTimes(0)
423+
424+
testTweenState.createOrReplace(testEntity, { state: TweenStateStatus.TS_COMPLETED, currentTime: 1 })
425+
await testEngine.update(1)
426+
expect(testCompleted).toBeCalledTimes(1)
427+
})
428+
})
288429
})

0 commit comments

Comments
 (0)