fix(engine): double-click move animation no longer plays twice

A successful double-click was rendering the slide-to-destination
animation twice — once from the first press's MoveRequestEvent
landing, and again from the release's StateChangedEvent racing the
in-flight CardAnim and replacing it from the mid-animation
position.

The frame trace:

  Frame N (second press):
    handle_double_click → MoveRequestEvent (queued)
    start_drag           → DragState set, drag.committed = false
                            (start_drag never mutates Transform; the
                             card is still visually in place)
    handle_move          → applies the move, fires StateChangedEvent
    sync_cards_on_change → cur ≠ target, inserts CardAnim slide
                            (animation #1 starts)

  Frames N+1, N+2, …:
    follow_drag idles (drag uncommitted, cursor not moving)
    CardAnim animates the card from old to new pile

  Frame N+K (release):
    end_drag             → drag.committed = false branch:
                            drag.clear() + StateChangedEvent  ← CULPRIT
    sync_cards_on_change → sees the card mid-CardAnim
                            (cur ≠ target), replaces CardAnim
                            with a fresh one starting at the
                            current mid-position (animation #2
                            visibly restarts the slide)

The fix is one line: drop the StateChangedEvent write in the
uncommitted-drag branch of end_drag. The defensive resync was
never needed there — start_drag only mutates the DragState
resource on press, never card transforms, so an uncommitted drag
has no visual side effect to undo. The committed-drag branch (line
762) keeps its StateChangedEvent write since snap-back from a
real drag does need a resync.

Existing tests pass unchanged. The bug only manifested in the
specific timing of double-click → quick-release before
animation-complete; an integration test would require driving
mouse press/release across several frames with a dispatched
GameMutation pass between, which is heavier than the fix
warrants.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 20:00:05 -07:00
parent d7ffb16df5
commit 6037596cc0
+15 -2
View File
@@ -644,10 +644,23 @@ fn end_drag(
}
// If the drag was never committed (user tapped without moving far enough),
// treat it as a click: just cancel the pending drag and resync card positions.
// treat it as a click: cancel the pending drag and exit. We deliberately
// do NOT fire `StateChangedEvent` here — `start_drag` only mutates the
// `DragState` resource on press, never card transforms, so an uncommitted
// drag has no visual side effect to undo.
//
// Firing one would race a CardAnim that's already in flight on the same
// card. Specifically: on a successful double-click, `handle_double_click`
// fires `MoveRequestEvent`, `start_drag` picks the card up the same
// frame (uncommitted), and `handle_move` queues a `StateChangedEvent` →
// `sync_cards_on_change` starts a slide animation. When the player
// releases the button mid-slide, `end_drag` would fire a second
// `StateChangedEvent`, `sync_cards_on_change` would see the card mid-
// animation (`cur != target`), and replace the in-flight CardAnim with
// a fresh one — restarting the slide and reading on screen as the move
// animation playing twice.
if !drag.committed {
drag.clear();
changed.write(StateChangedEvent);
return;
}
let Some(layout) = layout else {