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

695 lines
21 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 } 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>
);
}
// 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>
);
}
// Main scene component
function Scene() {
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 || []);
// Mining areas state
const [miningAreas, setMiningAreas] = useState([]);
const [selectedAreaId, setSelectedAreaId] = useState(null);
// 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 */}
<WorldBlocks blocks={worldBlocks} />
{/* 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
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>
);
}