Files
skribbl-gartic-color/server/socket-handlers.js
T

469 lines
15 KiB
JavaScript

// 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) {
// Per-socket emission so we can include drawer-only data
// (wordChoices during choosing, secret word during drawing) in the
// snapshot of the right viewer without leaking it to others.
for (const player of room.players) {
if (!player.socketId) continue;
const sock = io.sockets.sockets.get(player.socketId);
if (!sock) continue;
sock.emit("room:state", G.publicRoomState(room, player.id));
}
}
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" });
}
if (room.mode === "gartic" && room.players.filter((p) => p.connected).length < 3) {
return ack && ack({ ok: false, error: "Gartic Phone needs at least 3 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 };