feat: enhance UI with screen lock, timer improvements and better styles
- Implement screen lock for iOS/Android (Wake Lock API + video workaround) - Fix curtain reveal animation to prevent visual confusion - Add audio alarm when timer ends for game and deliberation phases - Improve overall UI/UX with scroll enhancements and mobile optimizations
This commit is contained in:
116
index.html
116
index.html
@@ -4,11 +4,18 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Juego del Impostor</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Crimson+Text:wght@600;700&family=Courier+Prime:wght@400;700&family=JetBrains+Mono:wght@400;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="icon" type="image/png" href="logo.png">
|
||||
<script defer src="https://analytics.dariosevilla.es/script.js" data-website-id="0520a008-d309-477f-9742-b4a674ac42eb"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Cinematic overlays -->
|
||||
<div class="vignette-overlay"></div>
|
||||
<div class="vhs-line"></div>
|
||||
|
||||
<button id="theme-toggle" class="theme-toggle" aria-label="Cambiar tema">
|
||||
<span class="theme-icon">🌙</span>
|
||||
</button>
|
||||
@@ -18,6 +25,10 @@
|
||||
<span class="language-text">EN</span>
|
||||
</button>
|
||||
|
||||
<button id="screen-lock-toggle" class="screen-lock-toggle" aria-label="Screen lock" title="Bloqueo de pantalla">
|
||||
<span class="screen-lock-icon">🔓</span>
|
||||
</button>
|
||||
|
||||
<button id="exit-game" class="exit-game" onclick="confirmExitGame()">
|
||||
<span class="exit-icon">🚪</span>
|
||||
<span class="exit-text" data-i18n="exitGame">Salir de la partida</span>
|
||||
@@ -28,53 +39,55 @@
|
||||
<div id="welcome-screen" class="screen active">
|
||||
<div class="welcome-content">
|
||||
<img src="logo.png" alt="Logo" class="welcome-logo">
|
||||
<h1 class="welcome-title">🎭 Juego del Impostor</h1>
|
||||
<h1 class="welcome-title">Juego del Impostor</h1>
|
||||
<p class="welcome-subtitle">¿Podrás descubrir quién es el impostor?</p>
|
||||
<div class="welcome-buttons">
|
||||
<button onclick="showScreen('setup-screen')" class="btn-primary">▶️ Jugar</button>
|
||||
<button onclick="showScreen('rules-screen')" class="btn-secondary">📖 Reglas</button>
|
||||
<button onclick="showScreen('setup-screen')" class="btn-primary">Jugar</button>
|
||||
<button onclick="showScreen('rules-screen')" class="btn-secondary">Reglas</button>
|
||||
</div>
|
||||
<p class="welcome-credits">Creado por Darío Sevilla</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rules screen -->
|
||||
<!-- Rules screen 1 -->
|
||||
<div id="rules-screen" class="screen">
|
||||
<h1>📖 Reglas del Juego</h1>
|
||||
<div class="rules-content">
|
||||
<div class="rule-section">
|
||||
<h3>🎯 Objetivo</h3>
|
||||
<p>Los <strong>civiles</strong> deben identificar a los <strong>impostores</strong> antes de que termine el tiempo.</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-section">
|
||||
<h3>🎲 Preparación</h3>
|
||||
<p>1. Cada jugador recibe una palabra secreta</p>
|
||||
<p>2. Los civiles reciben la misma palabra</p>
|
||||
<p>3. Los impostores reciben una palabra diferente pero relacionada</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-section">
|
||||
<h3>🗣️ Partida</h3>
|
||||
<p>1. Por turnos, cada jugador da un sinónimo o descripción de su palabra</p>
|
||||
<p>2. Intenta ser específico pero no revelar tu palabra exacta</p>
|
||||
<p>3. Los impostores deben intentar pasar desapercibidos</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-section">
|
||||
<h3>🗳️ Votación</h3>
|
||||
<p>1. Tras el tiempo de juego y deliberación, vota en secreto</p>
|
||||
<p>2. Los más votados son eliminados</p>
|
||||
<p>3. Si todos los impostores son eliminados, ganan los civiles</p>
|
||||
<p>4. Si queda algún impostor, ellos ganan</p>
|
||||
</div>
|
||||
<h1>Reglas del Juego</h1>
|
||||
<div class="rule-section">
|
||||
<h3>Objetivo</h3>
|
||||
<p>Los <strong>civiles</strong> deben identificar a los <strong>impostores</strong> antes de que termine el tiempo.</p>
|
||||
</div>
|
||||
<button onclick="showScreen('welcome-screen')">← Volver</button>
|
||||
<div class="rule-section">
|
||||
<h3>Preparación</h3>
|
||||
<p>1. Cada jugador recibe una palabra secreta</p>
|
||||
<p>2. Los civiles reciben la misma palabra</p>
|
||||
<p>3. Los impostores reciben una palabra diferente</p>
|
||||
</div>
|
||||
<button onclick="showScreen('rules-screen-2')">Siguiente →</button>
|
||||
<button class="ghost" onclick="showScreen('welcome-screen')">← Volver</button>
|
||||
</div>
|
||||
|
||||
<!-- Rules screen 2 -->
|
||||
<div id="rules-screen-2" class="screen">
|
||||
<h1>Reglas del Juego</h1>
|
||||
<div class="rule-section">
|
||||
<h3>Partida</h3>
|
||||
<p>1. Por turnos, da un sinónimo de tu palabra</p>
|
||||
<p>2. Sé específico pero no reveles tu palabra</p>
|
||||
<p>3. Los impostores deben pasar desapercibidos</p>
|
||||
</div>
|
||||
<div class="rule-section">
|
||||
<h3>Votación</h3>
|
||||
<p>1. Tras deliberar, vota en secreto</p>
|
||||
<p>2. Los más votados son eliminados</p>
|
||||
<p>3. Civiles ganan si eliminan a todos los impostores</p>
|
||||
</div>
|
||||
<button onclick="showScreen('welcome-screen')">Entendido ✓</button>
|
||||
<button class="ghost" onclick="showScreen('rules-screen')">← Atrás</button>
|
||||
</div>
|
||||
|
||||
<!-- Setup screen -->
|
||||
<div id="setup-screen" class="screen">
|
||||
<h1>⚙️ Configuración</h1>
|
||||
<h1>Configuración</h1>
|
||||
<div class="form-group compact">
|
||||
<label for="num-players">Jugadores:</label>
|
||||
<input type="number" id="num-players" min="3" max="10" value="6">
|
||||
@@ -91,27 +104,32 @@
|
||||
<label for="deliberation-time">Deliberación (seg):</label>
|
||||
<input type="number" id="deliberation-time" min="30" max="300" step="10" value="170">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Pools (toca para seleccionar):</label>
|
||||
<div class="pool-buttons-wrapper">
|
||||
<div id="pool-buttons" class="pool-buttons"></div>
|
||||
</div>
|
||||
<button onclick="goToPools()">Siguiente</button>
|
||||
<button class="ghost" onclick="showScreen('welcome-screen')">← Volver</button>
|
||||
</div>
|
||||
|
||||
<!-- Pools selection screen -->
|
||||
<div id="pools-screen" class="screen">
|
||||
<h1>Selección de Pools</h1>
|
||||
<p class="info-text">Toca para seleccionar las categorías de palabras que quieres usar en la partida.</p>
|
||||
<div class="pool-buttons-wrapper">
|
||||
<div id="pool-buttons" class="pool-buttons"></div>
|
||||
</div>
|
||||
<button onclick="goToNames()">Siguiente</button>
|
||||
<button class="ghost" onclick="showScreen('welcome-screen')">← Volver</button>
|
||||
<button class="ghost" onclick="showScreen('setup-screen')">← Volver</button>
|
||||
</div>
|
||||
|
||||
<!-- Player names screen -->
|
||||
<div id="names-screen" class="screen">
|
||||
<h1>👥 Nombres de jugadores</h1>
|
||||
<h1>Nombres de jugadores</h1>
|
||||
<div class="player-names-list" id="player-names-list"></div>
|
||||
<button onclick="startGame()">Comenzar partida</button>
|
||||
<button class="ghost" onclick="showScreen('setup-screen')">← Volver</button>
|
||||
<button class="ghost" onclick="showScreen('pools-screen')">← Volver</button>
|
||||
</div>
|
||||
|
||||
<!-- Pre-reveal screen -->
|
||||
<div id="pre-reveal-screen" class="screen">
|
||||
<h1>🎲 Listo para revelar</h1>
|
||||
<h1>Listo para revelar</h1>
|
||||
<div class="info-text" id="config-summary"></div>
|
||||
<p class="info-text">Cada jugador debe ver su rol en secreto. Desliza la cortina hacia arriba para revelar.</p>
|
||||
<button onclick="showScreen('reveal-screen'); loadCurrentReveal();">Empezar revelación</button>
|
||||
@@ -120,11 +138,11 @@
|
||||
|
||||
<!-- Revelation screen -->
|
||||
<div id="reveal-screen" class="screen">
|
||||
<h1>🔍 Revelación</h1>
|
||||
<h1>Revelación</h1>
|
||||
<p class="info-text">Turno de: <strong><span id="current-player-name">Jugador 1</span></strong><br><small>Los demás, no miréis. Mantén levantada la cortina para ver tu rol.</small></p>
|
||||
<div class="curtain" id="curtain">
|
||||
<div class="curtain-cover" id="curtain-cover">
|
||||
<div class="curtain-icon">⬆️</div>
|
||||
<div class="curtain-icon">▲</div>
|
||||
<div>LEVANTA LA CORTINA</div>
|
||||
</div>
|
||||
<div class="curtain-content">
|
||||
@@ -138,7 +156,7 @@
|
||||
|
||||
<!-- Game screen -->
|
||||
<div id="game-screen" class="screen">
|
||||
<h1>🎮 Partida en curso</h1>
|
||||
<h1>Partida en curso</h1>
|
||||
<p class="info-text">A decir sinónimos!</p>
|
||||
<div class="timer" id="game-timer">3:00</div>
|
||||
<button class="secondary" onclick="skipToDeliberation()">Saltar a deliberación →</button>
|
||||
@@ -146,7 +164,7 @@
|
||||
|
||||
<!-- Deliberation screen -->
|
||||
<div id="deliberation-screen" class="screen">
|
||||
<h1>🗣️ Deliberación</h1>
|
||||
<h1>Deliberación</h1>
|
||||
<p class="info-text">Últimos argumentos antes de votar.</p>
|
||||
<div class="timer" id="deliberation-timer">1:00</div>
|
||||
<button class="secondary" onclick="skipToVoting()">Ir a votación →</button>
|
||||
@@ -154,7 +172,7 @@
|
||||
|
||||
<!-- Voting screen -->
|
||||
<div id="voting-screen" class="screen">
|
||||
<h1>🗳️ Votación secreta</h1>
|
||||
<h1>Votación secreta</h1>
|
||||
<p class="info-text">Pasa el móvil a <strong id="voter-name">Jugador</strong>. Elige <span id="votes-needed">1</span> sospechoso(s).</p>
|
||||
<div class="player-list" id="vote-list"></div>
|
||||
<button id="confirm-vote-btn" disabled onclick="confirmCurrentVote()">Confirmar voto</button>
|
||||
@@ -162,7 +180,7 @@
|
||||
|
||||
<!-- Results screen -->
|
||||
<div id="results-screen" class="screen">
|
||||
<h1>🏆 Resultados</h1>
|
||||
<h1>Resultados</h1>
|
||||
<div class="results" id="results-content"></div>
|
||||
<button onclick="newMatch()">Nueva partida</button>
|
||||
</div>
|
||||
|
||||
355
script.js
355
script.js
@@ -5,6 +5,7 @@ const POOLS_CACHE_KEY = 'impostorWordPoolsV1';
|
||||
const POOLS_MANIFEST_URL = 'word-pools/manifest.json';
|
||||
const THEME_STORAGE_KEY = 'impostorGameTheme';
|
||||
const LANGUAGE_STORAGE_KEY = 'impostorGameLanguage';
|
||||
const SCREEN_LOCK_STORAGE_KEY = 'impostorGameScreenLock';
|
||||
|
||||
// ---------- Internationalization system ----------
|
||||
const TRANSLATIONS = {
|
||||
@@ -74,7 +75,9 @@ const TRANSLATIONS = {
|
||||
impostorsMustBeLess: 'Impostores debe ser menor que jugadores',
|
||||
animalsNature: 'Animales y Naturaleza',
|
||||
everydayObjects: 'Objetos Cotidianos',
|
||||
exitGame: 'Salir de la partida'
|
||||
exitGame: 'Salir de la partida',
|
||||
poolsSelection: 'Selección de Pools',
|
||||
poolsSelectionText: 'Toca para seleccionar las categorías de palabras que quieres usar en la partida.'
|
||||
},
|
||||
en: {
|
||||
gameTitle: 'The Impostor Game',
|
||||
@@ -142,7 +145,9 @@ const TRANSLATIONS = {
|
||||
impostorsMustBeLess: 'Impostors must be less than players',
|
||||
animalsNature: 'Animals and Nature',
|
||||
everydayObjects: 'Everyday Objects',
|
||||
exitGame: 'Exit Game'
|
||||
exitGame: 'Exit Game',
|
||||
poolsSelection: 'Pool Selection',
|
||||
poolsSelectionText: 'Tap to select the word categories you want to use in the game.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -206,42 +211,42 @@ async function updateUI() {
|
||||
function updateStaticTexts() {
|
||||
// Welcome screen
|
||||
const welcomeTitle = document.querySelector('.welcome-title');
|
||||
if (welcomeTitle) welcomeTitle.innerHTML = `🎭 ${t('gameTitle')}`;
|
||||
if (welcomeTitle) welcomeTitle.textContent = t('gameTitle');
|
||||
|
||||
const welcomeSubtitle = document.querySelector('.welcome-subtitle');
|
||||
if (welcomeSubtitle) welcomeSubtitle.textContent = t('gameSubtitle');
|
||||
|
||||
const playBtn = document.querySelector('.btn-primary');
|
||||
if (playBtn) playBtn.innerHTML = `▶️ ${t('play')}`;
|
||||
if (playBtn) playBtn.textContent = t('play');
|
||||
|
||||
const rulesBtn = document.querySelector('.btn-secondary');
|
||||
if (rulesBtn) rulesBtn.innerHTML = `📖 ${t('rules')}`;
|
||||
if (rulesBtn) rulesBtn.textContent = t('rules');
|
||||
|
||||
const credits = document.querySelector('.welcome-credits');
|
||||
if (credits) credits.textContent = t('createdBy');
|
||||
|
||||
// Rules screen
|
||||
const rulesTitle = document.querySelector('#rules-screen h1');
|
||||
if (rulesTitle) rulesTitle.innerHTML = `📖 ${t('rulesTitle')}`;
|
||||
if (rulesTitle) rulesTitle.textContent = t('rulesTitle');
|
||||
|
||||
const ruleSections = document.querySelectorAll('.rule-section');
|
||||
if (ruleSections.length >= 4) {
|
||||
ruleSections[0].querySelector('h3').innerHTML = `🎯 ${t('objective')}`;
|
||||
ruleSections[0].querySelector('h3').textContent = t('objective');
|
||||
ruleSections[0].querySelector('p').innerHTML = t('objectiveText');
|
||||
|
||||
ruleSections[1].querySelector('h3').innerHTML = `🎲 ${t('preparation')}`;
|
||||
ruleSections[1].querySelector('h3').textContent = t('preparation');
|
||||
const prepSteps = t('preparationSteps');
|
||||
ruleSections[1].querySelectorAll('p').forEach((p, i) => {
|
||||
if (prepSteps[i]) p.textContent = `${i + 1}. ${prepSteps[i]}`;
|
||||
});
|
||||
|
||||
ruleSections[2].querySelector('h3').innerHTML = `🗣️ ${t('gameplay')}`;
|
||||
ruleSections[2].querySelector('h3').textContent = t('gameplay');
|
||||
const gameSteps = t('gameplaySteps');
|
||||
ruleSections[2].querySelectorAll('p').forEach((p, i) => {
|
||||
if (gameSteps[i]) p.textContent = `${i + 1}. ${gameSteps[i]}`;
|
||||
});
|
||||
|
||||
ruleSections[3].querySelector('h3').innerHTML = `🗳️ ${t('voting')}`;
|
||||
ruleSections[3].querySelector('h3').textContent = t('voting');
|
||||
const voteSteps = t('votingSteps');
|
||||
ruleSections[3].querySelectorAll('p').forEach((p, i) => {
|
||||
if (voteSteps[i]) p.textContent = `${i + 1}. ${voteSteps[i]}`;
|
||||
@@ -250,7 +255,7 @@ function updateStaticTexts() {
|
||||
|
||||
// Setup screen
|
||||
const setupTitle = document.querySelector('#setup-screen h1');
|
||||
if (setupTitle) setupTitle.innerHTML = `⚙️ ${t('configuration')}`;
|
||||
if (setupTitle) setupTitle.textContent = t('configuration');
|
||||
|
||||
const labels = {
|
||||
'num-players': t('players'),
|
||||
@@ -264,45 +269,49 @@ function updateStaticTexts() {
|
||||
if (label) label.textContent = text + ':';
|
||||
});
|
||||
|
||||
const poolsLabel = document.querySelector('#setup-screen .form-group:last-of-type label');
|
||||
if (poolsLabel) poolsLabel.textContent = t('pools') + ':';
|
||||
// Pools screen
|
||||
const poolsTitle = document.querySelector('#pools-screen h1');
|
||||
if (poolsTitle) poolsTitle.textContent = t('poolsSelection');
|
||||
|
||||
const poolsText = document.querySelector('#pools-screen .info-text');
|
||||
if (poolsText) poolsText.textContent = t('poolsSelectionText');
|
||||
|
||||
// Names screen
|
||||
const namesTitle = document.querySelector('#names-screen h1');
|
||||
if (namesTitle) namesTitle.innerHTML = `👥 ${t('playerNames')}`;
|
||||
if (namesTitle) namesTitle.textContent = t('playerNames');
|
||||
|
||||
// Pre-reveal screen
|
||||
const preRevealTitle = document.querySelector('#pre-reveal-screen h1');
|
||||
if (preRevealTitle) preRevealTitle.innerHTML = `🎲 ${t('readyToReveal')}`;
|
||||
if (preRevealTitle) preRevealTitle.textContent = t('readyToReveal');
|
||||
|
||||
const preRevealText = document.querySelector('#pre-reveal-screen .info-text:not(#config-summary)');
|
||||
if (preRevealText) preRevealText.textContent = t('eachPlayerSecret');
|
||||
|
||||
// Reveal screen
|
||||
const revealTitle = document.querySelector('#reveal-screen h1');
|
||||
if (revealTitle) revealTitle.innerHTML = `🔍 ${t('revelation')}`;
|
||||
if (revealTitle) revealTitle.textContent = t('revelation');
|
||||
|
||||
// Game screen
|
||||
const gameTitle = document.querySelector('#game-screen h1');
|
||||
if (gameTitle) gameTitle.innerHTML = `🎮 ${t('gameInProgress')}`;
|
||||
if (gameTitle) gameTitle.textContent = t('gameInProgress');
|
||||
|
||||
const gameText = document.querySelector('#game-screen .info-text');
|
||||
if (gameText) gameText.textContent = t('giveSynonyms');
|
||||
|
||||
// Deliberation screen
|
||||
const delibTitle = document.querySelector('#deliberation-screen h1');
|
||||
if (delibTitle) delibTitle.innerHTML = `🗣️ ${t('deliberation')}`;
|
||||
if (delibTitle) delibTitle.textContent = t('deliberation');
|
||||
|
||||
const delibText = document.querySelector('#deliberation-screen .info-text');
|
||||
if (delibText) delibText.textContent = t('lastArguments');
|
||||
|
||||
// Voting screen
|
||||
const votingTitle = document.querySelector('#voting-screen h1');
|
||||
if (votingTitle) votingTitle.innerHTML = `🗳️ ${t('secretVoting')}`;
|
||||
if (votingTitle) votingTitle.textContent = t('secretVoting');
|
||||
|
||||
// Results screen
|
||||
const resultsTitle = document.querySelector('#results-screen h1');
|
||||
if (resultsTitle) resultsTitle.innerHTML = `🏆 ${t('results')}`;
|
||||
if (resultsTitle) resultsTitle.textContent = t('results');
|
||||
|
||||
// Buttons
|
||||
const backButtons = document.querySelectorAll('button.ghost');
|
||||
@@ -314,7 +323,8 @@ function updateStaticTexts() {
|
||||
|
||||
// Update all other buttons based on their onclick or content
|
||||
document.querySelectorAll('button').forEach(btn => {
|
||||
if (btn.getAttribute('onclick') === 'goToNames()') btn.textContent = t('next');
|
||||
if (btn.getAttribute('onclick') === 'goToPools()') btn.textContent = t('next');
|
||||
else if (btn.getAttribute('onclick') === 'goToNames()') btn.textContent = t('next');
|
||||
else if (btn.getAttribute('onclick') === 'startGame()') btn.textContent = t('startGame');
|
||||
else if (btn.getAttribute('onclick') === "showScreen('reveal-screen'); loadCurrentReveal();") btn.textContent = t('startReveal');
|
||||
else if (btn.id === 'next-player-btn') btn.textContent = t('nextPlayer') + ' →';
|
||||
@@ -561,7 +571,7 @@ function renderPoolButtons() {
|
||||
}
|
||||
|
||||
// ---------- Setup and player names ----------
|
||||
function goToNames() {
|
||||
function goToPools() {
|
||||
let nPlayers = parseInt(document.getElementById('num-players').value) || MIN_PLAYERS;
|
||||
nPlayers = Math.min(Math.max(nPlayers, MIN_PLAYERS), MAX_PLAYERS);
|
||||
const maxImpostors = Math.max(1, Math.floor(nPlayers / 2));
|
||||
@@ -572,7 +582,14 @@ function goToNames() {
|
||||
let dTime = parseInt(document.getElementById('deliberation-time').value) || defaultDeliberation(gTime);
|
||||
dTime = Math.min(Math.max(dTime, 30), Math.round(900 / 3));
|
||||
if (nImpostors >= nPlayers) { alert(t('impostorsMustBeLess')); return; }
|
||||
state.numPlayers = nPlayers; state.numImpostors = nImpostors; state.gameTime = gTime; state.deliberationTime = dTime;
|
||||
state.numPlayers = nPlayers;
|
||||
state.numImpostors = nImpostors;
|
||||
state.gameTime = gTime;
|
||||
state.deliberationTime = dTime;
|
||||
showScreen('pools-screen');
|
||||
}
|
||||
|
||||
function goToNames() {
|
||||
buildNameInputs();
|
||||
showScreen('names-screen');
|
||||
}
|
||||
@@ -662,6 +679,10 @@ function renderSummary() {
|
||||
// ---------- Role revelation ----------
|
||||
function loadCurrentReveal() {
|
||||
state.phase = 'reveal'; saveState();
|
||||
|
||||
// Activar Wake Lock para mantener pantalla encendida durante el juego
|
||||
requestWakeLock();
|
||||
|
||||
if (!state.revealOrder || state.revealOrder.length !== state.numPlayers) {
|
||||
const step = state.turnDirection === 'horario' ? 1 : -1;
|
||||
state.revealOrder = Array.from({length: state.numPlayers}, (_, k) => (state.startPlayer + step * k + state.numPlayers) % state.numPlayers);
|
||||
@@ -716,13 +737,17 @@ function nextReveal() { state.currentReveal++; saveState(); loadCurrentReveal();
|
||||
|
||||
// Curtain system with GRAVITY - The curtain always tends to fall
|
||||
// Supports both touch (mobile) and mouse (desktop)
|
||||
// On desktop: curtain stays up while mouse button is held, even if cursor leaves the area
|
||||
let curtainState = { isRevealed: false };
|
||||
let curtainDragState = {
|
||||
startY: null,
|
||||
isDragging: false,
|
||||
currentTranslateY: 0
|
||||
};
|
||||
|
||||
(() => {
|
||||
function initCurtainHandlers() {
|
||||
const curtain = document.getElementById('curtain');
|
||||
const cover = document.getElementById('curtain-cover');
|
||||
let startY = null;
|
||||
let isDragging = false;
|
||||
if (!curtain) return;
|
||||
|
||||
// Function to get Y position from event (touch or mouse)
|
||||
const getY = (e) => {
|
||||
@@ -731,9 +756,9 @@ let curtainState = { isRevealed: false };
|
||||
|
||||
// Start function (touch and mouse)
|
||||
const handleStart = (e) => {
|
||||
const coverEl = document.getElementById('curtain-cover');
|
||||
startY = getY(e);
|
||||
isDragging = true;
|
||||
curtainDragState.startY = getY(e);
|
||||
curtainDragState.isDragging = true;
|
||||
curtainDragState.currentTranslateY = 0;
|
||||
if (e.type === 'mousedown') {
|
||||
e.preventDefault(); // Prevent text selection on desktop
|
||||
}
|
||||
@@ -741,21 +766,22 @@ let curtainState = { isRevealed: false };
|
||||
|
||||
// Move function (touch and mouse)
|
||||
const handleMove = (e) => {
|
||||
if (startY === null || !isDragging) return;
|
||||
if (curtainDragState.startY === null || !curtainDragState.isDragging) return;
|
||||
const currentY = getY(e);
|
||||
const dy = currentY - startY;
|
||||
const dy = currentY - curtainDragState.startY;
|
||||
const coverEl = document.getElementById('curtain-cover');
|
||||
if (!coverEl) return;
|
||||
|
||||
// Calculate displacement: negative = up, positive = down
|
||||
// Limit upward movement (not beyond curtain height)
|
||||
// and don't allow going below initial position (0)
|
||||
const translateY = Math.max(Math.min(dy, 0), -cover.offsetHeight);
|
||||
// Allow going further up than the curtain height (user can keep dragging up)
|
||||
// but don't allow going below initial position (0)
|
||||
curtainDragState.currentTranslateY = Math.min(dy, 0);
|
||||
|
||||
coverEl.style.transform = `translateY(${translateY}px)`;
|
||||
coverEl.style.transform = `translateY(${curtainDragState.currentTranslateY}px)`;
|
||||
coverEl.style.transition = 'none';
|
||||
|
||||
// If lifted enough, show content
|
||||
if (translateY < -80 && !curtainState.isRevealed) {
|
||||
if (curtainDragState.currentTranslateY < -80 && !curtainState.isRevealed) {
|
||||
curtainState.isRevealed = true;
|
||||
const idx = state.revealOrder[state.currentReveal];
|
||||
const role = state.roles[idx];
|
||||
@@ -766,15 +792,14 @@ let curtainState = { isRevealed: false };
|
||||
document.getElementById('word-text').textContent = word;
|
||||
}
|
||||
|
||||
if (e.type === 'mousemove') {
|
||||
e.preventDefault(); // Prevent selection on desktop
|
||||
}
|
||||
e.preventDefault(); // Prevent selection
|
||||
};
|
||||
|
||||
// End function (touch and mouse)
|
||||
const handleEnd = (e) => {
|
||||
if (!isDragging || startY === null) return;
|
||||
if (!curtainDragState.isDragging || curtainDragState.startY === null) return;
|
||||
const coverEl = document.getElementById('curtain-cover');
|
||||
if (!coverEl) return;
|
||||
|
||||
// ALWAYS bring the curtain down when released (GRAVITY)
|
||||
coverEl.style.transition = 'transform 0.4s ease';
|
||||
@@ -791,32 +816,126 @@ let curtainState = { isRevealed: false };
|
||||
}, 400);
|
||||
}
|
||||
|
||||
startY = null;
|
||||
isDragging = false;
|
||||
curtainDragState.startY = null;
|
||||
curtainDragState.isDragging = false;
|
||||
curtainDragState.currentTranslateY = 0;
|
||||
};
|
||||
|
||||
// Touch events (mobile)
|
||||
curtain.addEventListener('touchstart', handleStart, {passive:true});
|
||||
curtain.addEventListener('touchmove', handleMove, {passive:true});
|
||||
curtain.addEventListener('touchend', handleEnd, {passive:true});
|
||||
curtain.addEventListener('touchcancel', handleEnd, {passive:true});
|
||||
curtain.addEventListener('touchstart', handleStart, {passive: false});
|
||||
curtain.addEventListener('touchmove', handleMove, {passive: false});
|
||||
curtain.addEventListener('touchend', handleEnd, {passive: true});
|
||||
curtain.addEventListener('touchcancel', handleEnd, {passive: true});
|
||||
|
||||
// Mouse events (desktop)
|
||||
// Mouse events (desktop) - start on curtain only
|
||||
curtain.addEventListener('mousedown', handleStart);
|
||||
curtain.addEventListener('mousemove', handleMove);
|
||||
curtain.addEventListener('mouseup', handleEnd);
|
||||
curtain.addEventListener('mouseleave', (e) => {
|
||||
// If mouse leaves area while dragging, release (gravity)
|
||||
if (isDragging) {
|
||||
handleEnd(e);
|
||||
|
||||
// Mouse move and up events on WINDOW so we can track even when cursor leaves everything
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleEnd);
|
||||
}
|
||||
|
||||
// Initialize curtain handlers when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCurtainHandlers);
|
||||
} else {
|
||||
initCurtainHandlers();
|
||||
}
|
||||
|
||||
// ---------- Screen Wake Lock (prevent screen from sleeping during timers) ----------
|
||||
let wakeLock = null;
|
||||
let wakeLockVideo = null; // For iOS workaround
|
||||
|
||||
// Detect if device is iOS
|
||||
function isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
// Check if screen lock is enabled in settings
|
||||
function isScreenLockEnabled() {
|
||||
const saved = localStorage.getItem(SCREEN_LOCK_STORAGE_KEY);
|
||||
return saved === null ? true : saved === 'true'; // Default enabled
|
||||
}
|
||||
|
||||
// Save screen lock preference
|
||||
function setScreenLockEnabled(enabled) {
|
||||
localStorage.setItem(SCREEN_LOCK_STORAGE_KEY, enabled.toString());
|
||||
updateScreenLockButton();
|
||||
}
|
||||
|
||||
async function requestWakeLock() {
|
||||
if (!isScreenLockEnabled()) return;
|
||||
|
||||
// Try native Wake Lock API first (works on Android Chrome, etc.)
|
||||
if ('wakeLock' in navigator) {
|
||||
try {
|
||||
wakeLock = await navigator.wakeLock.request('screen');
|
||||
wakeLock.addEventListener('release', () => {
|
||||
wakeLock = null;
|
||||
});
|
||||
console.log('Wake Lock activated (native API)');
|
||||
return;
|
||||
} catch (err) {
|
||||
console.log('Wake lock request failed:', err);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// Fallback for iOS - use hidden video loop
|
||||
if (isIOS() && !wakeLockVideo) {
|
||||
try {
|
||||
wakeLockVideo = document.createElement('video');
|
||||
wakeLockVideo.setAttribute('playsinline', '');
|
||||
wakeLockVideo.setAttribute('muted', '');
|
||||
wakeLockVideo.style.position = 'fixed';
|
||||
wakeLockVideo.style.opacity = '0';
|
||||
wakeLockVideo.style.pointerEvents = 'none';
|
||||
wakeLockVideo.style.width = '1px';
|
||||
wakeLockVideo.style.height = '1px';
|
||||
|
||||
// Minimal base64 encoded video (1 frame, silent)
|
||||
wakeLockVideo.src = 'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAu1tZGF0AAACrQYF//+p3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE1NSByMjkwMSA3ZDBmZjIyIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxOCAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTMgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAwWWIhAAz//727L4FNf2f0JcRLMXaSnA+KqSAgHc0wAAAAwAAAwAAJuKiZ0WFMeJsgAAAHGAFBCwCPCVC';
|
||||
wakeLockVideo.loop = true;
|
||||
|
||||
document.body.appendChild(wakeLockVideo);
|
||||
await wakeLockVideo.play();
|
||||
console.log('Wake Lock activated (iOS video workaround)');
|
||||
} catch (err) {
|
||||
console.log('iOS wake lock workaround failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseWakeLock() {
|
||||
// Release native Wake Lock
|
||||
if (wakeLock) {
|
||||
wakeLock.release();
|
||||
wakeLock = null;
|
||||
}
|
||||
|
||||
// Stop iOS video workaround
|
||||
if (wakeLockVideo) {
|
||||
wakeLockVideo.pause();
|
||||
wakeLockVideo.remove();
|
||||
wakeLockVideo = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-request wake lock when page becomes visible again
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (document.visibilityState === 'visible' && (wakeLock !== null || wakeLockVideo !== null)) {
|
||||
await requestWakeLock();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Timers ----------
|
||||
let timerInterval = null;
|
||||
function startPhaseTimer(phase, seconds, elementId, onEnd) {
|
||||
async function startPhaseTimer(phase, seconds, elementId, onEnd) {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
|
||||
// Request wake lock to keep screen on during timer
|
||||
await requestWakeLock();
|
||||
|
||||
const now = Date.now();
|
||||
state.timerPhase = phase;
|
||||
state.timerEndAt = now + seconds*1000;
|
||||
@@ -825,7 +944,12 @@ function startPhaseTimer(phase, seconds, elementId, onEnd) {
|
||||
const tick = () => {
|
||||
const remaining = Math.max(0, Math.round((state.timerEndAt - Date.now())/1000));
|
||||
updateTimerDisplay(el, remaining);
|
||||
if (remaining <= 0) { clearInterval(timerInterval); playBeep(); onEnd(); }
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
releaseWakeLock(); // Release wake lock when timer ends
|
||||
playBeep();
|
||||
onEnd();
|
||||
}
|
||||
};
|
||||
tick();
|
||||
timerInterval = setInterval(tick, 1000);
|
||||
@@ -847,17 +971,48 @@ function updateTimerDisplay(el, remaining) {
|
||||
}
|
||||
|
||||
function playBeep() {
|
||||
// Play alarm sound - 3 ascending beeps pattern repeated twice
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator(); const gain = ctx.createGain();
|
||||
osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = 820; osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.45);
|
||||
osc.start(); osc.stop(ctx.currentTime + 0.45);
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Frequencies for alarm pattern (ascending)
|
||||
const frequencies = [523, 659, 784]; // C5, E5, G5
|
||||
const beepDuration = 0.15;
|
||||
const gapDuration = 0.08;
|
||||
const patternGap = 0.3;
|
||||
|
||||
let time = now;
|
||||
|
||||
// Play pattern twice
|
||||
for (let pattern = 0; pattern < 2; pattern++) {
|
||||
for (let i = 0; i < frequencies.length; i++) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = frequencies[i];
|
||||
osc.type = 'square'; // More alarm-like sound
|
||||
|
||||
gain.gain.setValueAtTime(0, time);
|
||||
gain.gain.linearRampToValueAtTime(0.25, time + 0.02);
|
||||
gain.gain.setValueAtTime(0.25, time + beepDuration - 0.02);
|
||||
gain.gain.linearRampToValueAtTime(0, time + beepDuration);
|
||||
|
||||
osc.start(time);
|
||||
osc.stop(time + beepDuration);
|
||||
|
||||
time += beepDuration + gapDuration;
|
||||
}
|
||||
time += patternGap;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Game phases ----------
|
||||
function startGamePhase() { state.phase = 'game'; saveState(); showScreen('game-screen'); startPhaseTimer('game', state.gameTime, 'game-timer', startDeliberationPhase); }
|
||||
function startDeliberationPhase() { state.phase = 'deliberation'; saveState(); showScreen('deliberation-screen'); startPhaseTimer('deliberation', state.deliberationTime, 'deliberation-timer', startVotingPhase); }
|
||||
function startVotingPhase(candidates = null, isTiebreak = false) {
|
||||
releaseWakeLock(); // Release wake lock when voting starts (no timer)
|
||||
state.phase = 'voting';
|
||||
state.votingPlayer = 0;
|
||||
state.votes = {};
|
||||
@@ -868,8 +1023,8 @@ function startVotingPhase(candidates = null, isTiebreak = false) {
|
||||
renderVoting();
|
||||
showScreen('voting-screen');
|
||||
}
|
||||
function skipToDeliberation() { if (timerInterval) clearInterval(timerInterval); startDeliberationPhase(); }
|
||||
function skipToVoting() { if (timerInterval) clearInterval(timerInterval); startVotingPhase(); }
|
||||
function skipToDeliberation() { if (timerInterval) clearInterval(timerInterval); releaseWakeLock(); startDeliberationPhase(); }
|
||||
function skipToVoting() { if (timerInterval) clearInterval(timerInterval); releaseWakeLock(); startVotingPhase(); }
|
||||
function startTiebreakDeliberation(candidates) {
|
||||
state.phase = 'deliberation';
|
||||
state.tiebreakCandidates = candidates;
|
||||
@@ -896,16 +1051,22 @@ function renderVoting() {
|
||||
pool.forEach(i => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'player-item';
|
||||
|
||||
// Marcar como disabled ANTES de añadir al DOM para que la animación correcta se aplique
|
||||
if (i === state.votingPlayer) {
|
||||
item.classList.add('disabled');
|
||||
// NO aplicar opacity inline - dejamos que CSS lo maneje con la animación
|
||||
item.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
item.textContent = state.playerNames[i];
|
||||
if (state.votes[i]) item.innerHTML += `<span class="vote-count">${t('votes')}: ${state.votes[i]}</span>`;
|
||||
if (state.selections.includes(i)) item.classList.add('selected');
|
||||
if (i === state.votingPlayer) {
|
||||
item.classList.add('disabled');
|
||||
item.style.opacity = '0.5';
|
||||
item.style.pointerEvents = 'none';
|
||||
} else {
|
||||
item.onclick = () => toggleSelection(i, item);
|
||||
}
|
||||
|
||||
if (i !== state.votingPlayer) {
|
||||
item.onclick = () => toggleSelection(i, item);
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
updateConfirmButton();
|
||||
@@ -973,6 +1134,10 @@ function handleVoteOutcome() {
|
||||
// ---------- Results ----------
|
||||
function showResults(isTiebreak = false) {
|
||||
state.phase = 'results'; saveState();
|
||||
|
||||
// Liberar Wake Lock cuando termina la partida
|
||||
releaseWakeLock();
|
||||
|
||||
const executed = state.executed || [];
|
||||
let impostorsAlive = 0;
|
||||
state.roles.forEach((r,i) => { if (r === 'IMPOSTOR' && !executed.includes(i)) impostorsAlive++; });
|
||||
@@ -1005,6 +1170,8 @@ function showScreen(id) {
|
||||
|
||||
function newMatch() {
|
||||
clearState();
|
||||
releaseWakeLock(); // Make sure wake lock is released when exiting game
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
state = { ...state, phase:'welcome', timerEndAt:null, timerPhase:null, votingPool:null, isTiebreak:false, tiebreakCandidates:[] };
|
||||
saveState();
|
||||
showScreen('welcome-screen');
|
||||
@@ -1023,14 +1190,20 @@ function confirmExitGame() {
|
||||
function updateExitButtonVisibility() {
|
||||
const exitBtn = document.getElementById('exit-game');
|
||||
const langBtn = document.getElementById('language-toggle');
|
||||
const screenLockBtn = document.getElementById('screen-lock-toggle');
|
||||
|
||||
// Show exit button and hide language toggle in all phases except welcome and setup
|
||||
// Show exit button and hide language/screen-lock toggles in all phases except welcome and setup
|
||||
if (state.phase !== 'welcome' && state.phase !== 'setup') {
|
||||
exitBtn.classList.add('visible');
|
||||
if (langBtn) langBtn.style.display = 'none';
|
||||
if (screenLockBtn) screenLockBtn.classList.remove('visible');
|
||||
} else {
|
||||
exitBtn.classList.remove('visible');
|
||||
if (langBtn) langBtn.style.display = 'inline-flex';
|
||||
// Only show screen lock button on iOS
|
||||
if (screenLockBtn && isIOS()) {
|
||||
screenLockBtn.classList.add('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,6 +1240,35 @@ function toggleTheme() {
|
||||
const initialTheme = loadTheme();
|
||||
applyTheme(initialTheme);
|
||||
|
||||
// ---------- Screen Lock Button ----------
|
||||
function updateScreenLockButton() {
|
||||
const btn = document.getElementById('screen-lock-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
const enabled = isScreenLockEnabled();
|
||||
const icon = btn.querySelector('.screen-lock-icon');
|
||||
|
||||
if (enabled) {
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('title', 'Bloqueo de pantalla activado');
|
||||
if (icon) icon.textContent = '🔒';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('title', 'Bloqueo de pantalla desactivado');
|
||||
if (icon) icon.textContent = '🔓';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScreenLock() {
|
||||
const currentState = isScreenLockEnabled();
|
||||
setScreenLockEnabled(!currentState);
|
||||
|
||||
// If disabling, release any active wake lock
|
||||
if (currentState) {
|
||||
releaseWakeLock();
|
||||
}
|
||||
}
|
||||
|
||||
// Event listener for theme and language buttons
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
@@ -1079,6 +1281,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
languageToggle.addEventListener('click', toggleLanguage);
|
||||
}
|
||||
|
||||
const screenLockToggle = document.getElementById('screen-lock-toggle');
|
||||
if (screenLockToggle) {
|
||||
screenLockToggle.addEventListener('click', toggleScreenLock);
|
||||
updateScreenLockButton();
|
||||
}
|
||||
|
||||
// Initialize language
|
||||
currentLanguage = loadLanguage();
|
||||
setLanguage(currentLanguage);
|
||||
@@ -1133,4 +1341,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Initialize exit button visibility
|
||||
updateExitButtonVisibility();
|
||||
|
||||
// Initialize screen lock button for iOS
|
||||
initScreenLockButton();
|
||||
})();
|
||||
|
||||
2015
styles.css
2015
styles.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user