Files
remoteturtle/client/src/components/Map3D.jsx

1415 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useRef, useMemo, useEffect, useState, useCallback } from 'react';
import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber';
import { OrbitControls, Grid, Text, Line } from '@react-three/drei';
import * as THREE from 'three';
import { useTurtleStore } from '../store/turtleStore';
// World interaction modes
const INTERACTION_MODE = {
LOOK: 'look', // Default orbit camera
MOVE: 'move', // Click block to send turtle there
BUILD: 'build', // Click face to place block preview
SELECT: 'select' // Click-drag area selection
};
// Texture mapping for Minecraft blocks
const TEXTURE_MAP = {
// Basic blocks
'minecraft:stone': 'stone',
'minecraft:cobblestone': 'cobblestone',
'minecraft:dirt': 'dirt',
'minecraft:grass_block': 'grass_block_side',
'minecraft:sand': 'sand',
'minecraft:red_sand': 'red_sand',
'minecraft:gravel': 'gravel',
'minecraft:bedrock': 'bedrock',
'minecraft:oak_log': 'oak_log',
'minecraft:oak_planks': 'oak_planks',
'minecraft:glass': 'glass',
// Logs
'minecraft:spruce_log': 'spruce_log',
'minecraft:birch_log': 'birch_log',
'minecraft:jungle_log': 'jungle_log',
'minecraft:acacia_log': 'acacia_log',
'minecraft:dark_oak_log': 'dark_oak_log',
'minecraft:mangrove_log': 'mangrove_log',
'minecraft:cherry_log': 'cherry_log',
// Planks
'minecraft:spruce_planks': 'spruce_planks',
'minecraft:birch_planks': 'birch_planks',
'minecraft:jungle_planks': 'jungle_planks',
'minecraft:acacia_planks': 'acacia_planks',
'minecraft:dark_oak_planks': 'dark_oak_planks',
// Stone variants
'minecraft:granite': 'granite',
'minecraft:polished_granite': 'polished_granite',
'minecraft:diorite': 'diorite',
'minecraft:polished_diorite': 'polished_diorite',
'minecraft:andesite': 'andesite',
'minecraft:polished_andesite': 'polished_andesite',
'minecraft:deepslate': 'deepslate',
'minecraft:cobbled_deepslate': 'cobbled_deepslate',
'minecraft:tuff': 'tuff',
'minecraft:calcite': 'calcite',
'minecraft:dripstone_block': 'dripstone_block',
'minecraft:smooth_stone': 'smooth_stone',
'minecraft:sandstone': 'sandstone',
'minecraft:red_sandstone': 'red_sandstone',
// Ores (overworld)
'minecraft:coal_ore': 'coal_ore',
'minecraft:iron_ore': 'iron_ore',
'minecraft:gold_ore': 'gold_ore',
'minecraft:diamond_ore': 'diamond_ore',
'minecraft:emerald_ore': 'emerald_ore',
'minecraft:redstone_ore': 'redstone_ore',
'minecraft:lapis_ore': 'lapis_ore',
'minecraft:copper_ore': 'copper_ore',
// Deepslate ores
'minecraft:deepslate_coal_ore': 'deepslate_coal_ore',
'minecraft:deepslate_iron_ore': 'deepslate_iron_ore',
'minecraft:deepslate_gold_ore': 'deepslate_gold_ore',
'minecraft:deepslate_diamond_ore': 'deepslate_diamond_ore',
'minecraft:deepslate_emerald_ore': 'deepslate_emerald_ore',
'minecraft:deepslate_redstone_ore': 'deepslate_redstone_ore',
'minecraft:deepslate_lapis_ore': 'deepslate_lapis_ore',
'minecraft:deepslate_copper_ore': 'deepslate_copper_ore',
// Nether ores
'minecraft:nether_gold_ore': 'nether_gold_ore',
'minecraft:nether_quartz_ore': 'nether_quartz_ore',
'minecraft:ancient_debris': 'ancient_debris_side',
// Special blocks
'minecraft:obsidian': 'obsidian',
'minecraft:crying_obsidian': 'crying_obsidian',
'minecraft:netherrack': 'netherrack',
'minecraft:soul_sand': 'soul_sand',
'minecraft:soul_soil': 'soul_soil',
'minecraft:glowstone': 'glowstone',
'minecraft:end_stone': 'end_stone',
'minecraft:water': 'water_still',
'minecraft:lava': 'lava_still',
'minecraft:magma_block': 'magma',
'minecraft:amethyst_block': 'amethyst_block',
'minecraft:budding_amethyst': 'budding_amethyst',
'minecraft:moss_block': 'moss_block',
'minecraft:clay': 'clay',
'minecraft:terracotta': 'terracotta',
'minecraft:packed_ice': 'packed_ice',
'minecraft:blue_ice': 'blue_ice',
'minecraft:ice': 'ice',
'minecraft:snow_block': 'snow',
'minecraft:mycelium': 'mycelium_side',
'minecraft:podzol': 'podzol_side',
// Building blocks
'minecraft:bricks': 'bricks',
'minecraft:stone_bricks': 'stone_bricks',
'minecraft:cracked_stone_bricks': 'cracked_stone_bricks',
'minecraft:mossy_cobblestone': 'mossy_cobblestone',
'minecraft:mossy_stone_bricks': 'mossy_stone_bricks',
'minecraft:prismarine': 'prismarine',
'minecraft:dark_prismarine': 'dark_prismarine',
'minecraft:purpur_block': 'purpur_block',
'minecraft:nether_bricks': 'nether_bricks',
'minecraft:red_nether_bricks': 'red_nether_bricks',
'minecraft:quartz_block': 'quartz_block_side',
'minecraft:basalt': 'basalt_side',
'minecraft:polished_basalt': 'polished_basalt_side',
'minecraft:blackstone': 'blackstone',
'minecraft:polished_blackstone': 'polished_blackstone',
'minecraft:gilded_blackstone': 'gilded_blackstone',
// Functional blocks
'minecraft:crafting_table': 'crafting_table_front',
'minecraft:furnace': 'furnace_front',
'minecraft:chest': 'barrel_side',
'minecraft:barrel': 'barrel_side',
'minecraft:bookshelf': 'bookshelf',
'minecraft:tnt': 'tnt_side',
'minecraft:spawner': 'spawner',
};
// Blocks with different top/side/bottom textures
const MULTI_FACE_BLOCKS = {
'minecraft:grass_block': { top: 'grass_block_top', side: 'grass_block_side', bottom: 'dirt' },
'minecraft:mycelium': { top: 'mycelium_top', side: 'mycelium_side', bottom: 'dirt' },
'minecraft:podzol': { top: 'podzol_top', side: 'podzol_side', bottom: 'dirt' },
'minecraft:oak_log': { top: 'oak_log_top', side: 'oak_log', bottom: 'oak_log_top' },
'minecraft:spruce_log': { top: 'spruce_log_top', side: 'spruce_log', bottom: 'spruce_log_top' },
'minecraft:birch_log': { top: 'birch_log_top', side: 'birch_log', bottom: 'birch_log_top' },
'minecraft:jungle_log': { top: 'jungle_log_top', side: 'jungle_log', bottom: 'jungle_log_top' },
'minecraft:acacia_log': { top: 'acacia_log_top', side: 'acacia_log', bottom: 'acacia_log_top' },
'minecraft:dark_oak_log': { top: 'dark_oak_log_top', side: 'dark_oak_log', bottom: 'dark_oak_log_top' },
'minecraft:sandstone': { top: 'sandstone_top', side: 'sandstone', bottom: 'sandstone_bottom' },
'minecraft:red_sandstone': { top: 'red_sandstone_top', side: 'red_sandstone', bottom: 'red_sandstone_bottom' },
'minecraft:crafting_table': { top: 'crafting_table_top', side: 'crafting_table_front', bottom: 'oak_planks' },
'minecraft:furnace': { top: 'furnace_top', side: 'furnace_front', bottom: 'furnace_top' },
'minecraft:tnt': { top: 'tnt_top', side: 'tnt_side', bottom: 'tnt_bottom' },
'minecraft:quartz_block': { top: 'quartz_block_top', side: 'quartz_block_side', bottom: 'quartz_block_bottom' },
'minecraft:basalt': { top: 'basalt_top', side: 'basalt_side', bottom: 'basalt_top' },
'minecraft:barrel': { top: 'barrel_top', side: 'barrel_side', bottom: 'barrel_bottom' },
};
// Function to get texture URL from a Minecraft texture service
function getTextureURL(blockName) {
const textureName = TEXTURE_MAP[blockName] || 'stone';
// Using a public Minecraft texture API
return `https://raw.githubusercontent.com/InventivetalentDev/minecraft-assets/1.20.4/assets/minecraft/textures/block/${textureName}.png`;
}
// Custom hook to load multiple textures (including multi-face)
function useMinecraftTextures(blocks) {
const [textures, setTextures] = useState({});
const [multiFaceTextures, setMultiFaceTextures] = useState({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const textureLoader = new THREE.TextureLoader();
const uniqueBlocks = [...new Set(blocks.map(b => b.name))];
const loadedTextures = {};
const loadedMultiFace = {};
let totalToLoad = 0;
let loadedCount = 0;
if (uniqueBlocks.length === 0) {
setLoading(false);
return;
}
// Collect all unique texture names to load
const textureNames = new Set();
uniqueBlocks.forEach(blockName => {
const multiFace = MULTI_FACE_BLOCKS[blockName];
if (multiFace) {
textureNames.add(multiFace.top);
textureNames.add(multiFace.side);
textureNames.add(multiFace.bottom);
} else {
const textureName = TEXTURE_MAP[blockName] || null;
if (textureName) textureNames.add(textureName);
}
});
totalToLoad = textureNames.size;
if (totalToLoad === 0) {
setLoading(false);
return;
}
const loadedTexturesByName = {};
const checkDone = () => {
loadedCount++;
if (loadedCount >= totalToLoad) {
// Build per-block texture maps
uniqueBlocks.forEach(blockName => {
const multiFace = MULTI_FACE_BLOCKS[blockName];
if (multiFace) {
const top = loadedTexturesByName[multiFace.top];
const side = loadedTexturesByName[multiFace.side];
const bottom = loadedTexturesByName[multiFace.bottom];
if (top && side && bottom) {
loadedMultiFace[blockName] = [side, side, top, bottom, side, side];
} else {
// Fallback to single texture
loadedTextures[blockName] = side || top || bottom || null;
}
} else {
const textureName = TEXTURE_MAP[blockName];
if (textureName && loadedTexturesByName[textureName]) {
loadedTextures[blockName] = loadedTexturesByName[textureName];
}
}
});
setTextures(loadedTextures);
setMultiFaceTextures(loadedMultiFace);
setLoading(false);
}
};
textureNames.forEach(textureName => {
const url = `https://raw.githubusercontent.com/InventivetalentDev/minecraft-assets/1.20.4/assets/minecraft/textures/block/${textureName}.png`;
textureLoader.load(
url,
(texture) => {
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.colorSpace = THREE.SRGBColorSpace;
loadedTexturesByName[textureName] = texture;
checkDone();
},
undefined,
() => {
checkDone();
}
);
});
}, [blocks]);
return { textures, multiFaceTextures, loading };
}
// Minecraft-style turtle model
function TurtleModel({ turtle, isSelected, onClick }) {
const groupRef = useRef();
const { position, facing, mode, state } = turtle;
const activeMode = state || mode || 'idle';
useFrame((state) => {
if (groupRef.current && isSelected) {
groupRef.current.children[0].position.y = Math.sin(state.clock.elapsedTime * 2) * 0.05;
}
});
if (!position) return null;
// Calculate rotation based on facing (0=North(-Z), 1=East(+X), 2=South(+Z), 3=West(-X))
const facingRotations = [Math.PI, -Math.PI/2, 0, Math.PI/2];
const rotation = facing !== undefined ? [0, facingRotations[facing], 0] : [0, 0, 0];
const color = activeMode === 'mining' ? '#4ade80' :
activeMode === 'exploring' ? '#60a5fa' :
activeMode === 'returning' || activeMode === 'goHome' ? '#f59e0b' :
activeMode === 'refueling' ? '#ef4444' :
activeMode === 'farming' ? '#22c55e' :
activeMode === 'dumpInventory' || activeMode === 'dumping' ? '#a855f7' :
activeMode === 'moving' ? '#06b6d4' :
'#9ca3af';
return (
<group ref={groupRef} position={[position.x, position.y, position.z]} rotation={rotation} onClick={onClick}>
<group>
{/* Main shell body - rounded edges */}
<mesh position={[0, 0.2, 0]} castShadow>
<boxGeometry args={[0.9, 0.6, 1.0]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={isSelected ? 0.5 : 0.25}
roughness={0.3}
metalness={0.2}
/>
</mesh>
{/* Shell pattern - hexagonal plates */}
<mesh position={[0, 0.5, 0]}>
<boxGeometry args={[0.5, 0.05, 0.6]} />
<meshStandardMaterial color="#333" roughness={0.4} metalness={0.6} />
</mesh>
<mesh position={[-0.25, 0.5, 0.3]}>
<boxGeometry args={[0.3, 0.05, 0.3]} />
<meshStandardMaterial color="#333" roughness={0.4} metalness={0.6} />
</mesh>
<mesh position={[0.25, 0.5, 0.3]}>
<boxGeometry args={[0.3, 0.05, 0.3]} />
<meshStandardMaterial color="#333" roughness={0.4} metalness={0.6} />
</mesh>
<mesh position={[-0.25, 0.5, -0.3]}>
<boxGeometry args={[0.3, 0.05, 0.3]} />
<meshStandardMaterial color="#333" roughness={0.4} metalness={0.6} />
</mesh>
<mesh position={[0.25, 0.5, -0.3]}>
<boxGeometry args={[0.3, 0.05, 0.3]} />
<meshStandardMaterial color="#333" roughness={0.4} metalness={0.6} />
</mesh>
{/* Head with visor */}
<mesh position={[0, 0.15, 0.6]} castShadow>
<boxGeometry args={[0.55, 0.4, 0.35]} />
<meshStandardMaterial color={color} roughness={0.3} metalness={0.1} />
</mesh>
{/* Visor/Face plate */}
<mesh position={[0, 0.2, 0.78]}>
<boxGeometry args={[0.45, 0.25, 0.02]} />
<meshStandardMaterial
color="#1a1a1a"
emissive="#111"
roughness={0.1}
metalness={0.9}
/>
</mesh>
{/* Eyes/LED indicators */}
<mesh position={[-0.13, 0.22, 0.79]}>
<boxGeometry args={[0.08, 0.08, 0.01]} />
<meshStandardMaterial
color="#00ff00"
emissive={activeMode === 'mining' ? "#00ff00" : activeMode === 'returning' || activeMode === 'goHome' ? "#ffaa00" : "#00aaff"}
emissiveIntensity={1.5}
toneMapped={false}
/>
</mesh>
<mesh position={[0.13, 0.22, 0.79]}>
<boxGeometry args={[0.08, 0.08, 0.01]} />
<meshStandardMaterial
color="#00ff00"
emissive={activeMode === 'mining' ? "#00ff00" : activeMode === 'returning' || activeMode === 'goHome' ? "#ffaa00" : "#00aaff"}
emissiveIntensity={1.5}
toneMapped={false}
/>
</mesh>
{/* LED glow */}
<pointLight
position={[0, 0.2, 0.85]}
color={activeMode === 'mining' ? "#00ff00" : activeMode === 'returning' || activeMode === 'goHome' ? "#ffaa00" : "#00aaff"}
intensity={0.5}
distance={2}
/>
{/* Legs - more mechanical looking */}
<mesh position={[-0.32, -0.15, 0.3]} castShadow>
<boxGeometry args={[0.18, 0.25, 0.18]} />
<meshStandardMaterial color="#555" roughness={0.4} metalness={0.7} />
</mesh>
<mesh position={[0.32, -0.15, 0.3]} castShadow>
<boxGeometry args={[0.18, 0.25, 0.18]} />
<meshStandardMaterial color="#555" roughness={0.4} metalness={0.7} />
</mesh>
<mesh position={[-0.32, -0.15, -0.3]} castShadow>
<boxGeometry args={[0.18, 0.25, 0.18]} />
<meshStandardMaterial color="#555" roughness={0.4} metalness={0.7} />
</mesh>
<mesh position={[0.32, -0.15, -0.3]} castShadow>
<boxGeometry args={[0.18, 0.25, 0.18]} />
<meshStandardMaterial color="#555" roughness={0.4} metalness={0.7} />
</mesh>
{/* Feet */}
<mesh position={[-0.32, -0.35, 0.3]}>
<boxGeometry args={[0.22, 0.08, 0.25]} />
<meshStandardMaterial color="#444" roughness={0.5} metalness={0.6} />
</mesh>
<mesh position={[0.32, -0.35, 0.3]}>
<boxGeometry args={[0.22, 0.08, 0.25]} />
<meshStandardMaterial color="#444" roughness={0.5} metalness={0.6} />
</mesh>
<mesh position={[-0.32, -0.35, -0.3]}>
<boxGeometry args={[0.22, 0.08, 0.25]} />
<meshStandardMaterial color="#444" roughness={0.5} metalness={0.6} />
</mesh>
<mesh position={[0.32, -0.35, -0.3]}>
<boxGeometry args={[0.22, 0.08, 0.25]} />
<meshStandardMaterial color="#444" roughness={0.5} metalness={0.6} />
</mesh>
{/* Tool/Mining arm - only show when mining */}
{activeMode === 'mining' && (
<>
<mesh position={[0, 0.1, 0.7]}>
<boxGeometry args={[0.1, 0.1, 0.3]} />
<meshStandardMaterial color="#888" metalness={0.8} roughness={0.2} />
</mesh>
<mesh position={[0, 0.1, 0.9]}>
<boxGeometry args={[0.15, 0.15, 0.1]} />
<meshStandardMaterial
color="#ff6b00"
emissive="#ff6b00"
emissiveIntensity={0.5}
metalness={0.7}
/>
</mesh>
</>
)}
{/* Antenna */}
<mesh position={[0, 0.5, -0.2]}>
<cylinderGeometry args={[0.02, 0.02, 0.4, 8]} />
<meshStandardMaterial color="#666" metalness={0.8} />
</mesh>
<mesh position={[0, 0.72, -0.2]}>
<sphereGeometry args={[0.06, 8, 8]} />
<meshStandardMaterial
color="#ff0000"
emissive="#ff0000"
emissiveIntensity={0.8}
/>
</mesh>
{/* Ambient glow around turtle */}
<pointLight
position={[0, 0.2, 0]}
color={color}
intensity={isSelected ? 1 : 0.3}
distance={3}
/>
</group>
{/* Selection indicator */}
{isSelected && (
<>
<mesh position={[0, 1.3, 0]}>
<coneGeometry args={[0.25, 0.4, 4]} />
<meshStandardMaterial
color="#ffffff"
emissive="#ffffff"
emissiveIntensity={1.2}
transparent
opacity={0.8}
/>
</mesh>
{/* Animated selection ring */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.4, 0]}>
<ringGeometry args={[1.1, 1.3, 64]} />
<meshBasicMaterial
color={color}
side={THREE.DoubleSide}
transparent
opacity={0.6}
/>
</mesh>
</>
)}
{/* Turtle ID label */}
<Text
position={[0, 1.6, 0]}
fontSize={0.35}
color="white"
anchorX="center"
anchorY="middle"
>
🐢 {turtle.turtleID}
</Text>
</group>
);
}
// Path trail component
function PathTrail({ turtle }) {
const { position, homePosition } = turtle;
if (!position || !homePosition) return null;
const points = [
new THREE.Vector3(position.x, position.y, position.z),
new THREE.Vector3(homePosition.x, homePosition.y, homePosition.z)
];
return (
<Line
points={points}
color="#60a5fa"
lineWidth={2}
transparent
opacity={0.3}
dashed
dashScale={2}
/>
);
}
// Get color and texture info for block type
function getBlockAppearance(blockName) {
if (!blockName) return { color: '#888', pattern: 'solid' };
const name = blockName.toLowerCase();
// Ores - bright colors with sparkle
if (name.includes('diamond')) return { color: '#00ffff', pattern: 'ore', emissive: '#00ffff', intensity: 0.3 };
if (name.includes('emerald')) return { color: '#00ff00', pattern: 'ore', emissive: '#00ff00', intensity: 0.3 };
if (name.includes('gold')) return { color: '#ffd700', pattern: 'ore', emissive: '#ffd700', intensity: 0.2 };
if (name.includes('iron')) return { color: '#d4d4d4', pattern: 'ore' };
if (name.includes('coal')) return { color: '#1a1a1a', pattern: 'ore' };
if (name.includes('redstone')) return { color: '#ff0000', pattern: 'ore', emissive: '#ff0000', intensity: 0.2 };
if (name.includes('lapis')) return { color: '#1e40af', pattern: 'ore' };
if (name.includes('copper')) return { color: '#ff8c00', pattern: 'ore' };
// Common blocks - textured
if (name.includes('stone') && !name.includes('cobble')) return { color: '#7f7f7f', pattern: 'stone' };
if (name.includes('cobblestone')) return { color: '#808080', pattern: 'cobble' };
if (name.includes('dirt')) return { color: '#8b4513', pattern: 'dirt' };
if (name.includes('grass')) return { color: '#228b22', pattern: 'grass' };
if (name.includes('sand')) return { color: '#f4a460', pattern: 'sand' };
if (name.includes('gravel')) return { color: '#808080', pattern: 'gravel' };
if (name.includes('bedrock')) return { color: '#222', pattern: 'solid' };
if (name.includes('obsidian')) return { color: '#1a0033', pattern: 'solid' };
if (name.includes('netherrack')) return { color: '#8b0000', pattern: 'netherrack' };
return { color: '#666666', pattern: 'solid' };
}
// WorldBlocks component using chunked InstancedMesh rendering for performance
function WorldBlocks({ blocks, interactionMode, selectedTurtleId, onBlockClick }) {
const [hoveredBlock, setHoveredBlock] = useState(null);
const { textures, multiFaceTextures, loading } = useMinecraftTextures(blocks);
// Chunk size (16x16 like Minecraft)
const CHUNK_SIZE = 16;
// Group blocks into chunks and by block type within each chunk
const chunks = useMemo(() => {
const chunkMap = new Map();
blocks.forEach(block => {
const chunkX = Math.floor(block.x / CHUNK_SIZE);
const chunkZ = Math.floor(block.z / CHUNK_SIZE);
const chunkKey = `${chunkX},${chunkZ}`;
if (!chunkMap.has(chunkKey)) {
chunkMap.set(chunkKey, { key: chunkKey, chunkX, chunkZ, blocksByType: new Map() });
}
const chunk = chunkMap.get(chunkKey);
if (!chunk.blocksByType.has(block.name)) {
chunk.blocksByType.set(block.name, []);
}
chunk.blocksByType.get(block.name).push(block);
});
return Array.from(chunkMap.values());
}, [blocks]);
if (loading) {
return <Text position={[0, 5, 0]} fontSize={0.5} color="white">Loading textures...</Text>;
}
return (
<group>
{chunks.map(chunk => (
<ChunkMesh
key={chunk.key}
chunk={chunk}
textures={textures}
multiFaceTextures={multiFaceTextures}
hoveredBlock={hoveredBlock}
setHoveredBlock={setHoveredBlock}
interactionMode={interactionMode}
selectedTurtleId={selectedTurtleId}
onBlockClick={onBlockClick}
/>
))}
{/* Hover label overlay */}
{hoveredBlock && (
<Text
position={[hoveredBlock.x, hoveredBlock.y + 1.0, hoveredBlock.z]}
fontSize={0.3}
color="white"
anchorX="center"
anchorY="middle"
outlineWidth={0.05}
outlineColor="#000000"
>
{hoveredBlock.name.replace('minecraft:', '').replace(/_/g, ' ').toUpperCase()}
{'\n'}
{`(${hoveredBlock.x}, ${hoveredBlock.y}, ${hoveredBlock.z})`}
</Text>
)}
</group>
);
}
// Individual chunk rendered with InstancedMesh per block type
function ChunkMesh({ chunk, textures, multiFaceTextures, hoveredBlock, setHoveredBlock, interactionMode, selectedTurtleId, onBlockClick }) {
return (
<group>
{Array.from(chunk.blocksByType.entries()).map(([blockName, blockList]) => (
<BlockTypeInstances
key={`${chunk.key}-${blockName}`}
blockName={blockName}
blockList={blockList}
textures={textures}
multiFaceTextures={multiFaceTextures}
hoveredBlock={hoveredBlock}
setHoveredBlock={setHoveredBlock}
interactionMode={interactionMode}
selectedTurtleId={selectedTurtleId}
onBlockClick={onBlockClick}
/>
))}
</group>
);
}
// InstancedMesh for all blocks of one type within a chunk
function BlockTypeInstances({ blockName, blockList, textures, multiFaceTextures, hoveredBlock, setHoveredBlock, interactionMode, selectedTurtleId, onBlockClick }) {
const meshRef = useRef();
const appearance = useMemo(() => getBlockAppearance(blockName), [blockName]);
const texture = textures[blockName];
const multiFace = multiFaceTextures[blockName];
const tempMatrix = useMemo(() => new THREE.Matrix4(), []);
const tempColor = useMemo(() => new THREE.Color(), []);
// Set instance matrices
useEffect(() => {
if (!meshRef.current) return;
blockList.forEach((block, i) => {
tempMatrix.setPosition(block.x, block.y, block.z);
meshRef.current.setMatrixAt(i, tempMatrix);
// Default color (white for textured, appearance color for untextured)
tempColor.set(texture || multiFace ? '#ffffff' : appearance.color);
meshRef.current.setColorAt(i, tempColor);
});
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) meshRef.current.instanceColor.needsUpdate = true;
}, [blockList, texture, multiFace, appearance, tempMatrix, tempColor]);
// Update hover highlighting
useEffect(() => {
if (!meshRef.current || !meshRef.current.instanceColor) return;
blockList.forEach((block, i) => {
const isHovered = hoveredBlock &&
hoveredBlock.x === block.x &&
hoveredBlock.y === block.y &&
hoveredBlock.z === block.z;
if (isHovered) {
tempColor.set('#ffffff');
meshRef.current.setColorAt(i, tempColor);
} else {
tempColor.set(texture || multiFace ? '#ffffff' : appearance.color);
meshRef.current.setColorAt(i, tempColor);
}
});
meshRef.current.instanceColor.needsUpdate = true;
}, [hoveredBlock, blockList, texture, multiFace, appearance, tempColor]);
const handlePointerMove = useCallback((e) => {
e.stopPropagation();
const instanceId = e.instanceId;
if (instanceId !== undefined && instanceId < blockList.length) {
setHoveredBlock(blockList[instanceId]);
}
}, [blockList, setHoveredBlock]);
const handlePointerOut = useCallback(() => {
setHoveredBlock(null);
}, [setHoveredBlock]);
const handleClick = useCallback((e) => {
e.stopPropagation();
const instanceId = e.instanceId;
if (instanceId !== undefined && instanceId < blockList.length) {
const block = blockList[instanceId];
if (interactionMode === INTERACTION_MODE.MOVE && selectedTurtleId && onBlockClick) {
// In MOVE mode, click block surface to send turtle to adjacent position
const normal = e.face?.normal;
const target = {
x: block.x + (normal?.x || 0),
y: block.y + (normal?.y || 0),
z: block.z + (normal?.z || 0)
};
onBlockClick({ type: 'move', target, block });
} else if (interactionMode === INTERACTION_MODE.BUILD && onBlockClick) {
const normal = e.face?.normal;
const target = {
x: block.x + (normal?.x || 0),
y: block.y + (normal?.y || 0),
z: block.z + (normal?.z || 0)
};
onBlockClick({ type: 'build', target, block });
} else if (interactionMode === INTERACTION_MODE.SELECT && onBlockClick) {
onBlockClick({ type: 'select', block });
}
}
}, [blockList, interactionMode, selectedTurtleId, onBlockClick]);
// For multi-face blocks, we can't easily use InstancedMesh with different material per face,
// so we fall back to individual meshes for those (they're fewer — grass, logs, etc.)
if (multiFace) {
return (
<group>
{blockList.map((block) => {
const blockKey = `${block.x},${block.y},${block.z}`;
const isHovered = hoveredBlock &&
hoveredBlock.x === block.x &&
hoveredBlock.y === block.y &&
hoveredBlock.z === block.z;
return (
<mesh
key={blockKey}
position={[block.x, block.y, block.z]}
onPointerOver={(e) => { e.stopPropagation(); setHoveredBlock(block); }}
onPointerOut={() => setHoveredBlock(null)}
onClick={(e) => {
e.stopPropagation();
if (interactionMode === INTERACTION_MODE.MOVE && selectedTurtleId && onBlockClick) {
const normal = e.face?.normal;
onBlockClick({ type: 'move', target: { x: block.x + (normal?.x || 0), y: block.y + (normal?.y || 0), z: block.z + (normal?.z || 0) }, block });
} else if (interactionMode === INTERACTION_MODE.BUILD && onBlockClick) {
const normal = e.face?.normal;
onBlockClick({ type: 'build', target: { x: block.x + (normal?.x || 0), y: block.y + (normal?.y || 0), z: block.z + (normal?.z || 0) }, block });
} else if (interactionMode === INTERACTION_MODE.SELECT && onBlockClick) {
onBlockClick({ type: 'select', block });
}
}}
>
<boxGeometry args={[1.0, 1.0, 1.0]} />
{multiFace.map((faceTexture, i) => (
<meshStandardMaterial
key={i}
attach={`material-${i}`}
map={faceTexture}
color="#ffffff"
emissive={appearance.emissive || '#000000'}
emissiveIntensity={isHovered ? 0.25 : (appearance.intensity || 0.02)}
roughness={0.8}
metalness={0.0}
transparent
opacity={isHovered ? 0.98 : 0.9}
/>
))}
</mesh>
);
})}
</group>
);
}
return (
<instancedMesh
ref={meshRef}
args={[null, null, blockList.length]}
onPointerMove={handlePointerMove}
onPointerOut={handlePointerOut}
onClick={handleClick}
>
<boxGeometry args={[1.0, 1.0, 1.0]} />
<meshStandardMaterial
map={texture}
color={texture ? '#ffffff' : appearance.color}
emissive={appearance.emissive || appearance.color}
emissiveIntensity={appearance.intensity || 0.05}
roughness={appearance.pattern === 'ore' ? 0.4 : 0.8}
metalness={appearance.pattern === 'ore' ? 0.2 : 0.0}
transparent
opacity={0.9}
/>
</instancedMesh>
);
}
// Path trail component (old version removed above)
function OldPathTrail({ turtle }) {
const { position, homePosition } = turtle;
if (!position || !homePosition) return null;
const points = [
new THREE.Vector3(position.x, position.y, position.z),
new THREE.Vector3(homePosition.x, homePosition.y, homePosition.z)
];
return (
<Line
points={points}
color="#60a5fa"
lineWidth={2}
dashed
dashScale={2}
transparent
opacity={0.5}
/>
);
}
// Home marker component
function HomeMarker({ position }) {
if (!position) return null;
return (
<group position={[position.x, position.y, position.z]}>
<mesh>
<cylinderGeometry args={[1, 0.5, 0.5, 6]} />
<meshStandardMaterial color="#10b981" emissive="#10b981" emissiveIntensity={0.3} />
</mesh>
<Text
position={[0, 1.2, 0]}
fontSize={0.5}
color="#10b981"
anchorX="center"
anchorY="middle"
>
HOME
</Text>
</group>
);
}
// Mining area wireframe component
function MiningArea({ area, turtle, isSelected, onClick }) {
const meshRef = useRef();
const [hovered, setHovered] = useState(false);
// Calculate dimensions
const width = Math.abs(area.endX - area.startX) + 1;
const height = Math.abs(area.endY - area.startY) + 1;
const depth = Math.abs(area.endZ - area.startZ) + 1;
// Calculate center position
const centerX = (area.startX + area.endX) / 2;
const centerY = (area.startY + area.endY) / 2;
const centerZ = (area.startZ + area.endZ) / 2;
// Color based on turtle or status
const getColor = () => {
if (area.status === 'completed') return '#10b981'; // green
if (area.status === 'mining') return '#f59e0b'; // orange
if (turtle) return turtle.color || '#3b82f6'; // turtle color or blue
return '#6366f1'; // default purple
};
const color = getColor();
const opacity = isSelected ? 0.3 : hovered ? 0.2 : 0.1;
const lineWidth = isSelected ? 2.5 : hovered ? 2 : 1.5;
// Animate rotation slightly
useFrame((state) => {
if (meshRef.current && isSelected) {
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
}
});
return (
<group
position={[centerX, centerY, centerZ]}
onClick={onClick}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
ref={meshRef}
>
{/* Wireframe box */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(width, height, depth)]} />
<lineBasicMaterial color={color} linewidth={lineWidth} />
</lineSegments>
{/* Semi-transparent fill */}
<mesh>
<boxGeometry args={[width, height, depth]} />
<meshStandardMaterial
color={color}
transparent
opacity={opacity}
side={THREE.DoubleSide}
/>
</mesh>
{/* Label */}
<Text
position={[0, height / 2 + 1, 0]}
fontSize={0.5}
color={color}
anchorX="center"
anchorY="middle"
>
{area.areaName || `Area ${area.areaID}`}
</Text>
{/* Dimensions label */}
<Text
position={[0, height / 2 + 0.3, 0]}
fontSize={0.3}
color="white"
anchorX="center"
anchorY="middle"
>
{width}×{height}×{depth}
</Text>
{/* Status indicator */}
{area.status === 'mining' && (
<mesh position={[0, height / 2 + 1.8, 0]}>
<sphereGeometry args={[0.2, 16, 16]} />
<meshStandardMaterial color="#f59e0b" emissive="#f59e0b" emissiveIntensity={0.8} />
</mesh>
)}
</group>
);
}
// Player marker component
function PlayerMarker({ player }) {
const meshRef = useRef();
// Bobbing animation
useFrame((state) => {
if (meshRef.current) {
meshRef.current.position.y = player.position.y + Math.sin(state.clock.elapsedTime * 2) * 0.1;
}
});
return (
<group position={[player.position.x, player.position.y, player.position.z]}>
{/* Player head (Steve-like) */}
<mesh ref={meshRef} position={[0, 1, 0]}>
<boxGeometry args={[0.6, 0.6, 0.6]} />
<meshStandardMaterial
color="#8b7355"
emissive="#4a90e2"
emissiveIntensity={0.3}
/>
</mesh>
{/* Body */}
<mesh position={[0, 0.2, 0]}>
<boxGeometry args={[0.6, 0.9, 0.3]} />
<meshStandardMaterial color="#4a90e2" />
</mesh>
{/* Glow effect */}
<pointLight color="#4a90e2" intensity={1} distance={3} />
{/* Label */}
<Text
position={[0, 2, 0]}
fontSize={0.4}
color="#4a90e2"
anchorX="center"
anchorY="middle"
outlineWidth={0.05}
outlineColor="#000000"
>
Player {player.playerID}
</Text>
</group>
);
}
// Move target preview (ghost block showing where turtle will go)
function MoveTargetPreview({ position }) {
const meshRef = useRef();
useFrame((state) => {
if (meshRef.current) {
meshRef.current.position.y = position.y + Math.sin(state.clock.elapsedTime * 3) * 0.1;
meshRef.current.material.opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
}
});
return (
<group>
<mesh ref={meshRef} position={[position.x, position.y, position.z]}>
<boxGeometry args={[0.8, 0.8, 0.8]} />
<meshStandardMaterial
color="#3b82f6"
transparent
opacity={0.4}
emissive="#3b82f6"
emissiveIntensity={0.5}
/>
</mesh>
<Text
position={[position.x, position.y + 1.2, position.z]}
fontSize={0.3}
color="#3b82f6"
anchorX="center"
anchorY="middle"
outlineWidth={0.04}
outlineColor="#000000"
>
MOVE HERE
</Text>
</group>
);
}
// Build preview (ghost block showing where block will be placed)
function BuildPreview({ position }) {
const meshRef = useRef();
useFrame((state) => {
if (meshRef.current) {
meshRef.current.material.opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
}
});
return (
<mesh ref={meshRef} position={[position.x, position.y, position.z]}>
<boxGeometry args={[1.0, 1.0, 1.0]} />
<meshStandardMaterial
color="#22c55e"
transparent
opacity={0.4}
emissive="#22c55e"
emissiveIntensity={0.5}
wireframe={false}
/>
</mesh>
);
}
// Area selection wireframe for SELECT mode
function SelectionArea({ start, end }) {
if (!start || !end) return null;
const minX = Math.min(start.x, end.x);
const minY = Math.min(start.y, end.y);
const minZ = Math.min(start.z, end.z);
const maxX = Math.max(start.x, end.x);
const maxY = Math.max(start.y, end.y);
const maxZ = Math.max(start.z, end.z);
const width = maxX - minX + 1;
const height = maxY - minY + 1;
const depth = maxZ - minZ + 1;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const centerZ = (minZ + maxZ) / 2;
return (
<group position={[centerX, centerY, centerZ]}>
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(width, height, depth)]} />
<lineBasicMaterial color="#f59e0b" linewidth={2} />
</lineSegments>
<mesh>
<boxGeometry args={[width, height, depth]} />
<meshStandardMaterial
color="#f59e0b"
transparent
opacity={0.1}
side={THREE.DoubleSide}
/>
</mesh>
<Text
position={[0, height / 2 + 0.8, 0]}
fontSize={0.4}
color="#f59e0b"
anchorX="center"
anchorY="middle"
outlineWidth={0.04}
outlineColor="#000000"
>
{`${width}×${height}×${depth} Selection`}
</Text>
</group>
);
}
// Main scene component
function Scene({ interactionMode, onInteraction }) {
const turtles = useTurtleStore((state) => state.getTurtleArray());
const players = useTurtleStore((state) => Object.values(state.players || {}));
const selectedTurtleId = useTurtleStore((state) => state.selectedTurtleId);
const selectTurtle = useTurtleStore((state) => state.selectTurtle);
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
const sendCommand = useTurtleStore((state) => state.sendCommand);
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
// Mining areas state
const [miningAreas, setMiningAreas] = useState([]);
const [selectedAreaId, setSelectedAreaId] = useState(null);
// Interaction mode state
const [moveTarget, setMoveTarget] = useState(null);
const [buildPreview, setBuildPreview] = useState(null);
const [selectionStart, setSelectionStart] = useState(null);
const [selectionEnd, setSelectionEnd] = useState(null);
const controlsRef = useRef();
// Handle block clicks from interaction modes
const handleBlockClick = useCallback((event) => {
if (event.type === 'move' && selectedTurtleId) {
setMoveTarget(event.target);
// Send the turtle to the clicked position
setTurtleState(selectedTurtleId, 'moving', {
target: event.target
});
// Clear preview after a moment
setTimeout(() => setMoveTarget(null), 2000);
if (onInteraction) onInteraction({ mode: 'move', target: event.target, turtleId: selectedTurtleId });
} else if (event.type === 'build') {
setBuildPreview(event.target);
if (onInteraction) onInteraction({ mode: 'build', target: event.target });
} else if (event.type === 'select') {
if (!selectionStart) {
setSelectionStart(event.block);
setSelectionEnd(null);
} else {
setSelectionEnd(event.block);
if (onInteraction) onInteraction({
mode: 'select',
start: selectionStart,
end: event.block
});
// Reset after selection is complete
setTimeout(() => {
setSelectionStart(null);
setSelectionEnd(null);
}, 5000);
}
}
}, [selectedTurtleId, selectionStart, setTurtleState, onInteraction]);
// Disable orbit controls when in interactive mode
useEffect(() => {
if (controlsRef.current) {
controlsRef.current.enabled = interactionMode === INTERACTION_MODE.LOOK;
}
}, [interactionMode]);
// Load mining areas
useEffect(() => {
const fetchMiningAreas = async () => {
try {
const response = await fetch('http://localhost:3001/api/mining-areas');
if (response.ok) {
const data = await response.json();
setMiningAreas(data);
}
} catch (error) {
console.error('Failed to fetch mining areas:', error);
}
};
fetchMiningAreas();
const interval = setInterval(fetchMiningAreas, 5000); // Refresh every 5 seconds
return () => clearInterval(interval);
}, []);
// Calculate center point for camera focus
const centerPoint = useMemo(() => {
if (turtles.length === 0) return [0, 0, 0];
let sumX = 0, sumY = 0, sumZ = 0;
let count = 0;
turtles.forEach(turtle => {
if (turtle.position) {
sumX += turtle.position.x;
sumY += turtle.position.y;
sumZ += turtle.position.z;
count++;
}
});
if (count === 0) return [0, 0, 0];
return [sumX / count, sumY / count, sumZ / count];
}, [turtles]);
// Get home position from first turtle
const homePosition = turtles.find(t => t.homePosition)?.homePosition;
return (
<>
<ambientLight intensity={0.6} />
<pointLight position={[10, 10, 10]} intensity={1.2} />
<pointLight position={[-10, -10, -10]} intensity={0.6} />
<pointLight position={[0, 20, 0]} intensity={0.8} color="#ffffff" />
{/* Grid */}
<Grid
args={[100, 100]}
cellSize={1}
cellThickness={0.5}
cellColor="#1e293b"
sectionSize={5}
sectionThickness={1}
sectionColor="#334155"
fadeDistance={50}
fadeStrength={1}
followCamera={false}
infiniteGrid
/>
{/* Render discovered blocks with chunked rendering */}
<WorldBlocks
blocks={worldBlocks}
interactionMode={interactionMode}
selectedTurtleId={selectedTurtleId}
onBlockClick={handleBlockClick}
/>
{/* Interaction mode previews */}
{moveTarget && <MoveTargetPreview position={moveTarget} />}
{buildPreview && <BuildPreview position={buildPreview} />}
{(selectionStart || selectionEnd) && (
<SelectionArea start={selectionStart} end={selectionEnd || selectionStart} />
)}
{/* Mining areas */}
{miningAreas.map((area) => {
const turtle = turtles.find(t => t.turtleID === area.turtleID);
return (
<MiningArea
key={area.areaID}
area={area}
turtle={turtle}
isSelected={selectedAreaId === area.areaID}
onClick={() => setSelectedAreaId(selectedAreaId === area.areaID ? null : area.areaID)}
/>
);
})}
{/* Home marker */}
{homePosition && <HomeMarker position={homePosition} />}
{/* Turtles and paths */}
{turtles.map((turtle) => (
<React.Fragment key={turtle.turtleID}>
<PathTrail turtle={turtle} />
<TurtleModel
turtle={turtle}
isSelected={selectedTurtleId === turtle.turtleID}
onClick={() => selectTurtle(turtle.turtleID)}
/>
</React.Fragment>
))}
{/* Players */}
{players.map((player) => (
<PlayerMarker key={player.playerID} player={player} />
))}
{/* Camera controls */}
<OrbitControls
ref={controlsRef}
target={centerPoint}
enableDamping
dampingFactor={0.05}
minDistance={5}
maxDistance={100}
/>
</>
);
}
// Interaction mode toolbar overlay
function InteractionToolbar({ mode, setMode, lastInteraction }) {
const modes = [
{ key: INTERACTION_MODE.LOOK, icon: '👁️', label: 'Look', desc: 'Orbit camera' },
{ key: INTERACTION_MODE.MOVE, icon: '🎯', label: 'Move', desc: 'Click to send turtle' },
{ key: INTERACTION_MODE.BUILD, icon: '🧱', label: 'Build', desc: 'Click face to preview' },
{ key: INTERACTION_MODE.SELECT, icon: '⬜', label: 'Select', desc: 'Click two corners' },
];
return (
<div style={{
position: 'absolute',
bottom: '16px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '4px',
background: 'rgba(15, 23, 42, 0.9)',
padding: '6px',
borderRadius: '12px',
border: '1px solid rgba(100, 116, 139, 0.3)',
zIndex: 10,
backdropFilter: 'blur(10px)',
}}>
{modes.map(m => (
<button
key={m.key}
onClick={() => setMode(m.key)}
title={m.desc}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: mode === m.key ? '2px solid #3b82f6' : '1px solid transparent',
background: mode === m.key ? 'rgba(59, 130, 246, 0.2)' : 'rgba(30, 41, 59, 0.8)',
color: mode === m.key ? '#60a5fa' : '#94a3b8',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
gap: '6px',
transition: 'all 0.15s ease',
}}
>
<span style={{ fontSize: '16px' }}>{m.icon}</span>
{m.label}
</button>
))}
{lastInteraction && (
<div style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginBottom: '8px',
padding: '6px 12px',
background: 'rgba(15, 23, 42, 0.95)',
borderRadius: '8px',
border: '1px solid rgba(100, 116, 139, 0.3)',
color: '#94a3b8',
fontSize: '12px',
whiteSpace: 'nowrap',
}}>
{lastInteraction.mode === 'move' && `🎯 Moving turtle to (${lastInteraction.target.x}, ${lastInteraction.target.y}, ${lastInteraction.target.z})`}
{lastInteraction.mode === 'build' && `🧱 Build at (${lastInteraction.target.x}, ${lastInteraction.target.y}, ${lastInteraction.target.z})`}
{lastInteraction.mode === 'select' && lastInteraction.end &&
`⬜ Selected (${lastInteraction.start.x},${lastInteraction.start.y},${lastInteraction.start.z}) → (${lastInteraction.end.x},${lastInteraction.end.y},${lastInteraction.end.z})`}
</div>
)}
</div>
);
}
// Block count HUD
function BlockCountHUD({ blocks }) {
const count = blocks.length;
const chunkCount = useMemo(() => {
const chunks = new Set();
blocks.forEach(b => chunks.add(`${Math.floor(b.x / 16)},${Math.floor(b.z / 16)}`));
return chunks.size;
}, [blocks]);
return (
<div style={{
position: 'absolute',
top: '12px',
right: '12px',
padding: '8px 12px',
background: 'rgba(15, 23, 42, 0.85)',
borderRadius: '8px',
border: '1px solid rgba(100, 116, 139, 0.2)',
color: '#94a3b8',
fontSize: '12px',
zIndex: 10,
}}>
🧊 {count.toLocaleString()} blocks · 📦 {chunkCount} chunks
</div>
);
}
// Main Map3D component
export default function Map3D() {
const [interactionMode, setInteractionMode] = useState(INTERACTION_MODE.LOOK);
const [lastInteraction, setLastInteraction] = useState(null);
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
const handleInteraction = useCallback((event) => {
setLastInteraction(event);
// Clear notification after 5 seconds
setTimeout(() => setLastInteraction(null), 5000);
}, []);
return (
<div style={{ width: '100%', height: '100%', background: '#0a0e1a', position: 'relative' }}>
<Canvas
camera={{ position: [15, 15, 15], fov: 60 }}
style={{ background: '#0a0e1a' }}
>
<Scene interactionMode={interactionMode} onInteraction={handleInteraction} />
</Canvas>
<BlockCountHUD blocks={worldBlocks} />
<InteractionToolbar
mode={interactionMode}
setMode={setInteractionMode}
lastInteraction={lastInteraction}
/>
</div>
);
}