feat: implement competitive clicker MVP with Colyseus.js
- Add real-time multiplayer game server with Colyseus - Implement unique player naming system with auto-increment - Create lobby system with automatic matchmaking - Build 10-minute competitive clicking game rooms (max 2 players) - Add admin dashboard for game management (pause/resume/restart/kick) - Implement Vue 3 client with professional UI - Add WebSocket communication with state synchronization - Include TypeScript throughout with proper typing - Create REST API for admin operations - Add reconnection support and error handling
This commit is contained in:
2557
server/package-lock.json
generated
Normal file
2557
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
server/package.json
Normal file
25
server/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "snatchgame-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Competitive clicker game server",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "npx ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@colyseus/monitor": "latest",
|
||||
"@colyseus/schema": "latest",
|
||||
"colyseus": "latest",
|
||||
"cors": "latest",
|
||||
"express": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "latest",
|
||||
"@types/express": "latest",
|
||||
"@types/node": "latest",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
129
server/src/adminApi.ts
Normal file
129
server/src/adminApi.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Request, Response, Router } from "express";
|
||||
import { matchMaker } from "colyseus";
|
||||
import { GameRoom } from "./rooms/GameRoom";
|
||||
|
||||
const adminRouter = Router();
|
||||
|
||||
adminRouter.get("/rooms", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const rooms = await matchMaker.query({});
|
||||
const roomStats = rooms.map(room => ({
|
||||
roomId: room.roomId,
|
||||
name: room.name,
|
||||
clients: room.clients,
|
||||
maxClients: room.maxClients,
|
||||
metadata: room.metadata,
|
||||
locked: room.locked,
|
||||
private: room.private,
|
||||
createdAt: room.createdAt
|
||||
}));
|
||||
|
||||
res.json(roomStats);
|
||||
} catch (error) {
|
||||
console.error("[AdminAPI] Error fetching rooms:", error);
|
||||
res.status(500).json({ error: "Failed to fetch rooms" });
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.get("/rooms/:roomId/stats", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { roomId } = req.params;
|
||||
const roomData = await matchMaker.remoteRoomCall(roomId, "getState");
|
||||
|
||||
res.json(roomData);
|
||||
} catch (error) {
|
||||
console.error(`[AdminAPI] Error fetching room ${req.params.roomId} stats:`, error);
|
||||
res.status(500).json({ error: "Failed to fetch room stats" });
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post("/rooms/:roomId/pause", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { roomId } = req.params;
|
||||
const rooms = await matchMaker.query({ roomId });
|
||||
|
||||
if (rooms.length === 0) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
|
||||
await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:pause"]);
|
||||
|
||||
res.json({ success: true, message: "Room paused" });
|
||||
} catch (error) {
|
||||
console.error(`[AdminAPI] Error pausing room ${req.params.roomId}:`, error);
|
||||
res.status(500).json({ error: "Failed to pause room" });
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post("/rooms/:roomId/resume", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { roomId } = req.params;
|
||||
const rooms = await matchMaker.query({ roomId });
|
||||
|
||||
if (rooms.length === 0) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
|
||||
await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:resume"]);
|
||||
|
||||
res.json({ success: true, message: "Room resumed" });
|
||||
} catch (error) {
|
||||
console.error(`[AdminAPI] Error resuming room ${req.params.roomId}:`, error);
|
||||
res.status(500).json({ error: "Failed to resume room" });
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post("/rooms/:roomId/restart", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { roomId } = req.params;
|
||||
const rooms = await matchMaker.query({ roomId });
|
||||
|
||||
if (rooms.length === 0) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
|
||||
await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:restart"]);
|
||||
|
||||
res.json({ success: true, message: "Room restarted" });
|
||||
} catch (error) {
|
||||
console.error(`[AdminAPI] Error restarting room ${req.params.roomId}:`, error);
|
||||
res.status(500).json({ error: "Failed to restart room" });
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post("/rooms/:roomId/kick/:playerId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { roomId, playerId } = req.params;
|
||||
const rooms = await matchMaker.query({ roomId });
|
||||
|
||||
if (rooms.length === 0) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
|
||||
await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:kick", playerId]);
|
||||
|
||||
res.json({ success: true, message: `Player ${playerId} kicked` });
|
||||
} catch (error) {
|
||||
console.error(`[AdminAPI] Error kicking player from room ${req.params.roomId}:`, error);
|
||||
res.status(500).json({ error: "Failed to kick player" });
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.get("/stats", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await matchMaker.stats.fetchAll();
|
||||
const globalCCU = await matchMaker.stats.getGlobalCCU();
|
||||
|
||||
res.json({
|
||||
processes: stats,
|
||||
globalCCU,
|
||||
localCCU: matchMaker.stats.local.ccu,
|
||||
localRoomCount: matchMaker.stats.local.roomCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AdminAPI] Error fetching stats:", error);
|
||||
res.status(500).json({ error: "Failed to fetch stats" });
|
||||
}
|
||||
});
|
||||
|
||||
export { adminRouter };
|
||||
42
server/src/index.ts
Normal file
42
server/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Server } from "colyseus";
|
||||
import { createServer } from "http";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { monitor } from "@colyseus/monitor";
|
||||
|
||||
import { GameRoom } from "./rooms/GameRoom";
|
||||
import { LobbyRoom } from "./rooms/LobbyRoom";
|
||||
import { adminRouter } from "./adminApi";
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const server = createServer(app);
|
||||
const gameServer = new Server({
|
||||
server,
|
||||
});
|
||||
|
||||
gameServer.define("lobby", LobbyRoom)
|
||||
.filterBy(["maxClients"]);
|
||||
|
||||
gameServer.define("game", GameRoom)
|
||||
.filterBy(["maxClients"])
|
||||
.enableRealtimeListing();
|
||||
|
||||
app.use("/api", adminRouter);
|
||||
|
||||
app.use("/colyseus", monitor());
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ status: "healthy", uptime: process.uptime() });
|
||||
});
|
||||
|
||||
gameServer.listen(port);
|
||||
|
||||
console.log(`🎮 Snatch Game Server is running on port ${port}`);
|
||||
console.log(`📊 Monitor: http://localhost:${port}/colyseus`);
|
||||
console.log(`🌐 WebSocket: ws://localhost:${port}`);
|
||||
console.log(`🔧 Admin API: http://localhost:${port}/api`);
|
||||
200
server/src/rooms/GameRoom.ts
Normal file
200
server/src/rooms/GameRoom.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Room, Client } from "colyseus";
|
||||
import { GameState } from "./schemas/GameState";
|
||||
import { GameStatus } from "../../../shared/types";
|
||||
import { NameManager } from "../utils/nameManager";
|
||||
|
||||
export class GameRoom extends Room<GameState> {
|
||||
maxClients = 2;
|
||||
private gameInterval?: NodeJS.Timeout;
|
||||
private readonly TICK_RATE = 1000; // Update every second
|
||||
|
||||
onCreate(options: any) {
|
||||
this.setState(new GameState());
|
||||
this.state.roomId = this.roomId;
|
||||
|
||||
this.onMessage("click", (client) => {
|
||||
this.handleClick(client);
|
||||
});
|
||||
|
||||
this.onMessage("admin:pause", () => {
|
||||
this.state.pauseGame();
|
||||
});
|
||||
|
||||
this.onMessage("admin:resume", () => {
|
||||
this.state.resumeGame();
|
||||
});
|
||||
|
||||
this.onMessage("admin:restart", () => {
|
||||
this.handleRestart();
|
||||
});
|
||||
|
||||
this.onMessage("admin:kick", (client, playerId: string) => {
|
||||
this.handleKick(playerId);
|
||||
});
|
||||
}
|
||||
|
||||
onJoin(client: Client, options: any) {
|
||||
console.log(`[GameRoom] ${client.sessionId} joined room ${this.roomId}`);
|
||||
|
||||
const playerName = options.playerName || "player";
|
||||
const uniqueName = NameManager.getInstance().generateUniquePlayerName(playerName, client.sessionId);
|
||||
|
||||
this.state.addPlayer(client.sessionId, uniqueName);
|
||||
|
||||
client.send("playerInfo", {
|
||||
sessionId: client.sessionId,
|
||||
name: uniqueName,
|
||||
roomId: this.roomId
|
||||
});
|
||||
|
||||
if (this.state.players.size === 2 && this.state.gameStatus === GameStatus.WAITING) {
|
||||
this.startGame();
|
||||
}
|
||||
}
|
||||
|
||||
onLeave(client: Client, consented: boolean) {
|
||||
console.log(`[GameRoom] ${client.sessionId} left room ${this.roomId}`);
|
||||
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
if (player) {
|
||||
player.connected = false;
|
||||
NameManager.getInstance().releasePlayerName(client.sessionId);
|
||||
}
|
||||
|
||||
if (this.state.gameStatus === GameStatus.PLAYING) {
|
||||
if (this.getConnectedPlayersCount() < 2) {
|
||||
this.pauseGame();
|
||||
}
|
||||
}
|
||||
|
||||
this.allowReconnection(client, 30);
|
||||
}
|
||||
|
||||
async onReconnect(client: Client) {
|
||||
console.log(`[GameRoom] ${client.sessionId} reconnected to room ${this.roomId}`);
|
||||
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
if (player) {
|
||||
player.connected = true;
|
||||
}
|
||||
|
||||
if (this.state.gameStatus === GameStatus.PAUSED && this.getConnectedPlayersCount() === 2) {
|
||||
this.state.resumeGame();
|
||||
}
|
||||
}
|
||||
|
||||
onDispose() {
|
||||
console.log(`[GameRoom] Room ${this.roomId} disposing...`);
|
||||
|
||||
if (this.gameInterval) {
|
||||
clearInterval(this.gameInterval);
|
||||
}
|
||||
|
||||
this.state.players.forEach(player => {
|
||||
NameManager.getInstance().releasePlayerName(player.sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
private startGame() {
|
||||
console.log(`[GameRoom] Starting game in room ${this.roomId}`);
|
||||
|
||||
this.state.startGame();
|
||||
this.broadcast("gameStart");
|
||||
|
||||
this.gameInterval = setInterval(() => {
|
||||
this.state.updateTimer(this.TICK_RATE / 1000);
|
||||
|
||||
if (this.state.gameStatus === GameStatus.FINISHED) {
|
||||
this.endGame();
|
||||
}
|
||||
}, this.TICK_RATE);
|
||||
}
|
||||
|
||||
private pauseGame() {
|
||||
console.log(`[GameRoom] Pausing game in room ${this.roomId}`);
|
||||
this.state.pauseGame();
|
||||
this.broadcast("gamePaused");
|
||||
}
|
||||
|
||||
private endGame() {
|
||||
console.log(`[GameRoom] Game ended in room ${this.roomId}. Winner: ${this.state.winner}`);
|
||||
|
||||
if (this.gameInterval) {
|
||||
clearInterval(this.gameInterval);
|
||||
this.gameInterval = undefined;
|
||||
}
|
||||
|
||||
this.broadcast("gameEnd", {
|
||||
winner: this.state.winner,
|
||||
players: Array.from(this.state.players.values()).map(p => ({
|
||||
name: p.name,
|
||||
clicks: p.clicks
|
||||
}))
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.state.restartGame();
|
||||
if (this.state.players.size === 2) {
|
||||
this.startGame();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private handleClick(client: Client) {
|
||||
if (this.state.gameStatus !== GameStatus.PLAYING) {
|
||||
return;
|
||||
}
|
||||
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
if (player && player.connected) {
|
||||
player.incrementClicks();
|
||||
}
|
||||
}
|
||||
|
||||
private handleRestart() {
|
||||
console.log(`[GameRoom] Admin restart in room ${this.roomId}`);
|
||||
|
||||
if (this.gameInterval) {
|
||||
clearInterval(this.gameInterval);
|
||||
this.gameInterval = undefined;
|
||||
}
|
||||
|
||||
this.state.restartGame();
|
||||
this.broadcast("gameRestart");
|
||||
|
||||
if (this.state.players.size === 2) {
|
||||
setTimeout(() => this.startGame(), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleKick(playerId: string) {
|
||||
console.log(`[GameRoom] Admin kick player ${playerId} from room ${this.roomId}`);
|
||||
|
||||
const client = this.clients.find(c => c.sessionId === playerId);
|
||||
if (client) {
|
||||
client.leave(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private getConnectedPlayersCount(): number {
|
||||
let count = 0;
|
||||
this.state.players.forEach(player => {
|
||||
if (player.connected) count++;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
roomId: this.roomId,
|
||||
players: Array.from(this.state.players.values()).map(p => ({
|
||||
sessionId: p.sessionId,
|
||||
name: p.name,
|
||||
clicks: p.clicks
|
||||
})),
|
||||
gameStatus: this.state.gameStatus,
|
||||
timeRemaining: this.state.timeRemaining,
|
||||
winner: this.state.winner
|
||||
};
|
||||
}
|
||||
}
|
||||
157
server/src/rooms/LobbyRoom.ts
Normal file
157
server/src/rooms/LobbyRoom.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Room, Client, matchMaker } from "colyseus";
|
||||
import { LobbyState, AvailableRoom } from "./schemas/LobbyState";
|
||||
import { NameManager } from "../utils/nameManager";
|
||||
|
||||
export class LobbyRoom extends Room<LobbyState> {
|
||||
private updateInterval?: NodeJS.Timeout;
|
||||
|
||||
onCreate(options: any) {
|
||||
this.setState(new LobbyState());
|
||||
this.setPrivate(false);
|
||||
|
||||
this.onMessage("setName", (client, playerName: string) => {
|
||||
this.handleSetName(client, playerName);
|
||||
});
|
||||
|
||||
this.onMessage("quickPlay", (client) => {
|
||||
this.handleQuickPlay(client);
|
||||
});
|
||||
|
||||
this.onMessage("joinRoom", (client, roomId: string) => {
|
||||
this.handleJoinRoom(client, roomId);
|
||||
});
|
||||
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.updateAvailableRooms();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
onJoin(client: Client, options: any) {
|
||||
console.log(`[LobbyRoom] ${client.sessionId} joined lobby`);
|
||||
|
||||
const defaultName = `guest`;
|
||||
const uniqueName = NameManager.getInstance().generateUniquePlayerName(defaultName, client.sessionId);
|
||||
|
||||
this.state.addPlayer(client.sessionId, uniqueName);
|
||||
|
||||
client.send("welcome", {
|
||||
sessionId: client.sessionId,
|
||||
assignedName: uniqueName
|
||||
});
|
||||
|
||||
this.updateAvailableRooms();
|
||||
}
|
||||
|
||||
onLeave(client: Client, consented: boolean) {
|
||||
console.log(`[LobbyRoom] ${client.sessionId} left lobby`);
|
||||
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
if (player) {
|
||||
NameManager.getInstance().releasePlayerName(client.sessionId);
|
||||
}
|
||||
|
||||
this.state.removePlayer(client.sessionId);
|
||||
}
|
||||
|
||||
onDispose() {
|
||||
console.log("[LobbyRoom] Disposing lobby room");
|
||||
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
|
||||
this.state.players.forEach(player => {
|
||||
NameManager.getInstance().releasePlayerName(player.sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
private handleSetName(client: Client, playerName: string) {
|
||||
const currentPlayer = this.state.players.get(client.sessionId);
|
||||
if (!currentPlayer) return;
|
||||
|
||||
NameManager.getInstance().releasePlayerName(client.sessionId);
|
||||
|
||||
const uniqueName = NameManager.getInstance().generateUniquePlayerName(playerName, client.sessionId);
|
||||
|
||||
currentPlayer.name = uniqueName;
|
||||
|
||||
client.send("nameUpdated", {
|
||||
name: uniqueName
|
||||
});
|
||||
}
|
||||
|
||||
private async handleQuickPlay(client: Client) {
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
if (!player || player.inGame) return;
|
||||
|
||||
try {
|
||||
const reservation = await matchMaker.joinOrCreate("game", {
|
||||
playerName: player.name
|
||||
});
|
||||
|
||||
this.state.setPlayerInGame(client.sessionId, true);
|
||||
|
||||
client.send("roomReservation", {
|
||||
sessionId: reservation.sessionId,
|
||||
room: reservation.room
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
client.leave();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("[LobbyRoom] Error in quick play:", error);
|
||||
client.send("error", {
|
||||
message: "Could not find or create a game room"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleJoinRoom(client: Client, roomId: string) {
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
if (!player || player.inGame) return;
|
||||
|
||||
try {
|
||||
const reservation = await matchMaker.joinById(roomId, {
|
||||
playerName: player.name
|
||||
});
|
||||
|
||||
this.state.setPlayerInGame(client.sessionId, true);
|
||||
|
||||
client.send("roomReservation", {
|
||||
sessionId: reservation.sessionId,
|
||||
room: reservation.room
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
client.leave();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("[LobbyRoom] Error joining room:", error);
|
||||
client.send("error", {
|
||||
message: "Could not join the selected room"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async updateAvailableRooms() {
|
||||
try {
|
||||
const rooms = await matchMaker.query({ name: "game" });
|
||||
|
||||
const availableRooms = rooms
|
||||
.filter(room => !room.locked && room.clients < 2)
|
||||
.map(room => new AvailableRoom(
|
||||
room.roomId,
|
||||
room.clients,
|
||||
room.metadata?.gameStatus || "waiting"
|
||||
));
|
||||
|
||||
this.state.updateAvailableRooms(availableRooms);
|
||||
|
||||
} catch (error) {
|
||||
console.error("[LobbyRoom] Error updating available rooms:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
server/src/rooms/schemas/GameState.ts
Normal file
86
server/src/rooms/schemas/GameState.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Schema, type, MapSchema } from "@colyseus/schema";
|
||||
import { Player } from "./Player";
|
||||
import { GameStatus } from "../../../../shared/types";
|
||||
|
||||
export class GameState extends Schema {
|
||||
@type({ map: Player }) players = new MapSchema<Player>();
|
||||
@type("string") gameStatus: GameStatus = GameStatus.WAITING;
|
||||
@type("number") timeRemaining: number = 600; // 10 minutes in seconds
|
||||
@type("string") winner: string = "";
|
||||
@type("number") startTime: number = 0;
|
||||
@type("string") roomId: string = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
addPlayer(sessionId: string, name: string): Player {
|
||||
const player = new Player(sessionId, name);
|
||||
this.players.set(sessionId, player);
|
||||
return player;
|
||||
}
|
||||
|
||||
removePlayer(sessionId: string): void {
|
||||
this.players.delete(sessionId);
|
||||
}
|
||||
|
||||
startGame(): void {
|
||||
this.gameStatus = GameStatus.PLAYING;
|
||||
this.startTime = Date.now();
|
||||
this.timeRemaining = 600;
|
||||
this.resetAllPlayers();
|
||||
}
|
||||
|
||||
pauseGame(): void {
|
||||
if (this.gameStatus === GameStatus.PLAYING) {
|
||||
this.gameStatus = GameStatus.PAUSED;
|
||||
}
|
||||
}
|
||||
|
||||
resumeGame(): void {
|
||||
if (this.gameStatus === GameStatus.PAUSED) {
|
||||
this.gameStatus = GameStatus.PLAYING;
|
||||
}
|
||||
}
|
||||
|
||||
finishGame(): void {
|
||||
this.gameStatus = GameStatus.FINISHED;
|
||||
this.determineWinner();
|
||||
}
|
||||
|
||||
restartGame(): void {
|
||||
this.gameStatus = GameStatus.WAITING;
|
||||
this.timeRemaining = 600;
|
||||
this.winner = "";
|
||||
this.startTime = 0;
|
||||
this.resetAllPlayers();
|
||||
}
|
||||
|
||||
private resetAllPlayers(): void {
|
||||
this.players.forEach(player => player.reset());
|
||||
}
|
||||
|
||||
private determineWinner(): void {
|
||||
let maxClicks = -1;
|
||||
let winner = "";
|
||||
|
||||
this.players.forEach(player => {
|
||||
if (player.clicks > maxClicks) {
|
||||
maxClicks = player.clicks;
|
||||
winner = player.name;
|
||||
}
|
||||
});
|
||||
|
||||
this.winner = winner;
|
||||
}
|
||||
|
||||
updateTimer(deltaTime: number): void {
|
||||
if (this.gameStatus === GameStatus.PLAYING && this.timeRemaining > 0) {
|
||||
this.timeRemaining -= deltaTime;
|
||||
if (this.timeRemaining <= 0) {
|
||||
this.timeRemaining = 0;
|
||||
this.finishGame();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
server/src/rooms/schemas/LobbyState.ts
Normal file
61
server/src/rooms/schemas/LobbyState.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Schema, type, MapSchema, ArraySchema } from "@colyseus/schema";
|
||||
|
||||
export class LobbyPlayer extends Schema {
|
||||
@type("string") sessionId: string = "";
|
||||
@type("string") name: string = "";
|
||||
@type("boolean") inGame: boolean = false;
|
||||
|
||||
constructor(sessionId: string, name: string) {
|
||||
super();
|
||||
this.sessionId = sessionId;
|
||||
this.name = name;
|
||||
this.inGame = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class AvailableRoom extends Schema {
|
||||
@type("string") roomId: string = "";
|
||||
@type("number") playerCount: number = 0;
|
||||
@type("string") status: string = "";
|
||||
|
||||
constructor(roomId: string, playerCount: number, status: string) {
|
||||
super();
|
||||
this.roomId = roomId;
|
||||
this.playerCount = playerCount;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class LobbyState extends Schema {
|
||||
@type({ map: LobbyPlayer }) players = new MapSchema<LobbyPlayer>();
|
||||
@type([AvailableRoom]) availableRooms = new ArraySchema<AvailableRoom>();
|
||||
@type("number") totalPlayers: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
addPlayer(sessionId: string, name: string): LobbyPlayer {
|
||||
const player = new LobbyPlayer(sessionId, name);
|
||||
this.players.set(sessionId, player);
|
||||
this.totalPlayers = this.players.size;
|
||||
return player;
|
||||
}
|
||||
|
||||
removePlayer(sessionId: string): void {
|
||||
this.players.delete(sessionId);
|
||||
this.totalPlayers = this.players.size;
|
||||
}
|
||||
|
||||
updateAvailableRooms(rooms: AvailableRoom[]): void {
|
||||
this.availableRooms.clear();
|
||||
rooms.forEach(room => this.availableRooms.push(room));
|
||||
}
|
||||
|
||||
setPlayerInGame(sessionId: string, inGame: boolean): void {
|
||||
const player = this.players.get(sessionId);
|
||||
if (player) {
|
||||
player.inGame = inGame;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
server/src/rooms/schemas/Player.ts
Normal file
24
server/src/rooms/schemas/Player.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Schema, type } from "@colyseus/schema";
|
||||
|
||||
export class Player extends Schema {
|
||||
@type("string") sessionId: string = "";
|
||||
@type("string") name: string = "";
|
||||
@type("number") clicks: number = 0;
|
||||
@type("boolean") connected: boolean = true;
|
||||
|
||||
constructor(sessionId: string, name: string) {
|
||||
super();
|
||||
this.sessionId = sessionId;
|
||||
this.name = name;
|
||||
this.clicks = 0;
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
incrementClicks(): void {
|
||||
this.clicks++;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clicks = 0;
|
||||
}
|
||||
}
|
||||
46
server/src/utils/nameManager.ts
Normal file
46
server/src/utils/nameManager.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export class NameManager {
|
||||
private static instance: NameManager;
|
||||
private nameCounters: Map<string, number> = new Map();
|
||||
private sessionToName: Map<string, string> = new Map();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): NameManager {
|
||||
if (!NameManager.instance) {
|
||||
NameManager.instance = new NameManager();
|
||||
}
|
||||
return NameManager.instance;
|
||||
}
|
||||
|
||||
generateUniquePlayerName(baseName: string, sessionId: string): string {
|
||||
const normalizedName = baseName.trim().toLowerCase();
|
||||
|
||||
if (!normalizedName) {
|
||||
return this.generateUniquePlayerName('player', sessionId);
|
||||
}
|
||||
|
||||
const currentCounter = this.nameCounters.get(normalizedName) || 0;
|
||||
const newCounter = currentCounter + 1;
|
||||
this.nameCounters.set(normalizedName, newCounter);
|
||||
|
||||
const uniqueName = newCounter === 1 ? normalizedName : `${normalizedName}-${newCounter}`;
|
||||
this.sessionToName.set(sessionId, uniqueName);
|
||||
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
releasePlayerName(sessionId: string): void {
|
||||
const name = this.sessionToName.get(sessionId);
|
||||
if (name) {
|
||||
this.sessionToName.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
getPlayerName(sessionId: string): string | undefined {
|
||||
return this.sessionToName.get(sessionId);
|
||||
}
|
||||
|
||||
getAllActivePlayers(): string[] {
|
||||
return Array.from(this.sessionToName.values());
|
||||
}
|
||||
}
|
||||
21
server/tsconfig.json
Normal file
21
server/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "../",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*", "../shared/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user