Initial commit
This commit is contained in:
commit
6e55209159
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "yarn",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,837 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>YARN 2026 – Spin Your Story!</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-main: #4b0082;
|
||||||
|
--text-beige: #FFFFE4;
|
||||||
|
--box-bg: #0a0a0a;
|
||||||
|
--player-green: #00ff88;
|
||||||
|
--player-yellow: #ffdd00;
|
||||||
|
--header-bg: #3a0066;
|
||||||
|
--accent-purple: #6a0dad;
|
||||||
|
--chat-text: #ffffcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-main);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
color: var(--text-beige);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#layout {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
flex: 0 0 5%;
|
||||||
|
background: var(--header-bg);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 2px solid var(--accent-purple);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-text {
|
||||||
|
flex: 0 0 50%;
|
||||||
|
background: var(--box-bg);
|
||||||
|
margin: 1.2rem 2rem 0 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.6rem 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: "Times New Roman", Times, serif;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-beige);
|
||||||
|
border: 2px solid var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-segment {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-segment[data-player]::before {
|
||||||
|
content: attr(data-player) ": ";
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent-purple);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-bar {
|
||||||
|
flex: 0 0 10%;
|
||||||
|
background: var(--bg-main);
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-bar > * {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-bar input[type="text"],
|
||||||
|
#game-bar input[type="number"] {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 2px solid var(--accent-purple);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: var(--text-beige);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-bar button {
|
||||||
|
background: var(--accent-purple);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-bar button:hover {
|
||||||
|
background: #8a1dbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-bar button:disabled {
|
||||||
|
background: #444;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group input[type="number"] {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timer {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #ff6666;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timer.warning {
|
||||||
|
color: #ff3333;
|
||||||
|
animation: pulse 0.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#entry-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 500px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#voting-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-entry-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-btn {
|
||||||
|
background: var(--player-green);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-btn:disabled {
|
||||||
|
background: #444;
|
||||||
|
color: #888;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
margin: 0 2rem 1.2rem 2rem;
|
||||||
|
background: var(--box-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--chat-text);
|
||||||
|
border: 2px solid var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
#players {
|
||||||
|
width: 260px;
|
||||||
|
background: #111;
|
||||||
|
padding: 1rem 0.8rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
#players-header {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.3rem 1rem 0.8rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.15rem 1rem;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-row.submitted {
|
||||||
|
color: var(--player-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-row.pending {
|
||||||
|
color: var(--player-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-row.host::after {
|
||||||
|
content: " ★";
|
||||||
|
color: gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
text-align: left;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-score {
|
||||||
|
text-align: right;
|
||||||
|
min-width: 70px;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-and-input {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.3rem 1.6rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message strong {
|
||||||
|
color: var(--player-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-input {
|
||||||
|
height: 52px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
color: var(--text-beige);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
padding: 0 1.6rem;
|
||||||
|
outline: none;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-input:focus {
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting-message {
|
||||||
|
color: #aaa;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-message {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: gold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-display {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-winner {
|
||||||
|
color: var(--player-green);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="layout">
|
||||||
|
|
||||||
|
<div id="header">
|
||||||
|
YARN 2026 - Spin your story! <span id="room-display"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-text">
|
||||||
|
<em>Waiting for the game to begin...</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="game-bar">
|
||||||
|
<!-- Dynamic content will be inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chat-area">
|
||||||
|
|
||||||
|
<div id="players">
|
||||||
|
<div id="players-header">Players</div>
|
||||||
|
<div id="players-list">
|
||||||
|
<!-- Players will be listed here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chat-and-input">
|
||||||
|
<div id="chat-messages">
|
||||||
|
<!-- Chat messages will appear here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="text" id="chat-input" placeholder="Type message… (Enter to send)" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// Get room name from URL
|
||||||
|
const pathParts = window.location.pathname.split('/').filter(p => p);
|
||||||
|
const roomName = pathParts[0] || 'default';
|
||||||
|
document.getElementById('room-display').textContent = ` — Room: ${roomName}`;
|
||||||
|
|
||||||
|
// Get or create player UUID
|
||||||
|
let playerUUID = getCookie('yarn_player_uuid');
|
||||||
|
if (!playerUUID) {
|
||||||
|
playerUUID = generateUUID();
|
||||||
|
setCookie('yarn_player_uuid', playerUUID, 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stored player name
|
||||||
|
let playerName = getCookie('yarn_player_name') || '';
|
||||||
|
|
||||||
|
// Game state
|
||||||
|
let gameState = null;
|
||||||
|
let hasVoted = false;
|
||||||
|
let hasSubmitted = false;
|
||||||
|
|
||||||
|
// Connect to Socket.IO
|
||||||
|
const socket = io();
|
||||||
|
|
||||||
|
// Join the room
|
||||||
|
socket.emit('joinRoom', {
|
||||||
|
roomName: roomName,
|
||||||
|
playerUUID: playerUUID,
|
||||||
|
playerName: playerName
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Socket Event Handlers ===
|
||||||
|
|
||||||
|
socket.on('gameState', (state) => {
|
||||||
|
gameState = state;
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('chatHistory', (chat) => {
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
chatMessages.innerHTML = '';
|
||||||
|
chat.forEach(msg => appendChatMessage(msg));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('playersUpdate', (players) => {
|
||||||
|
if (gameState) {
|
||||||
|
gameState.players = players;
|
||||||
|
updatePlayersList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('settingsUpdate', (settings) => {
|
||||||
|
if (gameState) {
|
||||||
|
gameState.gameSettings = settings;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('nameSet', (data) => {
|
||||||
|
if (data.success) {
|
||||||
|
playerName = data.name;
|
||||||
|
setCookie('yarn_player_name', playerName, 365);
|
||||||
|
document.getElementById('chat-input').disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('playerJoined', (data) => {
|
||||||
|
appendSystemMessage(`${data.name} joined the game`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('playerLeft', (data) => {
|
||||||
|
appendSystemMessage(`${data.name} left the game`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('gameStarted', (state) => {
|
||||||
|
gameState = state;
|
||||||
|
hasSubmitted = false;
|
||||||
|
hasVoted = false;
|
||||||
|
updateUI();
|
||||||
|
appendSystemMessage('The game has started!');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timerUpdate', (time) => {
|
||||||
|
if (gameState) {
|
||||||
|
gameState.timeRemaining = time;
|
||||||
|
updateTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('votingPhase', (data) => {
|
||||||
|
if (gameState) {
|
||||||
|
gameState.currentPhase = 'voting';
|
||||||
|
gameState.round_data = data.entries;
|
||||||
|
gameState.timeRemaining = data.timeRemaining;
|
||||||
|
hasVoted = false;
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('roundResults', (data) => {
|
||||||
|
if (gameState) {
|
||||||
|
gameState.players = data.players;
|
||||||
|
gameState.yarnStory = data.story;
|
||||||
|
showRoundResults(data.winner);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('newRound', (state) => {
|
||||||
|
gameState = state;
|
||||||
|
hasSubmitted = false;
|
||||||
|
hasVoted = false;
|
||||||
|
updateUI();
|
||||||
|
appendSystemMessage('New round started!');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('gameOver', (data) => {
|
||||||
|
if (gameState) {
|
||||||
|
gameState.currentPhase = 'gameOver';
|
||||||
|
gameState.players = data.players;
|
||||||
|
gameState.yarnStory = data.story;
|
||||||
|
updateUI();
|
||||||
|
showGameOver(data.winner);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('gameReset', (state) => {
|
||||||
|
gameState = state;
|
||||||
|
hasSubmitted = false;
|
||||||
|
hasVoted = false;
|
||||||
|
updateUI();
|
||||||
|
appendSystemMessage('Game has been reset. Waiting for host to start...');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('newChatMessage', (msg) => {
|
||||||
|
appendChatMessage(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (data) => {
|
||||||
|
alert(data.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// === UI Functions ===
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
updateStory();
|
||||||
|
updateGameBar();
|
||||||
|
updatePlayersList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStory() {
|
||||||
|
const mainText = document.getElementById('main-text');
|
||||||
|
|
||||||
|
if (!gameState || !gameState.inProgress && gameState.yarnStory.length === 0) {
|
||||||
|
mainText.innerHTML = '<em>Waiting for the game to begin...</em>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainText.innerHTML = gameState.yarnStory.map(segment =>
|
||||||
|
`<span class="story-segment" data-player="${escapeHtml(segment.player)}">${escapeHtml(segment.str)} </span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGameBar() {
|
||||||
|
const gameBar = document.getElementById('game-bar');
|
||||||
|
|
||||||
|
// If player hasn't set name yet
|
||||||
|
if (!playerName) {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span>Enter your name to join:</span>
|
||||||
|
<input type="text" id="name-input" placeholder="Your name..." maxlength="20">
|
||||||
|
<button id="set-name-btn">Join Game</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('set-name-btn').onclick = setPlayerName;
|
||||||
|
document.getElementById('name-input').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') setPlayerName();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const isHost = gameState.gameHost === playerUUID;
|
||||||
|
|
||||||
|
switch (gameState.currentPhase) {
|
||||||
|
case 'lobby':
|
||||||
|
if (isHost) {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>Score Limit:</label>
|
||||||
|
<input type="number" id="score-limit" value="${gameState.gameSettings.scoreLimit}" min="10" max="100">
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>Time per Round (sec):</label>
|
||||||
|
<input type="number" id="time-limit" value="${gameState.gameSettings.timeLimit}" min="15" max="180">
|
||||||
|
</div>
|
||||||
|
<button id="start-game-btn">Start Game</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('start-game-btn').onclick = startGame;
|
||||||
|
} else {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span class="waiting-message">Waiting for host to start the game...</span>
|
||||||
|
<span>Score Limit: ${gameState.gameSettings.scoreLimit}</span>
|
||||||
|
<span>Time Limit: ${gameState.gameSettings.timeLimit}s</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'writing':
|
||||||
|
if (hasSubmitted) {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span id="timer">${gameState.timeRemaining}</span>
|
||||||
|
<span class="waiting-message">Entry submitted! Waiting for other players...</span>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span id="timer">${gameState.timeRemaining}</span>
|
||||||
|
<input type="text" id="entry-input" placeholder="Continue the story..." maxlength="200">
|
||||||
|
<button id="submit-entry-btn">Submit</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('submit-entry-btn').onclick = submitEntry;
|
||||||
|
document.getElementById('entry-input').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') submitEntry();
|
||||||
|
});
|
||||||
|
document.getElementById('entry-input').focus();
|
||||||
|
}
|
||||||
|
updateTimer();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'voting':
|
||||||
|
if (hasVoted) {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span id="timer">${gameState.timeRemaining}</span>
|
||||||
|
<span class="waiting-message">Vote cast! Waiting for other players...</span>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const entries = gameState.round_data.filter(e => e.entry_text);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span class="waiting-message">No entries to vote on...</span>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span id="timer">${gameState.timeRemaining}</span>
|
||||||
|
<div id="voting-container">
|
||||||
|
${entries.map(e => `
|
||||||
|
<div class="vote-entry">
|
||||||
|
<span class="vote-entry-text">"${escapeHtml(e.entry_text)}"</span>
|
||||||
|
<button class="vote-btn" data-index="${e.index}">Vote</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.querySelectorAll('.vote-btn').forEach(btn => {
|
||||||
|
btn.onclick = () => submitVote(parseInt(btn.dataset.index));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTimer();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gameOver':
|
||||||
|
if (isHost) {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span class="game-over-message">Game Over!</span>
|
||||||
|
<button id="play-again-btn">Play Again</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('play-again-btn').onclick = () => {
|
||||||
|
socket.emit('playAgain');
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<span class="game-over-message">Game Over! Waiting for host...</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
const timer = document.getElementById('timer');
|
||||||
|
if (timer && gameState) {
|
||||||
|
timer.textContent = gameState.timeRemaining;
|
||||||
|
if (gameState.timeRemaining <= 10) {
|
||||||
|
timer.classList.add('warning');
|
||||||
|
} else {
|
||||||
|
timer.classList.remove('warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayersList() {
|
||||||
|
const playersList = document.getElementById('players-list');
|
||||||
|
if (!gameState) {
|
||||||
|
playersList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playersList.innerHTML = gameState.players.map(p => {
|
||||||
|
let statusClass = '';
|
||||||
|
if (gameState.currentPhase === 'writing') {
|
||||||
|
statusClass = p.hasSubmitted ? 'submitted' : 'pending';
|
||||||
|
} else if (gameState.currentPhase === 'voting') {
|
||||||
|
statusClass = p.hasVoted ? 'submitted' : 'pending';
|
||||||
|
} else {
|
||||||
|
statusClass = 'submitted';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostClass = p.player_id === gameState.gameHost ? 'host' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="player-row ${statusClass} ${hostClass}">
|
||||||
|
<span class="player-name">${escapeHtml(p.name)}${p.player_id === playerUUID ? ' (You)' : ''}</span>
|
||||||
|
<span class="player-score">${p.score}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRoundResults(winner) {
|
||||||
|
const gameBar = document.getElementById('game-bar');
|
||||||
|
if (winner && winner.entry_text) {
|
||||||
|
const winnerPlayer = gameState.players.find(p => p.player_id === winner.player_id);
|
||||||
|
const winnerName = winnerPlayer ? winnerPlayer.name : 'Unknown';
|
||||||
|
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<div class="results-display">
|
||||||
|
<span class="round-winner">🏆 "${escapeHtml(winner.entry_text)}" by ${escapeHtml(winnerName)} wins!</span>
|
||||||
|
<br><span class="waiting-message">Next round starting soon...</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
gameBar.innerHTML = `
|
||||||
|
<div class="results-display">
|
||||||
|
<span class="waiting-message">No winner this round. Next round starting soon...</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStory();
|
||||||
|
updatePlayersList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGameOver(winner) {
|
||||||
|
const mainText = document.getElementById('main-text');
|
||||||
|
const storyHtml = gameState.yarnStory.map(segment =>
|
||||||
|
`<span class="story-segment" data-player="${escapeHtml(segment.player)}">${escapeHtml(segment.str)} </span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
mainText.innerHTML = `
|
||||||
|
<div style="text-align: center; margin-bottom: 1rem;">
|
||||||
|
<span class="game-over-message">🎉 ${escapeHtml(winner.name)} is victorious with ${winner.score} points! 🎉</span>
|
||||||
|
</div>
|
||||||
|
<hr style="border-color: var(--accent-purple); margin: 1rem 0;">
|
||||||
|
<strong>The Final Story:</strong><br><br>
|
||||||
|
${storyHtml}
|
||||||
|
`;
|
||||||
|
|
||||||
|
appendSystemMessage(`${winner.name} wins the game with ${winner.score} points!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Action Functions ===
|
||||||
|
|
||||||
|
function setPlayerName() {
|
||||||
|
const input = document.getElementById('name-input');
|
||||||
|
const name = input.value.trim();
|
||||||
|
|
||||||
|
if (name.length < 1 || name.length > 20) {
|
||||||
|
alert('Please enter a name (1-20 characters)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit('setName', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
const scoreLimit = parseInt(document.getElementById('score-limit').value) || 25;
|
||||||
|
const timeLimit = parseInt(document.getElementById('time-limit').value) || 60;
|
||||||
|
|
||||||
|
socket.emit('startGame', {
|
||||||
|
scoreLimit: Math.max(10, Math.min(100, scoreLimit)),
|
||||||
|
timeLimit: Math.max(15, Math.min(180, timeLimit))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitEntry() {
|
||||||
|
const input = document.getElementById('entry-input');
|
||||||
|
const entry = input.value.trim();
|
||||||
|
|
||||||
|
socket.emit('submitEntry', { entry });
|
||||||
|
hasSubmitted = true;
|
||||||
|
updateGameBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitVote(entryIndex) {
|
||||||
|
socket.emit('submitVote', { entryIndex });
|
||||||
|
hasVoted = true;
|
||||||
|
updateGameBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Chat Functions ===
|
||||||
|
|
||||||
|
document.getElementById('chat-input').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const msg = this.value.trim();
|
||||||
|
if (msg && playerName) {
|
||||||
|
socket.emit('chatMessage', { msg });
|
||||||
|
this.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function appendChatMessage(msg) {
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'chat-message';
|
||||||
|
div.innerHTML = `<strong>${escapeHtml(msg.name)}></strong> ${escapeHtml(msg.msg)}`;
|
||||||
|
chatMessages.appendChild(div);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSystemMessage(msg) {
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'chat-message system-message';
|
||||||
|
div.textContent = `* ${msg}`;
|
||||||
|
chatMessages.appendChild(div);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Utility Functions ===
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name, value, days) {
|
||||||
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return decodeURIComponent(parts.pop().split(';').shift());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,488 @@
|
||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
const path = require('path');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
//const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { randomUUID } = require('node:crypto');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = new Server(server);
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Store all game rooms
|
||||||
|
const games = {};
|
||||||
|
|
||||||
|
// YarnGame class
|
||||||
|
class YarnGame {
|
||||||
|
constructor(roomName) {
|
||||||
|
this.roomName = roomName;
|
||||||
|
this.inProgress = false;
|
||||||
|
this.gameHost = null;
|
||||||
|
this.gameSettings = {
|
||||||
|
scoreLimit: 25,
|
||||||
|
timeLimit: 60
|
||||||
|
};
|
||||||
|
this.players = []; // { player_id, name, score, socketId }
|
||||||
|
this.yarnStory = []; // { player: "", str: "" }
|
||||||
|
this.chat = []; // { id, name, msg }
|
||||||
|
this.round_data = []; // { entry_text, player_id, votes: [] }
|
||||||
|
this.currentPhase = 'lobby'; // lobby, writing, voting, gameOver
|
||||||
|
this.timer = null;
|
||||||
|
this.timeRemaining = 0;
|
||||||
|
this.submittedPlayers = new Set();
|
||||||
|
this.votedPlayers = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
addPlayer(playerId, name, socketId) {
|
||||||
|
const existingPlayer = this.players.find(p => p.player_id === playerId);
|
||||||
|
if (existingPlayer) {
|
||||||
|
existingPlayer.socketId = socketId;
|
||||||
|
existingPlayer.name = name;
|
||||||
|
return existingPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = {
|
||||||
|
player_id: playerId,
|
||||||
|
name: name,
|
||||||
|
score: 0,
|
||||||
|
socketId: socketId
|
||||||
|
};
|
||||||
|
this.players.push(player);
|
||||||
|
|
||||||
|
// First player becomes host
|
||||||
|
if (this.players.length === 1) {
|
||||||
|
this.gameHost = playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
removePlayer(playerId) {
|
||||||
|
const index = this.players.findIndex(p => p.player_id === playerId);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.players.splice(index, 1);
|
||||||
|
|
||||||
|
// Assign new host if host left
|
||||||
|
if (this.gameHost === playerId && this.players.length > 0) {
|
||||||
|
this.gameHost = this.players[0].player_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.players.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayer(playerId) {
|
||||||
|
return this.players.find(p => p.player_id === playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerBySocket(socketId) {
|
||||||
|
return this.players.find(p => p.socketId === socketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayerName(playerId, name) {
|
||||||
|
const player = this.getPlayer(playerId);
|
||||||
|
if (player) {
|
||||||
|
player.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startGame(settings) {
|
||||||
|
this.gameSettings = { ...this.gameSettings, ...settings };
|
||||||
|
this.inProgress = true;
|
||||||
|
this.yarnStory = [{ player: "Narrator", str: "It was a dark and stormy night..." }];
|
||||||
|
this.players.forEach(p => p.score = 0);
|
||||||
|
this.startWritingPhase();
|
||||||
|
}
|
||||||
|
|
||||||
|
startWritingPhase() {
|
||||||
|
this.currentPhase = 'writing';
|
||||||
|
this.round_data = [];
|
||||||
|
this.submittedPlayers = new Set();
|
||||||
|
this.timeRemaining = this.gameSettings.timeLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitEntry(playerId, entryText) {
|
||||||
|
if (this.currentPhase !== 'writing') return false;
|
||||||
|
if (this.submittedPlayers.has(playerId)) return false;
|
||||||
|
|
||||||
|
this.round_data.push({
|
||||||
|
entry_text: entryText.trim(),
|
||||||
|
player_id: playerId,
|
||||||
|
votes: []
|
||||||
|
});
|
||||||
|
this.submittedPlayers.add(playerId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
allPlayersSubmitted() {
|
||||||
|
return this.submittedPlayers.size >= this.players.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
startVotingPhase() {
|
||||||
|
this.currentPhase = 'voting';
|
||||||
|
this.votedPlayers = new Set();
|
||||||
|
this.timeRemaining = 30; // 30 seconds for voting
|
||||||
|
}
|
||||||
|
|
||||||
|
submitVote(playerId, entryIndex) {
|
||||||
|
if (this.currentPhase !== 'voting') return false;
|
||||||
|
if (this.votedPlayers.has(playerId)) return false;
|
||||||
|
|
||||||
|
|
||||||
|
const entry = this.round_data[entryIndex];
|
||||||
|
// Can't vote for own entry
|
||||||
|
//if (!entry || entry.player_id === playerId) return false;
|
||||||
|
|
||||||
|
entry.votes.push(playerId);
|
||||||
|
this.votedPlayers.add(playerId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
allPlayersVoted() {
|
||||||
|
return this.votedPlayers.size >= this.players.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
tallyVotes() {
|
||||||
|
if (this.round_data.length === 0) return null;
|
||||||
|
|
||||||
|
// Find winner (most votes)
|
||||||
|
let winner = this.round_data[0];
|
||||||
|
for (const entry of this.round_data) {
|
||||||
|
if (entry.votes.length > winner.votes.length) {
|
||||||
|
winner = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Award points
|
||||||
|
const winnerPlayer = this.getPlayer(winner.player_id);
|
||||||
|
if (winnerPlayer) {
|
||||||
|
winnerPlayer.score += 5 + winner.votes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to story
|
||||||
|
if (winner.entry_text) {
|
||||||
|
this.yarnStory.push({
|
||||||
|
player: winnerPlayer ? winnerPlayer.name : "Unknown",
|
||||||
|
str: winner.entry_text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return winner;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkGameOver() {
|
||||||
|
const winner = this.players.find(p => p.score >= this.gameSettings.scoreLimit);
|
||||||
|
if (winner) {
|
||||||
|
this.currentPhase = 'gameOver';
|
||||||
|
this.inProgress = false;
|
||||||
|
return winner;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetGame() {
|
||||||
|
this.inProgress = false;
|
||||||
|
this.currentPhase = 'lobby';
|
||||||
|
this.round_data = [];
|
||||||
|
this.yarnStory = [];
|
||||||
|
this.submittedPlayers = new Set();
|
||||||
|
this.votedPlayers = new Set();
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addChatMessage(playerId, msg) {
|
||||||
|
const player = this.getPlayer(playerId);
|
||||||
|
const chatMsg = {
|
||||||
|
id: playerId,
|
||||||
|
name: player ? player.name : 'Unknown',
|
||||||
|
msg: msg
|
||||||
|
};
|
||||||
|
this.chat.push(chatMsg);
|
||||||
|
// Keep last 100 messages
|
||||||
|
if (this.chat.length > 100) {
|
||||||
|
this.chat.shift();
|
||||||
|
}
|
||||||
|
return chatMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState() {
|
||||||
|
return {
|
||||||
|
roomName: this.roomName,
|
||||||
|
inProgress: this.inProgress,
|
||||||
|
gameHost: this.gameHost,
|
||||||
|
gameSettings: this.gameSettings,
|
||||||
|
players: this.players.map(p => ({
|
||||||
|
player_id: p.player_id,
|
||||||
|
name: p.name,
|
||||||
|
score: p.score,
|
||||||
|
hasSubmitted: this.submittedPlayers.has(p.player_id),
|
||||||
|
hasVoted: this.votedPlayers.has(p.player_id)
|
||||||
|
})),
|
||||||
|
yarnStory: this.yarnStory,
|
||||||
|
currentPhase: this.currentPhase,
|
||||||
|
timeRemaining: this.timeRemaining,
|
||||||
|
round_data: this.currentPhase === 'voting' ? this.round_data.map((e, i) => ({
|
||||||
|
index: i,
|
||||||
|
entry_text: e.entry_text,
|
||||||
|
voteCount: e.votes.length
|
||||||
|
})) : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create a game room
|
||||||
|
function getOrCreateGame(roomName) {
|
||||||
|
if (!games[roomName]) {
|
||||||
|
games[roomName] = new YarnGame(roomName);
|
||||||
|
}
|
||||||
|
return games[roomName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route for game rooms
|
||||||
|
app.get('/:roomName', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Socket.IO connection handling
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
let currentRoom = null;
|
||||||
|
let playerId = null;
|
||||||
|
|
||||||
|
socket.on('joinRoom', (data) => {
|
||||||
|
const { roomName, playerUUID, playerName } = data;
|
||||||
|
currentRoom = roomName;
|
||||||
|
playerId = playerUUID;
|
||||||
|
|
||||||
|
socket.join(roomName);
|
||||||
|
|
||||||
|
const game = getOrCreateGame(roomName);
|
||||||
|
|
||||||
|
if (playerName) {
|
||||||
|
game.addPlayer(playerId, playerName, socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send current game state to the joining player
|
||||||
|
socket.emit('gameState', game.getState());
|
||||||
|
socket.emit('chatHistory', game.chat);
|
||||||
|
|
||||||
|
// Notify others
|
||||||
|
if (playerName) {
|
||||||
|
io.to(roomName).emit('playerJoined', {
|
||||||
|
player_id: playerId,
|
||||||
|
name: playerName
|
||||||
|
});
|
||||||
|
io.to(roomName).emit('playersUpdate', game.getState().players);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('setName', (data) => {
|
||||||
|
const { name } = data;
|
||||||
|
if (!currentRoom || !playerId) return;
|
||||||
|
|
||||||
|
const game = games[currentRoom];
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
game.addPlayer(playerId, name, socket.id);
|
||||||
|
|
||||||
|
socket.emit('nameSet', { success: true, name });
|
||||||
|
io.to(currentRoom).emit('playersUpdate', game.getState().players);
|
||||||
|
io.to(currentRoom).emit('gameState', game.getState());
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('updateSettings', (data) => {
|
||||||
|
if (!currentRoom || !playerId) return;
|
||||||
|
|
||||||
|
const game = games[currentRoom];
|
||||||
|
if (!game || game.gameHost !== playerId) return;
|
||||||
|
|
||||||
|
game.gameSettings = { ...game.gameSettings, ...data };
|
||||||
|
io.to(currentRoom).emit('settingsUpdate', game.gameSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('startGame', (settings) => {
|
||||||
|
if (!currentRoom || !playerId) return;
|
||||||
|
|
||||||
|
const game = games[currentRoom];
|
||||||
|
if (!game || game.gameHost !== playerId) return;
|
||||||
|
if (game.players.length < 2) {
|
||||||
|
socket.emit('error', { message: 'Need at least 2 players to start' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.startGame(settings);
|
||||||
|
io.to(currentRoom).emit('gameStarted', game.getState());
|
||||||
|
|
||||||
|
startTimer(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('submitEntry', (data) => {
|
||||||
|
if (!currentRoom || !playerId) return;
|
||||||
|
|
||||||
|
const game = games[currentRoom];
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
const success = game.submitEntry(playerId, data.entry);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
io.to(currentRoom).emit('playersUpdate', game.getState().players);
|
||||||
|
|
||||||
|
if (game.allPlayersSubmitted()) {
|
||||||
|
clearInterval(game.timer);
|
||||||
|
game.startVotingPhase();
|
||||||
|
io.to(currentRoom).emit('votingPhase', {
|
||||||
|
entries: game.round_data.map((e, i) => ({
|
||||||
|
index: i,
|
||||||
|
entry_text: e.entry_text
|
||||||
|
})),
|
||||||
|
timeRemaining: game.timeRemaining
|
||||||
|
});
|
||||||
|
startVotingTimer(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('submitVote', (data) => {
|
||||||
|
if (!currentRoom || !playerId) return;
|
||||||
|
|
||||||
|
const game = games[currentRoom];
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
const success = game.submitVote(playerId, data.entryIndex);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
io.to(currentRoom).emit('playersUpdate', game.getState().players);
|
||||||
|
|
||||||
|
if (game.allPlayersVoted()) {
|
||||||
|
clearInterval(game.timer);
|
||||||
|
endVotingPhase(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('chatMessage', (data) => {
|
||||||
|
if (!currentRoom || !playerId) return;
|
||||||
|
|
||||||
|
const game = games[currentRoom];
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
const chatMsg = game.addChatMessage(playerId, data.msg);
|
||||||
|
io.to(currentRoom).emit('newChatMessage', chatMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('playAgain', (settings) => {
|
||||||
|
if (!currentRoom || !playerId) return;
|
||||||
|
|
||||||
|
const game = games[currentRoom];
|
||||||
|
if (!game || game.gameHost !== playerId) return;
|
||||||
|
|
||||||
|
game.resetGame();
|
||||||
|
io.to(currentRoom).emit('gameReset', game.getState());
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
if (!currentRoom || !playerId) return;
|
||||||
|
|
||||||
|
const game = games[currentRoom];
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
const player = game.getPlayer(playerId);
|
||||||
|
const playerName = player ? player.name : 'Unknown';
|
||||||
|
|
||||||
|
const remaining = game.removePlayer(playerId);
|
||||||
|
|
||||||
|
io.to(currentRoom).emit('playerLeft', { player_id: playerId, name: playerName });
|
||||||
|
io.to(currentRoom).emit('playersUpdate', game.getState().players);
|
||||||
|
io.to(currentRoom).emit('gameState', game.getState());
|
||||||
|
|
||||||
|
// Clean up empty rooms
|
||||||
|
if (remaining === 0) {
|
||||||
|
if (game.timer) clearInterval(game.timer);
|
||||||
|
delete games[currentRoom];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function startTimer(game) {
|
||||||
|
if (game.timer) clearInterval(game.timer);
|
||||||
|
|
||||||
|
game.timer = setInterval(() => {
|
||||||
|
game.timeRemaining--;
|
||||||
|
io.to(game.roomName).emit('timerUpdate', game.timeRemaining);
|
||||||
|
|
||||||
|
if (game.timeRemaining <= 0) {
|
||||||
|
clearInterval(game.timer);
|
||||||
|
|
||||||
|
// Force submit empty entries for players who haven't submitted
|
||||||
|
for (const player of game.players) {
|
||||||
|
if (!game.submittedPlayers.has(player.player_id)) {
|
||||||
|
game.submitEntry(player.player_id, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
game.startVotingPhase();
|
||||||
|
io.to(game.roomName).emit('votingPhase', {
|
||||||
|
entries: game.round_data.map((e, i) => ({
|
||||||
|
index: i,
|
||||||
|
entry_text: e.entry_text
|
||||||
|
})),
|
||||||
|
timeRemaining: game.timeRemaining
|
||||||
|
});
|
||||||
|
startVotingTimer(game);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVotingTimer(game) {
|
||||||
|
if (game.timer) clearInterval(game.timer);
|
||||||
|
|
||||||
|
game.timer = setInterval(() => {
|
||||||
|
game.timeRemaining--;
|
||||||
|
io.to(game.roomName).emit('timerUpdate', game.timeRemaining);
|
||||||
|
|
||||||
|
if (game.timeRemaining <= 0) {
|
||||||
|
clearInterval(game.timer);
|
||||||
|
endVotingPhase(game);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endVotingPhase(game) {
|
||||||
|
const winner = game.tallyVotes();
|
||||||
|
|
||||||
|
io.to(game.roomName).emit('roundResults', {
|
||||||
|
winner: winner,
|
||||||
|
players: game.getState().players,
|
||||||
|
story: game.yarnStory
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for game over
|
||||||
|
const gameWinner = game.checkGameOver();
|
||||||
|
if (gameWinner) {
|
||||||
|
io.to(game.roomName).emit('gameOver', {
|
||||||
|
winner: gameWinner,
|
||||||
|
players: game.getState().players,
|
||||||
|
story: game.yarnStory
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Start next round after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
game.startWritingPhase();
|
||||||
|
io.to(game.roomName).emit('newRound', game.getState());
|
||||||
|
startTimer(game);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`YARN server running on http://localhost:${PORT}`);
|
||||||
|
console.log(`Join a room at http://localhost:${PORT}/game101`);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue