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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user