feat: Next.js + Socket.IO 3-mode game (Skribbl, Gartic, Color)
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
// Pure game state machine — no socket dependencies. Functions take the room object
|
||||
// and return mutations + a list of side-effect descriptors that the socket layer
|
||||
// (socket-handlers.js) will translate into io.emit calls.
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { makeId } = require("./room-store.js");
|
||||
const {
|
||||
normalize,
|
||||
maskWord,
|
||||
pickRevealIndex,
|
||||
isCloseGuess,
|
||||
} = require("./word-utils.js");
|
||||
|
||||
const WORDS_PATH = path.join(__dirname, "..", "app", "lib", "words", "en.json");
|
||||
let WORDS = [];
|
||||
try {
|
||||
WORDS = JSON.parse(fs.readFileSync(WORDS_PATH, "utf8"));
|
||||
} catch (e) {
|
||||
console.warn("[words] could not read en.json:", e.message);
|
||||
WORDS = ["apple", "banana", "rocket", "robot", "tree"];
|
||||
}
|
||||
|
||||
function pickRandomWords(n = 3) {
|
||||
const out = new Set();
|
||||
while (out.size < n) {
|
||||
out.add(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function makePlayer({ nickname, avatar }) {
|
||||
return {
|
||||
id: makeId(),
|
||||
sessionToken: makeId(),
|
||||
nickname: String(nickname || "Player").slice(0, 20),
|
||||
avatar: String(avatar || "🐱"),
|
||||
score: 0,
|
||||
connected: true,
|
||||
socketId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoom({ code, hostId, mode, settings }) {
|
||||
return {
|
||||
code,
|
||||
hostId,
|
||||
mode, // 'skribbl' | 'gartic' | 'color'
|
||||
settings: settings || {},
|
||||
players: [],
|
||||
chat: [], // recent messages
|
||||
phase: "lobby", // lobby | playing | results
|
||||
lastActiveAt: Date.now(),
|
||||
// mode-specific state
|
||||
skribbl: null,
|
||||
gartic: null,
|
||||
color: null,
|
||||
timers: {},
|
||||
};
|
||||
}
|
||||
|
||||
function publicRoomState(room) {
|
||||
return {
|
||||
code: room.code,
|
||||
hostId: room.hostId,
|
||||
mode: room.mode,
|
||||
settings: room.settings,
|
||||
phase: room.phase,
|
||||
players: room.players.map((p) => ({
|
||||
id: p.id,
|
||||
nickname: p.nickname,
|
||||
avatar: p.avatar,
|
||||
score: p.score,
|
||||
connected: p.connected,
|
||||
isHost: p.id === room.hostId,
|
||||
})),
|
||||
skribbl: room.skribbl
|
||||
? {
|
||||
roundIndex: room.skribbl.roundIndex,
|
||||
totalRounds: room.skribbl.totalRounds,
|
||||
drawerId: room.skribbl.drawerId,
|
||||
wordMask: room.skribbl.wordMask,
|
||||
wordLength: room.skribbl.word ? room.skribbl.word.length : 0,
|
||||
phase: room.skribbl.phase, // 'choosing' | 'drawing' | 'between'
|
||||
endsAt: room.skribbl.endsAt,
|
||||
solvedIds: [...(room.skribbl.solvedIds || [])],
|
||||
}
|
||||
: null,
|
||||
gartic: room.gartic
|
||||
? {
|
||||
turnIndex: room.gartic.turnIndex,
|
||||
totalTurns: room.gartic.totalTurns,
|
||||
phase: room.gartic.phase,
|
||||
endsAt: room.gartic.endsAt,
|
||||
submittedIds: [...(room.gartic.submittedIds || [])],
|
||||
}
|
||||
: null,
|
||||
color: room.color
|
||||
? {
|
||||
canvasType: room.color.canvasType,
|
||||
templateId: room.color.templateId,
|
||||
strokeCount: room.color.strokes.length,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Skribbl ----------------------------------------------------------------
|
||||
|
||||
function skribblInit(room) {
|
||||
const rounds = Number(room.settings.rounds) || 4;
|
||||
const drawTimeSec = Number(room.settings.drawTimeSec) || 80;
|
||||
room.skribbl = {
|
||||
roundIndex: 0,
|
||||
totalRounds: rounds * room.players.length,
|
||||
drawerOrder: room.players.map((p) => p.id),
|
||||
drawerCursor: 0,
|
||||
drawerId: null,
|
||||
word: null,
|
||||
wordChoices: null,
|
||||
wordMask: null,
|
||||
revealed: new Set(),
|
||||
drawTimeSec,
|
||||
endsAt: 0,
|
||||
phase: "between",
|
||||
solvedIds: new Set(),
|
||||
solveOrder: [],
|
||||
};
|
||||
}
|
||||
|
||||
function skribblNextRound(room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk) return { done: true };
|
||||
if (sk.roundIndex >= sk.totalRounds) return { done: true };
|
||||
// pick next drawer that is connected
|
||||
let attempts = 0;
|
||||
while (attempts < sk.drawerOrder.length) {
|
||||
const candidate = sk.drawerOrder[sk.drawerCursor % sk.drawerOrder.length];
|
||||
sk.drawerCursor++;
|
||||
const p = room.players.find((pl) => pl.id === candidate);
|
||||
if (p && p.connected) {
|
||||
sk.drawerId = p.id;
|
||||
sk.phase = "choosing";
|
||||
sk.word = null;
|
||||
sk.wordChoices = pickRandomWords(3);
|
||||
sk.wordMask = null;
|
||||
sk.revealed = new Set();
|
||||
sk.solvedIds = new Set();
|
||||
sk.solveOrder = [];
|
||||
sk.endsAt = Date.now() + 15000;
|
||||
sk.roundIndex++;
|
||||
return { ok: true, drawer: p, choices: sk.wordChoices };
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
return { done: true };
|
||||
}
|
||||
|
||||
function skribblPickWord(room, drawerId, word) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk) return { ok: false, error: "no game" };
|
||||
if (sk.drawerId !== drawerId) return { ok: false, error: "not drawer" };
|
||||
if (sk.phase !== "choosing") return { ok: false, error: "not choosing" };
|
||||
if (!sk.wordChoices.includes(word)) return { ok: false, error: "bad word" };
|
||||
sk.word = word;
|
||||
sk.wordMask = maskWord(word, new Set());
|
||||
sk.revealed = new Set();
|
||||
sk.phase = "drawing";
|
||||
sk.endsAt = Date.now() + sk.drawTimeSec * 1000;
|
||||
return { ok: true, drawer: sk.drawerId, word, mask: sk.wordMask, endsAt: sk.endsAt };
|
||||
}
|
||||
|
||||
function skribblHandleGuess(room, fromId, text) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk || sk.phase !== "drawing" || !sk.word) {
|
||||
return { kind: "chat" };
|
||||
}
|
||||
if (fromId === sk.drawerId) return { kind: "chat" };
|
||||
if (sk.solvedIds.has(fromId)) return { kind: "chat" };
|
||||
const guess = normalize(text);
|
||||
const target = normalize(sk.word);
|
||||
if (guess === target) {
|
||||
sk.solvedIds.add(fromId);
|
||||
sk.solveOrder.push(fromId);
|
||||
// award points
|
||||
const total = sk.drawTimeSec * 1000;
|
||||
const left = Math.max(0, sk.endsAt - Date.now());
|
||||
const factor = left / total;
|
||||
const points = Math.floor(50 + factor * 100);
|
||||
const player = room.players.find((p) => p.id === fromId);
|
||||
if (player) player.score += points;
|
||||
// drawer bonus, +25 per guesser
|
||||
const drawer = room.players.find((p) => p.id === sk.drawerId);
|
||||
if (drawer) drawer.score += 25;
|
||||
return { kind: "correct", points };
|
||||
}
|
||||
if (isCloseGuess(text, sk.word)) {
|
||||
return { kind: "close" };
|
||||
}
|
||||
return { kind: "chat" };
|
||||
}
|
||||
|
||||
function skribblShouldEndRound(room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk || sk.phase !== "drawing") return false;
|
||||
// all non-drawer connected players solved?
|
||||
const guessers = room.players.filter(
|
||||
(p) => p.id !== sk.drawerId && p.connected
|
||||
);
|
||||
if (guessers.length === 0) return false;
|
||||
return guessers.every((p) => sk.solvedIds.has(p.id));
|
||||
}
|
||||
|
||||
function skribblRevealHint(room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk || !sk.word) return null;
|
||||
const idx = pickRevealIndex(sk.word, sk.revealed);
|
||||
if (idx == null) return null;
|
||||
sk.revealed.add(idx);
|
||||
sk.wordMask = maskWord(sk.word, sk.revealed);
|
||||
return sk.wordMask;
|
||||
}
|
||||
|
||||
// ---- Gartic ----------------------------------------------------------------
|
||||
|
||||
function garticInit(room) {
|
||||
const players = room.players.filter((p) => p.connected);
|
||||
const totalTurns = players.length; // each book goes around once
|
||||
const books = players.map((p) => ({
|
||||
ownerId: p.id,
|
||||
ownerName: p.nickname,
|
||||
pages: [], // [{type, content, authorId, authorName}]
|
||||
}));
|
||||
room.gartic = {
|
||||
players: players.map((p) => p.id),
|
||||
books,
|
||||
turnIndex: 0,
|
||||
totalTurns,
|
||||
phase: "prompt", // prompt | drawing | guess | done
|
||||
submittedIds: new Set(),
|
||||
endsAt: 0,
|
||||
durationMs: 60000,
|
||||
};
|
||||
}
|
||||
|
||||
function garticAssignmentForPlayer(gartic, playerId) {
|
||||
// book index for this player on this turn = (originalIndex + turnIndex) % len
|
||||
const playerIdx = gartic.players.indexOf(playerId);
|
||||
if (playerIdx < 0) return null;
|
||||
const bookIdx = (playerIdx + gartic.turnIndex) % gartic.players.length;
|
||||
return { bookIdx, book: gartic.books[bookIdx] };
|
||||
}
|
||||
|
||||
function garticTaskForTurn(turnIndex) {
|
||||
if (turnIndex === 0) return "prompt";
|
||||
return turnIndex % 2 === 1 ? "drawing" : "guess";
|
||||
}
|
||||
|
||||
function garticAdvanceTurn(room) {
|
||||
const g = room.gartic;
|
||||
if (!g) return { done: true };
|
||||
// collect submissions: for any player who didn't submit, place a placeholder
|
||||
for (const playerId of g.players) {
|
||||
if (g.submittedIds.has(playerId)) continue;
|
||||
const assign = garticAssignmentForPlayer(g, playerId);
|
||||
if (!assign) continue;
|
||||
const task = garticTaskForTurn(g.turnIndex);
|
||||
assign.book.pages.push({
|
||||
type: task,
|
||||
content: task === "drawing" ? "" : "...",
|
||||
authorId: playerId,
|
||||
authorName: (room.players.find((p) => p.id === playerId) || {}).nickname || "?",
|
||||
});
|
||||
}
|
||||
g.submittedIds = new Set();
|
||||
g.turnIndex++;
|
||||
if (g.turnIndex >= g.totalTurns) {
|
||||
g.phase = "done";
|
||||
return { done: true };
|
||||
}
|
||||
g.phase = garticTaskForTurn(g.turnIndex);
|
||||
g.endsAt = Date.now() + g.durationMs;
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
function garticSubmit(room, playerId, type, data) {
|
||||
const g = room.gartic;
|
||||
if (!g || g.phase === "done") return { ok: false };
|
||||
const expected = garticTaskForTurn(g.turnIndex);
|
||||
if (type !== expected) return { ok: false, error: "wrong type" };
|
||||
if (g.submittedIds.has(playerId)) return { ok: false, error: "already" };
|
||||
const assign = garticAssignmentForPlayer(g, playerId);
|
||||
if (!assign) return { ok: false, error: "no book" };
|
||||
const player = room.players.find((p) => p.id === playerId);
|
||||
assign.book.pages.push({
|
||||
type,
|
||||
content: String(data || ""),
|
||||
authorId: playerId,
|
||||
authorName: player ? player.nickname : "?",
|
||||
});
|
||||
g.submittedIds.add(playerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function garticAllSubmitted(room) {
|
||||
const g = room.gartic;
|
||||
if (!g) return false;
|
||||
return g.players.every((id) => g.submittedIds.has(id));
|
||||
}
|
||||
|
||||
// ---- Color Together --------------------------------------------------------
|
||||
|
||||
function colorInit(room) {
|
||||
room.color = {
|
||||
canvasType: room.settings.canvasType || "blank",
|
||||
templateId: room.settings.templateId || null,
|
||||
strokes: [],
|
||||
regionFills: {},
|
||||
};
|
||||
}
|
||||
|
||||
function colorAddStroke(room, stroke) {
|
||||
if (!room.color) return;
|
||||
// store last 1500 strokes max
|
||||
room.color.strokes.push(stroke);
|
||||
if (room.color.strokes.length > 1500) {
|
||||
room.color.strokes = room.color.strokes.slice(-1500);
|
||||
}
|
||||
}
|
||||
|
||||
function colorBucket(room, regionId, color) {
|
||||
if (!room.color) return;
|
||||
room.color.regionFills[regionId] = color;
|
||||
}
|
||||
|
||||
function colorReset(room, regionId) {
|
||||
if (!room.color) return;
|
||||
if (regionId) {
|
||||
delete room.color.regionFills[regionId];
|
||||
} else {
|
||||
room.color.strokes = [];
|
||||
room.color.regionFills = {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WORDS,
|
||||
makeRoom,
|
||||
makePlayer,
|
||||
publicRoomState,
|
||||
// skribbl
|
||||
skribblInit,
|
||||
skribblNextRound,
|
||||
skribblPickWord,
|
||||
skribblHandleGuess,
|
||||
skribblShouldEndRound,
|
||||
skribblRevealHint,
|
||||
// gartic
|
||||
garticInit,
|
||||
garticAssignmentForPlayer,
|
||||
garticTaskForTurn,
|
||||
garticAdvanceTurn,
|
||||
garticSubmit,
|
||||
garticAllSubmitted,
|
||||
// color
|
||||
colorInit,
|
||||
colorAddStroke,
|
||||
colorBucket,
|
||||
colorReset,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
const { customAlphabet } = require("nanoid");
|
||||
|
||||
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const makeCode = customAlphabet(codeAlphabet, 6);
|
||||
const makeId = customAlphabet(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
21
|
||||
);
|
||||
|
||||
const rooms = new Map();
|
||||
|
||||
function genCode() {
|
||||
let c;
|
||||
do {
|
||||
c = makeCode();
|
||||
} while (rooms.has(c));
|
||||
return c;
|
||||
}
|
||||
|
||||
function getRoom(code) {
|
||||
return rooms.get(String(code || "").toUpperCase());
|
||||
}
|
||||
|
||||
function setRoom(code, room) {
|
||||
rooms.set(code.toUpperCase(), room);
|
||||
}
|
||||
|
||||
function deleteRoom(code) {
|
||||
rooms.delete(String(code || "").toUpperCase());
|
||||
}
|
||||
|
||||
function allRooms() {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
// Idle cleanup — drop rooms with no active sockets older than 60min
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [code, room] of rooms.entries()) {
|
||||
const active = room.players.some((p) => p.connected);
|
||||
if (!active && now - (room.lastActiveAt || 0) > 60 * 60 * 1000) {
|
||||
rooms.delete(code);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
module.exports = { genCode, getRoom, setRoom, deleteRoom, allRooms, makeId };
|
||||
@@ -0,0 +1,457 @@
|
||||
// Wires socket.io events to the game-state machine.
|
||||
const { genCode, getRoom, setRoom, makeId } = require("./room-store.js");
|
||||
const G = require("./game-state.js");
|
||||
const { normalize } = require("./word-utils.js");
|
||||
|
||||
function escape(text) {
|
||||
return String(text || "")
|
||||
.slice(0, 200)
|
||||
.replace(/[<>]/g, (c) => (c === "<" ? "<" : ">"));
|
||||
}
|
||||
|
||||
function broadcastState(io, room) {
|
||||
io.to(room.code).emit("room:state", G.publicRoomState(room));
|
||||
}
|
||||
|
||||
function chatSystem(io, room, text) {
|
||||
const msg = { id: makeId(), fromId: null, fromName: "system", text, kind: "system" };
|
||||
room.chat.push(msg);
|
||||
io.to(room.code).emit("chat:msg", msg);
|
||||
}
|
||||
|
||||
function pushChat(io, room, msg) {
|
||||
room.chat.push(msg);
|
||||
if (room.chat.length > 200) room.chat = room.chat.slice(-200);
|
||||
io.to(room.code).emit("chat:msg", msg);
|
||||
}
|
||||
|
||||
// ---- Skribbl flow control -------------------------------------------------
|
||||
|
||||
function clearTimers(room) {
|
||||
for (const t of Object.values(room.timers || {})) {
|
||||
if (t) clearTimeout(t);
|
||||
}
|
||||
room.timers = {};
|
||||
}
|
||||
|
||||
function startSkribblNext(io, room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk) return;
|
||||
const r = G.skribblNextRound(room);
|
||||
if (r.done) {
|
||||
room.phase = "results";
|
||||
clearTimers(room);
|
||||
io.to(room.code).emit("skribbl:gameOver", {
|
||||
finalScores: room.players.map((p) => ({ id: p.id, name: p.nickname, avatar: p.avatar, score: p.score })),
|
||||
});
|
||||
broadcastState(io, room);
|
||||
return;
|
||||
}
|
||||
// notify drawer with choices
|
||||
const drawerSocket = io.sockets.sockets.get(r.drawer.socketId);
|
||||
if (drawerSocket) drawerSocket.emit("skribbl:wordChoices", sk.wordChoices);
|
||||
chatSystem(io, room, `${r.drawer.nickname} is choosing a word...`);
|
||||
broadcastState(io, room);
|
||||
|
||||
// 15s pick timer — auto-pick first
|
||||
if (room.timers.pick) clearTimeout(room.timers.pick);
|
||||
room.timers.pick = setTimeout(() => {
|
||||
if (sk.phase === "choosing") {
|
||||
const auto = sk.wordChoices[0];
|
||||
const pick = G.skribblPickWord(room, sk.drawerId, auto);
|
||||
if (pick.ok) startSkribblDrawing(io, room);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function startSkribblDrawing(io, room) {
|
||||
const sk = room.skribbl;
|
||||
io.to(room.code).emit("skribbl:roundStart", {
|
||||
drawerId: sk.drawerId,
|
||||
wordMask: sk.wordMask,
|
||||
durationMs: sk.drawTimeSec * 1000,
|
||||
roundIndex: sk.roundIndex,
|
||||
totalRounds: sk.totalRounds,
|
||||
});
|
||||
broadcastState(io, room);
|
||||
|
||||
// hints at 1/3 and 2/3
|
||||
if (room.timers.hint1) clearTimeout(room.timers.hint1);
|
||||
if (room.timers.hint2) clearTimeout(room.timers.hint2);
|
||||
if (room.timers.end) clearTimeout(room.timers.end);
|
||||
const total = sk.drawTimeSec * 1000;
|
||||
room.timers.hint1 = setTimeout(() => {
|
||||
const m = G.skribblRevealHint(room);
|
||||
if (m) io.to(room.code).emit("skribbl:hint", { wordMask: m });
|
||||
broadcastState(io, room);
|
||||
}, Math.floor(total * 0.4));
|
||||
room.timers.hint2 = setTimeout(() => {
|
||||
const m = G.skribblRevealHint(room);
|
||||
if (m) io.to(room.code).emit("skribbl:hint", { wordMask: m });
|
||||
broadcastState(io, room);
|
||||
}, Math.floor(total * 0.7));
|
||||
room.timers.end = setTimeout(() => endSkribblRound(io, room), total);
|
||||
}
|
||||
|
||||
function endSkribblRound(io, room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk) return;
|
||||
clearTimers(room);
|
||||
const word = sk.word;
|
||||
sk.phase = "between";
|
||||
io.to(room.code).emit("skribbl:roundEnd", {
|
||||
word,
|
||||
scores: room.players.map((p) => ({ id: p.id, name: p.nickname, score: p.score })),
|
||||
});
|
||||
chatSystem(io, room, `The word was: ${word}`);
|
||||
broadcastState(io, room);
|
||||
// 4-second pause then next
|
||||
room.timers.between = setTimeout(() => startSkribblNext(io, room), 4000);
|
||||
}
|
||||
|
||||
// ---- Gartic flow ---------------------------------------------------------
|
||||
|
||||
function startGarticTurn(io, room) {
|
||||
const g = room.gartic;
|
||||
if (!g) return;
|
||||
if (g.phase === "done") {
|
||||
room.phase = "results";
|
||||
clearTimers(room);
|
||||
io.to(room.code).emit("gartic:bookComplete", { books: g.books });
|
||||
broadcastState(io, room);
|
||||
return;
|
||||
}
|
||||
g.endsAt = Date.now() + g.durationMs;
|
||||
const task = G.garticTaskForTurn(g.turnIndex);
|
||||
// emit per-player task with their assigned book content
|
||||
for (const playerId of g.players) {
|
||||
const player = room.players.find((p) => p.id === playerId);
|
||||
if (!player || !player.connected || !player.socketId) continue;
|
||||
const assign = G.garticAssignmentForPlayer(g, playerId);
|
||||
if (!assign) continue;
|
||||
const lastPage = assign.book.pages[assign.book.pages.length - 1];
|
||||
const content = lastPage ? lastPage.content : "";
|
||||
const sock = io.sockets.sockets.get(player.socketId);
|
||||
if (sock) {
|
||||
sock.emit("gartic:turn", {
|
||||
turnIndex: g.turnIndex,
|
||||
totalTurns: g.totalTurns,
|
||||
task,
|
||||
content,
|
||||
durationMs: g.durationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
broadcastState(io, room);
|
||||
if (room.timers.gartic) clearTimeout(room.timers.gartic);
|
||||
room.timers.gartic = setTimeout(() => {
|
||||
G.garticAdvanceTurn(room);
|
||||
startGarticTurn(io, room);
|
||||
}, g.durationMs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findPlayerBySession(token) {
|
||||
if (!token) return null;
|
||||
const { allRooms } = require("./room-store.js");
|
||||
for (const room of allRooms().values()) {
|
||||
const p = room.players.find((pl) => pl.sessionToken === token);
|
||||
if (p) return { room, player: p };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function registerHandlers(io) {
|
||||
io.on("connection", (socket) => {
|
||||
socket.data = { roomCode: null, playerId: null };
|
||||
|
||||
socket.on("room:create", (payload, ack) => {
|
||||
try {
|
||||
const { nickname, avatar, mode, settings } = payload || {};
|
||||
if (!["skribbl", "gartic", "color"].includes(mode)) {
|
||||
return ack && ack({ ok: false, error: "bad mode" });
|
||||
}
|
||||
const code = genCode();
|
||||
const player = G.makePlayer({ nickname, avatar });
|
||||
player.socketId = socket.id;
|
||||
const room = G.makeRoom({ code, hostId: player.id, mode, settings });
|
||||
room.players.push(player);
|
||||
room.lastActiveAt = Date.now();
|
||||
setRoom(code, room);
|
||||
socket.join(code);
|
||||
socket.data.roomCode = code;
|
||||
socket.data.playerId = player.id;
|
||||
ack && ack({ ok: true, code, sessionToken: player.sessionToken, playerId: player.id });
|
||||
chatSystem(io, room, `${player.nickname} created the room`);
|
||||
broadcastState(io, room);
|
||||
} catch (e) {
|
||||
console.error("room:create err", e);
|
||||
ack && ack({ ok: false, error: "server error" });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("room:join", (payload, ack) => {
|
||||
try {
|
||||
const { code, nickname, avatar, sessionToken } = payload || {};
|
||||
const room = getRoom(code);
|
||||
if (!room) return ack && ack({ ok: false, error: "room not found" });
|
||||
// reconnect via session token
|
||||
let player = sessionToken
|
||||
? room.players.find((p) => p.sessionToken === sessionToken)
|
||||
: null;
|
||||
if (player) {
|
||||
player.connected = true;
|
||||
player.socketId = socket.id;
|
||||
if (nickname) player.nickname = String(nickname).slice(0, 20);
|
||||
if (avatar) player.avatar = String(avatar);
|
||||
} else {
|
||||
if (room.players.length >= 12) {
|
||||
return ack && ack({ ok: false, error: "room full" });
|
||||
}
|
||||
player = G.makePlayer({ nickname, avatar });
|
||||
player.socketId = socket.id;
|
||||
room.players.push(player);
|
||||
chatSystem(io, room, `${player.nickname} joined`);
|
||||
}
|
||||
room.lastActiveAt = Date.now();
|
||||
socket.join(room.code);
|
||||
socket.data.roomCode = room.code;
|
||||
socket.data.playerId = player.id;
|
||||
ack && ack({
|
||||
ok: true,
|
||||
code: room.code,
|
||||
sessionToken: player.sessionToken,
|
||||
playerId: player.id,
|
||||
mode: room.mode,
|
||||
});
|
||||
// send chat history
|
||||
for (const m of room.chat.slice(-30)) socket.emit("chat:msg", m);
|
||||
if (room.color) {
|
||||
socket.emit("color:state", {
|
||||
strokes: room.color.strokes,
|
||||
regions: room.color.regionFills,
|
||||
});
|
||||
}
|
||||
broadcastState(io, room);
|
||||
} catch (e) {
|
||||
console.error("room:join err", e);
|
||||
ack && ack({ ok: false, error: "server error" });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("room:leave", () => {
|
||||
const code = socket.data.roomCode;
|
||||
if (!code) return;
|
||||
const room = getRoom(code);
|
||||
if (!room) return;
|
||||
const p = room.players.find((pl) => pl.id === socket.data.playerId);
|
||||
if (p) {
|
||||
p.connected = false;
|
||||
p.socketId = null;
|
||||
}
|
||||
socket.leave(code);
|
||||
socket.data.roomCode = null;
|
||||
socket.data.playerId = null;
|
||||
broadcastState(io, room);
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
const code = socket.data.roomCode;
|
||||
if (!code) return;
|
||||
const room = getRoom(code);
|
||||
if (!room) return;
|
||||
const p = room.players.find((pl) => pl.id === socket.data.playerId);
|
||||
if (p) {
|
||||
p.connected = false;
|
||||
p.socketId = null;
|
||||
}
|
||||
room.lastActiveAt = Date.now();
|
||||
broadcastState(io, room);
|
||||
});
|
||||
|
||||
socket.on("chat:send", (payload) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room) return;
|
||||
const player = room.players.find((p) => p.id === socket.data.playerId);
|
||||
if (!player) return;
|
||||
const text = escape(payload && payload.text);
|
||||
if (!text.trim()) return;
|
||||
// skribbl: check guesses
|
||||
if (room.mode === "skribbl" && room.phase === "playing" && room.skribbl) {
|
||||
const result = G.skribblHandleGuess(room, player.id, text);
|
||||
if (result.kind === "correct") {
|
||||
// public message
|
||||
pushChat(io, room, {
|
||||
id: makeId(),
|
||||
fromId: player.id,
|
||||
fromName: player.nickname,
|
||||
text: `guessed the word! +${result.points}`,
|
||||
kind: "correct",
|
||||
});
|
||||
broadcastState(io, room);
|
||||
if (G.skribblShouldEndRound(room)) {
|
||||
endSkribblRound(io, room);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.kind === "close") {
|
||||
// private hint to guesser
|
||||
socket.emit("chat:msg", {
|
||||
id: makeId(),
|
||||
fromId: null,
|
||||
fromName: "system",
|
||||
text: `'${text}' is close!`,
|
||||
kind: "close",
|
||||
});
|
||||
// still broadcast their guess to others
|
||||
pushChat(io, room, {
|
||||
id: makeId(),
|
||||
fromId: player.id,
|
||||
fromName: player.nickname,
|
||||
text,
|
||||
kind: "chat",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// suppress messages from solved guessers? broadcast normal
|
||||
pushChat(io, room, {
|
||||
id: makeId(),
|
||||
fromId: player.id,
|
||||
fromName: player.nickname,
|
||||
text,
|
||||
kind: "chat",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// default chat
|
||||
pushChat(io, room, {
|
||||
id: makeId(),
|
||||
fromId: player.id,
|
||||
fromName: player.nickname,
|
||||
text,
|
||||
kind: "chat",
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("game:start", (payload, ack) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room) return ack && ack({ ok: false, error: "no room" });
|
||||
if (room.hostId !== socket.data.playerId)
|
||||
return ack && ack({ ok: false, error: "not host" });
|
||||
if (room.players.filter((p) => p.connected).length < 2 && room.mode !== "color") {
|
||||
return ack && ack({ ok: false, error: "need 2+ players" });
|
||||
}
|
||||
room.phase = "playing";
|
||||
if (room.mode === "skribbl") {
|
||||
G.skribblInit(room);
|
||||
startSkribblNext(io, room);
|
||||
} else if (room.mode === "gartic") {
|
||||
G.garticInit(room);
|
||||
// turn 0 (prompt) — start
|
||||
room.gartic.phase = G.garticTaskForTurn(0);
|
||||
startGarticTurn(io, room);
|
||||
} else if (room.mode === "color") {
|
||||
G.colorInit(room);
|
||||
broadcastState(io, room);
|
||||
}
|
||||
broadcastState(io, room);
|
||||
ack && ack({ ok: true });
|
||||
});
|
||||
|
||||
// Skribbl-specific
|
||||
socket.on("skribbl:pickWord", (payload, ack) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.skribbl) return ack && ack({ ok: false });
|
||||
const r = G.skribblPickWord(
|
||||
room,
|
||||
socket.data.playerId,
|
||||
(payload || {}).word
|
||||
);
|
||||
if (!r.ok) return ack && ack({ ok: false, error: r.error });
|
||||
if (room.timers.pick) clearTimeout(room.timers.pick);
|
||||
startSkribblDrawing(io, room);
|
||||
ack && ack({ ok: true });
|
||||
});
|
||||
|
||||
socket.on("skribbl:stroke", (data) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.skribbl) return;
|
||||
if (room.skribbl.drawerId !== socket.data.playerId) return;
|
||||
socket.to(code).emit("skribbl:stroke", data);
|
||||
});
|
||||
|
||||
socket.on("skribbl:clear", () => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.skribbl) return;
|
||||
if (room.skribbl.drawerId !== socket.data.playerId) return;
|
||||
socket.to(code).emit("skribbl:clear");
|
||||
});
|
||||
|
||||
// Gartic
|
||||
socket.on("gartic:submit", (payload, ack) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.gartic) return ack && ack({ ok: false });
|
||||
const { type, data } = payload || {};
|
||||
const r = G.garticSubmit(room, socket.data.playerId, type, data);
|
||||
if (!r.ok) return ack && ack({ ok: false, error: r.error });
|
||||
ack && ack({ ok: true });
|
||||
broadcastState(io, room);
|
||||
if (G.garticAllSubmitted(room)) {
|
||||
if (room.timers.gartic) clearTimeout(room.timers.gartic);
|
||||
G.garticAdvanceTurn(room);
|
||||
startGarticTurn(io, room);
|
||||
}
|
||||
});
|
||||
|
||||
// Color Together
|
||||
socket.on("color:stroke", (data) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.color) return;
|
||||
G.colorAddStroke(room, data);
|
||||
socket.to(code).emit("color:strokeBroadcast", data);
|
||||
});
|
||||
|
||||
socket.on("color:bucket", (payload) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.color) return;
|
||||
const { regionId, color } = payload || {};
|
||||
if (!regionId || !color) return;
|
||||
G.colorBucket(room, regionId, color);
|
||||
io.to(code).emit("color:bucketBroadcast", { regionId, color });
|
||||
});
|
||||
|
||||
socket.on("color:reset", (payload) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.color) return;
|
||||
G.colorReset(room, (payload || {}).regionId);
|
||||
io.to(code).emit("color:state", {
|
||||
strokes: room.color.strokes,
|
||||
regions: room.color.regionFills,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("presence:cursor", (data) => {
|
||||
const code = socket.data.roomCode;
|
||||
if (!code) return;
|
||||
socket.to(code).emit("presence:cursors", [
|
||||
{
|
||||
playerId: socket.data.playerId,
|
||||
x: (data && data.x) || 0,
|
||||
y: (data && data.y) || 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerHandlers };
|
||||
@@ -0,0 +1,56 @@
|
||||
// Word masking and hint helpers for Skribbl mode.
|
||||
|
||||
function normalize(s) {
|
||||
return String(s || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function maskWord(word, revealed = new Set()) {
|
||||
// produce a mask like "_ _ _ _" preserving spaces
|
||||
return word
|
||||
.split("")
|
||||
.map((ch, i) => {
|
||||
if (ch === " ") return " ";
|
||||
if (revealed.has(i)) return ch.toLowerCase();
|
||||
return "_";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function pickRevealIndex(word, revealed) {
|
||||
const candidates = [];
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
if (word[i] === " ") continue;
|
||||
if (revealed.has(i)) continue;
|
||||
candidates.push(i);
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
}
|
||||
|
||||
function isCloseGuess(guess, word) {
|
||||
const g = normalize(guess);
|
||||
const w = normalize(word);
|
||||
if (!g || !w || g === w) return false;
|
||||
if (Math.abs(g.length - w.length) > 2) return false;
|
||||
// simple Levenshtein
|
||||
const m = g.length, n = w.length;
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = g[i - 1] === w[j - 1] ? 0 : 1;
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + cost
|
||||
);
|
||||
}
|
||||
}
|
||||
return dp[m][n] <= 2;
|
||||
}
|
||||
|
||||
module.exports = { normalize, maskWord, pickRevealIndex, isCloseGuess };
|
||||
Reference in New Issue
Block a user