Compare commits
5 Commits
ff73f3ec5e
...
b0d070bad7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d070bad7 | ||
|
|
37a3b38f2c | ||
|
|
29dfde9f25 | ||
|
|
dc6a9a94b7 | ||
|
|
120831d750 |
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
77
QUICKSTART.md
Normal file
77
QUICKSTART.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 🚀 Quick Setup Guide
|
||||
|
||||
## Step 1: Install Dependencies
|
||||
|
||||
### Linux/Mac:
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Windows:
|
||||
```cmd
|
||||
start.bat
|
||||
```
|
||||
|
||||
The script will automatically:
|
||||
- Check for Node.js
|
||||
- Install all dependencies
|
||||
- Start both servers
|
||||
|
||||
## Step 2: Configure Minecraft Bridge
|
||||
|
||||
1. In Minecraft, place a computer with wireless modem
|
||||
2. Copy `webbridge.lua` to the computer
|
||||
3. Edit line 5 to match your setup:
|
||||
```lua
|
||||
local SERVER_URL = "http://localhost:3001" -- Change if needed
|
||||
```
|
||||
4. Run the script:
|
||||
```
|
||||
webbridge
|
||||
```
|
||||
|
||||
## Step 3: Deploy Turtles
|
||||
|
||||
1. Place mining turtles with wireless modems
|
||||
2. Copy `turtle.lua` to each turtle
|
||||
3. Run on each turtle:
|
||||
```
|
||||
turtle
|
||||
```
|
||||
4. First command for each turtle: `setHome` (via pocket or web)
|
||||
|
||||
## Step 4: Access Web Interface
|
||||
|
||||
Open your browser to: **http://localhost:3000**
|
||||
|
||||
You should see:
|
||||
- ✅ Connection status (top right)
|
||||
- 🐢 Turtle list (left panel)
|
||||
- 🗺️ 3D map (center)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Turtles not showing up?**
|
||||
- Check webbridge.lua is running
|
||||
- Verify SERVER_URL matches your server
|
||||
- Ensure turtles have wireless modems equipped
|
||||
|
||||
**Can't connect from web?**
|
||||
- Verify both servers are running (check terminal output)
|
||||
- Try accessing http://localhost:3001/api/turtles directly
|
||||
- Check browser console for errors
|
||||
|
||||
**GPS not working?**
|
||||
- Set up 4 GPS host computers at high altitude
|
||||
- Run `gps host X Y Z` on each (with their coordinates)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Select a turtle in the web interface
|
||||
2. Use "Set Home" button
|
||||
3. Try "Explore" command
|
||||
4. Watch the 3D map update in real-time!
|
||||
|
||||
---
|
||||
|
||||
Need more help? Check the full README.md
|
||||
334
README.md
Normal file
334
README.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# 🐢 Turtle Control Center - Web Panel
|
||||
|
||||
A full-stack web application for monitoring and controlling ComputerCraft turtles in Minecraft. Features a real-time 3D map, WebSocket communication, and an intuitive control panel.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 📋 Features
|
||||
|
||||
- **🗺️ Real-time 3D Map**: Visualize turtle positions and movements in a beautiful 3D environment
|
||||
- **🎮 Control Panel**: Monitor and control multiple turtles simultaneously
|
||||
- **⚡ WebSocket Communication**: Instant updates and commands
|
||||
- **🔄 Auto-reconnect**: Handles connection drops gracefully
|
||||
- **📊 Live Statistics**: Track fuel, inventory, position, and more
|
||||
- **🎯 Manual Control**: Direct control with arrow keys
|
||||
- **🤖 Autonomous Modes**: Explore, mine, and return home automatically
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ React Web │◄────────┤ Node.js │◄────────│ ComputerCraft │
|
||||
│ Interface │ WebSocket│ Bridge Server │ HTTP │ Bridge Script │
|
||||
│ (Browser) │ │ (Express + WS) │ │ (webbridge.lua)│
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
▲
|
||||
│ Modem
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Mining Turtles │
|
||||
│ (turtle.lua) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ installed on your computer
|
||||
- Minecraft with ComputerCraft mod installed
|
||||
- At least one turtle with a wireless modem
|
||||
- A computer in Minecraft to run the bridge script
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone or download this project**
|
||||
|
||||
2. **Install Server Dependencies**
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Install Client Dependencies**
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
```
|
||||
|
||||
4. **Start the Backend Server**
|
||||
```bash
|
||||
cd server
|
||||
npm start
|
||||
```
|
||||
Server will run on:
|
||||
- HTTP: http://localhost:3001
|
||||
- WebSocket: ws://localhost:3002
|
||||
|
||||
5. **Start the React Frontend**
|
||||
```bash
|
||||
cd client
|
||||
npm run dev
|
||||
```
|
||||
Frontend will open at: http://localhost:3000
|
||||
|
||||
6. **Set up Minecraft Bridge**
|
||||
- In Minecraft, place a computer with a wireless modem
|
||||
- Copy `webbridge.lua` to the computer
|
||||
- Edit the `SERVER_URL` in webbridge.lua to point to your server
|
||||
(If running locally, use `http://localhost:3001`)
|
||||
- Run: `webbridge`
|
||||
|
||||
7. **Deploy Turtles**
|
||||
- Copy `turtle.lua` to your turtles
|
||||
- Equip wireless modems on turtles
|
||||
- Run: `turtle`
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
remoteturtle/
|
||||
├── server/ # Node.js backend
|
||||
│ ├── server.js # Express + WebSocket server
|
||||
│ └── package.json
|
||||
├── client/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── Map3D.jsx # 3D map visualization
|
||||
│ │ │ ├── ControlPanel.jsx # Control interface
|
||||
│ │ │ └── ControlPanel.css
|
||||
│ │ ├── store/
|
||||
│ │ │ └── turtleStore.js # Zustand state management
|
||||
│ │ ├── App.jsx
|
||||
│ │ ├── App.css
|
||||
│ │ ├── main.jsx
|
||||
│ │ └── index.css
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.js
|
||||
│ └── package.json
|
||||
├── webbridge.lua # ComputerCraft bridge script
|
||||
├── turtle.lua # Advanced turtle control script
|
||||
├── pocketremote.lua # Pocket computer interface
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🎮 Usage
|
||||
|
||||
### Web Interface
|
||||
|
||||
1. **View Modes**:
|
||||
- **Split View**: See both map and control panel
|
||||
- **Map Only**: Full-screen 3D map
|
||||
- **Control Only**: Full-screen control panel
|
||||
|
||||
2. **Controlling Turtles**:
|
||||
- Click on a turtle card or 3D marker to select it
|
||||
- Use command buttons for autonomous actions:
|
||||
- 🔍 **Explore**: Start autonomous mining/exploration
|
||||
- ⛏️ **Mine**: Begin mining operation
|
||||
- 🏠 **Return Home**: Navigate back to home position
|
||||
- ⏹️ **Stop**: Stop current operation
|
||||
|
||||
3. **Manual Control**:
|
||||
- Use arrow buttons for directional movement
|
||||
- ⬆ Up / ⬇ Down for vertical movement
|
||||
- ← Left / → Right for turning
|
||||
|
||||
### In-Game Commands
|
||||
|
||||
**Turtle Commands** (via pocket computer or web):
|
||||
- `explore` - Start exploration mode
|
||||
- `returnHome` - Return to home position
|
||||
- `stop` - Stop current operation
|
||||
- `forward/back/up/down` - Movement
|
||||
- `turnLeft/turnRight` - Rotation
|
||||
- `dig/digUp/digDown` - Mining
|
||||
- `setHome` - Set current position as home
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Server Configuration (server/server.js)
|
||||
|
||||
```javascript
|
||||
const PORT = 3001; // HTTP server port
|
||||
const WS_PORT = 3002; // WebSocket port
|
||||
```
|
||||
|
||||
### Client Configuration (client/src/store/turtleStore.js)
|
||||
|
||||
```javascript
|
||||
const WS_URL = 'ws://localhost:3002';
|
||||
const API_URL = 'http://localhost:3001';
|
||||
```
|
||||
|
||||
### Minecraft Bridge (webbridge.lua)
|
||||
|
||||
```lua
|
||||
local SERVER_URL = "http://localhost:3001"
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
```
|
||||
|
||||
### Turtle Configuration (turtle.lua)
|
||||
|
||||
```lua
|
||||
local CHANNEL_RECEIVE = 100
|
||||
local CHANNEL_SEND = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
```
|
||||
|
||||
## 🌐 Remote Access
|
||||
|
||||
To access from other devices on your network:
|
||||
|
||||
1. Find your computer's local IP address:
|
||||
```bash
|
||||
# Linux/Mac
|
||||
ifconfig | grep "inet "
|
||||
|
||||
# Windows
|
||||
ipconfig
|
||||
```
|
||||
|
||||
2. Update configurations to use your IP instead of `localhost`:
|
||||
- In `webbridge.lua`: `local SERVER_URL = "http://192.168.1.100:3001"`
|
||||
- In `turtleStore.js`: Update WS_URL and API_URL
|
||||
|
||||
3. Ensure firewall allows connections on ports 3000, 3001, and 3002
|
||||
|
||||
## 📊 API Reference
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
**POST /api/turtle/update**
|
||||
- Body: Turtle status object
|
||||
- Updates turtle state
|
||||
|
||||
**GET /api/turtle/:id/commands**
|
||||
- Returns pending commands for turtle
|
||||
|
||||
**POST /api/turtle/:id/command**
|
||||
- Body: `{ command: string, param: any }`
|
||||
- Queues command for turtle
|
||||
|
||||
**GET /api/turtles**
|
||||
- Returns all connected turtles
|
||||
|
||||
### WebSocket Messages
|
||||
|
||||
**From Server:**
|
||||
```json
|
||||
{
|
||||
"type": "initial_state",
|
||||
"turtles": [...]
|
||||
}
|
||||
|
||||
{
|
||||
"type": "turtle_update",
|
||||
"turtle": {...}
|
||||
}
|
||||
|
||||
{
|
||||
"type": "turtle_disconnected",
|
||||
"turtleID": 123
|
||||
}
|
||||
```
|
||||
|
||||
**From Client:**
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"turtleID": 123,
|
||||
"command": "explore",
|
||||
"param": null
|
||||
}
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Turtles not appearing in web panel
|
||||
|
||||
1. Check that webbridge.lua is running
|
||||
2. Verify SERVER_URL is correct
|
||||
3. Check turtle.lua is running with wireless modem equipped
|
||||
4. Verify all scripts are using same channel numbers
|
||||
|
||||
### Connection issues
|
||||
|
||||
1. Check server is running: `http://localhost:3001/api/turtles`
|
||||
2. Check firewall settings
|
||||
3. Verify ports 3001 and 3002 are not in use
|
||||
4. Check browser console for WebSocket errors
|
||||
|
||||
### GPS not working
|
||||
|
||||
1. Set up GPS hosts in Minecraft (requires 4 computers)
|
||||
2. Position them in a square pattern at high Y level
|
||||
3. Each must run `gps host X Y Z` with their coordinates
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Adding New Commands
|
||||
|
||||
1. **Add to turtle.lua**:
|
||||
```lua
|
||||
commands.myCommand = function(param)
|
||||
-- Your code here
|
||||
return true, "Success message"
|
||||
end
|
||||
```
|
||||
|
||||
2. **Add button in ControlPanel.jsx**:
|
||||
```jsx
|
||||
<button onClick={() => sendCommand(turtle.turtleID, 'myCommand')}>
|
||||
My Command
|
||||
</button>
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
- Modify `client/src/components/ControlPanel.css` for panel styling
|
||||
- Modify `client/src/App.css` for layout changes
|
||||
- Colors use Tailwind-inspired palette
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License - Feel free to use and modify!
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
Built with:
|
||||
- [React](https://react.dev/) - UI framework
|
||||
- [Three.js](https://threejs.org/) - 3D graphics
|
||||
- [React Three Fiber](https://docs.pmnd.rs/react-three-fiber) - React renderer for Three.js
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
||||
- [Express](https://expressjs.com/) - Backend server
|
||||
- [ws](https://github.com/websockets/ws) - WebSocket server
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
- [ ] Path recording and playback
|
||||
- [ ] Multi-turtle task coordination
|
||||
- [ ] Inventory management interface
|
||||
- [ ] Mining area visualization
|
||||
- [ ] Task scheduling
|
||||
- [ ] Database persistence
|
||||
- [ ] Authentication/multi-user support
|
||||
- [ ] Mobile-responsive design
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
- Keep the web bridge computer loaded (use a chunk loader)
|
||||
- Monitor turtle fuel levels regularly
|
||||
- Set home position before starting exploration
|
||||
- Use GPS for accurate positioning
|
||||
- Keep backup fuel in turtle inventories
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Check the troubleshooting section or review the code comments!
|
||||
|
||||
**Have ideas?** Feel free to fork and extend this project!
|
||||
12
client/index.html
Normal file
12
client/index.html
Normal 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
26
client/package.json
Normal 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
93
client/src/App.css
Normal 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
54
client/src/App.jsx
Normal 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;
|
||||
345
client/src/components/ControlPanel.css
Normal file
345
client/src/components/ControlPanel.css
Normal 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;
|
||||
}
|
||||
229
client/src/components/ControlPanel.jsx
Normal file
229
client/src/components/ControlPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
client/src/components/Map3D.jsx
Normal file
203
client/src/components/Map3D.jsx
Normal 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
25
client/src/index.css
Normal 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
10
client/src/main.jsx
Normal 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>,
|
||||
)
|
||||
104
client/src/store/turtleStore.js
Normal file
104
client/src/store/turtleStore.js
Normal 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
9
client/vite.config.js
Normal 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
27
server/package.json
Normal 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
191
server/server.js
Normal 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`);
|
||||
});
|
||||
69
start.bat
Normal file
69
start.bat
Normal file
@@ -0,0 +1,69 @@
|
||||
@echo off
|
||||
echo 🐢 Turtle Control Center - Setup ^& Start
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Check if Node.js is installed
|
||||
where node >nul 2>nul
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo ❌ Node.js is not installed. Please install Node.js 18+ first.
|
||||
echo Visit: https://nodejs.org/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ✅ Node.js is installed
|
||||
node --version
|
||||
echo.
|
||||
|
||||
REM Install server dependencies
|
||||
echo 📦 Installing server dependencies...
|
||||
cd server
|
||||
if not exist "node_modules\" (
|
||||
call npm install
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo ❌ Failed to install server dependencies
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo ℹ️ Dependencies already installed (skipping)
|
||||
)
|
||||
cd ..
|
||||
|
||||
REM Install client dependencies
|
||||
echo 📦 Installing client dependencies...
|
||||
cd client
|
||||
if not exist "node_modules\" (
|
||||
call npm install
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo ❌ Failed to install client dependencies
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo ℹ️ Dependencies already installed (skipping)
|
||||
)
|
||||
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.
|
||||
|
||||
REM Start both servers
|
||||
start "Turtle Control Server" cmd /k "cd server && npm start"
|
||||
timeout /t 2 /nobreak >nul
|
||||
start "Turtle Control Client" cmd /k "cd client && npm run dev"
|
||||
|
||||
echo.
|
||||
echo ✅ Both servers are starting in separate windows
|
||||
pause
|
||||
70
start.sh
Executable file
70
start.sh
Executable 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
|
||||
93
webbridge.lua
Normal file
93
webbridge.lua
Normal file
@@ -0,0 +1,93 @@
|
||||
-- Web Bridge for Turtle System
|
||||
-- This script forwards turtle status updates to the web server
|
||||
-- Place this on a computer connected to the wireless network
|
||||
|
||||
local SERVER_URL = "http://localhost:3001" -- Change to your server address
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
|
||||
local modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
error("No wireless modem found!")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
|
||||
print("Web Bridge Started")
|
||||
print("Listening for turtle updates...")
|
||||
print("Server: " .. SERVER_URL)
|
||||
|
||||
-- Track turtles to forward updates
|
||||
local turtles = {}
|
||||
|
||||
-- Function to send data to web server
|
||||
local function sendToServer(data)
|
||||
local jsonData = textutils.serializeJSON(data)
|
||||
|
||||
local response = http.post(
|
||||
SERVER_URL .. "/api/turtle/update",
|
||||
jsonData,
|
||||
{["Content-Type"] = "application/json"}
|
||||
)
|
||||
|
||||
if response then
|
||||
response.close()
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- Function to poll for commands from server
|
||||
local function pollCommands(turtleID)
|
||||
local response = http.get(SERVER_URL .. "/api/turtle/" .. turtleID .. "/commands")
|
||||
|
||||
if response then
|
||||
local content = response.readAll()
|
||||
response.close()
|
||||
|
||||
local data = textutils.unserializeJSON(content)
|
||||
if data and data.commands then
|
||||
return data.commands
|
||||
end
|
||||
end
|
||||
|
||||
return {}
|
||||
end
|
||||
|
||||
-- Main loop
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
||||
-- Status update from turtle
|
||||
print("Status from Turtle " .. (message.turtleID or "?"))
|
||||
|
||||
-- Update local cache
|
||||
turtles[message.turtleID] = message
|
||||
|
||||
-- Forward to web server
|
||||
local success = sendToServer(message)
|
||||
if success then
|
||||
print(" -> Sent to server")
|
||||
|
||||
-- Check for commands from server
|
||||
local commands = pollCommands(message.turtleID)
|
||||
|
||||
-- Forward commands back to turtle
|
||||
for _, cmd in ipairs(commands) do
|
||||
print(" -> Command for Turtle " .. message.turtleID .. ": " .. cmd.command)
|
||||
modem.transmit(100, 101, {
|
||||
command = cmd.command,
|
||||
param = cmd.param,
|
||||
target = message.turtleID
|
||||
})
|
||||
end
|
||||
else
|
||||
print(" -> Failed to send to server")
|
||||
end
|
||||
end
|
||||
|
||||
sleep(0.1) -- Small delay to prevent overwhelming the system
|
||||
end
|
||||
Reference in New Issue
Block a user