world-simulator / frontend /src /scene /WorldView.tsx
kikikita's picture
Refactor action handling and update UI elements
81070c7
Raw
History Blame Contribute Delete
15.5 kB
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>
);
}
// 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<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;
// 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<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;
// 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 (
<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);
}