working ha
This commit is contained in:
27
client/.dockerignore
Normal file
27
client/.dockerignore
Normal file
@@ -0,0 +1,27 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
21
client/Dockerfile
Normal file
21
client/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN npm install -g pnpm && pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
22
client/nginx.conf
Normal file
22
client/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"phoenix": "^1.8.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/phoenix": "^1.6.7",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
|
||||
16
client/pnpm-lock.yaml
generated
16
client/pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
phoenix:
|
||||
specifier: ^1.8.4
|
||||
version: 1.8.4
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.4
|
||||
@@ -21,6 +24,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.10.1
|
||||
version: 24.10.13
|
||||
'@types/phoenix':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
'@types/react':
|
||||
specifier: ^19.2.7
|
||||
version: 19.2.14
|
||||
@@ -515,6 +521,9 @@ packages:
|
||||
'@types/node@24.10.13':
|
||||
resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==}
|
||||
|
||||
'@types/phoenix@1.6.7':
|
||||
resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -930,6 +939,9 @@ packages:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
phoenix@1.8.4:
|
||||
resolution: {integrity: sha512-5wdO9mqU4l0AmcexN8mA0m1BsC4ROv2l55MN31gbxmJPzghc4SxVxnNTh9qaummYa2pbwkoBvG8FezO7cjdr8A==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1480,6 +1492,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/phoenix@1.6.7': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
@@ -1930,6 +1944,8 @@ snapshots:
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
phoenix@1.8.4: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
||||
@@ -1,35 +1,232 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Socket, Channel } from "phoenix";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
interface Player {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export default App
|
||||
interface GameState {
|
||||
[playerId: string]: Player;
|
||||
}
|
||||
|
||||
// List of WebSocket servers - we'll connect to all of them
|
||||
const WS_SERVERS = [
|
||||
"ws://localhost:4001/socket",
|
||||
"ws://localhost:4002/socket",
|
||||
"ws://localhost:4003/socket",
|
||||
];
|
||||
|
||||
function App() {
|
||||
const [players, setPlayers] = useState<GameState>({});
|
||||
const [myPlayerId, setMyPlayerId] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<string>("connecting");
|
||||
const socketsRef = useRef<Socket[]>([]);
|
||||
const channelsRef = useRef<Channel[]>([]);
|
||||
const keysPressed = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to all servers concurrently
|
||||
const sockets = WS_SERVERS.map((serverUrl) => {
|
||||
console.log(`Connecting to ${serverUrl}`);
|
||||
|
||||
const socket = new Socket(serverUrl, {
|
||||
timeout: 3000,
|
||||
reconnectAfterMs: () => 2000, // Keep trying to reconnect
|
||||
});
|
||||
|
||||
// Handle connection events
|
||||
socket.onOpen(() => {
|
||||
console.log(`✓ Connected to ${serverUrl}`);
|
||||
updateConnectionStatus();
|
||||
});
|
||||
|
||||
socket.onError((error) => {
|
||||
console.error(`✗ Error on ${serverUrl}:`, error);
|
||||
updateConnectionStatus();
|
||||
});
|
||||
|
||||
socket.onClose(() => {
|
||||
console.log(`✗ Disconnected from ${serverUrl}`);
|
||||
updateConnectionStatus();
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
return socket;
|
||||
});
|
||||
|
||||
socketsRef.current = sockets;
|
||||
|
||||
// Join game channel on all connected sockets
|
||||
const channels = sockets.map((socket, index) => {
|
||||
const channel = socket.channel("game:lobby", {});
|
||||
|
||||
channel
|
||||
.join()
|
||||
.receive("ok", () => {
|
||||
console.log(`✓ Joined channel on ${WS_SERVERS[index]}`);
|
||||
updateConnectionStatus();
|
||||
})
|
||||
.receive("error", (resp) => {
|
||||
console.log(`✗ Failed to join on ${WS_SERVERS[index]}:`, resp);
|
||||
})
|
||||
.receive("timeout", () => {
|
||||
console.log(`✗ Timeout joining on ${WS_SERVERS[index]}`);
|
||||
});
|
||||
|
||||
// Listen for game state updates from any server
|
||||
channel.on("game_state", (payload: { players: GameState }) => {
|
||||
setPlayers(payload.players);
|
||||
|
||||
// Set our player ID from the first state update if not set
|
||||
if (!myPlayerId && Object.keys(payload.players).length > 0) {
|
||||
const playerIds = Object.keys(payload.players);
|
||||
if (playerIds.length > 0) {
|
||||
setMyPlayerId(playerIds[playerIds.length - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return channel;
|
||||
});
|
||||
|
||||
channelsRef.current = channels;
|
||||
|
||||
const updateConnectionStatus = () => {
|
||||
const joined = channels.filter((c) => c.state === "joined").length;
|
||||
setConnectionStatus(`${joined}/${WS_SERVERS.length} servers active`);
|
||||
};
|
||||
|
||||
// Periodic status update
|
||||
const statusInterval = setInterval(updateConnectionStatus, 1000);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
clearInterval(statusInterval);
|
||||
channels.forEach((channel) => channel.leave());
|
||||
sockets.forEach((socket) => socket.disconnect());
|
||||
};
|
||||
}, [myPlayerId]);
|
||||
|
||||
// Handle keyboard input - send to first available channel
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (["w", "a", "s", "d"].includes(key)) {
|
||||
e.preventDefault();
|
||||
|
||||
// Only send if not already pressed (prevent repeat)
|
||||
if (!keysPressed.current.has(key)) {
|
||||
keysPressed.current.add(key);
|
||||
|
||||
// Send to first joined channel (they all share same game state)
|
||||
const activeChannel = channelsRef.current.find(
|
||||
(c) => c.state === "joined",
|
||||
);
|
||||
if (activeChannel) {
|
||||
activeChannel.push("move", { direction: key });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (["w", "a", "s", "d"].includes(key)) {
|
||||
keysPressed.current.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
background: "#1a1a2e",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Connection status */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
color: "white",
|
||||
fontFamily: "monospace",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
padding: "10px",
|
||||
borderRadius: "5px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<div>Status: {connectionStatus}</div>
|
||||
<div>Players: {Object.keys(players).length}</div>
|
||||
</div>
|
||||
|
||||
{/* Game canvas */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "800px",
|
||||
height: "600px",
|
||||
background: "#16213e",
|
||||
margin: "50px auto",
|
||||
border: "2px solid #0f3460",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{Object.entries(players).map(([id, player]) => (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: player.x,
|
||||
top: player.y,
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "50%",
|
||||
background: id === myPlayerId ? "#e94560" : "#53a8b6",
|
||||
border:
|
||||
id === myPlayerId ? "3px solid #ff6b6b" : "2px solid #48d6e0",
|
||||
transition: "all 0.1s linear",
|
||||
transform: "translate(-50%, -50%)",
|
||||
boxShadow:
|
||||
id === myPlayerId ? "0 0 10px #e94560" : "0 0 5px #53a8b6",
|
||||
}}
|
||||
>
|
||||
{id === myPlayerId && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
color: "#fff",
|
||||
fontSize: "10px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
You
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -24,8 +24,6 @@ a:hover {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user