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:
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`);
|
||||
});
|
||||
70
start.sh
Normal file
70
start.sh
Normal 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
|
||||
Reference in New Issue
Block a user