Initial commit

This commit is contained in:
Cam Spry committed 2026-03-01 04:47:14 -05:00
commit 6e55209159
3 files changed
+1344

No files matched your search

+837
View File
@@ -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>