import { Billboard, Text } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; import { memo, useMemo, useRef } from "react"; import * as THREE from "three"; import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile"; import { AGENT_SCALE, WALK_ANIM_SPEED, } from "@/features/retro-office/core/constants"; import { toWorld } from "@/features/retro-office/core/geometry"; import type { JanitorActor, RenderAgent } from "@/features/retro-office/core/types"; import { AgentModelProps } from "@/features/retro-office/objects/types"; export const AgentModel = memo(function AgentModel({ agentId, name, status, color, appearance, agentsRef, agentLookupRef, onHover, onUnhover, onClick, onContextMenu, showSpeech = false, speechText = null, suppressSpeechBubble = false, }: AgentModelProps) { const groupRef = useRef(null); const leftArmRef = useRef(null); const rightArmRef = useRef(null); const leftLegRef = useRef(null); const rightLegRef = useRef(null); const statusDotMatRef = useRef(null); const pulseRingRef = useRef(null); const pulseRingMatRef = useRef(null); const leftEyeRef = useRef(null); const rightEyeRef = useRef(null); const leftEyeHighlightRef = useRef(null); const rightEyeHighlightRef = useRef(null); const mouthRef = useRef(null); const leftMouthCornerRef = useRef(null); const rightMouthCornerRef = useRef(null); const leftBrowRef = useRef(null); const rightBrowRef = useRef(null); const heldPaddleRef = useRef(null); const heldPaddleFaceRef = useRef(null); const heldCleaningToolRef = useRef(null); const heldCleaningHeadRef = useRef(null); const heldBucketRef = useRef(null); const heldScrubberRef = useRef(null); const speechBubbleRef = useRef(null); const speechBubbleMatRef = useRef(null); const awayBubbleRef = useRef(null); const bodyMatRef = useRef(null); const pos = useRef(new THREE.Vector3(0, 0, 0)); const resolvedAppearance = useMemo( () => appearance ?? createDefaultAgentAvatarProfile(agentId), [agentId, appearance] ); useFrame(() => { const agent = agentLookupRef?.current?.get(agentId) ?? agentsRef.current?.find((candidate) => candidate.id === agentId); if (!agent || !groupRef.current) return; const [wx, , wz] = toWorld(agent.x, agent.y); pos.current.set(wx, 0, wz); groupRef.current.position.lerp(pos.current, 0.15); const targetY = agent.facing; let rotDelta = targetY - groupRef.current.rotation.y; while (rotDelta > Math.PI) rotDelta -= Math.PI * 2; while (rotDelta < -Math.PI) rotDelta += Math.PI * 2; groupRef.current.rotation.y += rotDelta * 0.12; const isWorkout = agent.state === "working_out"; const isJanitor = "role" in agent && agent.role === "janitor"; const janitorTool = isJanitor ? (agent as RenderAgent & JanitorActor).janitorTool : undefined; const workoutStyle = agent.workoutStyle ?? "lift"; const frameValue = agent.frame + (agent.phaseOffset ?? 0) / WALK_ANIM_SPEED; const walkPhase = Math.sin(frameValue * WALK_ANIM_SPEED); const workoutPhase = Math.sin(agent.frame * 0.18 + (agent.phaseOffset ?? 0)); const workoutPushPhase = Math.sin(agent.frame * 0.18 + (agent.phaseOffset ?? 0) + Math.PI / 2); groupRef.current.rotation.z = 0; groupRef.current.rotation.x = agent.state === "sitting" ? -0.15 : isWorkout ? workoutStyle === "bike" ? 0.18 : workoutStyle === "row" ? -0.12 + Math.max(0, workoutPhase) * 0.08 : workoutStyle === "stretch" ? -0.08 : workoutStyle === "run" ? 0.08 : workoutStyle === "box" ? 0.04 : 0.02 : agent.pingPongUntil ? 0.08 : 0; const bounce = agent.state === "walking" ? Math.sin(frameValue * WALK_ANIM_SPEED) * 0.04 : isWorkout ? workoutStyle === "stretch" ? 0.012 + Math.abs(workoutPhase) * 0.018 : workoutStyle === "row" ? 0.015 + Math.abs(workoutPhase) * 0.028 : 0.02 + Math.abs(workoutPhase) * 0.04 : 0; const breathe = agent.state === "standing" || isWorkout || agent.pingPongUntil ? Math.sin(frameValue * 0.03) * 0.01 : 0; groupRef.current.position.y = bounce + breathe; if (leftArmRef.current) { leftArmRef.current.rotation.x = 0; leftArmRef.current.rotation.y = 0; leftArmRef.current.rotation.z = 0; if (isJanitor && janitorTool !== "broom") { leftArmRef.current.rotation.x = -0.22; leftArmRef.current.rotation.z = -0.08; } else if (agent.state === "walking") { leftArmRef.current.rotation.x = walkPhase * 0.4; } else if (isWorkout) { if (workoutStyle === "run") { leftArmRef.current.rotation.x = -(0.28 + workoutPhase * 1.05); leftArmRef.current.rotation.z = -0.08; } else if (workoutStyle === "bike") { leftArmRef.current.rotation.x = -(1.05 + workoutPushPhase * 0.16); leftArmRef.current.rotation.z = -0.18; leftArmRef.current.rotation.y = -0.12; } else if (workoutStyle === "row") { leftArmRef.current.rotation.x = -(0.95 - Math.max(0, workoutPhase) * 0.7); leftArmRef.current.rotation.z = -0.16; leftArmRef.current.rotation.y = -0.1; } else if (workoutStyle === "box") { leftArmRef.current.rotation.x = -(0.92 + Math.max(0, workoutPushPhase) * 0.45); leftArmRef.current.rotation.z = -0.52; leftArmRef.current.rotation.y = -0.06; groupRef.current.rotation.z = 0.05; } else if (workoutStyle === "stretch") { leftArmRef.current.rotation.x = -1.58; leftArmRef.current.rotation.z = -0.42; leftArmRef.current.rotation.y = -0.08; } else { leftArmRef.current.rotation.x = -(0.28 + Math.abs(workoutPhase) * 0.28); leftArmRef.current.rotation.z = -0.58; leftArmRef.current.rotation.y = -0.12; } } else if (agent.pingPongUntil) { leftArmRef.current.rotation.x = 0.2 + Math.sin(agent.frame * 0.08) * 0.28; } else if (agent.state === "sitting") { leftArmRef.current.rotation.x = 0.3; } } if (rightArmRef.current) { rightArmRef.current.rotation.x = 0; rightArmRef.current.rotation.y = 0; rightArmRef.current.rotation.z = 0; if (isJanitor && janitorTool !== "broom") { rightArmRef.current.rotation.x = -0.95; rightArmRef.current.rotation.y = 0.18; rightArmRef.current.rotation.z = 0.08; } else if (agent.state === "walking") { rightArmRef.current.rotation.x = -walkPhase * 0.4; } else if (isWorkout) { if (workoutStyle === "run") { rightArmRef.current.rotation.x = -(0.28 - workoutPhase * 1.05); rightArmRef.current.rotation.z = 0.08; } else if (workoutStyle === "bike") { rightArmRef.current.rotation.x = -(1.05 - workoutPushPhase * 0.16); rightArmRef.current.rotation.z = 0.18; rightArmRef.current.rotation.y = 0.12; } else if (workoutStyle === "row") { rightArmRef.current.rotation.x = -(0.95 - Math.max(0, -workoutPhase) * 0.7); rightArmRef.current.rotation.z = 0.16; rightArmRef.current.rotation.y = 0.1; } else if (workoutStyle === "box") { rightArmRef.current.rotation.x = -(0.92 + Math.max(0, -workoutPushPhase) * 0.45); rightArmRef.current.rotation.z = 0.52; rightArmRef.current.rotation.y = 0.06; groupRef.current.rotation.z = -0.05; } else if (workoutStyle === "stretch") { rightArmRef.current.rotation.x = -1.58; rightArmRef.current.rotation.z = 0.42; rightArmRef.current.rotation.y = 0.08; } else { rightArmRef.current.rotation.x = -(0.28 + Math.abs(workoutPhase) * 0.28); rightArmRef.current.rotation.z = 0.58; rightArmRef.current.rotation.y = 0.12; } } else if (agent.pingPongUntil) { rightArmRef.current.rotation.x = 0.08 - Math.sin(agent.frame * 0.08) * 0.16; } else if (agent.state === "sitting") { rightArmRef.current.rotation.x = 0.3; } } if (leftLegRef.current) { leftLegRef.current.rotation.x = agent.state === "walking" ? walkPhase * 0.35 : isWorkout ? workoutStyle === "run" ? workoutPhase * 0.7 : workoutStyle === "bike" ? workoutPhase * 0.82 : workoutStyle === "row" ? 0.14 + Math.max(0, workoutPhase) * 0.42 : workoutStyle === "stretch" ? -0.2 + Math.abs(workoutPhase) * 0.08 : workoutStyle === "box" ? 0.06 + workoutPhase * 0.14 : workoutPhase * 0.18 : 0; } if (rightLegRef.current) { rightLegRef.current.rotation.x = agent.state === "walking" ? -walkPhase * 0.35 : isWorkout ? workoutStyle === "run" ? -workoutPhase * 0.7 : workoutStyle === "bike" ? -workoutPhase * 0.82 : workoutStyle === "row" ? 0.14 + Math.max(0, -workoutPhase) * 0.42 : workoutStyle === "stretch" ? -0.12 + Math.abs(workoutPhase) * 0.08 : workoutStyle === "box" ? 0.06 - workoutPhase * 0.14 : -workoutPhase * 0.18 : 0; } const working = agent.state === "sitting" || isWorkout || agent.status === "working"; const isError = agent.status === "error"; const isAway = agent.state === "away"; if (statusDotMatRef.current) { statusDotMatRef.current.color.set( isError ? "#ef4444" : working ? "#22c55e" : "#f59e0b", ); } if (pulseRingRef.current && pulseRingMatRef.current) { if (working || isError) { const pulse = (Math.sin(agent.frame * 0.05) + 1) / 2; const scale = isError ? 1.25 + pulse * 0.55 : 1.2 + pulse * 0.8; pulseRingRef.current.scale.setScalar(scale); pulseRingMatRef.current.color.set(isError ? "#ef4444" : "#22c55e"); pulseRingMatRef.current.opacity = isError ? 0.7 - pulse * 0.3 : 0.55 - pulse * 0.45; pulseRingRef.current.visible = true; } else { pulseRingRef.current.visible = false; } } if (awayBubbleRef.current) awayBubbleRef.current.visible = isAway; if (bodyMatRef.current) bodyMatRef.current.opacity = isAway ? 0.45 : 1; if (groupRef.current) { groupRef.current.traverse((child) => { if ( child instanceof THREE.Mesh && child.material instanceof THREE.MeshLambertMaterial ) { child.material.transparent = isAway; child.material.opacity = isAway ? 0.45 : 1; } }); } const blinkSeed = agentId .split("") .reduce((sum, char) => sum + char.charCodeAt(0), 0); const blinkCycle = isAway ? 180 : isError ? 120 : working ? 170 : 240; const blinkWindow = isAway ? 26 : isError ? 18 : 12; const blinkPhase = (agent.frame + blinkSeed * 17) % blinkCycle; let eyeOpen = isError ? 0.92 : working ? 0.84 : 1.12; if (blinkPhase < blinkWindow) { const midpoint = blinkWindow / 2; eyeOpen *= Math.min(1, Math.abs(blinkPhase - midpoint) / midpoint); } if (working) eyeOpen = Math.max(0.48, eyeOpen); if (isError) eyeOpen = Math.max(0.28, eyeOpen); if (isAway) eyeOpen = Math.min(eyeOpen, 0.2); const eyeScaleX = isError ? 1.2 : working ? 1.06 : 1.12; const eyeScaleY = Math.max(0.05, eyeOpen); const eyeOffsetY = (working ? -0.006 : 0) + (isError ? -0.004 : 0) + (agent.state === "walking" ? 0.004 : 0) + (isAway ? -0.008 : 0); for (const eyeRef of [leftEyeRef, rightEyeRef]) { if (!eyeRef.current) continue; eyeRef.current.scale.x = eyeScaleX; eyeRef.current.scale.y = eyeScaleY; eyeRef.current.position.y = 0.475 + eyeOffsetY; } for (const highlightRef of [leftEyeHighlightRef, rightEyeHighlightRef]) { if (!highlightRef.current) continue; highlightRef.current.visible = eyeOpen > 0.45 && !isAway; highlightRef.current.position.y = 0.482 + eyeOffsetY; } if (mouthRef.current) { mouthRef.current.rotation.z = 0; mouthRef.current.position.set(0, 0.436, 0.074); if (isAway) { mouthRef.current.scale.set(0.5, 0.12, 1); mouthRef.current.position.y = 0.434; } else if (isError) { mouthRef.current.scale.set(1.28, 0.16, 1); mouthRef.current.position.y = 0.43; } else if (working) { mouthRef.current.scale.set(0.92, 0.14, 1); mouthRef.current.position.y = 0.437; } else if (agent.state === "walking") { const talkPulse = 0.38 + (Math.sin(agent.frame * 0.14 + blinkSeed) + 1) * 0.22; mouthRef.current.scale.set(0.95, talkPulse, 1); } else { mouthRef.current.scale.set(1.35, 0.34, 1); mouthRef.current.position.y = 0.428; } } const showSmileCorners = !isAway && !isError && !working && agent.state !== "walking"; const showFrownCorners = isError; if (leftMouthCornerRef.current && rightMouthCornerRef.current) { leftMouthCornerRef.current.visible = showSmileCorners || showFrownCorners; rightMouthCornerRef.current.visible = showSmileCorners || showFrownCorners; leftMouthCornerRef.current.position.set(-0.031, 0.434, 0.074); rightMouthCornerRef.current.position.set(0.031, 0.434, 0.074); if (showFrownCorners) { leftMouthCornerRef.current.rotation.z = -0.6; rightMouthCornerRef.current.rotation.z = 0.6; leftMouthCornerRef.current.position.y = 0.425; rightMouthCornerRef.current.position.y = 0.425; } else if (showSmileCorners) { leftMouthCornerRef.current.rotation.z = 0.62; rightMouthCornerRef.current.rotation.z = -0.62; leftMouthCornerRef.current.position.y = 0.438; rightMouthCornerRef.current.position.y = 0.438; } } if (leftBrowRef.current && rightBrowRef.current) { leftBrowRef.current.position.y = 0.52; rightBrowRef.current.position.y = 0.52; if (isAway) { leftBrowRef.current.rotation.z = -0.24; rightBrowRef.current.rotation.z = 0.24; leftBrowRef.current.position.y = 0.512; rightBrowRef.current.position.y = 0.512; } else if (isError) { leftBrowRef.current.rotation.z = 0.42; rightBrowRef.current.rotation.z = -0.42; leftBrowRef.current.position.y = 0.516; rightBrowRef.current.position.y = 0.516; } else if (working) { leftBrowRef.current.rotation.z = 0.3; rightBrowRef.current.rotation.z = -0.3; } else { leftBrowRef.current.rotation.z = -0.18; rightBrowRef.current.rotation.z = 0.18; leftBrowRef.current.position.y = 0.526; rightBrowRef.current.position.y = 0.526; } } const ambientBubbleVisible = (!suppressSpeechBubble && isError) || (!isAway && !suppressSpeechBubble && !working && !isError && agent.state === "standing" && (agent.frame + blinkSeed * 11) % 320 < 42); const bumpTalking = (agent.bumpTalkUntil ?? 0) > Date.now(); if (speechBubbleRef.current) { const bubbleVisible = !suppressSpeechBubble && (showSpeech || bumpTalking || ambientBubbleVisible); speechBubbleRef.current.visible = bubbleVisible; if (bubbleVisible) { if (showSpeech && speechText?.trim()) { speechBubbleRef.current.scale.setScalar(1); } else { const pulseBase = isError ? 1.06 : showSpeech || bumpTalking ? 1.03 : 0.98; const pulse = pulseBase + Math.sin(agent.frame * (isError ? 0.18 : 0.12)) * 0.06; speechBubbleRef.current.scale.setScalar(pulse); } } } if (speechBubbleMatRef.current) { speechBubbleMatRef.current.color.set( isError ? "#3a1016" : working ? "#1d2a17" : "#1a2030", ); speechBubbleMatRef.current.opacity = isError ? 0.97 : 0.92; } if (heldPaddleRef.current) { const isPlaying = agent.pingPongUntil !== undefined; heldPaddleRef.current.visible = isPlaying; if (isPlaying) { const swing = Math.sin(agent.frame * 0.08); heldPaddleRef.current.position.set(-0.01, -0.21, 0.07 + swing * 0.015); heldPaddleRef.current.rotation.set(-0.55 + swing * 0.1, 0.25, -0.35); } } if (heldPaddleFaceRef.current) { heldPaddleFaceRef.current.color.set( agent.pingPongSide === 0 ? "#1f4fa8" : "#c53b30", ); } if (heldCleaningToolRef.current) { const showBroom = isJanitor && janitorTool === "broom"; heldCleaningToolRef.current.visible = showBroom; if (showBroom) { const sweep = agent.state === "walking" ? Math.sin(agent.frame * 0.08) * 0.08 : 0; heldCleaningToolRef.current.position.set(-0.02, -0.2, 0.08 + sweep * 0.06); heldCleaningToolRef.current.rotation.set(-0.8, 0.18, -0.18); } } if (heldCleaningHeadRef.current) { heldCleaningHeadRef.current.color.set("#facc15"); } if (heldBucketRef.current) { const showVacuum = isJanitor && janitorTool === "vacuum"; heldBucketRef.current.visible = showVacuum; if (showVacuum) { heldBucketRef.current.position.set(-0.08, -0.1, 0.18); heldBucketRef.current.rotation.set(-0.32, 0.22, -0.38); } } if (heldScrubberRef.current) { const showScrubber = isJanitor && janitorTool === "floor_scrubber"; heldScrubberRef.current.visible = showScrubber; if (showScrubber) { heldScrubberRef.current.position.set(-0.1, -0.08, 0.2); heldScrubberRef.current.rotation.set(-0.28, 0.18, -0.42); } } }); const skin = resolvedAppearance.body.skinTone; const topColor = resolvedAppearance.clothing.topColor; const trouserColor = resolvedAppearance.clothing.bottomColor; const shoeColor = resolvedAppearance.clothing.shoesColor; const hairColor = resolvedAppearance.hair.color; const hairStyle = resolvedAppearance.hair.style; const topStyle = resolvedAppearance.clothing.topStyle; const bottomStyle = resolvedAppearance.clothing.bottomStyle; const hatStyle = resolvedAppearance.accessories.hatStyle; const showGlasses = resolvedAppearance.accessories.glasses; const showHeadset = resolvedAppearance.accessories.headset; const showBackpack = resolvedAppearance.accessories.backpack; const accessoryColor = topColor; const sleeveColor = topStyle === "jacket" ? "#dbe4ff" : topColor; const cuffColor = topStyle === "hoodie" ? "#d1d5db" : sleeveColor; const topAccentColor = topStyle === "jacket" ? "#1f2937" : cuffColor; const faceTexture = useMemo(() => { const canvas = document.createElement("canvas"); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext("2d"); if (!ctx) return new THREE.CanvasTexture(canvas); ctx.fillStyle = skin; ctx.fillRect(0, 0, 64, 64); ctx.fillStyle = "rgba(255,255,255,0.14)"; ctx.fillRect(0, 0, 64, 10); ctx.fillStyle = "rgba(196,122,84,0.18)"; ctx.beginPath(); ctx.arc(18, 38, 7, 0, Math.PI * 2); ctx.arc(46, 38, 7, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#d8a06e"; ctx.fillRect(30, 28, 4, 10); ctx.fillRect(29, 37, 6, 2); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.needsUpdate = true; return texture; }, [skin]); const resolvedSpeechText = showSpeech && speechText?.trim() ? speechText.trim() : status === "error" ? "error" : "..."; const activeSpeechBubble = showSpeech && Boolean(speechText?.trim()); const normalizedSpeechBubbleText = activeSpeechBubble ? resolvedSpeechText.replace(/\s+/g, " ").trim() : resolvedSpeechText; const speechBubbleDisplayText = normalizedSpeechBubbleText; const speechBubbleTextLength = speechBubbleDisplayText.length; const speechBubbleWidth = activeSpeechBubble ? Math.min(4.6, Math.max(1.8, 1.55 + speechBubbleTextLength * 0.018)) : 0.36; const speechBubblePaddingX = activeSpeechBubble ? 0.34 : 0.06; const speechBubblePaddingY = activeSpeechBubble ? 0.3 : 0.06; const speechBubbleMaxWidth = Math.max( 0.24, speechBubbleWidth - speechBubblePaddingX, ); const estimatedSpeechCharsPerLine = activeSpeechBubble ? Math.max(10, Math.floor(speechBubbleMaxWidth * 7)) : 8; const estimatedSpeechLines = activeSpeechBubble ? Math.max( 1, Math.ceil(speechBubbleTextLength / estimatedSpeechCharsPerLine), ) : 1; const speechBubbleHeight = activeSpeechBubble ? Math.max(0.72, estimatedSpeechLines * 0.26 + speechBubblePaddingY) : 0.2; const speechBubbleFontSize = activeSpeechBubble ? speechBubbleTextLength > 110 ? 0.188 : speechBubbleTextLength > 70 ? 0.2 : 0.216 : 0.13; const speechBubbleTextColor = activeSpeechBubble ? "#f8fafc" : status === "error" ? "#ff9aa5" : status === "working" ? "#b9f99d" : "#a0c8ff"; const speechBubbleBorderColor = activeSpeechBubble ? status === "error" ? "#ff7f93" : status === "working" ? "#93f57d" : "#8dc4ff" : "transparent"; const speechBubbleBorderInset = activeSpeechBubble ? 0.03 : 0; return ( { event.stopPropagation(); onHover?.(agentId); }} onPointerOut={() => onUnhover?.()} onClick={(event) => { event.stopPropagation(); onClick?.(agentId); }} onContextMenu={(event) => { event.stopPropagation(); const nativeEvent = event.nativeEvent as MouseEvent; onContextMenu?.(agentId, nativeEvent.clientX, nativeEvent.clientY); }} > {bottomStyle === "shorts" ? ( <> ) : ( <> {bottomStyle === "cuffed" ? ( ) : null} )} {bottomStyle === "shorts" ? ( <> ) : ( <> {bottomStyle === "cuffed" ? ( ) : null} )} {showBackpack ? ( ) : null} {topStyle === "hoodie" ? ( <> ) : null} {topStyle === "jacket" ? ( <> ) : null} {topStyle === "hoodie" ? ( ) : null} {/* Vacuum cleaner: larger upright silhouette so it reads clearly in-scene. */} {/* Floor scrubber: prominent handle, body, and wide cleaning base. */} {topStyle === "hoodie" ? ( ) : null} {hairStyle === "short" ? ( ) : null} {hairStyle === "parted" ? ( <> ) : null} {hairStyle === "spiky" ? ( <> ) : null} {hairStyle === "bun" ? ( <> ) : null} {hatStyle === "cap" ? ( <> ) : null} {hatStyle === "beanie" ? ( ) : null} {showHeadset ? ( <> ) : null} {showGlasses ? ( <> ) : null} {!activeSpeechBubble && name ? ( {name} ) : null} z z z {activeSpeechBubble ? ( ) : null} {speechBubbleDisplayText} ); }); AgentModel.displayName = "AgentModel";