Files
skribbl-gartic-color/server/game-state.js
T

385 lines
11 KiB
JavaScript

// 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, viewerId) {
const isViewerDrawer = !!(viewerId && room.skribbl && room.skribbl.drawerId === viewerId);
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 || [])],
// Drawer-only: surface current word choices in the snapshot so a
// late-mounted play page (after lobby→/play navigation) can render
// the pick modal even if the original skribbl:wordChoices event
// was emitted before the listener was attached.
wordChoices:
isViewerDrawer && room.skribbl.phase === "choosing"
? room.skribbl.wordChoices
: null,
// Drawer-only: reveal the actual word so they know what to draw.
word:
isViewerDrawer && room.skribbl.phase === "drawing"
? room.skribbl.word
: null,
}
: 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,
};