refactored frontent to separate files

This commit is contained in:
2026-02-24 12:56:54 -07:00
parent 9dc6e9749c
commit 8bb086eac9
13 changed files with 369 additions and 309 deletions

1
.envrc
View File

@@ -1 +0,0 @@
use flake

View File

@@ -1,132 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { Socket, Channel } from "phoenix";
import "./App.css"; import "./App.css";
import { UserInput } from "./game/UserInput";
interface Player { import { BoardDisplay } from "./game/BoardDisplay";
x: number; import { ConnectionStatus } from "./game/ConnectionStatus";
y: number;
}
interface GameState {
[playerId: string]: Player;
}
// Connect to nginx load balancer
const WS_SERVER = "ws://localhost:4000/socket";
function App() { function App() {
const [players, setPlayers] = useState<GameState>({});
const [myPlayerId, setMyPlayerId] = useState<string | null>(null);
const [connectionStatus, setConnectionStatus] =
useState<string>("connecting");
const socketRef = useRef<Socket | null>(null);
const channelRef = useRef<Channel | null>(null);
const keysPressed = useRef<Set<string>>(new Set());
useEffect(() => {
// Connect to nginx load balancer
console.log(`Connecting to ${WS_SERVER}`);
const socket = new Socket(WS_SERVER, {
timeout: 3000,
reconnectAfterMs: (tries) =>
[1000, 2000, 5000, 10000][tries - 1] || 10000,
});
socket.onOpen(() => {
console.log(`✓ Connected to load balancer`);
setConnectionStatus("Connected");
});
socket.onError((error) => {
console.error(`✗ Connection error:`, error);
setConnectionStatus("Connection error");
});
socket.onClose(() => {
console.log(`✗ Disconnected from load balancer`);
setConnectionStatus("Disconnected - reconnecting...");
});
socket.connect();
socketRef.current = socket;
// Join game channel
const channel = socket.channel("game:lobby", {});
channel
.join()
.receive("ok", () => {
console.log(`✓ Joined game channel`);
setConnectionStatus("Connected & playing");
})
.receive("error", (resp) => {
console.log(`✗ Failed to join:`, resp);
setConnectionStatus("Failed to join game");
})
.receive("timeout", () => {
console.log(`✗ Timeout joining`);
setConnectionStatus("Connection timeout");
});
// Listen for game state updates
channel.on("game_state", (payload: { players: GameState }) => {
setPlayers(payload.players);
if (!myPlayerId && Object.keys(payload.players).length > 0) {
const playerIds = Object.keys(payload.players);
if (playerIds.length > 0) {
setMyPlayerId(playerIds[playerIds.length - 1]);
}
}
});
channelRef.current = channel;
// Cleanup on unmount
return () => {
channel.leave();
socket.disconnect();
};
}, [myPlayerId]);
// Handle keyboard input - send to active 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 active channel
const activeChannel = channelRef.current;
if (activeChannel && activeChannel.state === "joined") {
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 ( return (
<>
<UserInput />
<div <div
style={{ style={{
width: "100vw", width: "100vw",
@@ -136,74 +16,10 @@ function App() {
position: "relative", position: "relative",
}} }}
> >
{/* Connection status */} <ConnectionStatus />
<div <BoardDisplay />
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> </div>
</>
); );
} }

View File

@@ -0,0 +1,81 @@
import { createContext, useEffect, useState, type ReactNode } from "react";
import { Channel } from "phoenix";
import { useWebSocketContext } from "./useWebSocketContext";
interface GameChannelContextValue {
channel: Channel | null;
channelStatus: string;
isJoined: boolean;
}
const GameChannelContext = createContext<GameChannelContextValue | undefined>(
undefined,
);
interface GameChannelProviderProps {
channelName: string;
params?: Record<string, unknown>;
children: ReactNode;
}
export function GameChannelProvider({
channelName,
params = {},
children,
}: GameChannelProviderProps) {
const { socket, isConnected } = useWebSocketContext();
const [channelStatus, setChannelStatus] = useState<string>("waiting");
const [isJoined, setIsJoined] = useState(false);
const [channel, setChannel] = useState<Channel | null>(null);
useEffect(() => {
if (!socket || !isConnected) {
return;
}
console.log(`Joining channel: ${channelName}`);
const newChannel = socket.channel(channelName, params);
newChannel
.join()
.receive("ok", () => {
console.log(`✓ Joined channel: ${channelName}`);
setChannelStatus("joined");
setIsJoined(true);
})
.receive("error", (resp: unknown) => {
console.log(`✗ Failed to join ${channelName}:`, resp);
setChannelStatus("join failed");
setIsJoined(false);
})
.receive("timeout", () => {
console.log(`✗ Timeout joining ${channelName}`);
setChannelStatus("timeout");
setIsJoined(false);
});
// Set channel asynchronously to avoid cascading renders
setTimeout(() => setChannel(newChannel), 0);
return () => {
console.log(`Leaving channel: ${channelName}`);
newChannel.leave();
setChannel(null);
setIsJoined(false);
};
}, [socket, isConnected, channelName, params]);
return (
<GameChannelContext.Provider
value={{
channel,
channelStatus,
isJoined,
}}
>
{children}
</GameChannelContext.Provider>
);
}
export { GameChannelContext };

View File

@@ -0,0 +1,78 @@
import { createContext, useEffect, useState, type ReactNode } from "react";
import { Socket } from "phoenix";
interface WebSocketContextValue {
socket: Socket | null;
connectionStatus: string;
isConnected: boolean;
}
const WebSocketContext = createContext<WebSocketContextValue | undefined>(
undefined,
);
interface WebSocketProviderProps {
url: string;
children: ReactNode;
}
export function WebSocketProvider({ url, children }: WebSocketProviderProps) {
const [connectionStatus, setConnectionStatus] =
useState<string>("connecting");
const [isConnected, setIsConnected] = useState(false);
const [socket, setSocket] = useState<Socket | null>(null);
useEffect(() => {
console.log(`Connecting to ${url}`);
const newSocket = new Socket(url, {
timeout: 100,
reconnectAfterMs: (tries) =>
[
300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300,
300, 5000, 10000,
][tries - 1] || 10000,
});
newSocket.onOpen(() => {
console.log(`✓ Connected to server`);
setConnectionStatus("Connected");
setIsConnected(true);
});
newSocket.onError((error) => {
console.error(`✗ Connection error:`, error);
setConnectionStatus("Connection error");
setIsConnected(false);
});
newSocket.onClose(() => {
console.log(`✗ Disconnected from server`);
setConnectionStatus("Disconnected - reconnecting...");
setIsConnected(false);
});
newSocket.connect();
// Set socket state asynchronously to avoid cascading renders
setTimeout(() => setSocket(newSocket), 0);
return () => {
newSocket.disconnect();
setSocket(null);
};
}, [url]);
return (
<WebSocketContext.Provider
value={{
socket,
connectionStatus,
isConnected,
}}
>
{children}
</WebSocketContext.Provider>
);
}
export { WebSocketContext };

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { GameChannelContext } from "./GameChannelContext";
export function useGameChannelContext() {
const context = useContext(GameChannelContext);
if (context === undefined) {
throw new Error("useGameChannel must be used within a GameChannelProvider");
}
return context;
}

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { WebSocketContext } from "./WebSocketContext";
export function useWebSocketContext() {
const context = useContext(WebSocketContext);
if (context === undefined) {
throw new Error("useWebSocket must be used within a WebSocketProvider");
}
return context;
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { useGameChannelContext } from "../contexts/useGameChannelContext";
interface Player {
x: number;
y: number;
}
interface GameState {
[playerId: string]: Player;
}
export const BoardDisplay = () => {
const { channel, isJoined } = useGameChannelContext();
const [players, setPlayers] = useState<GameState>({});
const [myPlayerId, setMyPlayerId] = useState<string | null>(null);
useEffect(() => {
if (!channel || !isJoined) return;
// Listen for game state updates
const ref = channel.on("game_state", (payload: { players: GameState }) => {
setPlayers(payload.players);
if (!myPlayerId && Object.keys(payload.players).length > 0) {
const playerIds = Object.keys(payload.players);
if (playerIds.length > 0) {
setMyPlayerId(playerIds[playerIds.length - 1]);
}
}
});
// Cleanup listener on unmount
return () => {
channel.off("game_state", ref);
};
}, [channel, isJoined, myPlayerId]);
return (
<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>
);
};

View File

@@ -0,0 +1,26 @@
import { useWebSocketContext } from "../contexts/useWebSocketContext";
import { useGameChannelContext } from "../contexts/useGameChannelContext";
export const ConnectionStatus = () => {
const { connectionStatus } = useWebSocketContext();
const { channelStatus } = useGameChannelContext();
return (
<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>WebSocket: {connectionStatus}</div>
<div>Channel: {channelStatus}</div>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import { useGameChannelContext } from "../contexts/useGameChannelContext";
export const UserInput = () => {
const { channel } = useGameChannelContext();
const keysPressed = useRef<Set<string>>(new Set());
useEffect(() => {
if (!channel) return;
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 active channel
if (channel.state === "joined") {
channel.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);
};
}, [channel]);
return null;
};

View File

@@ -1,10 +1,18 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import './index.css' import "./index.css";
import App from './App.tsx' import App from "./App.tsx";
import { GameChannelProvider } from "./contexts/GameChannelContext";
import { WebSocketProvider } from "./contexts/WebSocketContext.tsx";
createRoot(document.getElementById('root')!).render( const WS_SERVER = "ws://localhost:4000/socket";
createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<WebSocketProvider url={WS_SERVER}>
<GameChannelProvider channelName="game:lobby">
<App /> <App />
</GameChannelProvider>
</WebSocketProvider>
</StrictMode>, </StrictMode>,
) );

61
flake.lock generated
View File

@@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1771369470,
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0182a361324364ae3f436a63005877674cf45efb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,39 +0,0 @@
{
description = "Development environment with pnpm and Elixir";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Elixir and Erlang
elixir
elixir-ls
# Node.js and pnpm
nodejs_22
pnpm
# Additional tools
git
inotify-tools
];
shellHook = ''
echo "🚀 Development environment loaded"
echo "Elixir version: $(elixir --version | head -n 1)"
echo "Node.js version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
'';
};
}
);
}

View File

@@ -3,11 +3,9 @@ upstream phoenix_backend {
# Note: Each new connection gets a new key, so reconnections may route differently # Note: Each new connection gets a new key, so reconnections may route differently
hash $http_sec_websocket_key consistent; hash $http_sec_websocket_key consistent;
# Failover configuration: mark server as down after 3 failed attempts within 30s server phoenix1:4000 max_fails=1 fail_timeout=10s;
# Server will be retried after 30s server phoenix2:4000 max_fails=1 fail_timeout=10s;
server phoenix1:4000 max_fails=1 fail_timeout=30s; server phoenix3:4000 max_fails=1 fail_timeout=10s;
server phoenix2:4000 max_fails=1 fail_timeout=30s;
server phoenix3:4000 max_fails=1 fail_timeout=30s;
} }
server { server {