fast failover
This commit is contained in:
20
client/dev.Dockerfile
Normal file
20
client/dev.Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ENV USER="node"
|
||||
|
||||
RUN addgroup -g 1000 $USER && \
|
||||
adduser -D -u 1000 -G $USER $USER || true
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["pnpm", "dev", "--host"]
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
import { UserInput } from "./game/UserInput";
|
||||
import { BoardDisplay } from "./game/BoardDisplay";
|
||||
import { ConnectionStatus } from "./game/ConnectionStatus";
|
||||
import { NameInput } from "./game/NameInput";
|
||||
|
||||
const getPlayerNameFromUrl = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("name") || null;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [playerName, setPlayerName] = useState<string | null>(
|
||||
getPlayerNameFromUrl,
|
||||
);
|
||||
|
||||
if (!playerName) {
|
||||
return <NameInput onNameSubmit={setPlayerName} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserInput />
|
||||
<UserInput playerName={playerName} />
|
||||
<div
|
||||
style={{
|
||||
width: "100vw",
|
||||
@@ -17,7 +32,7 @@ function App() {
|
||||
}}
|
||||
>
|
||||
<ConnectionStatus />
|
||||
<BoardDisplay />
|
||||
<BoardDisplay playerName={playerName} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ interface GameChannelContextValue {
|
||||
channel: Channel | null;
|
||||
channelStatus: string;
|
||||
isJoined: boolean;
|
||||
joinGame: (name: string) => void;
|
||||
}
|
||||
|
||||
const GameChannelContext = createContext<GameChannelContextValue | undefined>(
|
||||
@@ -40,18 +41,15 @@ export function GameChannelProvider({
|
||||
.join()
|
||||
.receive("ok", () => {
|
||||
console.log(`✓ Joined channel: ${channelName}`);
|
||||
setChannelStatus("joined");
|
||||
setIsJoined(true);
|
||||
setChannelStatus("connected");
|
||||
})
|
||||
.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
|
||||
@@ -62,15 +60,34 @@ export function GameChannelProvider({
|
||||
newChannel.leave();
|
||||
setChannel(null);
|
||||
setIsJoined(false);
|
||||
setChannelStatus("waiting");
|
||||
};
|
||||
}, [socket, isConnected, channelName, params]);
|
||||
|
||||
const joinGame = (name: string) => {
|
||||
if (!channel) return;
|
||||
|
||||
channel
|
||||
.push("join_game", { name })
|
||||
.receive("ok", () => {
|
||||
console.log(`✓ Joined game as: ${name}`);
|
||||
setChannelStatus("joined");
|
||||
setIsJoined(true);
|
||||
})
|
||||
.receive("error", (resp: unknown) => {
|
||||
console.log(`✗ Failed to join game:`, resp);
|
||||
setChannelStatus("join failed");
|
||||
setIsJoined(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GameChannelContext.Provider
|
||||
value={{
|
||||
channel,
|
||||
channelStatus,
|
||||
isJoined,
|
||||
joinGame,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -7,34 +7,35 @@ interface Player {
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
[playerId: string]: Player;
|
||||
[playerName: string]: Player;
|
||||
}
|
||||
|
||||
export const BoardDisplay = () => {
|
||||
const { channel, isJoined } = useGameChannelContext();
|
||||
export const BoardDisplay = ({ playerName }: {
|
||||
playerName: string;
|
||||
}) => {
|
||||
const { channel, isJoined, joinGame } = useGameChannelContext();
|
||||
const [players, setPlayers] = useState<GameState>({});
|
||||
const [myPlayerId, setMyPlayerId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channel || !isJoined) return;
|
||||
if (channel && !isJoined) {
|
||||
// Send join_game message with player name
|
||||
joinGame(playerName);
|
||||
}
|
||||
}, [channel, isJoined, playerName, joinGame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channel) 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]);
|
||||
}, [channel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -48,9 +49,9 @@ export const BoardDisplay = () => {
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{Object.entries(players).map(([id, player]) => (
|
||||
{Object.entries(players).map(([name, player]) => (
|
||||
<div
|
||||
key={id}
|
||||
key={name}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: player.x,
|
||||
@@ -58,30 +59,28 @@ export const BoardDisplay = () => {
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "50%",
|
||||
background: id === myPlayerId ? "#e94560" : "#53a8b6",
|
||||
background: name === playerName ? "#e94560" : "#53a8b6",
|
||||
border:
|
||||
id === myPlayerId ? "3px solid #ff6b6b" : "2px solid #48d6e0",
|
||||
name === playerName ? "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",
|
||||
name === playerName ? "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
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
color: "#fff",
|
||||
fontSize: "10px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
105
client/src/game/NameInput.tsx
Normal file
105
client/src/game/NameInput.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface NameInputProps {
|
||||
onNameSubmit: (name: string) => void;
|
||||
}
|
||||
|
||||
const getNameFromUrl = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("name") || "";
|
||||
};
|
||||
|
||||
export const NameInput = ({ onNameSubmit }: NameInputProps) => {
|
||||
const [name, setName] = useState(getNameFromUrl);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
// Update URL with name
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("name", name.trim());
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${window.location.pathname}?${params}`,
|
||||
);
|
||||
|
||||
onNameSubmit(name.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
background: "#1a1a2e",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#16213e",
|
||||
padding: "40px",
|
||||
borderRadius: "10px",
|
||||
border: "2px solid #0f3460",
|
||||
maxWidth: "400px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
color: "#e94560",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "24px",
|
||||
marginBottom: "20px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Enter Your Name
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name..."
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
fontSize: "16px",
|
||||
background: "#0f3460",
|
||||
border: "2px solid #53a8b6",
|
||||
borderRadius: "5px",
|
||||
color: "white",
|
||||
fontFamily: "monospace",
|
||||
marginBottom: "20px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
fontSize: "16px",
|
||||
background: name.trim() ? "#e94560" : "#666",
|
||||
border: "none",
|
||||
borderRadius: "5px",
|
||||
color: "white",
|
||||
fontFamily: "monospace",
|
||||
cursor: name.trim() ? "pointer" : "not-allowed",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Join Game
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGameChannelContext } from "../contexts/useGameChannelContext";
|
||||
|
||||
export const UserInput = () => {
|
||||
export const UserInput = ({ playerName }: { playerName: string }) => {
|
||||
const { channel } = useGameChannelContext();
|
||||
const keysPressed = useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -12,16 +12,7 @@ export const UserInput = () => {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
keysPressed.current.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,14 +23,28 @@ export const UserInput = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
keysPressed.current.clear();
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (keysPressed.current.size > 0 && channel.state === "joined") {
|
||||
const directions = Array.from(keysPressed.current);
|
||||
channel.push("move", { directions, name: playerName });
|
||||
}
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [channel]);
|
||||
}, [channel, playerName]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user