world-simulator / frontend /src /scene /ResourceActor.tsx
DeltaZN
updated textures & refactor logic
5c435e7
Raw
History Blame Contribute Delete
9.23 kB
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);
}