88 * idle 时自主漫游(随机目标点 + 贝塞尔缓动),拖拽后 5s 恢复漫游。
99 * 位置持久化到 localStorage,下次打开应用水母在老位置。
1010 */
11- import { useRef , useEffect , useCallback } from 'react' ;
11+ import { useRef , useEffect , useCallback , useState , useMemo } from 'react' ;
1212import { motion , useMotionValue , animate } from 'framer-motion' ;
13+ import { Pin } from 'lucide-react' ;
1314import { useAssistantStore } from '../store/useAssistantStore' ;
1415import { CreatureBubble } from './CreatureBubble' ;
16+ import { useWorkAreaBounds } from '../hooks/useWorkAreaBounds' ;
17+ import { ContextMenu } from '@/components/ui/ContextMenu' ;
18+ import type { ContextMenuGroup } from '@/components/ui/ContextMenu' ;
1519import {
1620 CREATURE_SIZE_IDLE ,
1721 WANDER_INTERVAL_MIN_MS , WANDER_INTERVAL_MAX_MS ,
1822 WANDER_DURATION_MIN_MS , WANDER_DURATION_MAX_MS ,
1923 WANDER_RESUME_DELAY_MS , WANDER_BOUNDS ,
2024 CREATURE_POS_STORAGE_KEY ,
25+ ESCAPE_SPRING_STIFFNESS , ESCAPE_SPRING_DAMPING ,
2126} from '../constants' ;
2227import type { CreatureState } from '../types' ;
2328
@@ -64,6 +69,15 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
6469 const creatureState = useAssistantStore ( s => s . creatureState ) ;
6570 const bubbleMessage = useAssistantStore ( s => s . bubbleMessage ) ;
6671 const panelState = useAssistantStore ( s => s . panelState ) ;
72+ const userActive = useAssistantStore ( s => s . userActive ) ;
73+ const isFixed = useAssistantStore ( s => s . isFixed ) ;
74+ const setIsFixed = useAssistantStore ( s => s . setIsFixed ) ;
75+ const setAutoFixed = useAssistantStore ( s => s . setAutoFixed ) ;
76+
77+ const { isInWorkArea, getRandomTargetOutside } = useWorkAreaBounds ( ) ;
78+
79+ // 右键菜单状态
80+ const [ ctxMenuPos , setCtxMenuPos ] = useState < { x : number ; y : number } | null > ( null ) ;
6781
6882 // 位置由 MotionValue 驱动(GPU 合成,不触发 React 重渲染)
6983 const initial = useRef ( loadSavedPosition ( ) ) ;
@@ -75,11 +89,43 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
7589 const wanderAnimRef = useRef < { stop : ( ) => void } | null > ( null ) ;
7690 const isDraggingRef = useRef ( false ) ;
7791
92+ // 受惊弹开动画状态(一过性 scale 脉冲)
93+ const [ startledScale , setStartledScale ] = useState < number [ ] | null > ( null ) ;
94+ const prevUserActiveRef = useRef ( userActive ) ;
95+
96+ // 用 ref 保持 userActive 最新值供异步回调(wanderTo setTimeout 闭包)
97+ const userActiveRef = useRef ( userActive ) ;
98+ userActiveRef . current = userActive ;
99+
78100 /** 在视口漫游区域内随机选取目标点并平滑移动 */
79101 const wanderTo = useCallback ( ( ) => {
80- const { xMin, xMax, yMin, yMax } = WANDER_BOUNDS ;
81- const targetX = window . innerWidth * ( xMin + Math . random ( ) * ( xMax - xMin ) ) ;
82- const targetY = window . innerHeight * ( yMin + Math . random ( ) * ( yMax - yMin ) ) ;
102+ // 固定状态不漫游
103+ if ( useAssistantStore . getState ( ) . isFixed ) return ;
104+
105+ const vw = window . innerWidth ;
106+ const vh = window . innerHeight ;
107+
108+ let targetX : number ;
109+ let targetY : number ;
110+
111+ if ( userActiveRef . current ) {
112+ // 用户活跃时:仅在工作区外漫游
113+ const target = getRandomTargetOutside ( vw , vh , CREATURE_SIZE_IDLE ) ;
114+ targetX = target . x ;
115+ targetY = target . y ;
116+
117+ // 检查目标是否仍在工作区内(小窗口无安全区时自动固定)
118+ if ( isInWorkArea ( targetX , targetY , CREATURE_SIZE_IDLE ) ) {
119+ setAutoFixed ( true ) ;
120+ return ;
121+ }
122+ } else {
123+ // 用户空闲时:全屏漫游
124+ const { xMin, xMax, yMin, yMax } = WANDER_BOUNDS ;
125+ targetX = vw * ( xMin + Math . random ( ) * ( xMax - xMin ) ) ;
126+ targetY = vh * ( yMin + Math . random ( ) * ( yMax - yMin ) ) ;
127+ }
128+
83129 const duration = ( WANDER_DURATION_MIN_MS + Math . random ( ) * ( WANDER_DURATION_MAX_MS - WANDER_DURATION_MIN_MS ) ) / 1000 ;
84130
85131 // 停止上一次漫游动画
@@ -89,7 +135,7 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
89135 ease : 'easeInOut' ,
90136 } ) ;
91137 animate ( posY , targetY , { duration, ease : 'easeInOut' } ) ;
92- } , [ posX , posY ] ) ;
138+ } , [ posX , posY , getRandomTargetOutside , isInWorkArea , setAutoFixed ] ) ;
93139
94140 /** 调度下一次漫游(随机间隔) */
95141 const scheduleWander = useCallback ( ( ) => {
@@ -112,6 +158,45 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
112158 } ;
113159 } , [ scheduleWander ] ) ;
114160
161+ // 用户空闲→活跃转换:如果在工作区内则受惊弹开
162+ useEffect ( ( ) => {
163+ const wasIdle = prevUserActiveRef . current === false ;
164+ const nowActive = userActive === true ;
165+ prevUserActiveRef . current = userActive ;
166+
167+ if ( wasIdle && nowActive ) {
168+ const cx = posX . get ( ) ;
169+ const cy = posY . get ( ) ;
170+ if ( isInWorkArea ( cx , cy , CREATURE_SIZE_IDLE ) ) {
171+ // 停止当前漫游
172+ wanderAnimRef . current ?. stop ( ) ;
173+ if ( wanderTimerRef . current ) clearTimeout ( wanderTimerRef . current ) ;
174+
175+ // 选取逃逸目标点
176+ const target = getRandomTargetOutside ( window . innerWidth , window . innerHeight , CREATURE_SIZE_IDLE ) ;
177+
178+ // 弹簧动画:受惊弹开
179+ wanderAnimRef . current = animate ( posX , target . x , {
180+ type : 'spring' ,
181+ stiffness : ESCAPE_SPRING_STIFFNESS ,
182+ damping : ESCAPE_SPRING_DAMPING ,
183+ } ) ;
184+ animate ( posY , target . y , {
185+ type : 'spring' ,
186+ stiffness : ESCAPE_SPRING_STIFFNESS ,
187+ damping : ESCAPE_SPRING_DAMPING ,
188+ } ) ;
189+
190+ // 触发受惊视觉反馈(scale 弹性脉冲)
191+ setStartledScale ( [ 1 , 1.35 , 0.9 , 1.1 , 1 ] ) ;
192+ setTimeout ( ( ) => setStartledScale ( null ) , 600 ) ;
193+
194+ // 逃逸完成后重新调度漫游
195+ setTimeout ( ( ) => { if ( ! isDraggingRef . current ) scheduleWander ( ) ; } , ESCAPE_SPRING_DAMPING * 20 ) ;
196+ }
197+ }
198+ } , [ userActive , posX , posY , isInWorkArea , getRandomTargetOutside , scheduleWander ] ) ;
199+
115200 // 拖拽结束:持久化位置 + 5s 后恢复漫游
116201 const handleDragEnd = useCallback ( ( ) => {
117202 isDraggingRef . current = false ;
@@ -120,7 +205,38 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
120205 setTimeout ( ( ) => { if ( ! isDraggingRef . current ) wanderTo ( ) ; } , WANDER_RESUME_DELAY_MS ) ;
121206 } , [ posX , posY , wanderTo ] ) ;
122207
208+ // 右键菜单组
209+ const ctxMenuGroups = useMemo < ContextMenuGroup [ ] > ( ( ) => [
210+ {
211+ items : [
212+ {
213+ key : isFixed ? 'unfix' : 'fix' ,
214+ label : isFixed ? '恢复漫游' : '固定位置' ,
215+ icon : < Pin className = "w-4 h-4" strokeWidth = { 1.5 } /> ,
216+ } ,
217+ ] ,
218+ } ,
219+ ] , [ isFixed ] ) ;
220+
221+ const handleContextMenu = useCallback ( ( e : React . MouseEvent ) => {
222+ e . preventDefault ( ) ;
223+ e . stopPropagation ( ) ;
224+ setCtxMenuPos ( { x : e . clientX , y : e . clientY } ) ;
225+ } , [ ] ) ;
226+
227+ const handleCtxMenuSelect = useCallback ( ( key : string ) => {
228+ if ( key === 'fix' ) {
229+ setIsFixed ( true ) ;
230+ } else if ( key === 'unfix' ) {
231+ setIsFixed ( false ) ;
232+ // 恢复漫游
233+ scheduleWander ( ) ;
234+ }
235+ setCtxMenuPos ( null ) ;
236+ } , [ setIsFixed , scheduleWander ] ) ;
237+
123238 return (
239+ < >
124240 < motion . div
125241 className = "fixed top-0 left-0 z-50 flex flex-col items-end"
126242 style = { { x : posX , y : posY , width : CREATURE_SIZE_IDLE } }
@@ -147,15 +263,16 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
147263 { /* 水母主体 */ }
148264 < motion . button
149265 onClick = { onClick }
266+ onContextMenu = { handleContextMenu }
150267 className = "relative outline-none border-none bg-transparent cursor-pointer"
151268 style = { { width : CREATURE_SIZE_IDLE , height : CREATURE_SIZE_IDLE } }
152269 animate = { {
153270 y : creatureState === 'resting' ? [ 0 , 4 , 0 ] : [ 0 , - 3 , 0 ] ,
154- scale : creatureState === 'alerting' ? [ 1 , 1.08 , 1 ] : 1 ,
271+ scale : startledScale ?? ( creatureState === 'alerting' ? [ 1 , 1.08 , 1 ] : 1 ) ,
155272 } }
156273 transition = { {
157274 y : { duration : creatureState === 'resting' ? 6 : 4 , repeat : Infinity , ease : 'easeInOut' } ,
158- scale : { duration : 0. 6, repeat : creatureState === 'alerting' ? 3 : 0 } ,
275+ scale : { duration : startledScale ? 0.5 : 0. 6, repeat : startledScale ? 0 : ( creatureState === 'alerting' ? 3 : 0 ) } ,
159276 } }
160277 whileHover = { { scale : 1.1 } }
161278 whileTap = { { scale : 0.95 } }
@@ -182,5 +299,16 @@ export function CreatureAvatar({ onClick, onBubbleClick, onBubbleDismiss }: Prop
182299 ) ) }
183300 </ motion . button >
184301 </ motion . div >
302+
303+ { ctxMenuPos && (
304+ < ContextMenu
305+ groups = { ctxMenuGroups }
306+ position = { ctxMenuPos }
307+ context = { null }
308+ onSelect = { ( key ) => handleCtxMenuSelect ( key ) }
309+ onClose = { ( ) => setCtxMenuPos ( null ) }
310+ />
311+ ) }
312+ </ >
185313 ) ;
186314}
0 commit comments