1227 lines
35 KiB
HTML
1227 lines
35 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Y.A.R.N. HTML5 – Spin a 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;
|
||
gap: .4rem;
|
||
border-bottom: 2px solid var(--accent-purple);
|
||
letter-spacing: 1px;
|
||
padding: 0 1rem;
|
||
}
|
||
|
||
#header img { height: 70%; }
|
||
|
||
#room-meta {
|
||
font-size: .8rem;
|
||
font-weight: normal;
|
||
letter-spacing: 0;
|
||
color: #d9c7ff;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
#header button {
|
||
font-size: .9rem;
|
||
padding: .3rem .6rem;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: var(--accent-purple);
|
||
color: #fff;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
}
|
||
|
||
#header button:hover { background: #8a1dbd; }
|
||
|
||
#main-text {
|
||
flex: 0 0 46%;
|
||
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]:hover::before {
|
||
content: attr(data-player) ": ";
|
||
font-family: system-ui, sans-serif;
|
||
font-size: .7em;
|
||
font-weight: bold;
|
||
color: #b98cff;
|
||
vertical-align: super;
|
||
}
|
||
|
||
.story-segment.narrator { font-style: italic; color: #cbb8e8; }
|
||
|
||
#game-bar {
|
||
flex: 0 0 12%;
|
||
background: var(--bg-main);
|
||
padding: 0.8rem 2rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
#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;
|
||
}
|
||
|
||
#game-bar button.secondary {
|
||
background: #333;
|
||
font-size: .85rem;
|
||
padding: .4rem .8rem;
|
||
}
|
||
|
||
#game-bar button.secondary:hover { background: #555; }
|
||
|
||
.setting-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.setting-group label {
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.setting-group input[type="number"] { width: 80px; }
|
||
|
||
.setting-group input#start-text { width: 240px; }
|
||
|
||
.settings-summary {
|
||
font-size: .95rem;
|
||
font-weight: normal;
|
||
color: #e6d9ff;
|
||
text-align: center;
|
||
}
|
||
|
||
#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.own { border-color: #555; opacity: .75; }
|
||
|
||
.vote-entry-text {
|
||
flex: 1;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.vote-btn {
|
||
background: var(--player-green) !important;
|
||
color: #000 !important;
|
||
border: none;
|
||
padding: 0.4rem 1rem;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.vote-btn:disabled {
|
||
background: #444 !important;
|
||
color: #888 !important;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
#results-table {
|
||
width: 100%;
|
||
max-width: 860px;
|
||
border-collapse: collapse;
|
||
font-size: .95rem;
|
||
font-weight: normal;
|
||
}
|
||
|
||
#results-table th,
|
||
#results-table td {
|
||
padding: .35rem .6rem;
|
||
text-align: left;
|
||
border-bottom: 1px solid #2a2a2a;
|
||
}
|
||
|
||
#results-table th { color: #b98cff; font-size: .8rem; text-transform: uppercase; }
|
||
|
||
#results-table tr.winner td { color: var(--player-green); font-weight: bold; }
|
||
|
||
#results-table td.pts { color: var(--player-yellow); white-space: nowrap; }
|
||
|
||
.results-heading {
|
||
color: var(--player-green);
|
||
font-size: 1.2rem;
|
||
margin-bottom: .4rem;
|
||
}
|
||
|
||
.results-note { color: #aaa; font-size: .9rem; font-weight: normal; }
|
||
|
||
#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);
|
||
min-height: 0;
|
||
}
|
||
|
||
#players {
|
||
width: 280px;
|
||
background: #111;
|
||
padding: 1rem 0.8rem;
|
||
overflow-y: auto;
|
||
border-right: 1px solid #333;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#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;
|
||
align-items: center;
|
||
gap: .35rem;
|
||
padding: 0.15rem 0.4rem 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: 60px;
|
||
padding-left: 1rem;
|
||
}
|
||
|
||
.kick-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #ff6666;
|
||
cursor: pointer;
|
||
font-size: .85rem;
|
||
padding: 0 .2rem;
|
||
opacity: .5;
|
||
}
|
||
|
||
.kick-btn:hover { opacity: 1; }
|
||
|
||
#chat-and-input {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
#chat-messages {
|
||
flex: 1;
|
||
padding: 1.3rem 1.6rem;
|
||
overflow-y: auto;
|
||
font-size: 1.1rem;
|
||
line-height: 1.4;
|
||
min-height: 0;
|
||
}
|
||
|
||
.chat-message { margin-bottom: 0.3rem; overflow-wrap: anywhere; }
|
||
.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); }
|
||
|
||
#toast {
|
||
position: fixed;
|
||
top: 1rem;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #2a0d3f;
|
||
border: 2px solid #ff6666;
|
||
color: #ffe4e4;
|
||
padding: .7rem 1.2rem;
|
||
border-radius: 6px;
|
||
font-weight: bold;
|
||
z-index: 50;
|
||
max-width: 80vw;
|
||
transition: opacity .3s;
|
||
}
|
||
|
||
.hidden { display: none !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="layout">
|
||
|
||
<div id="header">
|
||
<img src="../images/yarnlogo.png" alt="Y.A.R.N." />
|
||
<span id="room-display"></span>
|
||
<span id="room-meta"></span>
|
||
<button id="change-name-btn" style="margin-left:auto;">Change Name</button>
|
||
<button id="leave-game-btn">Leave Game</button>
|
||
</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>
|
||
|
||
<div id="toast" class="hidden"></div>
|
||
|
||
<script src="/socket.io/socket.io.js"></script>
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
// === Identity & room =====================================================
|
||
|
||
const pathParts = window.location.pathname.split('/').filter((part) => part);
|
||
const roomName = decodeURIComponent(pathParts[pathParts.length - 1] || 'default');
|
||
document.getElementById('room-display').textContent = `Room: ${roomName}`;
|
||
|
||
let playerUUID = getCookie('yarn_player_uuid');
|
||
if (!playerUUID || !/^[A-Za-z0-9_-]{1,64}$/.test(playerUUID)) {
|
||
playerUUID = generateUUID();
|
||
setCookie('yarn_player_uuid', playerUUID, 365);
|
||
}
|
||
|
||
let playerName = getCookie('yarn_player_name') || '';
|
||
|
||
// === Client state ========================================================
|
||
|
||
let gameState = null;
|
||
let hasSubmitted = false;
|
||
let hasVoted = false;
|
||
let ownEntryIndex = -1;
|
||
let lastRenderedBarKey = null;
|
||
let settingsDebounce = null;
|
||
|
||
const socket = io();
|
||
|
||
socket.on('connect', () => {
|
||
socket.emit('joinRoom', {
|
||
roomName,
|
||
playerUUID,
|
||
playerName
|
||
});
|
||
});
|
||
|
||
// === Socket events =======================================================
|
||
|
||
socket.on('gameState', (state) => {
|
||
applyState(state);
|
||
});
|
||
|
||
socket.on('chatHistory', (chat) => {
|
||
const chatMessages = document.getElementById('chat-messages');
|
||
chatMessages.innerHTML = '';
|
||
(chat || []).forEach((msg) => appendChatMessage(msg));
|
||
});
|
||
|
||
socket.on('playersUpdate', (players) => {
|
||
if (!gameState) return;
|
||
gameState.players = players;
|
||
syncSelfFlagsFrom(players);
|
||
updatePlayersList();
|
||
// Late submissions may change whether the host sees "Skip".
|
||
renderGameBar();
|
||
});
|
||
|
||
socket.on('settingsUpdate', (settings) => {
|
||
if (!gameState) return;
|
||
gameState.gameSettings = settings;
|
||
renderGameBar();
|
||
});
|
||
|
||
socket.on('nameSet', (data) => {
|
||
if (!data || !data.success) return;
|
||
playerName = data.name;
|
||
setCookie('yarn_player_name', playerName, 365);
|
||
document.getElementById('chat-input').disabled = false;
|
||
renderAll(true);
|
||
});
|
||
|
||
socket.on('playerJoined', (data) => {
|
||
appendSystemMessage(`${data.name} joined the game`);
|
||
});
|
||
|
||
socket.on('playerLeft', (data) => {
|
||
appendSystemMessage(`${data.name} left the game`);
|
||
});
|
||
|
||
socket.on('newRound', (state) => {
|
||
applyState(state, { force: true });
|
||
hasSubmitted = false;
|
||
hasVoted = false;
|
||
ownEntryIndex = -1;
|
||
appendSystemMessage(`Round ${state.currentRound} of ${state.roundLimit || '∞'} started!`);
|
||
if (state.roundLimit && state.currentRound === state.roundLimit) {
|
||
appendSystemMessage('It\'s the final round. Points count double!');
|
||
}
|
||
renderAll(true);
|
||
});
|
||
|
||
socket.on('timerUpdate', (time) => {
|
||
if (!gameState) return;
|
||
gameState.timeRemaining = time;
|
||
updateTimer();
|
||
});
|
||
|
||
socket.on('votingPhase', (data) => {
|
||
if (!gameState) return;
|
||
gameState.currentPhase = 'voting';
|
||
gameState.round_data = data.entries || [];
|
||
gameState.timeRemaining = data.timeRemaining || 0;
|
||
ownEntryIndex = typeof data.ownEntryIndex === 'number' ? data.ownEntryIndex : -1;
|
||
hasVoted = false;
|
||
renderAll(true);
|
||
});
|
||
|
||
socket.on('storyUpdate', (story) => {
|
||
if (!gameState) return;
|
||
gameState.yarnStory = story;
|
||
updateStory();
|
||
});
|
||
|
||
socket.on('roundResults', (data) => {
|
||
if (!gameState) return;
|
||
gameState.currentPhase = 'results';
|
||
gameState.players = data.players || gameState.players;
|
||
gameState.yarnStory = data.story || gameState.yarnStory;
|
||
gameState.lastResults = {
|
||
winner: data.winner || null,
|
||
results: data.results || [],
|
||
reason: data.reason,
|
||
round: data.round
|
||
};
|
||
syncSelfFlagsFrom(gameState.players);
|
||
renderAll(true);
|
||
});
|
||
|
||
socket.on('gameOver', (data) => {
|
||
if (!gameState) return;
|
||
gameState.currentPhase = 'gameOver';
|
||
gameState.inProgress = false;
|
||
gameState.players = data.players || gameState.players;
|
||
gameState.yarnStory = data.story || gameState.yarnStory;
|
||
gameState.gameWinner = data.winner || null;
|
||
renderAll(true);
|
||
appendSystemMessage(
|
||
`${data.winner.name} wins the game with ${data.winner.score} points!`
|
||
);
|
||
});
|
||
|
||
socket.on('gameReset', (state) => {
|
||
hasSubmitted = false;
|
||
hasVoted = false;
|
||
ownEntryIndex = -1;
|
||
applyState(state, { force: true });
|
||
appendSystemMessage('Game has been reset. Waiting for host to start...');
|
||
});
|
||
|
||
socket.on('newChatMessage', (msg) => {
|
||
appendChatMessage(msg);
|
||
});
|
||
|
||
socket.on('error', (data) => {
|
||
showToast((data && data.message) || 'Something went wrong.');
|
||
});
|
||
|
||
socket.on('kicked', (data) => {
|
||
showToast((data && data.message) || 'You were removed from the game.');
|
||
setTimeout(() => { window.location.href = '/'; }, 2500);
|
||
});
|
||
|
||
// === State helpers =======================================================
|
||
|
||
function applyState(state, { force = false } = {}) {
|
||
if (!state) return;
|
||
gameState = state;
|
||
if (typeof state.ownEntryIndex === 'number') ownEntryIndex = state.ownEntryIndex;
|
||
syncSelfFlagsFrom(state.players || []);
|
||
if (force) lastRenderedBarKey = null;
|
||
renderAll(force);
|
||
}
|
||
|
||
function syncSelfFlagsFrom(players) {
|
||
const me = (players || []).find((player) => player.player_id === playerUUID);
|
||
if (!me) return;
|
||
if (gameState && gameState.currentPhase === 'writing') hasSubmitted = me.hasSubmitted;
|
||
if (gameState && gameState.currentPhase === 'voting') hasVoted = me.hasVoted;
|
||
}
|
||
|
||
// === Rendering ===========================================================
|
||
|
||
function renderAll(force = false) {
|
||
updateHeaderMeta();
|
||
updateStory();
|
||
updatePlayersList();
|
||
renderGameBar(force);
|
||
updateTimer();
|
||
}
|
||
|
||
function updateHeaderMeta() {
|
||
const meta = document.getElementById('room-meta');
|
||
if (!gameState) {
|
||
meta.textContent = '';
|
||
return;
|
||
}
|
||
|
||
const parts = [];
|
||
if (gameState.inProgress || gameState.currentPhase === 'gameOver') {
|
||
const limit = gameState.roundLimit || '∞';
|
||
parts.push(`Round ${gameState.currentRound}/${limit}`);
|
||
if (gameState.scoreLimit) parts.push(`Target: ${gameState.scoreLimit} pts`);
|
||
parts.push(gameState.currentPhase);
|
||
} else {
|
||
parts.push('lobby');
|
||
if (gameState.scoreLimit) parts.push(`Target: ${gameState.scoreLimit} pts`);
|
||
}
|
||
meta.textContent = `• ${parts.join(' • ')}`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
if (gameState.currentPhase === 'gameOver' && gameState.gameWinner) {
|
||
const winner = gameState.gameWinner;
|
||
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>
|
||
${renderStorySegments()}
|
||
`;
|
||
return;
|
||
}
|
||
|
||
mainText.innerHTML = renderStorySegments();
|
||
mainText.scrollTop = mainText.scrollHeight;
|
||
}
|
||
|
||
function renderStorySegments() {
|
||
return (gameState.yarnStory || [])
|
||
.map((segment) => {
|
||
const author = segment.player === 'Narrator' ? '' : escapeHtml(segment.player || '');
|
||
const narratorClass = segment.player === 'Narrator' ? ' narrator' : '';
|
||
return `<span class="story-segment${narratorClass}" data-player="${author}">${escapeHtml(segment.str)} </span>`;
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function barKey() {
|
||
if (!gameState) return 'no-state';
|
||
const isHost = gameState.gameHost === playerUUID;
|
||
const parts = [
|
||
playerName ? 'named' : 'unnamed',
|
||
isHost ? 'host' : 'guest',
|
||
gameState.currentPhase,
|
||
hasSubmitted ? 'sent' : 'typing',
|
||
hasVoted ? 'voted' : 'deciding',
|
||
`own:${ownEntryIndex}`
|
||
];
|
||
|
||
if (!isHost && gameState.currentPhase === 'lobby') {
|
||
parts.push(JSON.stringify(gameState.gameSettings));
|
||
}
|
||
if (gameState.currentPhase === 'results') {
|
||
parts.push(`results:${gameState.lastResults ? gameState.lastResults.round : 'none'}`);
|
||
}
|
||
return parts.join('|');
|
||
}
|
||
|
||
function renderGameBar(force = false) {
|
||
if (!playerName) {
|
||
const gameBar = document.getElementById('game-bar');
|
||
if (lastRenderedBarKey === 'unnamed' && !force) return;
|
||
lastRenderedBarKey = 'unnamed';
|
||
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>
|
||
`;
|
||
const submitName = () => setPlayerName();
|
||
document.getElementById('set-name-btn').onclick = submitName;
|
||
document.getElementById('name-input').addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter') submitName();
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!gameState) return;
|
||
|
||
const key = barKey();
|
||
if (!force && key === lastRenderedBarKey) return;
|
||
lastRenderedBarKey = key;
|
||
|
||
const gameBar = document.getElementById('game-bar');
|
||
const isHost = gameState.gameHost === playerUUID;
|
||
|
||
switch (gameState.currentPhase) {
|
||
case 'lobby':
|
||
gameBar.innerHTML = isHost ? lobbyHostHtml() : lobbyGuestHtml();
|
||
if (isHost) wireLobbyControls();
|
||
break;
|
||
|
||
case 'writing':
|
||
gameBar.innerHTML = writingHtml(isHost);
|
||
wireWritingControls();
|
||
break;
|
||
|
||
case 'voting':
|
||
gameBar.innerHTML = votingHtml(isHost);
|
||
wireVotingControls();
|
||
break;
|
||
|
||
case 'results':
|
||
gameBar.innerHTML = resultsHtml(isHost);
|
||
wireResultsControls();
|
||
break;
|
||
|
||
case 'gameOver':
|
||
gameBar.innerHTML = gameOverHtml(isHost);
|
||
wireGameOverControls();
|
||
break;
|
||
|
||
default:
|
||
gameBar.innerHTML = '<span class="waiting-message">Waiting…</span>';
|
||
}
|
||
}
|
||
|
||
function settingsSummary(settings) {
|
||
return `Rounds: ${settings.roundLimit || '∞'} · `
|
||
+ `Score limit: ${settings.scoreLimit || 'none'} · `
|
||
+ `Time: ${settings.timeLimit}s · `
|
||
+ `Bots: ${settings.botCount}`;
|
||
}
|
||
|
||
function lobbyHostHtml() {
|
||
const settings = gameState.gameSettings;
|
||
return `
|
||
<div class="setting-group">
|
||
<label>Round Limit:</label>
|
||
<input type="number" id="round-limit" value="${settings.roundLimit}" min="0" max="100">
|
||
</div>
|
||
<div class="setting-group">
|
||
<label>Score Limit:</label>
|
||
<input type="number" id="score-limit" value="${settings.scoreLimit}" min="0" max="9999">
|
||
</div>
|
||
<div class="setting-group">
|
||
<label>Time per Round (sec):</label>
|
||
<input type="number" id="time-limit" value="${settings.timeLimit}" min="15" max="600">
|
||
</div>
|
||
<div class="setting-group">
|
||
<label>Bots:</label>
|
||
<input type="number" id="bot-count" value="${settings.botCount}" min="0" max="8">
|
||
</div>
|
||
<div class="setting-group">
|
||
<label>Starting Text:</label>
|
||
<input type="text" id="start-text" value="${escapeHtml(settings.startText)}" maxlength="300">
|
||
</div>
|
||
<button id="start-game-btn">Start Game</button>
|
||
`;
|
||
}
|
||
|
||
function lobbyGuestHtml() {
|
||
if (!gameState.players.length) {
|
||
return '<span class="waiting-message">Waiting for the host…</span>';
|
||
}
|
||
return `
|
||
<span class="waiting-message">Waiting for host to start the game…</span>
|
||
<span class="settings-summary">${escapeHtml(settingsSummary(gameState.gameSettings))}</span>
|
||
`;
|
||
}
|
||
|
||
function writingHtml(isHost) {
|
||
const skip = isHost
|
||
? '<button id="skip-btn" class="secondary" title="End the writing phase now">Skip</button>'
|
||
: '';
|
||
if (hasSubmitted) {
|
||
return `
|
||
<span id="timer">${gameState.timeRemaining}</span>
|
||
<span class="waiting-message">Entry submitted! Waiting for other players…</span>
|
||
${skip}
|
||
`;
|
||
}
|
||
return `
|
||
<span id="timer">${gameState.timeRemaining}</span>
|
||
<input type="text" id="entry-input" placeholder="Continue the story..." maxlength="280">
|
||
<button id="submit-entry-btn">Submit</button>
|
||
${skip}
|
||
`;
|
||
}
|
||
|
||
function votingHtml(isHost) {
|
||
const skip = isHost
|
||
? '<button id="skip-btn" class="secondary" title="End voting now">Close voting</button>'
|
||
: '';
|
||
|
||
if (hasVoted) {
|
||
return `
|
||
<span id="timer">${gameState.timeRemaining}</span>
|
||
<span class="waiting-message">Vote cast! Waiting for other players…</span>
|
||
${skip}
|
||
`;
|
||
}
|
||
|
||
const entries = (gameState.round_data || []).filter((entry) => entry.entry_text);
|
||
if (!entries.length) {
|
||
return '<span class="waiting-message">No entries to vote on…</span>';
|
||
}
|
||
|
||
return `
|
||
<span id="timer">${gameState.timeRemaining}</span>
|
||
<div id="voting-container">
|
||
${entries.map((entry) => {
|
||
const isOwn = entry.index === ownEntryIndex;
|
||
return `
|
||
<div class="vote-entry${isOwn ? ' own' : ''}">
|
||
<span class="vote-entry-text">"${escapeHtml(entry.entry_text)}"</span>
|
||
<button class="vote-btn" data-index="${entry.index}"
|
||
${isOwn ? 'disabled title="You cannot vote for your own entry"' : ''}>
|
||
${isOwn ? 'Yours' : 'Vote'}
|
||
</button>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
${skip}
|
||
`;
|
||
}
|
||
|
||
function resultsHtml() {
|
||
const results = gameState.lastResults || { results: [], reason: 'none' };
|
||
const winner = results.winner;
|
||
|
||
let heading;
|
||
if (winner) {
|
||
heading = winner.wasTie
|
||
? `🤝 Tie broken by length: "${escapeHtml(winner.entry_text)}" by ${escapeHtml(winner.player_name)} (+${winner.points} pts)`
|
||
: `🏆 "${escapeHtml(winner.entry_text)}" by ${escapeHtml(winner.player_name)} wins the round! (+${winner.points} pts)`;
|
||
} else if (results.reason === 'no-votes') {
|
||
heading = 'No votes were cast — nobody scores this round.';
|
||
} else {
|
||
heading = 'No entries this round — nobody scores.';
|
||
}
|
||
|
||
const rows = (results.results || []).map((result) => `
|
||
<tr class="${winner && result.entry_text === winner.entry_text && result.player_id === winner.player_id ? 'winner' : ''}">
|
||
<td>${escapeHtml(result.entry_text || '(empty)')}</td>
|
||
<td>${escapeHtml(result.player_name)}</td>
|
||
<td>${result.votes}</td>
|
||
<td class="pts">+${result.points}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
return `
|
||
<div style="width:100%; text-align:center;">
|
||
<div class="results-heading">${heading}</div>
|
||
${rows ? `
|
||
<table id="results-table">
|
||
<thead><tr><th>Entry</th><th>Author</th><th>Votes</th><th>Points</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>` : ''}
|
||
<div class="results-note" style="margin-top:.5rem;">Next round starting soon…</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function gameOverHtml(isHost) {
|
||
const winner = gameState.gameWinner;
|
||
const banner = winner
|
||
? `${escapeHtml(winner.name)} wins with ${winner.score} points!`
|
||
: 'Game over!';
|
||
const hostControls = isHost
|
||
? '<button id="play-again-btn">Play Again</button>'
|
||
: '<span class="waiting-message">Waiting for host…</span>';
|
||
return `
|
||
<span class="game-over-message">🎉 ${banner}</span>
|
||
<button id="download-story-btn" class="secondary">Download Story</button>
|
||
<button id="copy-story-btn" class="secondary">Copy Story</button>
|
||
${hostControls}
|
||
`;
|
||
}
|
||
|
||
// === Wiring ==============================================================
|
||
|
||
function wireLobbyControls() {
|
||
const settingsInputs = ['round-limit', 'score-limit', 'time-limit', 'bot-count', 'start-text'];
|
||
settingsInputs.forEach((id) => {
|
||
const input = document.getElementById(id);
|
||
if (!input) return;
|
||
input.addEventListener('input', () => {
|
||
clearTimeout(settingsDebounce);
|
||
settingsDebounce = setTimeout(pushSettings, 250);
|
||
});
|
||
});
|
||
|
||
document.getElementById('start-game-btn').onclick = startGame;
|
||
}
|
||
|
||
function pushSettings() {
|
||
if (!gameState) return;
|
||
socket.emit('updateSettings', {
|
||
roundLimit: readNumber('round-limit', gameState.gameSettings.roundLimit),
|
||
scoreLimit: readNumber('score-limit', gameState.gameSettings.scoreLimit, 0),
|
||
timeLimit: readNumber('time-limit', gameState.gameSettings.timeLimit),
|
||
botCount: readNumber('bot-count', gameState.gameSettings.botCount, 0),
|
||
startText: document.getElementById('start-text')?.value ?? gameState.gameSettings.startText
|
||
});
|
||
}
|
||
|
||
function wireWritingControls() {
|
||
const submit = document.getElementById('submit-entry-btn');
|
||
const input = document.getElementById('entry-input');
|
||
if (submit && input) {
|
||
submit.onclick = submitEntry;
|
||
input.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter') submitEntry();
|
||
});
|
||
input.focus();
|
||
}
|
||
wireSkip();
|
||
}
|
||
|
||
function wireVotingControls() {
|
||
document.querySelectorAll('.vote-btn').forEach((button) => {
|
||
button.onclick = () => submitVote(Number.parseInt(button.dataset.index, 10));
|
||
});
|
||
wireSkip();
|
||
}
|
||
|
||
function wireResultsControls() {
|
||
// Results are read-only; the next round arrives via socket events.
|
||
}
|
||
|
||
function wireGameOverControls() {
|
||
const download = document.getElementById('download-story-btn');
|
||
if (download) download.onclick = downloadStory;
|
||
|
||
const copy = document.getElementById('copy-story-btn');
|
||
if (copy) copy.onclick = copyStory;
|
||
|
||
const again = document.getElementById('play-again-btn');
|
||
if (again) again.onclick = () => socket.emit('playAgain');
|
||
}
|
||
|
||
function wireSkip() {
|
||
const skip = document.getElementById('skip-btn');
|
||
if (skip) skip.onclick = () => socket.emit('skipPhase');
|
||
}
|
||
|
||
function readNumber(id, fallback, zeroIsValid = NaN) {
|
||
const element = document.getElementById(id);
|
||
if (!element) return fallback;
|
||
const value = Number.parseInt(element.value, 10);
|
||
if (Number.isNaN(value)) return fallback;
|
||
if (value === 0 && zeroIsValid === 0) return 0;
|
||
return value;
|
||
}
|
||
|
||
function updateTimer() {
|
||
const timer = document.getElementById('timer');
|
||
if (timer && gameState) {
|
||
timer.textContent = gameState.timeRemaining;
|
||
timer.classList.toggle('warning', gameState.timeRemaining <= 10);
|
||
}
|
||
}
|
||
|
||
function updatePlayersList() {
|
||
const playersList = document.getElementById('players-list');
|
||
if (!gameState) {
|
||
playersList.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
const isHost = gameState.gameHost === playerUUID;
|
||
const phase = gameState.currentPhase;
|
||
|
||
playersList.innerHTML = gameState.players.map((player) => {
|
||
let statusClass = '';
|
||
if (phase === 'writing') {
|
||
statusClass = player.hasSubmitted ? 'submitted' : 'pending';
|
||
} else if (phase === 'voting') {
|
||
statusClass = player.hasVoted ? 'submitted' : 'pending';
|
||
} else {
|
||
statusClass = 'submitted';
|
||
}
|
||
|
||
const hostClass = player.player_id === gameState.gameHost ? 'host' : '';
|
||
const isSelf = player.player_id === playerUUID;
|
||
const canKick = isHost && !isSelf && !player.isBot && (gameState.inProgress || phase === 'gameOver');
|
||
const kick = canKick
|
||
? `<button class="kick-btn" data-kick="${escapeHtml(player.player_id)}" title="Remove player">✕</button>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="player-row ${statusClass} ${hostClass}">
|
||
<span class="player-name">${escapeHtml(player.name)}${isSelf ? ' (You)' : ''}</span>
|
||
<span class="player-score">${player.score}</span>
|
||
${kick}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
playersList.querySelectorAll('[data-kick]').forEach((button) => {
|
||
button.onclick = () => {
|
||
const target = button.dataset.kick;
|
||
if (window.confirm('Remove this player from the game?')) {
|
||
socket.emit('kickPlayer', { playerId: target });
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// === Actions =============================================================
|
||
|
||
function setPlayerName() {
|
||
const input = document.getElementById('name-input');
|
||
const name = input.value.trim();
|
||
if (name.length < 1 || name.length > 20) {
|
||
showToast('Please enter a name (1-20 characters).');
|
||
return;
|
||
}
|
||
socket.emit('setName', { name });
|
||
}
|
||
|
||
function startGame() {
|
||
socket.emit('startGame', {
|
||
roundLimit: readNumber('round-limit', gameState.gameSettings.roundLimit),
|
||
scoreLimit: readNumber('score-limit', gameState.gameSettings.scoreLimit, 0),
|
||
timeLimit: readNumber('time-limit', gameState.gameSettings.timeLimit),
|
||
botCount: readNumber('bot-count', gameState.gameSettings.botCount, 0),
|
||
startText: document.getElementById('start-text').value
|
||
});
|
||
}
|
||
|
||
function submitEntry() {
|
||
const input = document.getElementById('entry-input');
|
||
if (!input) return;
|
||
const entry = input.value.trim();
|
||
if (!entry) {
|
||
showToast('Your entry cannot be empty.');
|
||
return;
|
||
}
|
||
// The server broadcasts playersUpdate with hasSubmitted once it accepts
|
||
// the entry; until then the input stays editable so nothing is lost.
|
||
socket.emit('submitEntry', { entry });
|
||
}
|
||
|
||
function submitVote(entryIndex) {
|
||
if (!Number.isInteger(entryIndex)) return;
|
||
// Confirmed via the playersUpdate broadcast that follows a valid vote.
|
||
socket.emit('submitVote', { entryIndex });
|
||
}
|
||
|
||
function downloadStory() {
|
||
if (!gameState) return;
|
||
const storyText = (gameState.yarnStory || []).map((segment) => segment.str).join(' ');
|
||
const blob = new Blob([storyText + '\n\n— The End —\n'], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `${roomName} - ${new Date().toISOString().slice(0, 10)}.txt`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function copyStory() {
|
||
if (!gameState) return;
|
||
const storyText = (gameState.yarnStory || []).map((segment) => segment.str).join(' ');
|
||
try {
|
||
await navigator.clipboard.writeText(storyText);
|
||
showToast('Story copied to clipboard.');
|
||
} catch {
|
||
showToast('Could not copy the story in this browser.');
|
||
}
|
||
}
|
||
|
||
// === Header ==============================================================
|
||
|
||
document.getElementById('change-name-btn').addEventListener('click', () => {
|
||
const newName = window.prompt('Enter a new name (1-20 characters):', playerName);
|
||
if (newName && newName.trim() && newName.trim() !== playerName) {
|
||
socket.emit('setName', { name: newName.trim() });
|
||
}
|
||
});
|
||
|
||
document.getElementById('leave-game-btn').addEventListener('click', () => {
|
||
if (window.confirm('Are you sure you want to leave the game?')) {
|
||
window.location.href = '/';
|
||
}
|
||
});
|
||
|
||
// === Chat ================================================================
|
||
|
||
document.getElementById('chat-input').addEventListener('keydown', function (event) {
|
||
if (event.key !== 'Enter') return;
|
||
event.preventDefault();
|
||
const msg = this.value.trim();
|
||
if (msg && playerName) {
|
||
socket.emit('chatMessage', { msg });
|
||
this.value = '';
|
||
}
|
||
});
|
||
|
||
function appendChatMessage(msg) {
|
||
if (!msg) return;
|
||
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 =============================================================
|
||
|
||
let toastTimer = null;
|
||
function showToast(message) {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.classList.remove('hidden');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => toast.classList.add('hidden'), 4000);
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text == null ? '' : String(text);
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function generateUUID() {
|
||
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||
return window.crypto.randomUUID();
|
||
}
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (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=/; SameSite=Lax`;
|
||
}
|
||
|
||
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>
|