First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,859 @@
|
||||
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 {
|
||||
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,
|
||||
agentsRef,
|
||||
agentLookupRef,
|
||||
onHover,
|
||||
onUnhover,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
showSpeech = false,
|
||||
speechText = null,
|
||||
suppressSpeechBubble = false,
|
||||
}: AgentModelProps) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const leftArmRef = useRef<THREE.Group>(null);
|
||||
const rightArmRef = useRef<THREE.Group>(null);
|
||||
const leftLegRef = useRef<THREE.Group>(null);
|
||||
const rightLegRef = useRef<THREE.Group>(null);
|
||||
const statusDotMatRef = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const pulseRingRef = useRef<THREE.Mesh>(null);
|
||||
const pulseRingMatRef = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const leftEyeRef = useRef<THREE.Mesh>(null);
|
||||
const rightEyeRef = useRef<THREE.Mesh>(null);
|
||||
const leftEyeHighlightRef = useRef<THREE.Mesh>(null);
|
||||
const rightEyeHighlightRef = useRef<THREE.Mesh>(null);
|
||||
const mouthRef = useRef<THREE.Mesh>(null);
|
||||
const leftMouthCornerRef = useRef<THREE.Mesh>(null);
|
||||
const rightMouthCornerRef = useRef<THREE.Mesh>(null);
|
||||
const leftBrowRef = useRef<THREE.Mesh>(null);
|
||||
const rightBrowRef = useRef<THREE.Mesh>(null);
|
||||
const heldPaddleRef = useRef<THREE.Group>(null);
|
||||
const heldPaddleFaceRef = useRef<THREE.MeshStandardMaterial>(null);
|
||||
const heldCleaningToolRef = useRef<THREE.Group>(null);
|
||||
const heldCleaningHeadRef = useRef<THREE.MeshStandardMaterial>(null);
|
||||
const heldBucketRef = useRef<THREE.Group>(null);
|
||||
const heldScrubberRef = useRef<THREE.Group>(null);
|
||||
const speechBubbleRef = useRef<THREE.Group>(null);
|
||||
const speechBubbleMatRef = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const awayBubbleRef = useRef<THREE.Group>(null);
|
||||
const bodyMatRef = useRef<THREE.MeshLambertMaterial>(null);
|
||||
const pos = useRef(new THREE.Vector3(0, 0, 0));
|
||||
|
||||
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 = "#f4c58a";
|
||||
const trouserColor = "#2d3748";
|
||||
const shoeColor = "#1a1a1a";
|
||||
const hairColor = "#3e2723";
|
||||
|
||||
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 (
|
||||
<group
|
||||
ref={groupRef}
|
||||
scale={[AGENT_SCALE, AGENT_SCALE, AGENT_SCALE]}
|
||||
onPointerOver={(event) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<mesh position={[0, 0.001, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[0.12, 12]} />
|
||||
<meshBasicMaterial color="#000" transparent opacity={0.2} />
|
||||
</mesh>
|
||||
<group ref={rightLegRef} position={[-0.045, 0.1, 0]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
||||
<meshLambertMaterial color={trouserColor} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.09, 0]}>
|
||||
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
||||
<meshLambertMaterial color={shoeColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
<group ref={leftLegRef} position={[0.045, 0.1, 0]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
||||
<meshLambertMaterial color={trouserColor} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.09, 0]}>
|
||||
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
||||
<meshLambertMaterial color={shoeColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
<mesh position={[0, 0.28, 0]}>
|
||||
<boxGeometry args={[0.18, 0.2, 0.1]} />
|
||||
<meshLambertMaterial ref={bodyMatRef} color={color} />
|
||||
</mesh>
|
||||
<group ref={rightArmRef} position={[-0.12, 0.28, 0]}>
|
||||
<mesh position={[0, -0.08, 0]}>
|
||||
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
||||
<meshLambertMaterial color={color} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.17, 0]}>
|
||||
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
||||
<meshLambertMaterial color={skin} />
|
||||
</mesh>
|
||||
<group ref={heldPaddleRef} position={[-0.01, -0.21, 0.07]} visible={false}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.042, 0.042, 0.012, 18]} />
|
||||
<meshStandardMaterial
|
||||
ref={heldPaddleFaceRef}
|
||||
color="#c53b30"
|
||||
roughness={0.72}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, -0.045, -0.015]} rotation={[0.12, 0, 0]}>
|
||||
<boxGeometry args={[0.014, 0.07, 0.014]} />
|
||||
<meshStandardMaterial color="#c59a68" roughness={0.74} />
|
||||
</mesh>
|
||||
</group>
|
||||
<group
|
||||
ref={heldCleaningToolRef}
|
||||
position={[-0.02, -0.2, 0.08]}
|
||||
rotation={[-0.8, 0.18, -0.18]}
|
||||
visible={false}
|
||||
>
|
||||
<mesh position={[0, -0.13, 0]}>
|
||||
<boxGeometry args={[0.012, 0.28, 0.012]} />
|
||||
<meshStandardMaterial color="#9a6b3c" roughness={0.76} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.28, 0.012]}>
|
||||
<boxGeometry args={[0.09, 0.028, 0.03]} />
|
||||
<meshStandardMaterial
|
||||
ref={heldCleaningHeadRef}
|
||||
color="#facc15"
|
||||
roughness={0.68}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
{/* Vacuum cleaner: larger upright silhouette so it reads clearly in-scene. */}
|
||||
<group ref={heldBucketRef} position={[-0.08, -0.1, 0.18]} visible={false}>
|
||||
<mesh position={[0, -0.02, 0]}>
|
||||
<boxGeometry args={[0.015, 0.3, 0.015]} />
|
||||
<meshStandardMaterial color="#555" roughness={0.72} />
|
||||
</mesh>
|
||||
<mesh position={[0.025, -0.16, 0]}>
|
||||
<boxGeometry args={[0.08, 0.12, 0.07]} />
|
||||
<meshStandardMaterial color="#dc2626" roughness={0.48} />
|
||||
</mesh>
|
||||
<mesh position={[0.05, -0.24, 0.02]}>
|
||||
<boxGeometry args={[0.11, 0.024, 0.06]} />
|
||||
<meshStandardMaterial color="#1f2937" roughness={0.65} />
|
||||
</mesh>
|
||||
<mesh position={[0.02, -0.11, 0.035]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<torusGeometry args={[0.03, 0.005, 10, 18, Math.PI]} />
|
||||
<meshStandardMaterial color="#94a3b8" roughness={0.36} metalness={0.18} />
|
||||
</mesh>
|
||||
</group>
|
||||
{/* Floor scrubber: prominent handle, body, and wide cleaning base. */}
|
||||
<group ref={heldScrubberRef} position={[-0.1, -0.08, 0.2]} visible={false}>
|
||||
<mesh position={[0, -0.02, 0]}>
|
||||
<boxGeometry args={[0.015, 0.32, 0.015]} />
|
||||
<meshStandardMaterial color="#777" roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh position={[0.035, -0.17, 0]}>
|
||||
<boxGeometry args={[0.085, 0.08, 0.065]} />
|
||||
<meshStandardMaterial color="#f59e0b" roughness={0.46} />
|
||||
</mesh>
|
||||
<mesh position={[0.06, -0.27, 0.02]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[0.075, 0.075, 0.018, 24]} />
|
||||
<meshStandardMaterial color="#0ea5e9" roughness={0.52} />
|
||||
</mesh>
|
||||
<mesh position={[0.06, -0.23, 0.02]}>
|
||||
<boxGeometry args={[0.12, 0.018, 0.07]} />
|
||||
<meshStandardMaterial color="#1f2937" roughness={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
<group ref={leftArmRef} position={[0.12, 0.28, 0]}>
|
||||
<mesh position={[0, -0.08, 0]}>
|
||||
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
||||
<meshLambertMaterial color={color} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.17, 0]}>
|
||||
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
||||
<meshLambertMaterial color={skin} />
|
||||
</mesh>
|
||||
</group>
|
||||
<mesh position={[0, 0.39, 0]}>
|
||||
<boxGeometry args={[0.07, 0.05, 0.07]} />
|
||||
<meshLambertMaterial color={skin} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.47, 0]}>
|
||||
<boxGeometry args={[0.16, 0.16, 0.14]} />
|
||||
<meshLambertMaterial attach="material-0" color={skin} />
|
||||
<meshLambertMaterial attach="material-1" color={skin} />
|
||||
<meshLambertMaterial attach="material-2" color={skin} />
|
||||
<meshLambertMaterial attach="material-3" color={skin} />
|
||||
<meshLambertMaterial attach="material-4" map={faceTexture} />
|
||||
<meshLambertMaterial attach="material-5" color={skin} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.555, 0]}>
|
||||
<boxGeometry args={[0.17, 0.05, 0.15]} />
|
||||
<meshLambertMaterial color={hairColor} />
|
||||
</mesh>
|
||||
<mesh ref={leftBrowRef} position={[-0.04, 0.52, 0.074]}>
|
||||
<boxGeometry args={[0.04, 0.01, 0.01]} />
|
||||
<meshBasicMaterial color="#342016" />
|
||||
</mesh>
|
||||
<mesh ref={rightBrowRef} position={[0.04, 0.52, 0.074]}>
|
||||
<boxGeometry args={[0.04, 0.01, 0.01]} />
|
||||
<meshBasicMaterial color="#342016" />
|
||||
</mesh>
|
||||
<mesh ref={leftEyeRef} position={[-0.04, 0.475, 0.072]}>
|
||||
<boxGeometry args={[0.03, 0.03, 0.01]} />
|
||||
<meshBasicMaterial color="#1a1a2e" />
|
||||
</mesh>
|
||||
<mesh ref={rightEyeRef} position={[0.04, 0.475, 0.072]}>
|
||||
<boxGeometry args={[0.03, 0.03, 0.01]} />
|
||||
<meshBasicMaterial color="#1a1a2e" />
|
||||
</mesh>
|
||||
<mesh ref={leftEyeHighlightRef} position={[-0.03, 0.482, 0.074]}>
|
||||
<boxGeometry args={[0.008, 0.008, 0.01]} />
|
||||
<meshBasicMaterial color="#fff" />
|
||||
</mesh>
|
||||
<mesh ref={rightEyeHighlightRef} position={[0.05, 0.482, 0.074]}>
|
||||
<boxGeometry args={[0.008, 0.008, 0.01]} />
|
||||
<meshBasicMaterial color="#fff" />
|
||||
</mesh>
|
||||
<mesh ref={mouthRef} position={[0, 0.436, 0.074]}>
|
||||
<boxGeometry args={[0.05, 0.014, 0.01]} />
|
||||
<meshBasicMaterial color="#9c4a4a" />
|
||||
</mesh>
|
||||
<mesh ref={leftMouthCornerRef} position={[-0.031, 0.438, 0.074]} visible={false}>
|
||||
<boxGeometry args={[0.014, 0.014, 0.01]} />
|
||||
<meshBasicMaterial color="#9c4a4a" />
|
||||
</mesh>
|
||||
<mesh ref={rightMouthCornerRef} position={[0.031, 0.438, 0.074]} visible={false}>
|
||||
<boxGeometry args={[0.014, 0.014, 0.01]} />
|
||||
<meshBasicMaterial color="#9c4a4a" />
|
||||
</mesh>
|
||||
<mesh
|
||||
ref={pulseRingRef}
|
||||
position={[0, 0.005, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
visible={false}
|
||||
>
|
||||
<ringGeometry args={[0.13, 0.19, 24]} />
|
||||
<meshBasicMaterial
|
||||
ref={pulseRingMatRef}
|
||||
color="#22c55e"
|
||||
transparent
|
||||
opacity={0.5}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
{!activeSpeechBubble && name ? (
|
||||
<Billboard position={[0, 1.05, 0]}>
|
||||
<mesh position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[0.82, 0.24]} />
|
||||
<meshBasicMaterial color="#080c14" transparent opacity={0.9} />
|
||||
</mesh>
|
||||
<mesh position={[-0.392, 0, 0]}>
|
||||
<planeGeometry args={[0.028, 0.24]} />
|
||||
<meshBasicMaterial color={color} />
|
||||
</mesh>
|
||||
<mesh position={[0.355, 0, 0]}>
|
||||
<circleGeometry args={[0.052, 14]} />
|
||||
<meshBasicMaterial ref={statusDotMatRef} color="#ef4444" />
|
||||
</mesh>
|
||||
<Text
|
||||
position={[-0.02, 0, 0.001]}
|
||||
fontSize={0.16}
|
||||
color="#e8dfc0"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
maxWidth={0.68}
|
||||
font={undefined}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
</Billboard>
|
||||
) : null}
|
||||
<group ref={awayBubbleRef} visible={false}>
|
||||
<Billboard position={[0, 1.3, 0]}>
|
||||
<mesh position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[0.32, 0.18]} />
|
||||
<meshBasicMaterial color="#0d1015" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<Text
|
||||
position={[0, 0, 0.001]}
|
||||
fontSize={0.11}
|
||||
color="#6080b0"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
>
|
||||
z z z
|
||||
</Text>
|
||||
</Billboard>
|
||||
</group>
|
||||
<group ref={speechBubbleRef} visible={false}>
|
||||
<Billboard position={[0, 1.45, 0]}>
|
||||
{activeSpeechBubble ? (
|
||||
<mesh position={[0, 0, -0.0015]} renderOrder={99998}>
|
||||
<planeGeometry
|
||||
args={[
|
||||
speechBubbleWidth + speechBubbleBorderInset,
|
||||
speechBubbleHeight + speechBubbleBorderInset,
|
||||
]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={speechBubbleBorderColor}
|
||||
transparent
|
||||
opacity={0.88}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
) : null}
|
||||
<mesh position={[0, 0, -0.001]} renderOrder={99999}>
|
||||
<planeGeometry args={[speechBubbleWidth, speechBubbleHeight]} />
|
||||
<meshBasicMaterial
|
||||
ref={speechBubbleMatRef}
|
||||
color="#1a2030"
|
||||
transparent
|
||||
opacity={activeSpeechBubble ? 0.76 : 0.92}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
<Text
|
||||
position={
|
||||
activeSpeechBubble
|
||||
? [-speechBubbleWidth / 2 + speechBubblePaddingX / 2, 0, 0.001]
|
||||
: [0, 0, 0.001]
|
||||
}
|
||||
fontSize={speechBubbleFontSize}
|
||||
color={speechBubbleTextColor}
|
||||
anchorX={activeSpeechBubble ? "left" : "center"}
|
||||
anchorY="middle"
|
||||
maxWidth={speechBubbleMaxWidth}
|
||||
textAlign={activeSpeechBubble ? "left" : "center"}
|
||||
lineHeight={1.1}
|
||||
renderOrder={100000}
|
||||
depthOffset={-10}
|
||||
material-depthTest={false}
|
||||
material-depthWrite={false}
|
||||
>
|
||||
{speechBubbleDisplayText}
|
||||
</Text>
|
||||
</Billboard>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
|
||||
AgentModel.displayName = "AgentModel";
|
||||
Reference in New Issue
Block a user