Skip to content

Commit 17e61f6

Browse files
authored
修复 CAT 拖拽时误触发走路动画的竞态问题 (#1654)
* Fix return ball drag safety cleanup * fix: guard delayed return ball drag cleanup
1 parent acf41a6 commit 17e61f6

2 files changed

Lines changed: 208 additions & 11 deletions

File tree

static/avatar-ui-buttons.js

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2726,6 +2726,10 @@ function _stepNekoIdleCat1Walk(button, timestamp) {
27262726
function _startNekoIdleCat1Walk(button, target) {
27272727
const state = _getNekoIdleCat1Journey(button);
27282728
if (!state) return;
2729+
if (_isNekoIdleReturnDragActionActive(button)) return;
2730+
const walkContainer = _getNekoIdleReturnContainerFromButton(button);
2731+
const walkDragging = walkContainer && walkContainer.getAttribute('data-dragging');
2732+
if (walkDragging && walkDragging !== 'false') return;
27292733
const profile = state.profile;
27302734
state.target = target;
27312735
state.targetKind = target && target.kind ? target.kind : '';
@@ -2963,7 +2967,8 @@ function _refreshNekoIdleCat1Observer(button) {
29632967
const currentState = button.__nekoIdleReturnSubactionState || button.__nekoIdleCat1Journey;
29642968
if (!currentState || currentState.paused) return;
29652969
if (currentState.substate === currentState.profile.walkingSubstate) return;
2966-
if (container.getAttribute('data-dragging') === 'true') return;
2970+
const observerDragging = container.getAttribute('data-dragging');
2971+
if (observerDragging && observerDragging !== 'false') return;
29672972
_scheduleNekoIdleCat1JourneySync(button);
29682973
});
29692974
state.containerObserver.observe(container, {
@@ -2977,6 +2982,9 @@ function _refreshNekoIdleCat1Observer(button) {
29772982
function _syncNekoIdleCat1Journey(button, tier) {
29782983
if (!button) return;
29792984
if (_isNekoIdleCompactSurfaceDragging()) return;
2985+
const initialContainer = _getNekoIdleReturnContainerFromButton(button);
2986+
const initialDragging = initialContainer && initialContainer.getAttribute('data-dragging');
2987+
if (initialDragging && initialDragging !== 'false') return;
29802988
const normalizedTier = _normalizeNekoIdleReturnTier(tier || button.getAttribute('data-neko-idle-tier'));
29812989
const profile = _getNekoIdleReturnSubactionProfile(normalizedTier);
29822990
const state = _getNekoIdleReturnSubactionState(button, profile);
@@ -3911,9 +3919,40 @@ const AvatarButtonMixin = {
39113919
ManagerPrototype._setupReturnButtonDrag = function(container) {
39123920
let isDragging = false;
39133921
let dragActiveDispatched = false;
3922+
let dragSafetyTimer = 0;
3923+
let dragSafetyToken = 0;
39143924
let dragStartX = 0, dragStartY = 0, containerStartX = 0, containerStartY = 0;
39153925

3926+
const clearDragSafetyTimer = () => {
3927+
if (!dragSafetyTimer) return;
3928+
clearTimeout(dragSafetyTimer);
3929+
dragSafetyTimer = 0;
3930+
};
3931+
3932+
const finishDragState = (moved, safetyToken) => {
3933+
if (safetyToken !== dragSafetyToken) return;
3934+
container.setAttribute('data-dragging', 'false');
3935+
if (moved) {
3936+
_dispatchNekoIdleReturnBallManualMove(container, 'return-ball-drag-end', {
3937+
movedDistancePx: Math.hypot(
3938+
(parseFloat(container.style.left) || containerStartX) - containerStartX,
3939+
(parseFloat(container.style.top) || containerStartY) - containerStartY
3940+
)
3941+
});
3942+
}
3943+
};
3944+
3945+
const resetDragStateAfterMissingEnd = (safetyToken) => {
3946+
if (dragSafetyToken !== safetyToken || !isDragging) return;
3947+
const moved = container.getAttribute('data-dragging') === 'true';
3948+
isDragging = false;
3949+
dragActiveDispatched = false;
3950+
container.style.cursor = 'grab';
3951+
finishDragState(moved, safetyToken);
3952+
};
3953+
39163954
const handleStart = (clientX, clientY) => {
3955+
clearDragSafetyTimer();
39173956
_dispatchNekoIdleReturnBallManualMove(container, 'return-ball-drag-start');
39183957
isDragging = true;
39193958
dragActiveDispatched = false;
@@ -3927,8 +3966,14 @@ const AvatarButtonMixin = {
39273966
container.style.bottom = '';
39283967
container.style.left = `${containerStartX}px`;
39293968
container.style.top = `${containerStartY}px`;
3930-
container.setAttribute('data-dragging', 'false');
3969+
container.setAttribute('data-dragging', 'pending');
39313970
container.style.cursor = 'grabbing';
3971+
const safetyToken = dragSafetyToken + 1;
3972+
dragSafetyToken = safetyToken;
3973+
dragSafetyTimer = setTimeout(() => {
3974+
dragSafetyTimer = 0;
3975+
resetDragStateAfterMissingEnd(safetyToken);
3976+
}, 5000);
39323977
};
39333978

39343979
const handleMove = (clientX, clientY) => {
@@ -3949,21 +3994,15 @@ const AvatarButtonMixin = {
39493994
};
39503995

39513996
const handleEnd = () => {
3997+
clearDragSafetyTimer();
39523998
if (isDragging) {
3999+
const safetyToken = dragSafetyToken;
39534000
const moved = container.getAttribute('data-dragging') === 'true';
39544001
isDragging = false;
39554002
dragActiveDispatched = false;
39564003
container.style.cursor = 'grab';
39574004
setTimeout(() => {
3958-
container.setAttribute('data-dragging', 'false');
3959-
if (moved) {
3960-
_dispatchNekoIdleReturnBallManualMove(container, 'return-ball-drag-end', {
3961-
movedDistancePx: Math.hypot(
3962-
(parseFloat(container.style.left) || containerStartX) - containerStartX,
3963-
(parseFloat(container.style.top) || containerStartY) - containerStartY
3964-
)
3965-
});
3966-
}
4005+
finishDragState(moved, safetyToken);
39674006
}, 10);
39684007
}
39694008
};

tests/unit/test_avatar_return_button_idle_tiers_static.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@
3434
CAT_MODEL_CHANGE_ASSET_PATH = PROJECT_ROOT / "static" / "assets" / "neko-idle" / "cat_model_change.gif"
3535

3636

37+
def _source_slice_between(source, start_marker, end_marker, block_name):
38+
start = source.find(start_marker)
39+
assert start != -1, f"{block_name} start marker not found: {start_marker}"
40+
end = source.find(end_marker, start + len(start_marker))
41+
assert end != -1, f"{block_name} end marker not found after start: {end_marker}"
42+
assert start < end, f"{block_name} start marker must precede end marker"
43+
return source[start:end]
44+
45+
46+
def _assert_source_contains(block, expected, block_name):
47+
assert expected in block, f"{block_name} missing expected source: {expected}"
48+
49+
50+
def _assert_source_order(block, block_name, *expected_markers):
51+
positions = []
52+
for marker in expected_markers:
53+
position = block.find(marker)
54+
assert position != -1, f"{block_name} missing expected source: {marker}"
55+
positions.append(position)
56+
assert positions == sorted(positions), f"{block_name} expected order: {' -> '.join(expected_markers)}"
57+
58+
3759
def test_return_button_idle_tier_assets_are_mapped_in_source():
3860
source = AVATAR_UI_BUTTONS_PATH.read_text(encoding="utf-8")
3961
app_ui_source = APP_UI_PATH.read_text(encoding="utf-8")
@@ -497,6 +519,142 @@ def test_cat1_walk_to_minimized_chat_contract_is_present():
497519
assert "if (!window.__NEKO_MULTI_WINDOW__)" in source
498520

499521

522+
def test_cat1_walk_is_blocked_while_return_ball_drag_is_active_or_pending():
523+
source = AVATAR_UI_BUTTONS_PATH.read_text(encoding="utf-8")
524+
525+
drag_setup = _source_slice_between(
526+
source,
527+
"ManagerPrototype._setupReturnButtonDrag = function(container) {",
528+
"container.addEventListener('mousedown'",
529+
"return button drag setup",
530+
)
531+
handle_start = _source_slice_between(
532+
source,
533+
"const handleStart = (clientX, clientY) => {",
534+
"const handleMove = (clientX, clientY) => {",
535+
"return button drag start handler",
536+
)
537+
handle_end = _source_slice_between(
538+
source,
539+
"const handleEnd = () => {",
540+
"container.addEventListener('mousedown'",
541+
"return button drag end handler",
542+
)
543+
544+
for expected in (
545+
"let dragSafetyTimer = 0;",
546+
"let dragSafetyToken = 0;",
547+
"const clearDragSafetyTimer = () => {",
548+
"const resetDragStateAfterMissingEnd = (safetyToken) => {",
549+
"if (dragSafetyToken !== safetyToken || !isDragging) return;",
550+
"const finishDragState = (moved, safetyToken) => {",
551+
"if (safetyToken !== dragSafetyToken) return;",
552+
"container.setAttribute('data-dragging', 'false');",
553+
"_dispatchNekoIdleReturnBallManualMove(container, 'return-ball-drag-end'",
554+
):
555+
_assert_source_contains(drag_setup, expected, "return button drag setup")
556+
_assert_source_order(
557+
drag_setup,
558+
"return button drag setup helpers",
559+
"const finishDragState = (moved, safetyToken) => {",
560+
"const resetDragStateAfterMissingEnd = (safetyToken) => {",
561+
"finishDragState(moved, safetyToken);",
562+
)
563+
_assert_source_contains(
564+
handle_start,
565+
"container.setAttribute('data-dragging', 'pending')",
566+
"return button drag start handler",
567+
)
568+
_assert_source_contains(handle_start, "const safetyToken = dragSafetyToken + 1", "return button drag start handler")
569+
_assert_source_contains(handle_start, "dragSafetyTimer = setTimeout(() => {", "return button drag start handler")
570+
_assert_source_contains(
571+
handle_start,
572+
"resetDragStateAfterMissingEnd(safetyToken);",
573+
"return button drag start handler",
574+
)
575+
_assert_source_contains(handle_start, "}, 5000);", "return button drag start handler")
576+
_assert_source_order(
577+
handle_start,
578+
"return button drag start handler",
579+
"clearDragSafetyTimer();",
580+
"container.setAttribute('data-dragging', 'pending')",
581+
"dragSafetyTimer = setTimeout(() => {",
582+
)
583+
_assert_source_contains(handle_end, "clearDragSafetyTimer();", "return button drag end handler")
584+
_assert_source_contains(handle_end, "const safetyToken = dragSafetyToken;", "return button drag end handler")
585+
_assert_source_contains(
586+
handle_end,
587+
"finishDragState(moved, safetyToken);",
588+
"return button drag end handler",
589+
)
590+
_assert_source_order(
591+
handle_end,
592+
"return button drag end handler",
593+
"clearDragSafetyTimer();",
594+
"if (isDragging) {",
595+
"const safetyToken = dragSafetyToken;",
596+
"finishDragState(moved, safetyToken);",
597+
)
598+
599+
sync_block = _source_slice_between(
600+
source,
601+
"function _syncNekoIdleCat1Journey",
602+
"function _scheduleNekoIdleCat1JourneySync(button)",
603+
"cat1 journey sync",
604+
)
605+
for expected in (
606+
"const initialContainer = _getNekoIdleReturnContainerFromButton(button)",
607+
"const initialDragging = initialContainer && initialContainer.getAttribute('data-dragging')",
608+
"if (initialDragging && initialDragging !== 'false') return",
609+
):
610+
_assert_source_contains(sync_block, expected, "cat1 journey sync")
611+
_assert_source_order(
612+
sync_block,
613+
"cat1 journey sync drag guard",
614+
"if (_isNekoIdleCompactSurfaceDragging()) return",
615+
"if (initialDragging && initialDragging !== 'false') return",
616+
"const normalizedTier",
617+
)
618+
619+
container_observer = _source_slice_between(
620+
source,
621+
"state.containerObserver = new MutationObserver(() => {",
622+
"state.containerObserver.observe(container",
623+
"cat1 container observer",
624+
)
625+
_assert_source_contains(
626+
container_observer,
627+
"const observerDragging = container.getAttribute('data-dragging');",
628+
"cat1 container observer",
629+
)
630+
_assert_source_contains(
631+
container_observer,
632+
"if (observerDragging && observerDragging !== 'false') return;",
633+
"cat1 container observer",
634+
)
635+
636+
walk_start = _source_slice_between(
637+
source,
638+
"function _startNekoIdleCat1Walk",
639+
"function _scheduleNekoIdleCat1WalkStart",
640+
"cat1 walk start",
641+
)
642+
for expected in (
643+
"if (_isNekoIdleReturnDragActionActive(button)) return",
644+
"const walkContainer = _getNekoIdleReturnContainerFromButton(button)",
645+
"const walkDragging = walkContainer && walkContainer.getAttribute('data-dragging')",
646+
"if (walkDragging && walkDragging !== 'false') return",
647+
):
648+
_assert_source_contains(walk_start, expected, "cat1 walk start")
649+
_assert_source_order(
650+
walk_start,
651+
"cat1 walk start drag guard",
652+
"if (!state) return",
653+
"if (_isNekoIdleReturnDragActionActive(button)) return",
654+
"const profile = state.profile",
655+
)
656+
657+
500658
def test_return_button_idle_tier_assets_are_version_tracked():
501659
for path in (APP_UI_PATH, APP_INTERPAGE_PATH, COMMON_UI_HUD_PATH,
502660
APP_REACT_CHAT_WINDOW_PATH,

0 commit comments

Comments
 (0)