docs: add contributor and Colyseus guides
feat(client): parametrize WS/API via VITE_WS_URL and VITE_API_URL; add .env.example and README env section
This commit is contained in:
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `server/`: Colyseus + Express TypeScript backend (`src/index.ts`, rooms, schemas, utils, `adminApi.ts`).
|
||||||
|
- `client/`: Vue 3 + Vite TypeScript app (`src/views`, `services/colyseus.ts`, `router`, `main.ts`).
|
||||||
|
- `shared/`: Cross-cutting TypeScript types (`shared/types.ts`).
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- From repo root:
|
||||||
|
- `npm run install:all`: Install client and server dependencies.
|
||||||
|
- `npm run dev`: Run server and client in watch mode.
|
||||||
|
- `npm run build`: Type-check and build server and client.
|
||||||
|
- Per package:
|
||||||
|
- Server: `cd server && npm run dev | build | start`.
|
||||||
|
- Client: `cd client && npm run dev | build | preview`.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- Language: TypeScript (server/client). Indentation: 2 spaces.
|
||||||
|
- Quotes/Semicolons: double quotes and trailing semicolons (match existing code).
|
||||||
|
- Naming: `PascalCase` for classes and Vue SFC files (e.g., `Game.vue`), `camelCase` for variables/functions, `kebab-case` for non-component file names when appropriate.
|
||||||
|
- Keep server state changes server-authoritative; avoid mutating Colyseus state on client.
|
||||||
|
- No linter is configured; keep changes consistent with surrounding files.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- No tests yet. Prefer adding:
|
||||||
|
- Client: Vitest + Vue Test Utils for views/services.
|
||||||
|
- Server: Jest for room logic and schema helpers.
|
||||||
|
- Conventions: mirror source path and name tests `*.spec.ts` (e.g., `server/src/rooms/GameRoom.spec.ts`).
|
||||||
|
- CI-friendly: tests must run headless and not require a live server.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Commits: follow Conventional Commits (`feat:`, `fix:`, `docs:`) as in history.
|
||||||
|
- PRs include:
|
||||||
|
- Clear description and rationale; link issues (e.g., `Fixes #123`).
|
||||||
|
- Screenshots/GIFs for UI changes (`client/src/views/*`).
|
||||||
|
- Steps to reproduce and test notes.
|
||||||
|
- Scope limited to one logical change; update README/QUICKSTART if behavior changes.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
- Server port via `PORT` (default 3000). Monitor at `/colyseus`, REST at `/api`.
|
||||||
|
- Client WebSocket URL is set in `client/src/services/colyseus.ts`; consider env (`VITE_WS_URL`) for deployments.
|
||||||
|
- CORS is enabled; restrict origins in production.
|
||||||
117
COLYSEUS-GUIA.md
Normal file
117
COLYSEUS-GUIA.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Colyseus: Guía de Integración para Snatchgame
|
||||||
|
|
||||||
|
Esta guía resume las APIs de Colyseus necesarias para: un lobby unificado, salas de juego (máx. 2 jugadores) y un dashboard para inspección/acciones por sala y globales.
|
||||||
|
|
||||||
|
## Conceptos Clave
|
||||||
|
- Rooms: unidades de ejecución con ciclo de vida (`onCreate`, `onJoin`, `onLeave`, `onDispose`).
|
||||||
|
- Schema: sincroniza estado autoritativo del servidor a clientes con `@type`.
|
||||||
|
- Mensajería: `room.onMessage(type, handler)`, `client.send(type, payload)`, `room.broadcast(type, payload)`.
|
||||||
|
- Matchmaker: descubrimiento/control de salas (`matchMaker.query`, `remoteRoomCall`, `stats`).
|
||||||
|
- Monitor: panel listo para usar en Express (`@colyseus/monitor`).
|
||||||
|
|
||||||
|
## Lobby Unificado
|
||||||
|
- Definir una sala pública única `lobby` que gestione nombres, matchmaking y listado.
|
||||||
|
- API útil: `gameServer.define("lobby", LobbyRoom)`, `setPrivate(false)`, `enableRealtimeListing()` en salas de juego para listar.
|
||||||
|
- Mensajes recomendados: `setName`, `quickPlay`, `joinRoom`.
|
||||||
|
|
||||||
|
Ejemplo (server/src/rooms/LobbyRoom.ts):
|
||||||
|
```ts
|
||||||
|
export class LobbyRoom extends Room<LobbyState> {
|
||||||
|
onCreate() {
|
||||||
|
this.setState(new LobbyState());
|
||||||
|
this.onMessage("setName", (client, name) => {/* validar + asignar */});
|
||||||
|
this.onMessage("quickPlay", async (client) => {
|
||||||
|
// Crear o elegir una sala "game" con cupo
|
||||||
|
const rooms = await matchMaker.query({ name: "game", locked: false });
|
||||||
|
const target = rooms.find(r => r.clients < r.maxClients) ||
|
||||||
|
await matchMaker.createRoom("game", { maxClients: 2 });
|
||||||
|
client.send("gameJoined", { roomId: target.roomId || target.roomId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Game Rooms (2 jugadores)
|
||||||
|
- Definición: `gameServer.define("game", GameRoom).filterBy(["maxClients"]).enableRealtimeListing()`.
|
||||||
|
- Capacidad: `maxClients = 2;` en la clase de la sala.
|
||||||
|
- Re-conexión: `await this.allowReconnection(client, 30);` para tolerancia a fallos.
|
||||||
|
- Ticks: usar `this.setSimulationInterval((deltaTime) => { /* actualizar timer */ }, 1000);`.
|
||||||
|
|
||||||
|
Ejemplo (server/src/rooms/GameRoom.ts):
|
||||||
|
```ts
|
||||||
|
export class GameRoom extends Room<GameState> {
|
||||||
|
maxClients = 2;
|
||||||
|
onCreate() {
|
||||||
|
this.setState(new GameState());
|
||||||
|
this.onMessage("click", (client) => {/* sumar clicks si PLAYING */});
|
||||||
|
}
|
||||||
|
onJoin(client, { playerName }) {
|
||||||
|
this.state.addPlayer(client.sessionId, playerName);
|
||||||
|
if (this.state.players.size === 2) this.state.startGame();
|
||||||
|
}
|
||||||
|
onLeave(client) {
|
||||||
|
this.state.players.get(client.sessionId)!.connected = false;
|
||||||
|
this.allowReconnection(client, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estado Autoritativo (Schema)
|
||||||
|
- Definir modelos con `@type` y colecciones `MapSchema`/`ArraySchema`.
|
||||||
|
- Patrón: solo el servidor muta estado; clientes envían mensajes de intención.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
```ts
|
||||||
|
export class GameState extends Schema {
|
||||||
|
@type({ map: Player }) players = new MapSchema<Player>();
|
||||||
|
@type("string") gameStatus: GameStatus = GameStatus.WAITING;
|
||||||
|
@type("number") timeRemaining = 600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard y Control Administrativo
|
||||||
|
- Monitor integrado: `app.use("/colyseus", monitor());` para ver salas/CCU.
|
||||||
|
- REST/Admin personalizado vía `matchMaker`:
|
||||||
|
- Listar: `await matchMaker.query({ name: "game" });`
|
||||||
|
- Llamar métodos remotos: `await matchMaker.remoteRoomCall(roomId, "getState");`
|
||||||
|
- Acciones por sala: `await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:pause"]);`
|
||||||
|
|
||||||
|
Ejemplos (server/src/adminApi.ts):
|
||||||
|
```ts
|
||||||
|
router.get("/rooms", async (_, res) => {
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
res.json(rooms);
|
||||||
|
});
|
||||||
|
router.post("/rooms/:roomId/restart", async (req, res) => {
|
||||||
|
await matchMaker.remoteRoomCall(req.params.roomId, "broadcast", ["admin:restart"]);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Acciones globales (todas las salas "game"):
|
||||||
|
```ts
|
||||||
|
const rooms = await matchMaker.query({ name: "game" });
|
||||||
|
await Promise.all(rooms.map(r => matchMaker.remoteRoomCall(r.roomId, "broadcast", ["admin:pause"])));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Buenas Prácticas
|
||||||
|
- Validar inputs en `onMessage` y nunca confiar en el cliente.
|
||||||
|
- Usar `metadata`/`filterBy` para matchmaking por atributos.
|
||||||
|
- Mantener `maxClients` y reglas de inicio/pausa dentro del servidor.
|
||||||
|
- Exponer solo métodos necesarios para `remoteRoomCall` (p. ej., `getState`).
|
||||||
|
|
||||||
|
Referencias: documentación oficial Colyseus (Rooms, Schema, Matchmaker, Monitor).
|
||||||
|
|
||||||
|
## Configuración de WebSocket (Cliente)
|
||||||
|
- Variable de entorno en Vite: `VITE_WS_URL`.
|
||||||
|
- Si no se define, el cliente usa `ws(s)://<hostname>:3000` según el protocolo actual.
|
||||||
|
- Ejemplos:
|
||||||
|
- Desarrollo local: `VITE_WS_URL=ws://localhost:3000`
|
||||||
|
- Producción (TLS): `VITE_WS_URL=wss://api.midominio.com`
|
||||||
|
|
||||||
|
## Configuración de REST API (Cliente)
|
||||||
|
- Variable de entorno en Vite: `VITE_API_URL` con la base completa del API (incluye `/api`).
|
||||||
|
- Si no se define, el cliente usa `http(s)://<hostname>:3000/api` según el protocolo actual.
|
||||||
|
- Ejemplos:
|
||||||
|
- Desarrollo local: `VITE_API_URL=http://localhost:3000/api`
|
||||||
|
- Producción (TLS): `VITE_API_URL=https://api.midominio.com/api`
|
||||||
11
README.md
11
README.md
@@ -188,6 +188,17 @@ This will start:
|
|||||||
- Server on http://localhost:3000
|
- Server on http://localhost:3000
|
||||||
- Client on http://localhost:5173
|
- Client on http://localhost:5173
|
||||||
|
|
||||||
|
### Environment Variables (Client)
|
||||||
|
|
||||||
|
Create `client/.env` (or copy `client/.env.example`) to configure endpoints:
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_WS_URL=ws://localhost:3000
|
||||||
|
VITE_API_URL=http://localhost:3000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
For production over HTTPS, use `wss://` for `VITE_WS_URL` and an HTTPS API base for `VITE_API_URL`.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
- **Game**: http://localhost:5173
|
- **Game**: http://localhost:5173
|
||||||
|
|||||||
10
client/.env.example
Normal file
10
client/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Client environment variables (copy to client/.env)
|
||||||
|
|
||||||
|
# WebSocket endpoint for Colyseus server
|
||||||
|
# e.g., ws://localhost:3000 (dev) or wss://api.my-domain.com (prod)
|
||||||
|
VITE_WS_URL=ws://localhost:3000
|
||||||
|
|
||||||
|
# REST API base (must include /api)
|
||||||
|
# e.g., http://localhost:3000/api (dev) or https://api.my-domain.com/api (prod)
|
||||||
|
VITE_API_URL=http://localhost:3000/api
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ export interface AvailableRoom {
|
|||||||
class ColyseusService {
|
class ColyseusService {
|
||||||
private client: Client;
|
private client: Client;
|
||||||
private currentRoom: Room | null = null;
|
private currentRoom: Room | null = null;
|
||||||
|
private apiBase: string;
|
||||||
|
|
||||||
public lobbyRoom: Ref<Room | null> = ref(null);
|
public lobbyRoom: Ref<Room | null> = ref(null);
|
||||||
public gameRoom: Ref<Room | null> = ref(null);
|
public gameRoom: Ref<Room | null> = ref(null);
|
||||||
@@ -30,7 +31,16 @@ class ColyseusService {
|
|||||||
public sessionId: Ref<string> = ref("");
|
public sessionId: Ref<string> = ref("");
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = new Client("ws://localhost:3000");
|
const defaultHost = typeof window !== "undefined" ? window.location.hostname : "localhost";
|
||||||
|
const defaultProtocol = typeof window !== "undefined" && window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const defaultPort = 3000;
|
||||||
|
const fallbackUrl = `${defaultProtocol}://${defaultHost}:${defaultPort}`;
|
||||||
|
const url = import.meta.env.VITE_WS_URL || fallbackUrl;
|
||||||
|
this.client = new Client(url);
|
||||||
|
|
||||||
|
const httpProtocol = typeof window !== "undefined" && window.location.protocol === "https:" ? "https" : "http";
|
||||||
|
const apiFallback = `${httpProtocol}://${defaultHost}:${defaultPort}/api`;
|
||||||
|
this.apiBase = (import.meta.env as any).VITE_API_URL || apiFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinLobby(): Promise<Room> {
|
async joinLobby(): Promise<Room> {
|
||||||
@@ -170,7 +180,7 @@ class ColyseusService {
|
|||||||
|
|
||||||
async fetchRooms(): Promise<any[]> {
|
async fetchRooms(): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:3000/api/rooms");
|
const response = await fetch(`${this.apiBase}/rooms`);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch rooms:", error);
|
console.error("Failed to fetch rooms:", error);
|
||||||
@@ -180,7 +190,7 @@ class ColyseusService {
|
|||||||
|
|
||||||
async fetchRoomStats(roomId: string): Promise<any> {
|
async fetchRoomStats(roomId: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:3000/api/rooms/${roomId}/stats`);
|
const response = await fetch(`${this.apiBase}/rooms/${roomId}/stats`);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch room stats:", error);
|
console.error("Failed to fetch room stats:", error);
|
||||||
@@ -189,24 +199,24 @@ class ColyseusService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async pauseRoom(roomId: string): Promise<void> {
|
async pauseRoom(roomId: string): Promise<void> {
|
||||||
await fetch(`http://localhost:3000/api/rooms/${roomId}/pause`, { method: "POST" });
|
await fetch(`${this.apiBase}/rooms/${roomId}/pause`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeRoom(roomId: string): Promise<void> {
|
async resumeRoom(roomId: string): Promise<void> {
|
||||||
await fetch(`http://localhost:3000/api/rooms/${roomId}/resume`, { method: "POST" });
|
await fetch(`${this.apiBase}/rooms/${roomId}/resume`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async restartRoom(roomId: string): Promise<void> {
|
async restartRoom(roomId: string): Promise<void> {
|
||||||
await fetch(`http://localhost:3000/api/rooms/${roomId}/restart`, { method: "POST" });
|
await fetch(`${this.apiBase}/rooms/${roomId}/restart`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async kickPlayer(roomId: string, playerId: string): Promise<void> {
|
async kickPlayer(roomId: string, playerId: string): Promise<void> {
|
||||||
await fetch(`http://localhost:3000/api/rooms/${roomId}/kick/${playerId}`, { method: "POST" });
|
await fetch(`${this.apiBase}/rooms/${roomId}/kick/${playerId}`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchGlobalStats(): Promise<any> {
|
async fetchGlobalStats(): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:3000/api/stats");
|
const response = await fetch(`${this.apiBase}/stats`);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch global stats:", error);
|
console.error("Failed to fetch global stats:", error);
|
||||||
|
|||||||
Reference in New Issue
Block a user