feat(frontend): add WebSocket client service with identity and typed messages
This commit is contained in:
parent
66db5b1931
commit
906fe59add
5 changed files with 361 additions and 0 deletions
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_WS_URL=ws://localhost:8080
|
||||||
105
frontend/src/net/client.ts
Normal file
105
frontend/src/net/client.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// Owns the single WebSocket connection to the multiplayer server. See
|
||||||
|
// backend/src/PROTOCOL.md for the wire contract this implements.
|
||||||
|
|
||||||
|
import { getOrCreatePlayerId, getPlayerName } from './identity';
|
||||||
|
import type { ClientMessage, ServerMessage } from './messages';
|
||||||
|
|
||||||
|
const DEFAULT_WS_URL = 'ws://localhost:8080';
|
||||||
|
|
||||||
|
/** `VITE_WS_URL` (see `frontend/.env.example`), defaulting to the local
|
||||||
|
* dev backend. */
|
||||||
|
export const WS_URL = import.meta.env.VITE_WS_URL || DEFAULT_WS_URL;
|
||||||
|
|
||||||
|
/** Lifecycle of the underlying socket. `closed` covers both a clean
|
||||||
|
* close and an error — reconnecting is out of scope here, callers that
|
||||||
|
* care just need to know the connection is gone. */
|
||||||
|
export type ConnectionState = 'connecting' | 'open' | 'closed';
|
||||||
|
|
||||||
|
type ServerMessageOfType<K extends ServerMessage['type']> = Extract<ServerMessage, { type: K }>;
|
||||||
|
type MessageListener<K extends ServerMessage['type']> = (msg: ServerMessageOfType<K>) => void;
|
||||||
|
type Unsubscribe = () => void;
|
||||||
|
|
||||||
|
/** Thin wrapper around a browser `WebSocket` that speaks the theme-guess
|
||||||
|
* protocol: sends `identify` as soon as the socket opens, and gives
|
||||||
|
* callers a typed `send`/`on` API instead of hand-parsing `event.data`. */
|
||||||
|
export class GameClient {
|
||||||
|
private socket: WebSocket | null = null;
|
||||||
|
private state: ConnectionState = 'closed';
|
||||||
|
private readonly listeners = new Map<string, Set<MessageListener<any>>>();
|
||||||
|
private readonly stateListeners = new Set<(state: ConnectionState) => void>();
|
||||||
|
|
||||||
|
constructor(private readonly url: string = WS_URL) {}
|
||||||
|
|
||||||
|
/** Opens the socket and sends `identify` once it's open. */
|
||||||
|
connect(): void {
|
||||||
|
this.setState('connecting');
|
||||||
|
const socket = new WebSocket(this.url);
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
this.setState('open');
|
||||||
|
this.send({ type: 'identify', playerId: getOrCreatePlayerId(), name: getPlayerName() });
|
||||||
|
});
|
||||||
|
socket.addEventListener('message', (event) => this.handleMessage(event.data));
|
||||||
|
socket.addEventListener('close', () => this.setState('closed'));
|
||||||
|
socket.addEventListener('error', () => this.setState('closed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the socket. No reconnect is attempted. */
|
||||||
|
close(): void {
|
||||||
|
this.socket?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): ConnectionState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a typed protocol message as JSON. Throws if the socket isn't
|
||||||
|
* open — callers should check `getState()` (or react to
|
||||||
|
* `onStateChange`) before sending. */
|
||||||
|
send<T extends ClientMessage>(msg: T): void {
|
||||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error('GameClient: cannot send, socket is not open');
|
||||||
|
}
|
||||||
|
this.socket.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribes to one server message type. Returns an unsubscribe
|
||||||
|
* function. */
|
||||||
|
on<K extends ServerMessage['type']>(type: K, cb: MessageListener<K>): Unsubscribe {
|
||||||
|
let set = this.listeners.get(type);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.listeners.set(type, set);
|
||||||
|
}
|
||||||
|
set.add(cb);
|
||||||
|
return () => set!.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribes to connection lifecycle changes, e.g. to surface a
|
||||||
|
* "connection lost" state in the UI. Returns an unsubscribe
|
||||||
|
* function. */
|
||||||
|
onStateChange(cb: (state: ConnectionState) => void): Unsubscribe {
|
||||||
|
this.stateListeners.add(cb);
|
||||||
|
return () => this.stateListeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: ConnectionState): void {
|
||||||
|
if (this.state === state) return;
|
||||||
|
this.state = state;
|
||||||
|
for (const cb of this.stateListeners) cb(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(data: unknown): void {
|
||||||
|
if (typeof data !== 'string') return;
|
||||||
|
let msg: ServerMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(data) as ServerMessage;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const set = this.listeners.get(msg.type);
|
||||||
|
if (!set) return;
|
||||||
|
for (const cb of set) cb(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/src/net/identity.ts
Normal file
33
frontend/src/net/identity.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Per-browser player identity. There is no account system (see
|
||||||
|
// backend/src/PROTOCOL.md's "Identity" section) — just a stable UUID
|
||||||
|
// generated once and reused, plus a freely editable display name. Both
|
||||||
|
// are persisted in localStorage so they survive reloads and reconnects.
|
||||||
|
|
||||||
|
const PLAYER_ID_KEY = 'themeGuess.playerId';
|
||||||
|
const PLAYER_NAME_KEY = 'themeGuess.playerName';
|
||||||
|
|
||||||
|
/** Returns the persisted `playerId`, generating and storing a new
|
||||||
|
* `crypto.randomUUID()` the first time this browser is seen. */
|
||||||
|
export function getOrCreatePlayerId(): string {
|
||||||
|
const existing = localStorage.getItem(PLAYER_ID_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = crypto.randomUUID();
|
||||||
|
localStorage.setItem(PLAYER_ID_KEY, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the persisted display name, falling back to (and persisting)
|
||||||
|
* a generated default derived from the player's id so `identify` never
|
||||||
|
* has to send an empty `name`. */
|
||||||
|
export function getPlayerName(): string {
|
||||||
|
const existing = localStorage.getItem(PLAYER_NAME_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const fallback = `Player${getOrCreatePlayerId().slice(0, 4)}`;
|
||||||
|
localStorage.setItem(PLAYER_NAME_KEY, fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overwrites the persisted display name. */
|
||||||
|
export function setPlayerName(name: string): void {
|
||||||
|
localStorage.setItem(PLAYER_NAME_KEY, name);
|
||||||
|
}
|
||||||
212
frontend/src/net/messages.ts
Normal file
212
frontend/src/net/messages.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
// Hand-maintained TypeScript twin of the WebSocket JSON contract
|
||||||
|
// described in `backend/src/PROTOCOL.md`. There is no shared
|
||||||
|
// package/codegen between frontend and backend (see AGENTS.md), so this
|
||||||
|
// file only exists to give the frontend compile-time safety around
|
||||||
|
// message shapes — `backend/src/PROTOCOL.md` is the source of truth;
|
||||||
|
// keep this in sync with it by hand whenever the protocol changes.
|
||||||
|
|
||||||
|
import type { CategoryId, ThemeId } from '../types';
|
||||||
|
|
||||||
|
export type TimeMode = 1 | 2;
|
||||||
|
export type ThemeMode = 'random' | 'chosen';
|
||||||
|
export type RoomErrorReason = 'not_found' | 'full';
|
||||||
|
export type RoomClosedReason = 'quit' | 'matchLimit' | 'finished';
|
||||||
|
|
||||||
|
// -- Identity -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Client -> server, always the first message on a connection. */
|
||||||
|
export interface IdentifyMessage {
|
||||||
|
type: 'identify';
|
||||||
|
playerId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> client, acknowledges `identify`. */
|
||||||
|
export interface IdentifyAckMessage {
|
||||||
|
type: 'identify:ack';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Round lifecycle (shared by matchmaking and rooms) -------------------
|
||||||
|
|
||||||
|
/** Server -> both players, start of a round. */
|
||||||
|
export interface RoundStartMessage {
|
||||||
|
type: 'round:start';
|
||||||
|
snippetIndex: number;
|
||||||
|
themeId: ThemeId;
|
||||||
|
timeMode: TimeMode;
|
||||||
|
endsAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client -> server -> relayed to opponent; fired per category painted.
|
||||||
|
* Carries only which category changed, never the color value. */
|
||||||
|
export interface RoundProgressMessage {
|
||||||
|
type: 'round:progress';
|
||||||
|
categoryId: CategoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> both players, fired once, 20s before `endsAt`. */
|
||||||
|
export interface RoundTimeWarningMessage {
|
||||||
|
type: 'round:timeWarning';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client -> server, a player's final full guess. */
|
||||||
|
export interface RoundSubmitMessage {
|
||||||
|
type: 'round:submit';
|
||||||
|
colors: Record<CategoryId, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> both players, sent once `endsAt` has passed. Keyed by both
|
||||||
|
* players' `playerId`; a player who never submitted may be omitted. */
|
||||||
|
export interface RoundRevealMessage {
|
||||||
|
type: 'round:reveal';
|
||||||
|
colors: Record<string, Record<CategoryId, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> both players, private rooms only: next match in the series
|
||||||
|
* is about to start. */
|
||||||
|
export interface RoundNextMatchMessage {
|
||||||
|
type: 'round:nextMatch';
|
||||||
|
match: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Matchmaking ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Client -> server, join the matchmaking queue. */
|
||||||
|
export interface QueueJoinMessage {
|
||||||
|
type: 'queue:join';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client -> server, leave the matchmaking queue. */
|
||||||
|
export interface QueueLeaveMessage {
|
||||||
|
type: 'queue:leave';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> client, sent instead of queuing while matchmaking-banned. */
|
||||||
|
export interface QueueBannedMessage {
|
||||||
|
type: 'queue:banned';
|
||||||
|
bannedUntil: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> both matched players, transition out of the queue. */
|
||||||
|
export interface MatchFoundMessage {
|
||||||
|
type: 'match:found';
|
||||||
|
opponentName: string;
|
||||||
|
timeMode: TimeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Private rooms ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Client -> server, create a private room. */
|
||||||
|
export interface RoomCreateMessage {
|
||||||
|
type: 'room:create';
|
||||||
|
timeMode: TimeMode;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> creator, with the room's shareable code. */
|
||||||
|
export interface RoomCreatedMessage {
|
||||||
|
type: 'room:created';
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client -> server, attempt to join an existing room by code. */
|
||||||
|
export interface RoomJoinMessage {
|
||||||
|
type: 'room:join';
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> both players, once a second player joins a room. */
|
||||||
|
export interface RoomJoinedMessage {
|
||||||
|
type: 'room:joined';
|
||||||
|
code: number;
|
||||||
|
opponentName: string;
|
||||||
|
timeMode: TimeMode;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> the joining client only, when `room:join` fails. */
|
||||||
|
export interface RoomErrorMessage {
|
||||||
|
type: 'room:error';
|
||||||
|
reason: RoomErrorReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> the remaining player, opponent is gone. */
|
||||||
|
export interface RoomPlayerLeftMessage {
|
||||||
|
type: 'room:playerLeft';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> whichever player(s) are still connected, terminal room
|
||||||
|
* message. */
|
||||||
|
export interface RoomClosedMessage {
|
||||||
|
type: 'room:closed';
|
||||||
|
reason: RoomClosedReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Theme voting (private rooms with `themeMode: 'chosen'`) --------------
|
||||||
|
|
||||||
|
/** Server -> both players, a 10s countdown to pick a theme. */
|
||||||
|
export interface VoteStartMessage {
|
||||||
|
type: 'vote:start';
|
||||||
|
themeIds: ThemeId[];
|
||||||
|
endsAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client -> server, cast (or re-cast) a vote. */
|
||||||
|
export interface VoteCastMessage {
|
||||||
|
type: 'vote:cast';
|
||||||
|
themeId: ThemeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> the other player, live relay of an accepted vote. */
|
||||||
|
export interface VoteOpponentChoiceMessage {
|
||||||
|
type: 'vote:opponentChoice';
|
||||||
|
themeId: ThemeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server -> both players, once voting's `endsAt` elapses. */
|
||||||
|
export interface VoteSettledMessage {
|
||||||
|
type: 'vote:settled';
|
||||||
|
themeId: ThemeId;
|
||||||
|
agreed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Quit -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Client -> server, explicit leave (also synthesized server-side on an
|
||||||
|
* unexpected socket close). */
|
||||||
|
export interface PlayerQuitMessage {
|
||||||
|
type: 'player:quit';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Unions -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Messages this client ever sends to the server. */
|
||||||
|
export type ClientMessage =
|
||||||
|
| IdentifyMessage
|
||||||
|
| QueueJoinMessage
|
||||||
|
| QueueLeaveMessage
|
||||||
|
| RoomCreateMessage
|
||||||
|
| RoomJoinMessage
|
||||||
|
| RoundProgressMessage
|
||||||
|
| RoundSubmitMessage
|
||||||
|
| VoteCastMessage
|
||||||
|
| PlayerQuitMessage;
|
||||||
|
|
||||||
|
/** Messages this client ever receives from the server. */
|
||||||
|
export type ServerMessage =
|
||||||
|
| IdentifyAckMessage
|
||||||
|
| QueueBannedMessage
|
||||||
|
| MatchFoundMessage
|
||||||
|
| RoundStartMessage
|
||||||
|
| RoundProgressMessage
|
||||||
|
| RoundTimeWarningMessage
|
||||||
|
| RoundRevealMessage
|
||||||
|
| RoundNextMatchMessage
|
||||||
|
| RoomCreatedMessage
|
||||||
|
| RoomJoinedMessage
|
||||||
|
| RoomErrorMessage
|
||||||
|
| RoomPlayerLeftMessage
|
||||||
|
| RoomClosedMessage
|
||||||
|
| VoteStartMessage
|
||||||
|
| VoteOpponentChoiceMessage
|
||||||
|
| VoteSettledMessage;
|
||||||
10
frontend/src/vite-env.d.ts
vendored
Normal file
10
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** Multiplayer WebSocket server URL. See `.env.example`. */
|
||||||
|
readonly VITE_WS_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue