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(); 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 ( {snapshot.entities.map((entity) => ( onSelectEntity(entity.id)} /> ))} {(snapshot.houses ?? []).map((house) => ( onSelectEntity(house.id)} /> ))} {(snapshot.countries ?? []).map((country) => ( ))} {(snapshot.resource_nodes ?? []).map((node) => ( onSelectEntity(node.id)} /> ))} {(snapshot.beasts ?? []).map((beast) => ( onSelectEntity(beast.id)} /> ))} ); } function CountryAssets({ country }: { country: CountrySnapshot }) { return ( <> {country.treasury ? : null} {country.cannon ? : null} > ); } function TreasuryMarker({ treasury, country }: { treasury: TreasurySnapshot; country: CountrySnapshot }) { const resources = treasury.resources; return ( {country.badge} Treasury W{resources.wood ?? 0} C{resources.coins ?? 0} ); } function CannonMarker({ cannon, country }: { cannon: CannonSnapshot; country: CountrySnapshot }) { return ( {country.badge} Cannon {cannon.operator_id ? "armed" : "no operator"} ); } // Stepped slabs read as voxels where a smooth cone would not. 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) => { event.stopPropagation(); onSelect(); }; return ( {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). {ROOF_STEPS.map((step) => ( ))} {[-1.05, 1.05].map((x) => ( ))} ) : isDestroyed ? ( {/* Charred wall stumps where the house stood. */} {/* Collapsed roof slab leaning into the rubble. */} {/* 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]) => ( ))} {/* 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]) => ( ))} ) : ( {/* Frame rising with build progress. */} {[-1.45, 1.45].flatMap((x) => [-1.45, 1.45].map((z) => ( )), )} )} { // 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}`} > {isDestroyed ? "💥" : isComplete ? "🏠" : "🚧"}{" "} {isDestroyed ? "Ruins" : isComplete ? `Home ${occupancy}` : "Building"} {isDestroyed ? null : isComplete ? ( isDamaged ? ( 0.5 ? "#6fd96a" : hpRatio > 0.25 ? "#f0c34e" : "#f06a4e", }} /> ) : null ) : ( )} ); } type TerrainProps = { width: number; depth: number; color: string; }; const VOXEL_SIZE = 1; const VOXEL_HEIGHT = 0.6; // Purely visual border (in tiles per side) so structures placed on the // playable edge still rest on ground instead of hanging into the void. // Gameplay bounds are unchanged — the backend never places anything here. const EDGE_MARGIN = 3; function Terrain({ width, depth, color }: TerrainProps) { const meshRef = useRef(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; // Rendered span including the border; keeps the grid centered on the origin // so playable coordinates from the backend still line up. 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); // Subtle steps only — keep the surface readable as flat ground. 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 ( {/* dirt slab so the world edge reads as a floating voxel block */} ); } 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); }