Files

1227 lines
35 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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)}&gt;</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>