feat: Initialize Turtle Control Center with React and WebSocket server

- Added index.html for the main entry point of the client application.
- Created package.json for client dependencies and scripts.
- Implemented App component with view controls and layout.
- Added CSS styles for the application and components.
- Developed ControlPanel component for turtle management and commands.
- Created Map3D component for 3D visualization of turtles.
- Established Zustand store for state management of turtles and WebSocket connection.
- Set up server with Express and WebSocket for handling turtle updates and commands.
- Added REST API endpoints for turtle status updates and command handling.
- Implemented start.sh script for setting up and running the application.
This commit is contained in:
MayaTheShy
2026-02-15 23:49:46 -05:00
parent dc6a9a94b7
commit 29dfde9f25
14 changed files with 1398 additions and 0 deletions

12
client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Turtle Control Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

26
client/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "turtle-control-panel",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"three": "^0.159.0",
"@react-three/fiber": "^8.15.12",
"@react-three/drei": "^9.92.7",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/three": "^0.159.0",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}

93
client/src/App.css Normal file
View File

@@ -0,0 +1,93 @@
.app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view-controls {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
background: #1e293b;
border-bottom: 2px solid #334155;
}
.view-controls button {
padding: 0.5rem 1rem;
border: none;
background: #334155;
color: #e2e8f0;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.view-controls button:hover {
background: #475569;
transform: translateY(-1px);
}
.view-controls button.active {
background: #3b82f6;
color: white;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
}
.app-content {
flex: 1;
display: flex;
overflow: hidden;
}
.app-content.split {
display: flex;
}
.app-content.split .map-container {
flex: 1;
}
.app-content.split .panel-container {
width: 600px;
}
.app-content.map .panel-container {
display: none;
}
.app-content.panel .map-container {
display: none;
}
.map-container {
position: relative;
background: #0a0e1a;
}
.panel-container {
background: #0f172a;
overflow: hidden;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
}

54
client/src/App.jsx Normal file
View File

