469 lines
15 KiB
JavaScript
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 === "<" ? "<" : ">"));
|
|
}
|
|
|
|
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 };
|