1415 lines
48 KiB
JavaScript
1415 lines
48 KiB
JavaScript
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>
|
||
);
|
||
}
|