mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
Implement chaser crab functionality in ActionGraph component
- Added state management for chaser crab, including position, target, and behavior states (idle, running, attacking). - Integrated chaser crab into the ActionGraph layout, allowing it to dynamically chase new nodes. - Removed the ChaserCrab component, consolidating functionality directly within ActionGraph for improved performance and maintainability. - Enhanced animation loop for chaser crab movement and attack logic, improving interaction dynamics.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -16,7 +16,7 @@ import '@xyflow/react/dist/style.css'
|
||||
import { SessionNode } from './SessionNode'
|
||||
import { ActionNode } from './ActionNode'
|
||||
import { CrabNode } from './CrabNode'
|
||||
import { ChaserCrab } from './ChaserCrab'
|
||||
import { ChaserCrabNode, type ChaserCrabState } from './ChaserCrabNode'
|
||||
import { layoutGraph } from '~/lib/graph-layout'
|
||||
import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot'
|
||||
|
||||
@@ -28,12 +28,18 @@ interface ActionGraphProps {
|
||||
}
|
||||
|
||||
const CRAB_NODE_ID = 'crab-origin'
|
||||
const CHASER_CRAB_ID = 'chaser-crab'
|
||||
|
||||
const CRAB_SPEED = 4 // pixels per frame
|
||||
const ATTACK_DISTANCE = 40 // how close before attacking
|
||||
const ATTACK_CHANCE = 0.4 // 40% chance to attack when close
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const nodeTypes: NodeTypes = {
|
||||
session: SessionNode as any,
|
||||
action: ActionNode as any,
|
||||
crab: CrabNode as any,
|
||||
chaserCrab: ChaserCrabNode as any,
|
||||
}
|
||||
|
||||
// Inner component that uses ReactFlow hooks
|
||||
@@ -43,6 +49,16 @@ function ActionGraphInner({
|
||||
selectedSession,
|
||||
onSessionSelect,
|
||||
}: ActionGraphProps) {
|
||||
// Chaser crab state
|
||||
const [chaserPosition, setChaserPosition] = useState({ x: 0, y: 0 })
|
||||
const [chaserTarget, setChaserTarget] = useState<{ x: number; y: number } | null>(null)
|
||||
const [chaserState, setChaserState] = useState<ChaserCrabState>('idle')
|
||||
const [facingLeft, setFacingLeft] = useState(false)
|
||||
|
||||
const prevNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const animationFrameRef = useRef<number>(undefined)
|
||||
const attackTimeoutRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
// Filter actions for selected session, or show all if none selected
|
||||
const visibleActions = useMemo(() => {
|
||||
if (!selectedSession) return actions.slice(-50) // Last 50 actions
|
||||
@@ -178,17 +194,130 @@ function ActionGraphInner({
|
||||
})
|
||||
}, [rawNodes, rawEdges])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes)
|
||||
// Add chaser crab to layouted nodes
|
||||
const nodesWithChaser = useMemo(() => {
|
||||
const chaserNode: Node = {
|
||||
id: CHASER_CRAB_ID,
|
||||
type: 'chaserCrab',
|
||||
position: chaserPosition,
|
||||
data: { state: chaserState, facingLeft },
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
zIndex: 1000,
|
||||
}
|
||||
return [...layoutedNodes, chaserNode]
|
||||
}, [layoutedNodes, chaserPosition, chaserState, facingLeft])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithChaser)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges)
|
||||
|
||||
// Get node IDs for the chaser crab
|
||||
const nodeIds = useMemo(() => nodes.map((n) => n.id), [nodes])
|
||||
|
||||
// Update nodes when layout changes
|
||||
// Detect new nodes and set them as targets for the chaser
|
||||
useEffect(() => {
|
||||
setNodes(layoutedNodes)
|
||||
const currentIds = new Set(layoutedNodes.map((n) => n.id))
|
||||
const prevIds = prevNodeIdsRef.current
|
||||
|
||||
// Find newly added nodes (excluding the central crab node)
|
||||
for (const node of layoutedNodes) {
|
||||
if (!prevIds.has(node.id) && !node.id.includes('crab')) {
|
||||
// New node found! Set it as target
|
||||
const nodeCenter = {
|
||||
x: node.position.x + 100, // Approximate center
|
||||
y: node.position.y + 40,
|
||||
}
|
||||
setChaserTarget(nodeCenter)
|
||||
setChaserState('running')
|
||||
|
||||
// Clear any existing attack timeout
|
||||
if (attackTimeoutRef.current) {
|
||||
clearTimeout(attackTimeoutRef.current)
|
||||
}
|
||||
break // Only chase one new node at a time
|
||||
}
|
||||
}
|
||||
|
||||
prevNodeIdsRef.current = currentIds
|
||||
}, [layoutedNodes])
|
||||
|
||||
// Animation loop for chaser crab movement
|
||||
useEffect(() => {
|
||||
if (!chaserTarget || chaserState === 'attacking') return
|
||||
|
||||
const animate = () => {
|
||||
setChaserPosition((current) => {
|
||||
const dx = chaserTarget.x - current.x
|
||||
const dy = chaserTarget.y - current.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// Check if we're close enough
|
||||
if (distance < ATTACK_DISTANCE) {
|
||||
// Randomly decide to attack or go idle
|
||||
if (Math.random() < ATTACK_CHANCE) {
|
||||
setChaserState('attacking')
|
||||
// Return to idle after attack animation
|
||||
attackTimeoutRef.current = setTimeout(() => {
|
||||
setChaserState('idle')
|
||||
setChaserTarget(null)
|
||||
}, 400)
|
||||
} else {
|
||||
setChaserState('idle')
|
||||
setChaserTarget(null)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// Move towards target
|
||||
const vx = (dx / distance) * CRAB_SPEED
|
||||
const vy = (dy / distance) * CRAB_SPEED
|
||||
|
||||
// Update facing direction
|
||||
if (Math.abs(vx) > 0.1) {
|
||||
setFacingLeft(vx < 0)
|
||||
}
|
||||
|
||||
return {
|
||||
x: current.x + vx,
|
||||
y: current.y + vy,
|
||||
}
|
||||
})
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [chaserTarget, chaserState])
|
||||
|
||||
// Update nodes when layout or chaser changes
|
||||
useEffect(() => {
|
||||
const chaserNode: Node = {
|
||||
id: CHASER_CRAB_ID,
|
||||
type: 'chaserCrab',
|
||||
position: chaserPosition,
|
||||
data: { state: chaserState, facingLeft },
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
zIndex: 1000,
|
||||
}
|
||||
setNodes([...layoutedNodes, chaserNode])
|
||||
setEdges(layoutedEdges)
|
||||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges])
|
||||
}, [layoutedNodes, layoutedEdges, chaserPosition, chaserState, facingLeft, setNodes, setEdges])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
if (attackTimeoutRef.current) {
|
||||
clearTimeout(attackTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: Node) => {
|
||||
@@ -220,14 +349,13 @@ function ActionGraphInner({
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
if (node.type === 'crab') return '#ef4444' // crab red
|
||||
if (node.type === 'chaserCrab') return '#ef4444' // chaser crab red
|
||||
if (node.type === 'session') return '#98ffc8' // neon mint
|
||||
return '#52526e' // shell
|
||||
}}
|
||||
maskColor="rgba(10, 10, 15, 0.8)"
|
||||
/>
|
||||
</ReactFlow>
|
||||
{/* Chaser crab overlay */}
|
||||
<ChaserCrab nodeIds={nodeIds} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import { CrabIdleAnimation, CrabJumpAnimation, CrabAttackAnimation } from '~/components/ani'
|
||||
|
||||
type CrabState = 'idle' | 'running' | 'attacking'
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface ChaserCrabProps {
|
||||
nodeIds: string[]
|
||||
}
|
||||
|
||||
const CRAB_SIZE = 20
|
||||
const CRAB_SPEED = 3 // pixels per frame
|
||||
const ATTACK_DISTANCE = 30 // how close before attacking
|
||||
const ATTACK_CHANCE = 0.4 // 40% chance to attack when close
|
||||
|
||||
export function ChaserCrab({ nodeIds }: ChaserCrabProps) {
|
||||
const { getNodes, getViewport } = useReactFlow()
|
||||
|
||||
const [position, setPosition] = useState<Position>({ x: 0, y: 0 })
|
||||
const [target, setTarget] = useState<Position | null>(null)
|
||||
const [crabState, setCrabState] = useState<CrabState>('idle')
|
||||
const [facingLeft, setFacingLeft] = useState(false)
|
||||
|
||||
const prevNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const animationFrameRef = useRef<number>(undefined)
|
||||
const attackTimeoutRef = useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
// Detect new nodes and set them as targets
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(nodeIds)
|
||||
const prevIds = prevNodeIdsRef.current
|
||||
|
||||
// Find newly added nodes (excluding the central crab node)
|
||||
for (const id of currentIds) {
|
||||
if (!prevIds.has(id) && !id.includes('crab-origin')) {
|
||||
// New node found! Get its position
|
||||
const nodes = getNodes()
|
||||
const newNode = nodes.find((n) => n.id === id)
|
||||
if (newNode) {
|
||||
const nodeCenter = {
|
||||
x: newNode.position.x + 100, // Approximate center
|
||||
y: newNode.position.y + 40,
|
||||
}
|
||||
setTarget(nodeCenter)
|
||||
setCrabState('running')
|
||||
|
||||
// Clear any existing attack timeout
|
||||
if (attackTimeoutRef.current) {
|
||||
clearTimeout(attackTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
break // Only chase one new node at a time
|
||||
}
|
||||
}
|
||||
|
||||
prevNodeIdsRef.current = currentIds
|
||||
}, [nodeIds, getNodes])
|
||||
|
||||
// Animation loop for movement
|
||||
useEffect(() => {
|
||||
if (!target || crabState === 'attacking') return
|
||||
|
||||
const animate = () => {
|
||||
setPosition((current) => {
|
||||
const dx = target.x - current.x
|
||||
const dy = target.y - current.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// Check if we're close enough
|
||||
if (distance < ATTACK_DISTANCE) {
|
||||
// Randomly decide to attack or go idle
|
||||
if (Math.random() < ATTACK_CHANCE) {
|
||||
setCrabState('attacking')
|
||||
// Return to idle after attack animation (4 frames at 10fps = 400ms)
|
||||
attackTimeoutRef.current = setTimeout(() => {
|
||||
setCrabState('idle')
|
||||
setTarget(null)
|
||||
}, 400)
|
||||
} else {
|
||||
setCrabState('idle')
|
||||
setTarget(null)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// Move towards target
|
||||
const vx = (dx / distance) * CRAB_SPEED
|
||||
const vy = (dy / distance) * CRAB_SPEED
|
||||
|
||||
// Update facing direction
|
||||
if (Math.abs(vx) > 0.1) {
|
||||
setFacingLeft(vx < 0)
|
||||
}
|
||||
|
||||
return {
|
||||
x: current.x + vx,
|
||||
y: current.y + vy,
|
||||
}
|
||||
})
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [target, crabState])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
if (attackTimeoutRef.current) {
|
||||
clearTimeout(attackTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get viewport for proper positioning
|
||||
const viewport = getViewport()
|
||||
|
||||
// Transform crab position to screen coordinates
|
||||
const screenX = position.x * viewport.zoom + viewport.x
|
||||
const screenY = position.y * viewport.zoom + viewport.y
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none z-50 transition-transform"
|
||||
style={{
|
||||
left: screenX - CRAB_SIZE / 2,
|
||||
top: screenY - CRAB_SIZE / 2,
|
||||
width: CRAB_SIZE,
|
||||
height: CRAB_SIZE,
|
||||
transform: `scaleX(${facingLeft ? -1 : 1})`,
|
||||
}}
|
||||
>
|
||||
{crabState === 'idle' && <CrabIdleAnimation className="w-full h-full" />}
|
||||
{crabState === 'running' && <CrabJumpAnimation className="w-full h-full" />}
|
||||
{crabState === 'attacking' && <CrabAttackAnimation className="w-full h-full" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { memo } from 'react'
|
||||
import type { NodeProps } from '@xyflow/react'
|
||||
import { CrabIdleAnimation, CrabJumpAnimation, CrabAttackAnimation } from '~/components/ani'
|
||||
|
||||
export type ChaserCrabState = 'idle' | 'running' | 'attacking'
|
||||
|
||||
interface ChaserCrabData {
|
||||
state: ChaserCrabState
|
||||
facingLeft: boolean
|
||||
}
|
||||
|
||||
function ChaserCrabNodeComponent({ data }: NodeProps) {
|
||||
const { state, facingLeft } = data as unknown as ChaserCrabData
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-5 h-5 transition-transform duration-75"
|
||||
style={{
|
||||
transform: `scaleX(${facingLeft ? -1 : 1})`,
|
||||
}}
|
||||
>
|
||||
{state === 'idle' && <CrabIdleAnimation className="w-full h-full" />}
|
||||
{state === 'running' && <CrabJumpAnimation className="w-full h-full" />}
|
||||
{state === 'attacking' && <CrabAttackAnimation className="w-full h-full" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChaserCrabNode = memo(ChaserCrabNodeComponent)
|
||||
Reference in New Issue
Block a user