512 lines
16 KiB
JavaScript
512 lines
16 KiB
JavaScript
import React, { useRef, useMemo, useEffect, useState } from 'react';
|
|
import { Canvas, useFrame, useLoader } from '@react-three/fiber';
|
|
import { OrbitControls, Grid, Text, Line } from '@react-three/drei';
|
|
import * as THREE from 'three';
|
|
import { useTurtleStore } from '../store/turtleStore';
|
|
|
|
// 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',
|
|
|
|
// Stone variants
|
|
'minecraft:granite': 'granite',
|
|
'minecraft:diorite': 'diorite',
|
|
'minecraft:andesite': 'andesite',
|
|
'minecraft:deepslate': 'deepslate',
|
|
'minecraft:tuff': 'tuff',
|
|
'minecraft:calcite': 'calcite',
|
|
'minecraft:dripstone_block': 'dripstone_block',
|
|
|
|
// 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',
|
|
|
|
// Special blocks
|
|
'minecraft:obsidian': 'obsidian',
|
|
'minecraft:netherrack': 'netherrack',
|
|
'minecraft:soul_sand': 'soul_sand',
|
|
'minecraft:glowstone': 'glowstone',
|
|
'minecraft:end_stone': 'end_stone',
|
|
'minecraft:water': 'water_still',
|
|
'minecraft:lava': 'lava_still',
|
|
|
|
// Building blocks
|
|
'minecraft:bricks': 'bricks',
|
|
'minecraft:stone_bricks': 'stone_bricks',
|
|
'minecraft:mossy_cobblestone': 'mossy_cobblestone',
|
|
'minecraft:mossy_stone_bricks': 'mossy_stone_bricks',
|
|
'minecraft:prismarine': 'prismarine',
|
|
'minecraft:dark_prismarine': 'dark_prismarine',
|
|
};
|
|
|
|
// 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
|
|
function useMinecraftTextures(blocks) {
|
|
const [textures, setTextures] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const textureLoader = new THREE.TextureLoader();
|
|
const uniqueBlocks = [...new Set(blocks.map(b => b.name))];
|
|
const loadedTextures = {};
|
|
let loadedCount = 0;
|
|
|
|
if (uniqueBlocks.length === 0) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
uniqueBlocks.forEach(blockName => {
|
|
const url = getTextureURL(blockName);
|
|
|
|
textureLoader.load(
|
|
url,
|
|
(texture) => {
|
|
texture.magFilter = THREE.NearestFilter;
|
|
texture.minFilter = THREE.NearestFilter;
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
loadedTextures[blockName] = texture;
|
|
loadedCount++;
|
|
|
|
if (loadedCount === uniqueBlocks.length) {
|
|
setTextures(loadedTextures);
|
|
setLoading(false);
|
|
}
|
|
},
|
|
undefined,
|
|
(error) => {
|
|
console.warn(`Failed to load texture for ${blockName}, using color fallback`);
|
|
loadedCount++;
|
|
|
|
if (loadedCount === uniqueBlocks.length) {
|
|
setTextures(loadedTextures);
|
|
setLoading(false);
|
|
}
|
|
}
|
|
);
|
|
});
|
|
}, [blocks]);
|
|
|
|
return { textures, loading };
|
|
}
|
|
|
|
// Minecraft-style turtle model
|
|
function TurtleModel({ turtle, isSelected, onClick }) {
|
|
const groupRef = useRef();
|
|
const { position, facing, mode } = turtle;
|
|
|
|
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))
|
|
// Add 180 degrees (Math.PI) to face the head forward initially, then apply facing rotation
|
|
const rotation = facing !== undefined ? [0, (facing * Math.PI / 2) + Math.PI, 0] : [0, Math.PI, 0];
|
|
|
|
const color = mode === 'mining' ? '#4ade80' :
|
|
mode === 'exploring' ? '#60a5fa' :
|
|
mode === 'returning' ? '#f59e0b' :
|
|
'#9ca3af';
|
|
|
|
return (
|
|
<group ref={groupRef} position={[position.x, position.y, position.z]} rotation={rotation} onClick={onClick}>
|
|
<group>
|
|
{/* Shell (main body) */}
|
|
<mesh position={[0, 0.2, 0]}>
|
|
<boxGeometry args={[0.8, 0.5, 0.9]} />
|
|
<meshStandardMaterial
|
|
color={color}
|
|
emissive={color}
|
|
emissiveIntensity={isSelected ? 0.4 : 0.2}
|
|
roughness={0.4}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Head */}
|
|
<mesh position={[0, 0.15, 0.55]}>
|
|
<boxGeometry args={[0.5, 0.35, 0.3]} />
|
|
<meshStandardMaterial color={color} roughness={0.3} />
|
|
</mesh>
|
|
|
|
{/* Eyes */}
|
|
<mesh position={[-0.12, 0.2, 0.7]}>
|
|
<boxGeometry args={[0.1, 0.1, 0.05]} />
|
|
<meshStandardMaterial color="#ffffff" emissive="#ffff00" emissiveIntensity={0.5} />
|
|
</mesh>
|
|
<mesh position={[0.12, 0.2, 0.7]}>
|
|
<boxGeometry args={[0.1, 0.1, 0.05]} />
|
|
<meshStandardMaterial color="#ffffff" emissive="#ffff00" emissiveIntensity={0.5} />
|
|
</mesh>
|
|
|
|
{/* Legs */}
|
|
<mesh position={[-0.3, -0.15, 0.25]}>
|
|
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
|
<meshStandardMaterial color="#666" />
|
|
</mesh>
|
|
<mesh position={[0.3, -0.15, 0.25]}>
|
|
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
|
<meshStandardMaterial color="#666" />
|
|
</mesh>
|
|
<mesh position={[-0.3, -0.15, -0.25]}>
|
|
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
|
<meshStandardMaterial color="#666" />
|
|
</mesh>
|
|
<mesh position={[0.3, -0.15, -0.25]}>
|
|
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
|
<meshStandardMaterial color="#666" />
|
|
</mesh>
|
|
|
|
{/* Tail */}
|
|
<mesh position={[0, 0.1, -0.55]}>
|
|
<boxGeometry args={[0.15, 0.15, 0.2]} />
|
|
<meshStandardMaterial color={color} />
|
|
</mesh>
|
|
</group>
|
|
|
|
{/* Selection indicator */}
|
|
{isSelected && (
|
|
<>
|
|
<mesh position={[0, 1.2, 0]}>
|
|
<coneGeometry args={[0.3, 0.5, 4]} />
|
|
<meshStandardMaterial color="#ffffff" emissive="#ffffff" emissiveIntensity={0.8} />
|
|
</mesh>
|
|
|
|
{/* Selection ring */}
|
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.5, 0]}>
|
|
<ringGeometry args={[1, 1.2, 32]} />
|
|
<meshBasicMaterial color="#ffffff" side={THREE.DoubleSide} transparent opacity={0.5} />
|
|
</mesh>
|
|
</>
|
|
)}
|
|
|
|
{/* Turtle ID label */}
|
|
<Text
|
|
position={[0, 1.5, 0]}
|
|
fontSize={0.4}
|
|
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 to render discovered blocks with Minecraft textures
|
|
function WorldBlocks({ blocks }) {
|
|
const [hoveredBlock, setHoveredBlock] = useState(null);
|
|
const { textures, loading } = useMinecraftTextures(blocks);
|
|
|
|
// Group blocks by block name for better performance
|
|
const blockGroups = useMemo(() => {
|
|
const groups = new Map();
|
|
|
|
blocks.forEach(block => {
|
|
if (!groups.has(block.name)) {
|
|
groups.set(block.name, []);
|
|
}
|
|
groups.get(block.name).push(block);
|
|
});
|
|
|
|
return Array.from(groups.entries());
|
|
}, [blocks]);
|
|
|
|
if (loading) {
|
|
return <Text position={[0, 5, 0]} fontSize={0.5} color="white">Loading textures...</Text>;
|
|
}
|
|
|
|
return (
|
|
<group>
|
|
{blockGroups.map(([blockName, blockList]) => {
|
|
const appearance = getBlockAppearance(blockName);
|
|
const texture = textures[blockName];
|
|
|
|
return (
|
|
<group key={blockName}>
|
|
{blockList.map((block) => {
|
|
const blockKey = `${block.x},${block.y},${block.z}`;
|
|
const isHovered = hoveredBlock === blockKey;
|
|
|
|
return (
|
|
<group key={blockKey}>
|
|
<mesh
|
|
position={[block.x, block.y, block.z]}
|
|
onPointerOver={(e) => {
|
|
e.stopPropagation();
|
|
setHoveredBlock(blockKey);
|
|
}}
|
|
onPointerOut={() => setHoveredBlock(null)}
|
|
>
|
|
<boxGeometry args={[1.0, 1.0, 1.0]} />
|
|
<meshStandardMaterial
|
|
map={texture}
|
|
color={texture ? '#ffffff' : appearance.color}
|
|
emissive={appearance.emissive || appearance.color}
|
|
emissiveIntensity={isHovered ? (appearance.intensity || 0.05) + 0.2 : (appearance.intensity || 0.05)}
|
|
roughness={appearance.pattern === 'ore' ? 0.4 : 0.8}
|
|
metalness={appearance.pattern === 'ore' ? 0.2 : 0.0}
|
|
transparent
|
|
opacity={isHovered ? 0.98 : 0.9}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Block label on hover */}
|
|
{isHovered && (
|
|
<Text
|
|
position={[block.x, block.y + 0.8, block.z]}
|
|
fontSize={0.3}
|
|
color="white"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.05}
|
|
outlineColor="#000000"
|
|
>
|
|
{block.name.replace('minecraft:', '').replace(/_/g, ' ').toUpperCase()}
|
|
</Text>
|
|
)}
|
|
</group>
|
|
);
|
|
})}
|
|
</group>
|
|
);
|
|
})}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// 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>
|
|
);
|
|
}
|
|
|
|
// Main scene component
|
|
function Scene() {
|
|
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
|
const selectedTurtleId = useTurtleStore((state) => state.selectedTurtleId);
|
|
const selectTurtle = useTurtleStore((state) => state.selectTurtle);
|
|
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
|
|
|
|
// 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 */}
|
|
<WorldBlocks blocks={worldBlocks} />
|
|
|
|
{/* 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>
|
|
))}
|
|
|
|
{/* Camera controls */}
|
|
<OrbitControls
|
|
target={centerPoint}
|
|
enableDamping
|
|
dampingFactor={0.05}
|
|
minDistance={5}
|
|
maxDistance={100}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Main Map3D component
|
|
export default function Map3D() {
|
|
return (
|
|
<div style={{ width: '100%', height: '100%', background: '#0a0e1a' }}>
|
|
<Canvas
|
|
camera={{ position: [15, 15, 15], fov: 60 }}
|
|
style={{ background: '#0a0e1a' }}
|
|
>
|
|
<Scene />
|
|
</Canvas>
|
|
</div>
|
|
);
|
|
}
|