Skip to content

Commit 04f936d

Browse files
authored
feat(idle-cat): polish cat transitions and CAT1 chat follow (#1648)
* 完善模型猫形态切换过渡 - 增加模型与猫形态双向切换遮罩动画,并统一互斥与定位链路 - 统一 Live2D、VRM、MMD 的原处缩小渐隐退场和猫形态返回模型入场 - 补充过渡资源版本、样式、设计文档和静态契约测试 验证: - node --check static/app-ui.js - node --check static/avatar-ui-buttons.js - pytest tests/unit/test_avatar_return_button_idle_tiers_static.py tests/unit/test_auto_goodbye_goodbye_return_contract.py -q - pytest tests/unit/test_app_auto_goodbye_phase1.py tests/unit/test_mmd_interaction_static_contracts.py -q - git diff --check * 移除桌面联动旧聊天容器选择器 - 移除 app-interpage 中模型管理器隐藏样式对旧 #chat-container 的引用 - 保持 React chat overlay 作为当前桌面聊天 UI 边界 验证: - node --check static/app-interpage.js - pytest tests/unit/test_avatar_return_button_idle_tiers_static.py tests/unit/test_react_chat_idle_dock_static.py tests/unit/test_app_auto_goodbye_phase1.py tests/unit/test_phase5_regression_boundary.py tests/unit/test_react_chat_window_static.py -q * 优化CAT1走路与上缘跟随手感 - 调整CAT1走路速度与到达阈值,让走路GIF和实际位移更接近,并减少到达目标时的吸附跳位。\n- 避免hover/click GIF播放中被随机走路打断。\n- compact上缘甩下猫猫改为连续快速移动判定,减少单帧速度尖峰误触发。\n- 更新三态猫文档和静态契约测试。 * 修复模型猫过渡审查问题 - 过渡 Promise 失败时记录警告并清理 return ball transition 标记。\n- 抽取模型容器 transition CSS 变量,并补齐 VRM 缩放 transform-origin。\n- 补充 VRM goodbye 统一链路断言和 CSS helper 说明。
1 parent ad3375c commit 04f936d

9 files changed

Lines changed: 1084 additions & 210 deletions

File tree

docs/design/cat-idle-states-feature.md

Lines changed: 251 additions & 127 deletions
Large diffs are not rendered by default.

main_routers/pages_router.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
_PROJECT_ROOT / "static/assets/neko-idle/cat-idle-cat-move-1.gif",
5757
_PROJECT_ROOT / "static/assets/neko-idle/cat-idle-cat-move-2.gif",
5858
_PROJECT_ROOT / "static/assets/neko-idle/cat-idle-cat-move-3.gif",
59+
_PROJECT_ROOT / "static/assets/neko-idle/cat_model_change.gif",
5960
_PROJECT_ROOT / "static/assets/neko-idle/chat-minimized-yarn-ball.png",
6061
_PROJECT_ROOT / "static/assets/neko-idle/cat1-voice-click.mp3",
6162
_PROJECT_ROOT / "static/assets/neko-idle/cat1-voice1.mp3",

static/app-interpage.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@
6161
'body.neko-main-ui-hidden-by-model-manager #live2d-canvas,',
6262
'body.neko-main-ui-hidden-by-model-manager #vrm-canvas,',
6363
'body.neko-main-ui-hidden-by-model-manager #mmd-canvas,',
64-
'body.neko-main-ui-hidden-by-model-manager #chat-container,',
6564
'body.neko-main-ui-hidden-by-model-manager #react-chat-window-overlay,',
6665
'body.neko-main-ui-hidden-by-model-manager #live2d-floating-buttons,',
6766
'body.neko-main-ui-hidden-by-model-manager #vrm-floating-buttons,',

static/app-ui.js

Lines changed: 585 additions & 63 deletions
Large diffs are not rendered by default.
638 KB
Loading

static/avatar-ui-buttons.js

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,14 @@ const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_FOLLOW_DISTANCE_PX = 200;
206206
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_REARM_DISTANCE_PX = 100;
207207
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_STICK_MAX_SPEED_PX_PER_SEC = 1100;
208208
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_STICK_MAX_STEP_PX = 210;
209+
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_FAST_MOVE_COUNT = 3;
209210
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_PX = 52;
210211
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_ANIMATION_MS = 360;
211212
const _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_COOLDOWN_MS = 900;
212213
const _NEKO_IDLE_CAT1_COMPACT_SURFACE_SETTLE_SYNC_MS = 160;
213214
const _NEKO_IDLE_CAT1_WALK_ENTER_DISTANCE_PX = 120;
214-
const _NEKO_IDLE_CAT1_WALK_EXIT_DISTANCE_PX = 42;
215-
const _NEKO_IDLE_CAT1_WALK_SPEED_PX_PER_SEC = 101;
215+
const _NEKO_IDLE_CAT1_WALK_EXIT_DISTANCE_PX = 14;
216+
const _NEKO_IDLE_CAT1_WALK_SPEED_PX_PER_SEC = 82;
216217
const _NEKO_IDLE_CAT1_WALK_MAX_SPEED_RATE = 1.5;
217218
const _NEKO_IDLE_CAT1_WALK_DISTANCE_INCREASE_THRESHOLD_PX = 6;
218219
const _NEKO_IDLE_CAT1_WALK_DISTANCE_GROWTH_FOR_MAX_RATE_PX = 220;
@@ -235,7 +236,7 @@ const _NEKO_IDLE_CAT1_PAIR_MOVE_LONG_DELAY_MAX_MS = 5 * 60 * 1000;
235236
const _NEKO_IDLE_CAT1_PAIR_MOVE_MIN_DISTANCE_PX = 72;
236237
const _NEKO_IDLE_CAT1_PAIR_MOVE_MAX_DISTANCE_PX = 160;
237238
const _NEKO_IDLE_CAT1_PAIR_MOVE_MIN_USABLE_DISTANCE_PX = 36;
238-
const _NEKO_IDLE_CAT1_PAIR_MOVE_SPEED_PX_PER_SEC = 96;
239+
const _NEKO_IDLE_CAT1_PAIR_MOVE_SPEED_PX_PER_SEC = 82;
239240
const _NEKO_IDLE_CAT1_PAIR_MOVE_MIN_DURATION_MS = 720;
240241
const _NEKO_IDLE_CAT1_PAIR_MOVE_MAX_DURATION_MS = 2200;
241242
const _NEKO_IDLE_DESKTOP_CHAT_RECT_STALE_MS = 2500;
@@ -1168,7 +1169,8 @@ function _getNekoIdleReturnSubactionState(button, profile) {
11681169
compactTopEdgeDropUntil: 0,
11691170
compactTopEdgeRearmRequired: false,
11701171
compactTopEdgeDropAnimationTimer: 0,
1171-
compactTopEdgeDropCooldownTimer: 0
1172+
compactTopEdgeDropCooldownTimer: 0,
1173+
compactTopEdgeFastMoveCount: 0
11721174
};
11731175
button.__nekoIdleCat1Journey = button.__nekoIdleReturnSubactionState;
11741176
return button.__nekoIdleReturnSubactionState;
@@ -1529,6 +1531,7 @@ function _resetNekoIdleCat1CompactFollowState(state, options = {}) {
15291531
state.compactFollowLastSurfaceRect = null;
15301532
state.compactFollowLastAt = 0;
15311533
state.compactFollowAnchorRatio = null;
1534+
state.compactTopEdgeFastMoveCount = 0;
15321535
if (!options.keepDropCooldown) {
15331536
state.compactTopEdgeDropUntil = 0;
15341537
}
@@ -1867,10 +1870,17 @@ function _syncNekoIdleCat1CompactTopEdgeSurfaceFollow(detail) {
18671870
}
18681871

18691872
const motion = _getNekoIdleCat1CompactFollowSpeed(state, surfaceRect, nowMs);
1870-
const tooFast = motion.hasPrevious && (
1873+
const fastMove = motion.hasPrevious && (
18711874
motion.speedPxPerSec > _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_STICK_MAX_SPEED_PX_PER_SEC ||
18721875
(motion.elapsedMs <= 240 && motion.distancePx > _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_STICK_MAX_STEP_PX)
18731876
);
1877+
state.compactTopEdgeFastMoveCount = fastMove
1878+
? Math.min(
1879+
_NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_FAST_MOVE_COUNT,
1880+
(Number(state.compactTopEdgeFastMoveCount) || 0) + 1
1881+
)
1882+
: 0;
1883+
const tooFast = state.compactTopEdgeFastMoveCount >= _NEKO_IDLE_CAT1_COMPACT_TOP_EDGE_DROP_FAST_MOVE_COUNT;
18741884
if (tooFast) {
18751885
_setNekoIdleCat1CompactMirrorActive(button, container, false, { reason: 'drop-from-compact-top-edge' });
18761886
_dropNekoIdleCat1FromCompactTopEdge(button, target, nowMs);
@@ -2750,6 +2760,13 @@ function _scheduleNekoIdleCat1WalkStart(button, target) {
27502760
state.targetKind = target && target.kind ? target.kind : '';
27512761
state.facingRight = !!(target && target.facingRight);
27522762
_setNekoIdleCat1Classes(button, state);
2763+
const art = button.querySelector('.neko-idle-return-art');
2764+
if (art && art.__nekoIdleHoverSrc) {
2765+
if (!art.__nekoIdleHoverTimer) {
2766+
_finishNekoIdleHoverArtAfterPlayback(art, profile.tier);
2767+
}
2768+
return;
2769+
}
27532770
if (state.pendingWalkReady) {
27542771
state.pendingWalkReady = false;
27552772
_startNekoIdleCat1Walk(button, target);
@@ -3833,7 +3850,11 @@ const AvatarButtonMixin = {
38333850
});
38343851

38353852
returnBtn.addEventListener('click', (e) => {
3836-
if (returnButtonContainer.getAttribute('data-dragging') === 'true') {
3853+
if (
3854+
returnButtonContainer.getAttribute('data-dragging') === 'true' ||
3855+
returnButtonContainer.getAttribute('data-neko-model-cat-transitioning') === 'cat-to-model' ||
3856+
(typeof window.isNekoModelCatTransitionActive === 'function' && window.isNekoModelCatTransitionActive())
3857+
) {
38373858
e.preventDefault();
38383859
e.stopPropagation();
38393860
return;
@@ -3852,7 +3873,24 @@ const AvatarButtonMixin = {
38523873
}
38533874
}
38543875
});
3855-
window.dispatchEvent(event);
3876+
const dispatchReturnEvent = () => {
3877+
window.dispatchEvent(event);
3878+
};
3879+
if (typeof window.playNekoModelCatTransition === 'function') {
3880+
returnButtonContainer.setAttribute('data-neko-model-cat-transitioning', 'cat-to-model');
3881+
window.playNekoModelCatTransition({
3882+
direction: 'cat-to-model',
3883+
anchorRect: rect,
3884+
coverRect: window._savedGoodbyeRect || null,
3885+
container: returnButtonContainer
3886+
}).catch((error) => {
3887+
console.warn('[AvatarButtonMixin] model/cat return transition failed:', error);
3888+
returnButtonContainer.removeAttribute('data-neko-model-cat-transitioning');
3889+
});
3890+
dispatchReturnEvent();
3891+
return;
3892+
}
3893+
dispatchReturnEvent();
38563894
});
38573895

38583896
returnBtn.appendChild(returnArt);

static/css/index.css

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
@import url('base.css');
22

3+
:root {
4+
--neko-model-opacity-transition: opacity 280ms ease-in;
5+
--neko-model-transform-transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
6+
--neko-model-visibility-delay: 400ms;
7+
}
8+
39
html,
410
body {
511
height: 100%;
@@ -233,18 +239,18 @@ button * {
233239
opacity: 1;
234240
overflow: hidden;
235241
visibility: visible;
236-
transition: all 1s ease-in-out, height 0ms 1s, width 0ms 1s, visibility 0ms 1s;
242+
transition: var(--neko-model-opacity-transition), var(--neko-model-transform-transition), height 0ms var(--neko-model-visibility-delay), width 0ms var(--neko-model-visibility-delay), visibility 0ms var(--neko-model-visibility-delay);
237243
/* GPU 合成层隔离:减少窗口级重绘对 canvas 的影响 */
238244
will-change: transform;
239245
transform: translateZ(0);
246+
transform-origin: var(--neko-model-exit-origin-x, 50%) var(--neko-model-exit-origin-y, 50%);
240247
}
241248

242249
/* 看板娘判定框-最小化状态 */
243250
#live2d-container.minimized {
244-
transform: translateX(300px) translateZ(0);
245-
/* 向右平滑滑出(配合 opacity 渐隐) */
251+
transform: scale(0.38) translateZ(0);
252+
/* 原处缩小渐隐(配合模型/猫切换遮罩) */
246253
opacity: 0;
247-
visibility: hidden;
248254
}
249255

250256
/* 看板娘判定框-悬停时半透明 */
@@ -268,6 +274,7 @@ button * {
268274
/* GPU 合成层隔离 */
269275
will-change: transform;
270276
transform: translateZ(0);
277+
transform-origin: var(--neko-model-exit-origin-x, 50%) var(--neko-model-exit-origin-y, 50%);
271278
}
272279

273280
/* 看板娘判定框-最小化状态时画布透明 */
@@ -292,10 +299,11 @@ button * {
292299
/* 【修复】仅过渡 opacity 和 transform,排除 visibility 的过渡影响,
293300
* 避免 MMD 模式下隐藏 VRM 容器时因 transition: all 导致约 1s 的视觉
294301
* 残留窗口,使 VRM canvas 中缓存的旧模型(如 sister1.0)短暂可见。 */
295-
transition: opacity 1s ease-in-out, transform 1s ease-in-out;
302+
transition: var(--neko-model-opacity-transition), var(--neko-model-transform-transition);
296303
/* GPU 合成层隔离:减少窗口级重绘对 canvas 的影响 */
297304
will-change: transform;
298305
transform: translateZ(0);
306+
transform-origin: var(--neko-model-exit-origin-x, 50%) var(--neko-model-exit-origin-y, 50%);
299307
}
300308

301309
/* 看板娘MMD模型判定框-容器 */
@@ -310,17 +318,21 @@ button * {
310318
z-index: 10;
311319
pointer-events: none;
312320
background: none;
321+
opacity: 1;
313322
overflow: hidden;
323+
visibility: visible;
324+
transition: var(--neko-model-opacity-transition), var(--neko-model-transform-transition);
314325
/* GPU 合成层隔离 */
315326
will-change: transform;
316327
transform: translateZ(0);
328+
transform-origin: var(--neko-model-exit-origin-x, 50%) var(--neko-model-exit-origin-y, 50%);
317329
}
318330

319-
/* 看板娘VR模型判定框-最小化状态 */
320-
#vrm-container.minimized {
321-
transform: translateX(300px) translateZ(0);
331+
/* 看板娘VR/MMD模型判定框-最小化状态 */
332+
#vrm-container.minimized,
333+
#mmd-container.minimized {
334+
transform: scale(0.38) translateZ(0);
322335
opacity: 0;
323-
visibility: hidden;
324336
}
325337

326338
/* 游戏路由激活期间隐藏 pet 透明窗口上一切可见的可交互 UI,让 mini-game

tests/unit/test_auto_goodbye_goodbye_return_contract.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ def test_auto_goodbye_reuses_existing_goodbye_base_chain():
2323
assert "window.dispatchEvent(new CustomEvent('live2d-goodbye-click'" in auto_source
2424
assert "action: 'start_session'" not in auto_source
2525
assert "resetSessionButton.click();" in ui_source
26-
assert "live2dContainerForGoodbye.classList.add('minimized');" in ui_source
26+
assert "function playModelGoodbyeExit(container, rect)" in ui_source
27+
assert "playModelGoodbyeExit(live2dContainerForGoodbye, savedGoodbyeRect)" in ui_source
28+
assert "playModelGoodbyeExit(mmdContainer, savedGoodbyeRect)" in ui_source
29+
assert "playModelGoodbyeExit(vrmContainer, savedGoodbyeRect)" in ui_source
30+
assert "container.classList.add('minimized');" in ui_source
2731
assert "resetSessionButton.disabled = false;\n resetSessionButton.click();" in ui_source
2832

2933
reset_block = _between(

0 commit comments

Comments
 (0)