feat: Next.js + Socket.IO 3-mode game (Skribbl, Gartic, Color)

This commit is contained in:
PM
2026-05-01 20:12:36 +00:00
parent b02976c10b
commit 2a40097fad
47 changed files with 7907 additions and 0 deletions
+370
View File
@@ -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,
};
+47
View File
@@ -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 };
+457
View File
@@ -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 === "<" ? "&lt;" : "&gt;"));
}
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 };
+56
View File
@@ -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 };