yarn-ng/public/index.html

838 lines
21 KiB
HTML
Raw 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>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>