diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..6e686f5 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + Turtle Control Center + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..1409449 --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/src/App.css b/client/src/App.css new file mode 100644 index 0000000..97d5444 --- /dev/null +++ b/client/src/App.css @@ -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; +} diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..cef37b9 --- /dev/null +++ b/client/src/App.jsx @@ -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 ( +
+
+ + + +
+ +
+ {(view === 'split' || view === 'map') && ( +
+ +
+ )} + {(view === 'split' || view === 'panel') && ( +
+ +
+ )} +
+
+ ); +} + +export default App; diff --git a/client/src/components/ControlPanel.css b/client/src/components/ControlPanel.css new file mode 100644 index 0000000..fb14eba --- /dev/null +++ b/client/src/components/ControlPanel.css @@ -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; +} diff --git a/client/src/components/ControlPanel.jsx b/client/src/components/ControlPanel.jsx new file mode 100644 index 0000000..377e26d --- /dev/null +++ b/client/src/components/ControlPanel.jsx @@ -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 ( +
+
+

Turtle {turtle.turtleID}

+ + {mode} + +
+ +
+
+ Position: + + {turtle.position + ? `${turtle.position.x}, ${turtle.position.y}, ${turtle.position.z}` + : 'No GPS' + } + +
+ +
+ Fuel: + {fuel} +
+ +
+ Inventory: + {inventoryCount} items +
+ + {turtle.position && turtle.homePosition && ( +
+ Home Distance: + + {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 + +
+ )} +
+
+ ); +} + +function TurtleDetails({ turtle }) { + const sendCommand = useTurtleStore((state) => state.sendCommand); + + if (!turtle) { + return ( +
+

Select a turtle to view details and control it

+
+ ); + } + + const handleCommand = (command, param = null) => { + sendCommand(turtle.turtleID, command, param); + }; + + return ( +
+

Turtle {turtle.turtleID} Control

+ +
+

Status

+
+
+ Mode: + {turtle.mode || 'unknown'} +
+
+ Fuel: + + {turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel} + +
+
+ Position: + + {turtle.position + ? `X: ${turtle.position.x}, Y: ${turtle.position.y}, Z: ${turtle.position.z}` + : 'No GPS' + } + +
+
+ Home: + + {turtle.homePosition + ? `X: ${turtle.homePosition.x}, Y: ${turtle.homePosition.y}, Z: ${turtle.homePosition.z}` + : 'Not set' + } + +
+
+
+ +
+

Commands

+
+ + + + +
+ +
+

Manual Control

+
+ +
+ + +
+ +
+
+ + +
+
+
+ + {turtle.inventory && turtle.inventory.length > 0 && ( +
+

Inventory

+
+ {turtle.inventory.map((item, index) => ( +
+ + {item.name.replace('minecraft:', '')} + + x{item.count} +
+ ))} +
+
+ )} +
+ ); +} + +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 ( +
+
+

đŸĸ Turtle Control Center

+
+ + {connected ? 'Connected' : 'Disconnected'} +
+
+ +
+
+

Turtles ({turtles.length})

+ {turtles.length === 0 ? ( +
+

No turtles connected

+

Waiting for turtles to come online...

+
+ ) : ( +
+ {turtles.map((turtle) => ( + selectTurtle(turtle.turtleID)} + /> + ))} +
+ )} +
+ + +
+
+ ); +} diff --git a/client/src/components/Map3D.jsx b/client/src/components/Map3D.jsx new file mode 100644 index 0000000..d674234 --- /dev/null +++ b/client/src/components/Map3D.jsx @@ -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 ( + + {/* Turtle body */} + + + + + + {/* Selection indicator */} + {isSelected && ( + <> + + + + + + {/* Selection ring */} + + + + + + )} + + {/* Turtle ID label */} + + T-{turtle.turtleID} + + + ); +} + +// 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 ( + + ); +} + +// Home marker component +function HomeMarker({ position }) { + if (!position) return null; + + return ( + + + + + + + HOME + + + ); +} + +// 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 ( + <> + + + + + {/* Grid */} + + + {/* Home marker */} + {homePosition && } + + {/* Turtles and paths */} + {turtles.map((turtle) => ( + + + selectTurtle(turtle.turtleID)} + /> + + ))} + + {/* Camera controls */} + + + ); +} + +// Main Map3D component +export default function Map3D() { + return ( +
+ + + +
+ ); +} diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..e119605 --- /dev/null +++ b/client/src/index.css @@ -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; +} diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 0000000..5cc5991 --- /dev/null +++ b/client/src/main.jsx @@ -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( + + + , +) diff --git a/client/src/store/turtleStore.js b/client/src/store/turtleStore.js new file mode 100644 index 0000000..e7a4841 --- /dev/null +++ b/client/src/store/turtleStore.js @@ -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; + } +})); diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..3f905c3 --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000 + } +}) diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..1156d28 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..e3a9d5f --- /dev/null +++ b/server/server.js @@ -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`); +}); diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..0b03bab --- /dev/null +++ b/start.sh @@ -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