| import { Html } from "@react-three/drei"; |
| import { useFrame } from "@react-three/fiber"; |
| import { useMemo, useRef } from "react"; |
| import type { Group, Mesh } from "three"; |
| import { MathUtils } from "three"; |
|
|
| import type { ResourceNodeSnapshot } from "../types"; |
| import { hashToUnit } from "./characterUtils"; |
|
|
| type ResourceActorProps = { |
| node: ResourceNodeSnapshot; |
| isSelected: boolean; |
| onSelect: () => void; |
| }; |
|
|
| export function ResourceActor({ node, isSelected, onSelect }: ResourceActorProps) { |
| const pulseRef = useRef<Mesh>(null); |
| const swayRef = useRef<Group>(null); |
| const animationPhase = useMemo(() => hashToUnit(node.id) * Math.PI * 2, [node.id]); |
| const color = resourceColor(node.type); |
| const richness = resourceRichness(node); |
|
|
| useFrame((state) => { |
| const time = state.clock.elapsedTime + animationPhase; |
| if (pulseRef.current) { |
| const pulse = 1 + Math.sin(state.clock.elapsedTime * 4) * 0.08; |
| pulseRef.current.scale.setScalar(isSelected ? pulse : 0.001); |
| } |
| if (swayRef.current) { |
| swayRef.current.rotation.z = Math.sin(time * 1.4) * 0.035; |
| swayRef.current.rotation.x = Math.sin(time * 1.1) * 0.025; |
| } |
| }); |
|
|
| return ( |
| <group position={[node.position.x, node.position.y, node.position.z]}> |
| <mesh |
| ref={pulseRef} |
| position={[0, 0.035, 0]} |
| rotation={[-Math.PI / 2, 0, 0]} |
| onClick={(event) => { |
| event.stopPropagation(); |
| onSelect(); |
| }} |
| > |
| <ringGeometry args={[0.78, 1.1, 40]} /> |
| <meshBasicMaterial color={color} transparent opacity={0.78} /> |
| </mesh> |
| <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.025, 0]}> |
| <ringGeometry args={[0.55, 0.72, 20]} /> |
| <meshBasicMaterial color={color} transparent opacity={0.4} /> |
| </mesh> |
| <group |
| scale={[richness, richness, richness]} |
| onClick={(event) => { |
| event.stopPropagation(); |
| onSelect(); |
| }} |
| > |
| <ResourceModel type={node.type} swayRef={swayRef} /> |
| </group> |
| <Html |
| center |
| distanceFactor={24} |
| position={[0, 2.05, 0]} |
| className="npcLabel" |
| zIndexRange={[6, 0]} |
| > |
| <button |
| type="button" |
| onClick={(event) => { |
| // Keep the click out of the r3f event container, where it would |
| // raycast into empty space and fire onPointerMissed (deselect). |
| event.stopPropagation(); |
| onSelect(); |
| }} |
| aria-label={`Select ${node.type} node`} |
| > |
| <strong>{resourceTitle(node.type)}</strong> |
| <span>{node.amount} left</span> |
| </button> |
| </Html> |
| </group> |
| ); |
| } |
|
|
| function ResourceModel({ |
| type, |
| swayRef, |
| }: { |
| type: string; |
| swayRef: React.RefObject<Group | null>; |
| }) { |
| if (type === "food") { |
| return <BerryBush swayRef={swayRef} />; |
| } |
| if (type === "herbs") { |
| return <HerbPatch swayRef={swayRef} />; |
| } |
| if (type === "wood") { |
| return <LogPile />; |
| } |
| if (type === "weapon") { |
| return <WeaponCache />; |
| } |
| return <SupplyCrate />; |
| } |
|
|
| function BerryBush({ swayRef }: { swayRef: React.RefObject<Group | null> }) { |
| return ( |
| <group> |
| {/* trunk stub */} |
| <mesh castShadow position={[0, 0.16, 0]}> |
| <boxGeometry args={[0.22, 0.32, 0.22]} /> |
| <meshStandardMaterial color="#6b4a2c" roughness={0.9} /> |
| </mesh> |
| <group ref={swayRef} position={[0, 0.32, 0]}> |
| {/* foliage blocks */} |
| <mesh castShadow position={[0, 0.34, 0]}> |
| <boxGeometry args={[0.92, 0.62, 0.92]} /> |
| <meshStandardMaterial color="#4e8f3e" roughness={0.88} /> |
| </mesh> |
| <mesh castShadow position={[0.3, 0.72, 0.12]}> |
| <boxGeometry args={[0.5, 0.4, 0.5]} /> |
| <meshStandardMaterial color="#5fa84a" roughness={0.88} /> |
| </mesh> |
| <mesh castShadow position={[-0.28, 0.66, -0.18]}> |
| <boxGeometry args={[0.44, 0.36, 0.44]} /> |
| <meshStandardMaterial color="#5fa84a" roughness={0.88} /> |
| </mesh> |
| {/* berries */} |
| <Berry x={0.32} y={0.42} z={0.42} /> |
| <Berry x={-0.34} y={0.3} z={0.36} /> |
| <Berry x={0.42} y={0.26} z={-0.26} /> |
| <Berry x={-0.18} y={0.52} z={-0.4} /> |
| <Berry x={0.05} y={0.78} z={0.3} /> |
| </group> |
| </group> |
| ); |
| } |
|
|
| function Berry({ x, y, z }: { x: number; y: number; z: number }) { |
| return ( |
| <mesh castShadow position={[x, y, z]}> |
| <boxGeometry args={[0.14, 0.14, 0.14]} /> |
| <meshStandardMaterial color="#d8453e" roughness={0.55} /> |
| </mesh> |
| ); |
| } |
|
|
| function HerbPatch({ swayRef }: { swayRef: React.RefObject<Group | null> }) { |
| return ( |
| <group> |
| {/* dirt mound */} |
| <mesh receiveShadow castShadow position={[0, 0.07, 0]}> |
| <boxGeometry args={[1.05, 0.14, 1.05]} /> |
| <meshStandardMaterial color="#5e4630" roughness={0.95} /> |
| </mesh> |
| <group ref={swayRef} position={[0, 0.14, 0]}> |
| <HerbSprout x={-0.28} z={-0.2} height={0.52} /> |
| <HerbSprout x={0.26} z={-0.28} height={0.42} /> |
| <HerbSprout x={0.05} z={0.27} height={0.6} /> |
| <HerbSprout x={-0.3} z={0.3} height={0.36} /> |
| </group> |
| </group> |
| ); |
| } |
|
|
| function HerbSprout({ x, z, height }: { x: number; z: number; height: number }) { |
| return ( |
| <group position={[x, 0, z]}> |
| <mesh castShadow position={[0, height / 2, 0]}> |
| <boxGeometry args={[0.08, height, 0.08]} /> |
| <meshStandardMaterial color="#3f7a3c" roughness={0.85} /> |
| </mesh> |
| <mesh castShadow position={[0, height + 0.08, 0]}> |
| <boxGeometry args={[0.24, 0.16, 0.24]} /> |
| <meshStandardMaterial color="#7cc96d" roughness={0.8} /> |
| </mesh> |
| </group> |
| ); |
| } |
|
|
| function LogPile() { |
| return ( |
| <group> |
| <Log x={-0.24} y={0.16} rotationY={0.06} /> |
| <Log x={0.26} y={0.16} rotationY={-0.04} /> |
| <Log x={0} y={0.46} rotationY={0.02} /> |
| </group> |
| ); |
| } |
|
|
| function Log({ x, y, rotationY }: { x: number; y: number; rotationY: number }) { |
| return ( |
| <group position={[x, y, 0]} rotation={[0, rotationY, 0]}> |
| <mesh castShadow receiveShadow> |
| <boxGeometry args={[0.3, 0.3, 1.1]} /> |
| <meshStandardMaterial color="#6b4a2c" roughness={0.9} /> |
| </mesh> |
| <mesh position={[0, 0, 0.56]}> |
| <boxGeometry args={[0.24, 0.24, 0.03]} /> |
| <meshStandardMaterial color="#c9a76a" roughness={0.8} /> |
| </mesh> |
| <mesh position={[0, 0, -0.56]}> |
| <boxGeometry args={[0.24, 0.24, 0.03]} /> |
| <meshStandardMaterial color="#c9a76a" roughness={0.8} /> |
| </mesh> |
| </group> |
| ); |
| } |
|
|
| function WeaponCache() { |
| return ( |
| <group> |
| {/* stone block */} |
| <mesh castShadow receiveShadow position={[0, 0.2, 0]}> |
| <boxGeometry args={[0.85, 0.4, 0.7]} /> |
| <meshStandardMaterial color="#7c8089" roughness={0.9} /> |
| </mesh> |
| {/* sword blade */} |
| <group position={[0, 0.4, 0]} rotation={[0, 0.5, 0.16]}> |
| <mesh castShadow position={[0, 0.5, 0]}> |
| <boxGeometry args={[0.1, 0.85, 0.05]} /> |
| <meshStandardMaterial color="#d7dde6" roughness={0.3} metalness={0.6} /> |
| </mesh> |
| <mesh castShadow position={[0, 0.92, 0]}> |
| <boxGeometry args={[0.34, 0.09, 0.09]} /> |
| <meshStandardMaterial color="#8a6b35" roughness={0.6} /> |
| </mesh> |
| <mesh castShadow position={[0, 1.08, 0]}> |
| <boxGeometry args={[0.09, 0.24, 0.09]} /> |
| <meshStandardMaterial color="#5c4322" roughness={0.7} /> |
| </mesh> |
| </group> |
| {/* axe leaning on the stone */} |
| <group position={[0.42, 0.32, 0.18]} rotation={[0, 0, -0.45]}> |
| <mesh castShadow position={[0, 0.3, 0]}> |
| <boxGeometry args={[0.07, 0.62, 0.07]} /> |
| <meshStandardMaterial color="#5c4322" roughness={0.7} /> |
| </mesh> |
| <mesh castShadow position={[0.1, 0.56, 0]}> |
| <boxGeometry args={[0.26, 0.18, 0.06]} /> |
| <meshStandardMaterial color="#b7bec8" roughness={0.35} metalness={0.55} /> |
| </mesh> |
| </group> |
| </group> |
| ); |
| } |
|
|
| function SupplyCrate() { |
| return ( |
| <group> |
| <mesh castShadow receiveShadow position={[0, 0.3, 0]}> |
| <boxGeometry args={[0.7, 0.6, 0.7]} /> |
| <meshStandardMaterial color="#a98c52" roughness={0.85} /> |
| </mesh> |
| <mesh castShadow position={[0, 0.62, 0]}> |
| <boxGeometry args={[0.76, 0.08, 0.76]} /> |
| <meshStandardMaterial color="#7d6437" roughness={0.85} /> |
| </mesh> |
| </group> |
| ); |
| } |
|
|
| export function resourceTitle(type: string): string { |
| if (!type) { |
| return "Resource"; |
| } |
| return `${type.charAt(0).toUpperCase()}${type.slice(1)}`; |
| } |
|
|
| export function resourceColor(type: string): string { |
| if (type === "food") { |
| return "#e3c454"; |
| } |
| if (type === "herbs") { |
| return "#58a65c"; |
| } |
| if (type === "wood") { |
| return "#7a5234"; |
| } |
| if (type === "weapon") { |
| return "#b7bec8"; |
| } |
| return "#d9d2aa"; |
| } |
|
|
| function resourceRichness(node: ResourceNodeSnapshot): number { |
| const ratio = node.max_amount |
| ? MathUtils.clamp(node.amount / node.max_amount, 0, 1) |
| : MathUtils.clamp(node.amount / 10, 0, 1); |
| return MathUtils.lerp(0.55, 1.15, ratio); |
| } |
|
|