diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..2ec742f --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_WS_URL=ws://localhost:8080 diff --git a/frontend/src/net/client.ts b/frontend/src/net/client.ts new file mode 100644 index 0000000..77e71dc --- /dev/null +++ b/frontend/src/net/client.ts @@ -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 = Extract; +type MessageListener = (msg: ServerMessageOfType) => 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>>(); + 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(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(type: K, cb: MessageListener): 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); + } +} diff --git a/frontend/src/net/identity.ts b/frontend/src/net/identity.ts new file mode 100644 index 0000000..d8eb81c --- /dev/null +++ b/frontend/src/net/identity.ts @@ -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); +} diff --git a/frontend/src/net/messages.ts b/frontend/src/net/messages.ts new file mode 100644 index 0000000..247eef8 --- /dev/null +++ b/frontend/src/net/messages.ts @@ -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; +} + +/** 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>; +} + +/** 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; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..36d330a --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + /** Multiplayer WebSocket server URL. See `.env.example`. */ + readonly VITE_WS_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}