| import { Html } from "@react-three/drei"; |
| import type { ThreeEvent } from "@react-three/fiber"; |
| import { useLayoutEffect, useMemo, useRef } from "react"; |
| import { Color, Matrix4 } from "three"; |
| import type { InstancedMesh } from "three"; |
|
|
| import type { CannonSnapshot, CountrySnapshot, HouseSnapshot, TreasurySnapshot, WorldSnapshot } from "../types"; |
| import { BeastActor } from "./BeastActor"; |
| import { NpcActor } from "./NpcActor"; |
| import { ResourceActor } from "./ResourceActor"; |
| import { getHouseTextures } from "./voxelTextures"; |
|
|
| type WorldViewProps = { |
| snapshot: WorldSnapshot; |
| selectedId: string | null; |
| onSelectEntity: (id: string) => void; |
| }; |
|
|
| export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewProps) { |
| const positionsById = useMemo(() => { |
| const map = new Map<string, readonly [number, number, number]>(); |
| for (const entity of snapshot.entities) { |
| map.set(entity.id, [entity.position.x, entity.position.y, entity.position.z]); |
| } |
| for (const beast of snapshot.beasts ?? []) { |
| map.set(beast.id, [beast.position.x, beast.position.y, beast.position.z]); |
| } |
| for (const house of snapshot.houses ?? []) { |
| map.set(house.id, [house.position.x, house.position.y, house.position.z]); |
| } |
| return map; |
| }, [snapshot]); |
|
|
| return ( |
| <group> |
| <Terrain |
| width={snapshot.terrain.width} |
| depth={snapshot.terrain.depth} |
| color={snapshot.terrain.color} |
| /> |
| {snapshot.entities.map((entity) => ( |
| <NpcActor |
| key={entity.id} |
| entity={entity} |
| tick={snapshot.tick} |
| focusTarget={ |
| entity.state.focus_target_id |
| ? (positionsById.get(entity.state.focus_target_id) ?? null) |
| : null |
| } |
| isSelected={entity.id === selectedId} |
| onSelect={() => onSelectEntity(entity.id)} |
| /> |
| ))} |
| {(snapshot.houses ?? []).map((house) => ( |
| <HouseBuilding |
| key={house.id} |
| house={house} |
| isSelected={house.id === selectedId} |
| onSelect={() => onSelectEntity(house.id)} |
| /> |
| ))} |
| {(snapshot.countries ?? []).map((country) => ( |
| <CountryAssets key={country.id} country={country} /> |
| ))} |
| {(snapshot.resource_nodes ?? []).map((node) => ( |
| <ResourceActor |
| key={node.id} |
| node={node} |
| isSelected={node.id === selectedId} |
| onSelect={() => onSelectEntity(node.id)} |
| /> |
| ))} |
| {(snapshot.beasts ?? []).map((beast) => ( |
| <BeastActor |
| key={beast.id} |
| beast={beast} |
| tick={snapshot.tick} |
| focusTarget={ |
| beast.state === "attacking" |
| ? (positionsById.get(beast.target_npc_id ?? beast.target_house_id ?? "") ?? null) |
| : null |
| } |
| isSelected={beast.id === selectedId} |
| onSelect={() => onSelectEntity(beast.id)} |
| /> |
| ))} |
| </group> |
| ); |
| } |
|
|
| function CountryAssets({ country }: { country: CountrySnapshot }) { |
| return ( |
| <> |
| {country.treasury ? <TreasuryMarker treasury={country.treasury} country={country} /> : null} |
| {country.cannon ? <CannonMarker cannon={country.cannon} country={country} /> : null} |
| </> |
| ); |
| } |
|
|
| function TreasuryMarker({ treasury, country }: { treasury: TreasurySnapshot; country: CountrySnapshot }) { |
| const resources = treasury.resources; |
| return ( |
| <group position={[treasury.position.x, treasury.position.y, treasury.position.z]}> |
| <mesh castShadow receiveShadow position={[0, 0.45, 0]}> |
| <boxGeometry args={[2.1, 0.9, 2.1]} /> |
| <meshStandardMaterial color={country.color} roughness={0.8} metalness={0.05} /> |
| </mesh> |
| <mesh castShadow receiveShadow position={[0, 1.05, 0]}> |
| <boxGeometry args={[1.45, 0.35, 1.45]} /> |
| <meshStandardMaterial color="#f4d36a" roughness={0.65} metalness={0.15} /> |
| </mesh> |
| <Html center distanceFactor={28} position={[0, 1.95, 0]} className="npcLabel" zIndexRange={[6, 0]}> |
| <div className="resourceTag treasuryTag"> |
| <span>{country.badge} Treasury</span> |
| <span>W{resources.wood ?? 0} C{resources.coins ?? 0}</span> |
| </div> |
| </Html> |
| </group> |
| ); |
| } |
|
|
| function CannonMarker({ cannon, country }: { cannon: CannonSnapshot; country: CountrySnapshot }) { |
| return ( |
| <group position={[cannon.position.x, cannon.position.y, cannon.position.z]} rotation={[0, Math.PI / 4, 0]}> |
| <mesh castShadow receiveShadow position={[0, 0.45, 0]}> |
| <cylinderGeometry args={[0.28, 0.38, 2.1, 18]} /> |
| <meshStandardMaterial color="#2e3338" roughness={0.75} metalness={0.35} /> |
| </mesh> |
| <mesh castShadow receiveShadow position={[0, 0.18, -0.6]}> |
| <boxGeometry args={[1.6, 0.28, 1.15]} /> |
| <meshStandardMaterial color={country.color} roughness={0.82} /> |
| </mesh> |
| <Html center distanceFactor={28} position={[0, 1.35, 0]} className="npcLabel" zIndexRange={[6, 0]}> |
| <div className="resourceTag cannonTag"> |
| <span>{country.badge} Cannon</span> |
| <span>{cannon.operator_id ? "armed" : "no operator"}</span> |
| </div> |
| </Html> |
| </group> |
| ); |
| } |
|
|
| |
| const ROOF_STEPS: Array<{ size: number; y: number }> = [ |
| { size: 3.9, y: 2.175 }, |
| { size: 3.1, y: 2.525 }, |
| { size: 2.3, y: 2.875 }, |
| { size: 1.5, y: 3.225 }, |
| { size: 0.7, y: 3.575 }, |
| ]; |
|
|
| type HouseBuildingProps = { |
| house: HouseSnapshot; |
| isSelected: boolean; |
| onSelect: () => void; |
| }; |
|
|
| function HouseBuilding({ house, isSelected, onSelect }: HouseBuildingProps) { |
| const isComplete = house.state === "completed"; |
| const isDestroyed = house.state === "destroyed"; |
| const isDamaged = house.hp < house.max_hp; |
| const buildRatio = Math.min(1, house.build_progress / 10); |
| const hpRatio = house.max_hp > 0 ? Math.max(0, house.hp / house.max_hp) : 0; |
| const occupancy = `${house.occupant_ids.length}/${house.capacity}`; |
| const textures = getHouseTextures(); |
| const handleSelect = (event: ThreeEvent<MouseEvent>) => { |
| event.stopPropagation(); |
| onSelect(); |
| }; |
|
|
| return ( |
| <group position={[house.position.x, house.position.y, house.position.z]} onClick={handleSelect}> |
| {isComplete ? ( |
| // Keyed so a house transitioning building -> completed remounts these |
| // meshes instead of reusing the frame's material (which would leak its |
| // transparent/opacity/color onto the wall and make it render wrong). |
| <group key="completed"> |
| <mesh castShadow receiveShadow position={[0, 1.0, 0]}> |
| <boxGeometry args={[3.2, 2.0, 3.2]} /> |
| <meshStandardMaterial map={textures.wall} roughness={0.85} /> |
| </mesh> |
| {ROOF_STEPS.map((step) => ( |
| <mesh key={step.size} castShadow receiveShadow position={[0, step.y, 0]}> |
| <boxGeometry args={[step.size, 0.35, step.size]} /> |
| <meshStandardMaterial map={textures.roof} roughness={0.8} /> |
| </mesh> |
| ))} |
| <mesh position={[0, 0.7, 1.62]}> |
| <boxGeometry args={[0.8, 1.4, 0.08]} /> |
| <meshStandardMaterial map={textures.door} roughness={0.9} /> |
| </mesh> |
| {[-1.05, 1.05].map((x) => ( |
| <mesh key={x} position={[x, 1.25, 1.62]}> |
| <boxGeometry args={[0.55, 0.55, 0.08]} /> |
| <meshStandardMaterial map={textures.window} roughness={0.4} /> |
| </mesh> |
| ))} |
| </group> |
| ) : isDestroyed ? ( |
| <group key="destroyed"> |
| {/* Charred wall stumps where the house stood. */} |
| <mesh castShadow receiveShadow position={[-0.95, 0.34, -1.05]} rotation={[0, 0.12, 0]}> |
| <boxGeometry args={[1.3, 0.68, 1.1]} /> |
| <meshStandardMaterial map={textures.wall} color="#5d5149" roughness={0.95} /> |
| </mesh> |
| <mesh castShadow receiveShadow position={[1.1, 0.52, -0.85]} rotation={[0, -0.2, 0.06]}> |
| <boxGeometry args={[0.95, 1.04, 0.9]} /> |
| <meshStandardMaterial map={textures.wall} color="#4f443c" roughness={0.95} /> |
| </mesh> |
| <mesh castShadow receiveShadow position={[-1.25, 0.22, 0.9]} rotation={[0, 0.4, 0]}> |
| <boxGeometry args={[0.85, 0.44, 0.7]} /> |
| <meshStandardMaterial map={textures.wall} color="#564b42" roughness={0.95} /> |
| </mesh> |
| {/* Collapsed roof slab leaning into the rubble. */} |
| <mesh castShadow receiveShadow position={[0.45, 0.32, 0.75]} rotation={[0.4, 0.5, 0.12]}> |
| <boxGeometry args={[1.7, 0.26, 1.7]} /> |
| <meshStandardMaterial map={textures.roof} color="#544b41" roughness={0.92} /> |
| </mesh> |
| {/* Burnt corner posts. */} |
| {[ |
| [-1.45, 0.55, -1.45, 1.1], |
| [1.45, 0.4, -1.45, 0.8], |
| [-1.45, 0.3, 1.45, 0.6], |
| ].map(([x, y, z, height]) => ( |
| <mesh key={`${x}:${z}`} castShadow position={[x, y, z]} rotation={[0, 0, 0.08]}> |
| <boxGeometry args={[0.14, height, 0.14]} /> |
| <meshStandardMaterial color="#2e2723" roughness={0.95} /> |
| </mesh> |
| ))} |
| {/* Rubble scatter. */} |
| {[ |
| [0.2, -0.4, 0.34], |
| [-0.7, 0.5, 0.26], |
| [0.9, 1.1, 0.3], |
| [-0.2, 1.2, 0.22], |
| ].map(([x, z, size]) => ( |
| <mesh key={`${x}:${z}`} castShadow position={[x, size / 2, z]} rotation={[0, x + z, 0]}> |
| <boxGeometry args={[size, size, size]} /> |
| <meshStandardMaterial color="#6b5d51" roughness={0.95} /> |
| </mesh> |
| ))} |
| </group> |
| ) : ( |
| <group key="building"> |
| {/* Frame rising with build progress. */} |
| <mesh castShadow receiveShadow position={[0, Math.max(0.15, buildRatio), 0]}> |
| <boxGeometry args={[3.2, Math.max(0.3, buildRatio * 2.0), 3.2]} /> |
| <meshStandardMaterial |
| map={textures.wall} |
| color="#e8d2ab" |
| roughness={0.9} |
| transparent |
| opacity={0.85} |
| /> |
| </mesh> |
| {[-1.45, 1.45].flatMap((x) => |
| [-1.45, 1.45].map((z) => ( |
| <mesh key={`${x}:${z}`} castShadow position={[x, 1.1, z]}> |
| <boxGeometry args={[0.14, 2.2, 0.14]} /> |
| <meshStandardMaterial color="#8a6437" roughness={0.9} /> |
| </mesh> |
| )), |
| )} |
| </group> |
| )} |
| |
| <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.035, 0]}> |
| <ringGeometry args={[2.2, 2.55, 32]} /> |
| <meshBasicMaterial |
| color={isSelected ? "#9fd8ff" : isDestroyed ? "#a3786a" : isComplete ? "#f2d9a0" : "#ffd07a"} |
| transparent |
| opacity={isSelected ? 0.85 : isDestroyed ? 0.26 : 0.38} |
| /> |
| </mesh> |
| |
| <Html |
| center |
| distanceFactor={26} |
| position={[0, isComplete ? 4.2 : isDestroyed ? 2.2 : 2.9, 0]} |
| className="npcLabel" |
| zIndexRange={[7, 0]} |
| > |
| <button |
| type="button" |
| className={`houseTag ${isDestroyed ? "houseTagDestroyed" : isComplete ? "" : "houseTagBuilding"} ${isSelected ? "houseTagSelected" : ""}`} |
| 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 house ${house.id}`} |
| > |
| <span className="houseTagTitle"> |
| {isDestroyed ? "💥" : isComplete ? "🏠" : "🚧"}{" "} |
| {isDestroyed ? "Ruins" : isComplete ? `Home ${occupancy}` : "Building"} |
| </span> |
| {isDestroyed ? null : isComplete ? ( |
| isDamaged ? ( |
| <span className="npcBar houseHpBar"> |
| <span |
| className="npcBarFill" |
| style={{ |
| width: `${hpRatio * 100}%`, |
| background: hpRatio > 0.5 ? "#6fd96a" : hpRatio > 0.25 ? "#f0c34e" : "#f06a4e", |
| }} |
| /> |
| </span> |
| ) : null |
| ) : ( |
| <span className="npcBar houseHpBar"> |
| <span |
| className="npcBarFill" |
| style={{ width: `${buildRatio * 100}%`, background: "#ffd07a" }} |
| /> |
| </span> |
| )} |
| </button> |
| </Html> |
| </group> |
| ); |
| } |
|
|
| type TerrainProps = { |
| width: number; |
| depth: number; |
| color: string; |
| }; |
|
|
| const VOXEL_SIZE = 1; |
| const VOXEL_HEIGHT = 0.6; |
| |
| |
| |
| const EDGE_MARGIN = 3; |
|
|
| function Terrain({ width, depth, color }: TerrainProps) { |
| const meshRef = useRef<InstancedMesh>(null); |
| const cols = Math.max(1, Math.round(width / VOXEL_SIZE)) + EDGE_MARGIN * 2; |
| const rows = Math.max(1, Math.round(depth / VOXEL_SIZE)) + EDGE_MARGIN * 2; |
| const count = cols * rows; |
| |
| |
| const renderWidth = cols * VOXEL_SIZE; |
| const renderDepth = rows * VOXEL_SIZE; |
|
|
| useLayoutEffect(() => { |
| const mesh = meshRef.current; |
| if (!mesh) { |
| return; |
| } |
| const base = new Color(color); |
| const dirt = new Color("#7a5a36"); |
| const shade = new Color(); |
| const matrix = new Matrix4(); |
| let index = 0; |
| for (let col = 0; col < cols; col += 1) { |
| for (let row = 0; row < rows; row += 1) { |
| const x = (col + 0.5) * VOXEL_SIZE - renderWidth / 2; |
| const z = (row + 0.5) * VOXEL_SIZE - renderDepth / 2; |
| const heightRoll = tileNoise(col, row, 1); |
| const tintRoll = tileNoise(col, row, 2); |
| |
| const lift = heightRoll > 0.94 ? 0.08 : heightRoll > 0.84 ? 0.04 : 0; |
| matrix.makeTranslation(x, lift - VOXEL_HEIGHT / 2, z); |
| mesh.setMatrixAt(index, matrix); |
| shade.copy(base); |
| shade.offsetHSL((tintRoll - 0.5) * 0.02, (heightRoll - 0.5) * 0.08, (tintRoll - 0.5) * 0.07); |
| if (tintRoll > 0.975) { |
| shade.lerp(dirt, 0.65); |
| } |
| mesh.setColorAt(index, shade); |
| index += 1; |
| } |
| } |
| mesh.instanceMatrix.needsUpdate = true; |
| if (mesh.instanceColor) { |
| mesh.instanceColor.needsUpdate = true; |
| } |
| }, [cols, rows, renderWidth, renderDepth, color]); |
|
|
| return ( |
| <group> |
| <instancedMesh key={count} ref={meshRef} args={[undefined, undefined, count]} receiveShadow> |
| <boxGeometry args={[VOXEL_SIZE, VOXEL_HEIGHT, VOXEL_SIZE]} /> |
| <meshStandardMaterial color="#ffffff" roughness={0.9} metalness={0.02} /> |
| </instancedMesh> |
| {/* dirt slab so the world edge reads as a floating voxel block */} |
| <mesh position={[0, -VOXEL_HEIGHT - 0.7, 0]}> |
| <boxGeometry args={[renderWidth, 1.6, renderDepth]} /> |
| <meshStandardMaterial color="#6b4a2c" roughness={0.95} /> |
| </mesh> |
| </group> |
| ); |
| } |
|
|
| function tileNoise(x: number, z: number, seed: number): number { |
| const value = Math.sin(x * 127.1 + z * 311.7 + seed * 74.7) * 43758.5453; |
| return value - Math.floor(value); |
| } |
|
|