@@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import Map3D from './components/Map3D';
import ControlPanel from './components/ControlPanel';
import { useTurtleStore } from './store/turtleStore';
import './App.css';
function App() {
const connect = useTurtleStore((state) => state.connect);
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
useEffect(() => {
connect();
}, [connect]);
return (
<div className="app">
<div className="view-controls">
<button
className={view === 'split' ? 'active' : ''}
onClick={() => setView('split')}
>
📊 Split View
</button>
<button
className={view === 'map' ? 'active' : ''}
onClick={() => setView('map')}
>
🗺 Map Only
</button>
<button
className={view === 'panel' ? 'active' : ''}
onClick={() => setView('panel')}
>
🎮 Control Only
</button>
</div>
<div className={`app-content ${view}`}>
{(view === 'split' || view === 'map') && (
<div className="map-container">
<Map3D />
</div>
)}
{(view === 'split' || view === 'panel') && (
<div className="panel-container">
<ControlPanel />
</div>
)}
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,345 @@
.control-panel {
height: 100%;
display: flex;
flex-direction: column;
background: #0f172a;
color: #e2e8f0;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: #1e293b;
border-bottom: 2px solid #334155;
}
.panel-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: #f1f5f9;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
}
.connection-status.connected {
background: #064e3b;
color: #6ee7b7;
}
.connection-status.disconnected {
background: #7f1d1d;
color: #fca5a5;
}
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
animation: pulse 2s infinite;
}
.connected .status-dot {
background: #10b981;
}
.disconnected .status-dot {
background: #ef4444;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.panel-content {
display: flex;
flex: 1;
overflow: hidden;
}
.turtle-list {
width: 350px;
border-right: 2px solid #334155;
display: flex;
flex-direction: column;
background: #1e293b;
}
.turtle-list h2 {
padding: 1rem 1.5rem;
font-size: 1.25rem;
font-weight: 600;
border-bottom: 1px solid #334155;
}
.turtle-cards {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.empty-state {
padding: 2rem 1.5rem;
text-align: center;
color: #94a3b8;
}
.empty-state .hint {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #64748b;
}
.turtle-card {
background: #0f172a;
border: 2px solid #334155;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.turtle-card:hover {
border-color: #60a5fa;
transform: translateX(4px);
}
.turtle-card.selected {
border-color: #60a5fa;
background: #1e293b;
box-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
}
.turtle-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.turtle-header h3 {
font-size: 1.125rem;
font-weight: 600;
}
.mode-badge {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: white;
}
.turtle-stats {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.turtle-stats .stat {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
}
.turtle-stats .label {
color: #94a3b8;
}
.turtle-stats .value {
color: #f1f5f9;
font-weight: 500;
font-family: 'Courier New', monospace;
}
.turtle-details {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.turtle-details.empty {
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 1.125rem;
}
.turtle-details h2 {
font-size: 1.75rem;
margin-bottom: 1.5rem;
color: #60a5fa;
}
.detail-section {
margin-bottom: 2rem;
padding: 1.5rem;
background: #1e293b;
border-radius: 0.5rem;
border: 1px solid #334155;
}
.detail-section h3 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #f1f5f9;
}
.detail-section h4 {
font-size: 1rem;
margin: 1.5rem 0 1rem;
color: #cbd5e1;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-item .label {
color: #94a3b8;
font-size: 0.875rem;
}
.status-item .value {
color: #f1f5f9;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.command-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.command-btn {
padding: 1rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: white;
}
.command-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.command-btn.explore {
background: #3b82f6;
}
.command-btn.mine {
background: #10b981;
}
.command-btn.return {
background: #f59e0b;
}
.command-btn.stop {
background: #ef4444;
}
.manual-controls {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #334155;
}
.direction-pad {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.horizontal-controls {
display: flex;
gap: 3rem;
}
.direction-pad button,
.vertical-controls button {
width: 3rem;
height: 3rem;
border: none;
background: #334155;
color: white;
border-radius: 0.5rem;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.2s;
}
.direction-pad button:hover,
.vertical-controls button:hover {
background: #475569;
transform: scale(1.1);
}
.vertical-controls {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.vertical-controls button {
width: auto;
padding: 0 1.5rem;
font-size: 1rem;
}
.inventory-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.inventory-item {
display: flex;
justify-content: space-between;
padding: 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.375rem;
}
.item-name {
color: #cbd5e1;
text-transform: capitalize;
}
.item-count {
color: #94a3b8;
font-weight: 600;
}

View File

@@ -0,0 +1,229 @@
import React from 'react';
import { useTurtleStore } from '../store/turtleStore';
import './ControlPanel.css';
function TurtleCard({ turtle, isSelected, onSelect }) {
const mode = turtle.mode || 'unknown';
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
const inventoryCount = turtle.inventory?.length || 0;
const modeColors = {
mining: '#4ade80',
exploring: '#60a5fa',
returning: '#f59e0b',
idle: '#9ca3af',
manual: '#a78bfa',
unknown: '#6b7280'
};
return (
<div
className={`turtle-card ${isSelected ? 'selected' : ''}`}
onClick={onSelect}
style={{ borderColor: modeColors[mode] }}
>
<div className="turtle-header">
<h3>Turtle {turtle.turtleID}</h3>
<span className="mode-badge" style={{ background: modeColors[mode] }}>
{mode}
</span>
</div>
<div className="turtle-stats">
<div className="stat">
<span className="label">Position:</span>
<span className="value">
{turtle.position
? `${turtle.position.x}, ${turtle.position.y}, ${turtle.position.z}`
: 'No GPS'
}
</span>
</div>
<div className="stat">
<span className="label">Fuel:</span>
<span className="value">{fuel}</span>
</div>
<div className="stat">
<span className="label">Inventory:</span>
<span className="value">{inventoryCount} items</span>
</div>
{turtle.position && turtle.homePosition && (
<div className="stat">
<span className="label">Home Distance:</span>
<span className="value">
{Math.abs(turtle.position.x - turtle.homePosition.x) +
Math.abs(turtle.position.y - turtle.homePosition.y) +
Math.abs(turtle.position.z - turtle.homePosition.z)} blocks
</span>
</div>
)}
</div>
</div>
);
}
function TurtleDetails({ turtle }) {
const sendCommand = useTurtleStore((state) => state.sendCommand);
if (!turtle) {
return (
<div className="turtle-details empty">
<p>Select a turtle to view details and control it</p>
</div>
);
}
const handleCommand = (command, param = null) => {
sendCommand(turtle.turtleID, command, param);
};
return (
<div className="turtle-details">
<h2>Turtle {turtle.turtleID} Control</h2>
<div className="detail-section">
<h3>Status</h3>
<div className="status-grid">
<div className="status-item">
<span className="label">Mode:</span>
<span className="value">{turtle.mode || 'unknown'}</span>
</div>
<div className="status-item">
<span className="label">Fuel:</span>
<span className="value">
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
</span>
</div>
<div className="status-item">
<span className="label">Position:</span>
<span className="value">
{turtle.position
? `X: ${turtle.position.x}, Y: ${turtle.position.y}, Z: ${turtle.position.z}`
: 'No GPS'
}
</span>
</div>
<div className="status-item">
<span className="label">Home:</span>
<span className="value">
{turtle.homePosition
? `X: ${turtle.homePosition.x}, Y: ${turtle.homePosition.y}, Z: ${turtle.homePosition.z}`
: 'Not set'
}
</span>
</div>
</div>
</div>
<div className="detail-section">
<h3>Commands</h3>
<div className="command-grid">
<button
className="command-btn explore"
onClick={() => handleCommand('explore')}
>
🔍 Explore
</button>
<button
className="command-btn mine"
onClick={() => handleCommand('mine')}
>
Mine
</button>
<button
className="command-btn return"
onClick={() => handleCommand('returnHome')}
>
🏠 Return Home
</button>
<button
className="command-btn stop"
onClick={() => handleCommand('stop')}
>
Stop
</button>
</div>
<div className="manual-controls">
<h4>Manual Control</h4>
<div className="direction-pad">
<button onClick={() => handleCommand('forward')}></button>
<div className="horizontal-controls">
<button onClick={() => handleCommand('turnLeft')}></button>
<button onClick={() => handleCommand('turnRight')}></button>
</div>
<button onClick={() => handleCommand('back')}></button>
</div>
<div className="vertical-controls">
<button onClick={() => handleCommand('up')}> Up</button>
<button onClick={() => handleCommand('down')}> Down</button>
</div>
</div>
</div>
{turtle.inventory && turtle.inventory.length > 0 && (
<div className="detail-section">
<h3>Inventory</h3>
<div className="inventory-list">
{turtle.inventory.map((item, index) => (
<div key={index} className="inventory-item">
<span className="item-name">
{item.name.replace('minecraft:', '')}
</span>
<span className="item-count">x{item.count}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export default function ControlPanel() {
const turtles = useTurtleStore((state) => state.getTurtleArray());
const selectedTurtleId = useTurtleStore((state) => state.selectedTurtleId);
const selectTurtle = useTurtleStore((state) => state.selectTurtle);
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
const connected = useTurtleStore((state) => state.connected);
return (
<div className="control-panel">
<div className="panel-header">
<h1>🐢 Turtle Control Center</h1>
<div className={`connection-status ${connected ? 'connected' : 'disconnected'}`}>
<span className="status-dot"></span>
{connected ? 'Connected' : 'Disconnected'}
</div>
</div>
<div className="panel-content">
<div className="turtle-list">
<h2>Turtles ({turtles.length})</h2>
{turtles.length === 0 ? (
<div className="empty-state">
<p>No turtles connected</p>
<p className="hint">Waiting for turtles to come online...</p>
</div>
) : (
<div className="turtle-cards">
{turtles.map((turtle) => (
<TurtleCard
key={turtle.turtleID}
turtle={turtle}
isSelected={selectedTurtleId === turtle.turtleID}
onSelect={() => selectTurtle(turtle.turtleID)}
/>
))}
</div>
)}
</div>
<TurtleDetails turtle={selectedTurtle} />
</div>
</div>
);
}

View File

@@ -0,0 +1,203 @@
import React, { useRef, useMemo } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Grid, Text, Line } from '@react-three/drei';
import * as THREE from 'three';
import { useTurtleStore } from '../store/turtleStore';
// Turtle marker component
function TurtleMarker({ turtle, isSelected, onClick }) {
const meshRef = useRef();
const { position, mode } = turtle;
useFrame((state) => {
if (meshRef.current && isSelected) {
meshRef.current.rotation.y += 0.02;
}
});
if (!position) return null;
const color = mode === 'mining' ? '#4ade80' :
mode === 'exploring' ? '#60a5fa' :
mode === 'returning' ? '#f59e0b' :
'#9ca3af';
return (
<group position={[position.x, position.y, position.z]} onClick={onClick}>
{/* Turtle body */}
<mesh ref={meshRef}>
<boxGeometry args={[0.8, 0.6, 0.8]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={isSelected ? 0.5 : 0.2}
/>
</mesh>
{/* Selection indicator */}
{isSelected && (
<>
<mesh position={[0, 1, 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.4, 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"
>
T-{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}
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);
// 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.5} />
<pointLight position={[10, 10, 10]} intensity={1} />
<pointLight position={[-10, -10, -10]} intensity={0.5} />
{/* 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
/>
{/* Home marker */}
{homePosition && <HomeMarker position={homePosition} />}
{/* Turtles and paths */}
{turtles.map((turtle) => (
<React.Fragment key={turtle.turtleID}>
<PathTrail turtle={turtle} />
<TurtleMarker
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>
);
}

25
client/src/index.css Normal file
View File

@@ -0,0 +1,25 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #0a0e1a;
color: #e0e0e0;
overflow: hidden;
}
#root {
width: 100vw;
height: 100vh;
}
code {
font-family: 'Courier New', monospace;
}

10
client/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,104 @@
import { create } from 'zustand';
const WS_URL = 'ws://localhost:3002';
const API_URL = 'http://localhost:3001';
export const useTurtleStore = create((set, get) => ({
// State
turtles: {},
selectedTurtleId: null,
connected: false,
ws: null,
// WebSocket connection
connect: () => {
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('✅ Connected to server');
set({ connected: true, ws });
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'initial_state') {
const turtlesMap = {};
data.turtles.forEach(turtle => {
turtlesMap[turtle.turtleID] = turtle;
});
set({ turtles: turtlesMap });
} else if (data.type === 'turtle_update') {
set(state => ({
turtles: {
...state.turtles,
[data.turtle.turtleID]: data.turtle
}
}));
} else if (data.type === 'turtle_disconnected') {
set(state => {
const newTurtles = { ...state.turtles };
delete newTurtles[data.turtleID];
return { turtles: newTurtles };
});
}
} catch (error) {
console.error('Error processing message:', error);
}
};
ws.onclose = () => {
console.log('❌ Disconnected from server');
set({ connected: false, ws: null });
// Attempt reconnect after 3 seconds
setTimeout(() => {
get().connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
},
// Actions
selectTurtle: (turtleId) => {
set({ selectedTurtleId: turtleId });
},
sendCommand: async (turtleId, command, param = null) => {
const { ws } = get();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'command',
turtleID: turtleId,
command,
param
}));
} else {
// Fallback to REST API
try {
await fetch(`${API_URL}/api/turtle/${turtleId}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, param })
});
} catch (error) {
console.error('Error sending command:', error);
}
}
},
// Helper getters
getTurtleArray: () => {
return Object.values(get().turtles);
},
getSelectedTurtle: () => {
const { turtles, selectedTurtleId } = get();
return selectedTurtleId ? turtles[selectedTurtleId] : null;
}
}));

9
client/vite.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000
}
})

27
server/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "turtle-control-server",
"version": "1.0.0",
"description": "WebSocket server for Minecraft turtle remote control",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [
"minecraft",
"computercraft",
"turtle",
"websocket"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.14.2",
"cors": "^2.8.5"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

191
server/server.js Normal file
View File

@@ -0,0 +1,191 @@
import express from 'express';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import { createServer } from 'http';
const app = express();
const PORT = 3001;
const WS_PORT = 3002;
app.use(cors());
app.use(express.json());
// Store connected web clients and turtle data
const webClients = new Set();
const turtleData = new Map(); // turtleID -> turtle state
// Create HTTP server
const server = createServer(app);
// WebSocket server for web clients
const wss = new WebSocketServer({ port: WS_PORT });
console.log(`🚀 Turtle Control Server starting...`);
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
console.log(`🔌 WebSocket Server: ws://localhost:${WS_PORT}`);
// WebSocket connection handler
wss.on('connection', (ws) => {
console.log('🌐 New web client connected');
webClients.add(ws);
// Send current turtle data to new client
ws.send(JSON.stringify({
type: 'initial_state',
turtles: Array.from(turtleData.values())
}));
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
console.log('📨 Received from web client:', data);
// Handle commands from web client
if (data.type === 'command') {
// Store command for turtle to poll
const turtleID = data.turtleID;
if (turtleData.has(turtleID)) {
const turtle = turtleData.get(turtleID);
turtle.pendingCommands = turtle.pendingCommands || [];
turtle.pendingCommands.push({
command: data.command,
param: data.param,
timestamp: Date.now()
});
console.log(`📤 Command queued for turtle ${turtleID}:`, data.command);
}
}
} catch (error) {
console.error('❌ Error processing web client message:', error);
}
});
ws.on('close', () => {
console.log('👋 Web client disconnected');
webClients.delete(ws);
});
ws.on('error', (error) => {
console.error('❌ WebSocket error:', error);
});
});
// Broadcast to all web clients
function broadcastToClients(data) {
const message = JSON.stringify(data);
webClients.forEach((client) => {
if (client.readyState === 1) { // OPEN
client.send(message);
}
});
}
// REST API endpoint for turtles to update their status
app.post('/api/turtle/update', (req, res) => {
try {
const turtleUpdate = req.body;
const turtleID = turtleUpdate.turtleID;
console.log(`🐢 Status update from Turtle ${turtleID}`);
// Update turtle data
if (!turtleData.has(turtleID)) {
console.log(`✨ New turtle registered: ${turtleID}`);
}
turtleData.set(turtleID, {
...turtleUpdate,
lastUpdate: Date.now()
});
// Broadcast to web clients
broadcastToClients({
type: 'turtle_update',
turtle: turtleData.get(turtleID)
});
res.json({ success: true });
} catch (error) {
console.error('❌ Error processing turtle update:', error);
res.status(500).json({ error: error.message });
}
});
// Endpoint for turtles to poll for commands
app.get('/api/turtle/:id/commands', (req, res) => {
try {
const turtleID = parseInt(req.params.id);
if (turtleData.has(turtleID)) {
const turtle = turtleData.get(turtleID);
const commands = turtle.pendingCommands || [];
// Clear pending commands
turtle.pendingCommands = [];
res.json({ commands });
} else {
res.json({ commands: [] });
}
} catch (error) {
console.error('❌ Error getting commands:', error);
res.status(500).json({ error: error.message });
}
});
// Get all turtles
app.get('/api/turtles', (req, res) => {
res.json({
turtles: Array.from(turtleData.values())
});
});
// Send command to turtle
app.post('/api/turtle/:id/command', (req, res) => {
try {
const turtleID = parseInt(req.params.id);
const { command, param } = req.body;
if (turtleData.has(turtleID)) {
const turtle = turtleData.get(turtleID);
turtle.pendingCommands = turtle.pendingCommands || [];
turtle.pendingCommands.push({
command,
param,
timestamp: Date.now()
});
console.log(`📤 Command queued for turtle ${turtleID}:`, command);
res.json({ success: true });
} else {
res.status(404).json({ error: 'Turtle not found' });
}
} catch (error) {
console.error('❌ Error sending command:', error);
res.status(500).json({ error: error.message });
}
});
// Cleanup stale turtles (haven't updated in 30 seconds)
setInterval(() => {
const now = Date.now();
const timeout = 30000; // 30 seconds
for (const [turtleID, turtle] of turtleData.entries()) {
if (now - turtle.lastUpdate > timeout) {
console.log(`🗑️ Removing stale turtle: ${turtleID}`);
turtleData.delete(turtleID);
broadcastToClients({
type: 'turtle_disconnected',
turtleID
});
}
}
}, 10000); // Check every 10 seconds
server.listen(PORT, () => {
console.log(`✅ Server ready!`);
console.log(`\nConfigured turtles to send updates to:`);
console.log(` http://localhost:${PORT}/api/turtle/update`);
});

70
start.sh Normal file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
echo "🐢 Turtle Control Center - Setup & Start"
echo "========================================"
echo ""
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "❌ Node.js is not installed. Please install Node.js 18+ first."
echo " Visit: https://nodejs.org/"
exit 1
fi
echo "✅ Node.js version: $(node --version)"
echo ""
# Install server dependencies
echo "📦 Installing server dependencies..."
cd server
if [ ! -d "node_modules" ]; then
npm install
if [ $? -ne 0 ]; then
echo "❌ Failed to install server dependencies"
exit 1
fi
else
echo " Dependencies already installed (skipping)"
fi
cd ..
# Install client dependencies
echo "📦 Installing client dependencies..."
cd client
if [ ! -d "node_modules" ]; then
npm install
if [ $? -ne 0 ]; then
echo "❌ Failed to install client dependencies"
exit 1
fi
else
echo " Dependencies already installed (skipping)"
fi
cd ..
echo ""
echo "✨ Setup complete!"
echo ""
echo "🚀 Starting servers..."
echo ""
echo "Server will be available at:"
echo " 🌐 Web Interface: http://localhost:3000"
echo " 📡 API Server: http://localhost:3001"
echo " 🔌 WebSocket: ws://localhost:3002"
echo ""
echo "Press Ctrl+C to stop all servers"
echo ""
# Start both server and client
trap 'kill 0' SIGINT
cd server
npm start &
SERVER_PID=$!
cd ../client
npm run dev &
CLIENT_PID=$!
# Wait for both processes
wait $SERVER_PID $CLIENT_PID