diff --git a/www/script.8a765c63.js b/www/script.8a765c63.js new file mode 100644 index 0000000..3889ae6 --- /dev/null +++ b/www/script.8a765c63.js @@ -0,0 +1,1496 @@ +const STORAGE_KEY = 'impostorGameStateV2'; +const MAX_PLAYERS = 10; +const MIN_PLAYERS = 3; +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'; + +// Detect if running as a native app (Capacitor/Cordova) +(function detectNativeApp() { + const isCapacitor = window.Capacitor !== undefined; + const isCordova = window.cordova !== undefined; + const isNativeApp = isCapacitor || isCordova; + + if (isNativeApp) { + document.documentElement.classList.add('native-app'); + } +})(); + +// ---------- Internationalization system ---------- +const TRANSLATIONS = { + es: { + gameTitle: 'Juego del Impostor', + gameSubtitle: '¿Podrás descubrir quién es el impostor?', + play: 'Jugar', + rules: 'Reglas', + createdBy: 'Creado por Darío Sevilla', + rulesTitle: 'Reglas del Juego', + objective: 'Objetivo', + objectiveText: 'Los civiles deben identificar a los impostores antes de que termine el tiempo.', + preparation: 'Preparación', + preparationSteps: ['Cada jugador recibe una palabra secreta', 'Los civiles reciben la misma palabra', 'Los impostores reciben una palabra diferente pero relacionada'], + gameplay: 'Partida', + gameplaySteps: ['Por turnos, cada jugador da un sinónimo o descripción de su palabra', 'Intenta ser específico pero no revelar tu palabra exacta', 'Los impostores deben intentar pasar desapercibidos'], + voting: 'Votación', + votingSteps: ['Tras el tiempo de juego y deliberación, vota en secreto', 'Los más votados son eliminados', 'Si todos los impostores son eliminados, ganan los civiles', 'Si queda algún impostor, ellos ganan'], + back: 'Volver', + configuration: 'Configuración', + players: 'Jugadores', + impostors: 'Impostores', + gameTime: 'Tiempo de partida (seg)', + deliberationTime: 'Deliberación (seg)', + pools: 'Pools (toca para seleccionar)', + next: 'Siguiente', + playerNames: 'Nombres de jugadores', + startGame: 'Comenzar partida', + player: 'Jugador', + readyToReveal: 'Listo para revelar', + eachPlayerSecret: 'Cada jugador debe ver su rol en secreto. Desliza la cortina hacia arriba para revelar.', + startReveal: 'Empezar revelación', + revelation: 'Revelación', + turnOf: 'Turno de', + othersLookAway: 'Los demás, no miréis. Mantén levantada la cortina para ver tu rol.', + liftCurtain: 'LEVANTA LA CORTINA', + nextPlayer: 'Siguiente jugador', + startMatch: '¡Iniciar partida!', + gameInProgress: 'Partida en curso', + giveSynonyms: 'A decir sinónimos!', + skipToDeliberation: 'Saltar a deliberación', + deliberation: 'Deliberación', + lastArguments: 'Últimos argumentos antes de votar.', + goToVoting: 'Ir a votación', + secretVoting: 'Votación secreta', + passMobileTo: 'Pasa el móvil a', + chooseSuspects: 'Elige', + suspect: 'sospechoso(s)', + confirmVote: 'Confirmar voto', + votes: 'Votos', + results: 'Resultados', + civiliansWin: '¡GANAN LOS CIVILES!', + impostorsWin: '¡GANAN LOS IMPOSTORES!', + executed: 'Ejecutados', + nobody: 'Nadie', + noVotes: 'Sin votos', + revealedRoles: 'Roles revelados', + newMatch: 'Nueva partida', + civil: 'CIVIL', + impostor: 'IMPOSTOR', + civilians: 'civiles', + poolsLabel: 'Pools', + starts: 'Empieza', + order: 'Orden', + clockwise: 'Horario', + counterclockwise: 'Antihorario', + impostorsMustBeLess: 'Impostores debe ser menor que jugadores', + animalsNature: 'Animales y Naturaleza', + everydayObjects: 'Objetos Cotidianos', + 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.', + votingMode: 'Modo de votación', + individualVoting: 'Individual', + individualVotingDesc: 'Cada jugador vota en secreto', + raisedHandVoting: 'Mano alzada', + raisedHandVotingDesc: 'Votación grupal única', + groupVotingTitle: 'Votación a mano alzada', + groupVotingInstruction: 'Decidid en grupo a quién eliminar. Seleccionad', + groupVotingSuspects: 'sospechoso(s)' + }, + en: { + gameTitle: 'The Impostor Game', + gameSubtitle: 'Can you figure out who the impostor is?', + play: 'Play', + rules: 'Rules', + createdBy: 'Created by Darío Sevilla', + rulesTitle: 'Game Rules', + objective: 'Objective', + objectiveText: 'Civilians must identify the impostors before time runs out.', + preparation: 'Setup', + preparationSteps: ['Each player receives a secret word', 'Civilians receive the same word', 'Impostors receive a different but related word'], + gameplay: 'Gameplay', + gameplaySteps: ['Taking turns, each player gives a synonym or description of their word', 'Try to be specific but don\'t reveal your exact word', 'Impostors must try to blend in'], + voting: 'Voting', + votingSteps: ['After game time and deliberation, vote in secret', 'The most voted players are eliminated', 'If all impostors are eliminated, civilians win', 'If any impostor remains, they win'], + back: 'Back', + configuration: 'Setup', + players: 'Players', + impostors: 'Impostors', + gameTime: 'Game time (sec)', + deliberationTime: 'Deliberation (sec)', + pools: 'Pools (tap to select)', + next: 'Next', + playerNames: 'Player names', + startGame: 'Start game', + player: 'Player', + readyToReveal: 'Ready to reveal', + eachPlayerSecret: 'Each player must see their role in secret. Swipe the curtain up to reveal.', + startReveal: 'Start reveal', + revelation: 'Revelation', + turnOf: 'Turn of', + othersLookAway: 'Others, look away. Keep the curtain lifted to see your role.', + liftCurtain: 'LIFT THE CURTAIN', + nextPlayer: 'Next player', + startMatch: 'Start match!', + gameInProgress: 'Game in progress', + giveSynonyms: 'Give synonyms!', + skipToDeliberation: 'Skip to deliberation', + deliberation: 'Deliberation', + lastArguments: 'Last arguments before voting.', + goToVoting: 'Go to voting', + secretVoting: 'Secret voting', + passMobileTo: 'Pass the phone to', + chooseSuspects: 'Choose', + suspect: 'suspect(s)', + confirmVote: 'Confirm vote', + votes: 'Votes', + results: 'Results', + civiliansWin: 'CIVILIANS WIN!', + impostorsWin: 'IMPOSTORS WIN!', + executed: 'Executed', + nobody: 'Nobody', + noVotes: 'No votes', + revealedRoles: 'Revealed roles', + newMatch: 'New match', + civil: 'CIVILIAN', + impostor: 'IMPOSTOR', + civilians: 'civilians', + poolsLabel: 'Pools', + starts: 'Starts', + order: 'Order', + clockwise: 'Clockwise', + counterclockwise: 'Counterclockwise', + impostorsMustBeLess: 'Impostors must be less than players', + animalsNature: 'Animals and Nature', + everydayObjects: 'Everyday Objects', + exitGame: 'Exit Game', + poolsSelection: 'Pool Selection', + poolsSelectionText: 'Tap to select the word categories you want to use in the game.', + votingMode: 'Voting mode', + individualVoting: 'Individual', + individualVotingDesc: 'Each player votes secretly', + raisedHandVoting: 'Raised hand', + raisedHandVotingDesc: 'Single group vote', + groupVotingTitle: 'Raised hand voting', + groupVotingInstruction: 'Decide as a group who to eliminate. Select', + groupVotingSuspects: 'suspect(s)' + } +}; + +let currentLanguage = 'es'; + +function getBrowserLanguage() { + const lang = navigator.language || navigator.userLanguage; + return lang.startsWith('es') ? 'es' : 'en'; +} + +function getUrlLanguage() { + const urlParams = new URLSearchParams(window.location.search); + const lang = urlParams.get('lang'); + if (lang === 'en' || lang === 'es') { + return lang; + } + return null; +} + +function loadLanguage() { + // Priority: URL param > localStorage > browser language + const urlLang = getUrlLanguage(); + if (urlLang) { + return urlLang; + } + const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY); + return saved || getBrowserLanguage(); +} + +function saveLanguage(lang) { + localStorage.setItem(LANGUAGE_STORAGE_KEY, lang); +} + +function t(key) { + return TRANSLATIONS[currentLanguage][key] || key; +} + +function setLanguage(lang) { + currentLanguage = lang; + saveLanguage(lang); + document.documentElement.setAttribute('lang', lang); + updateUI(); +} + +function toggleLanguage() { + const newLang = currentLanguage === 'es' ? 'en' : 'es'; + setLanguage(newLang); +} + +async function updateUI() { + // Update language button + const langText = document.querySelector('.language-text'); + if (langText) { + langText.textContent = currentLanguage.toUpperCase(); + } + + // Update all static text elements + updateStaticTexts(); + + // Update voting mode buttons + updateVotingModeButtons(); + + // Reload pools for the new language (wait for it to complete) + await loadPoolsList(); + + // Re-render dynamic content if in specific phases + if (state.phase === 'names') { + buildNameInputs(); + } else if (state.phase === 'pre-reveal') { + renderSummary(); + } else if (state.phase === 'voting') { + renderVoting(); + } else if (state.phase === 'results') { + showResults(); + } +} + +function updateStaticTexts() { + // Welcome screen + const welcomeTitle = document.querySelector('.welcome-title'); + 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.textContent = t('play'); + + const rulesBtn = document.querySelector('.btn-secondary'); + 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.textContent = t('rulesTitle'); + + const ruleSections = document.querySelectorAll('.rule-section'); + if (ruleSections.length >= 4) { + ruleSections[0].querySelector('h3').textContent = t('objective'); + ruleSections[0].querySelector('p').innerHTML = t('objectiveText'); + + 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').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').textContent = t('voting'); + const voteSteps = t('votingSteps'); + ruleSections[3].querySelectorAll('p').forEach((p, i) => { + if (voteSteps[i]) p.textContent = `${i + 1}. ${voteSteps[i]}`; + }); + } + + // Setup screen + const setupTitle = document.querySelector('#setup-screen h1'); + if (setupTitle) setupTitle.textContent = t('configuration'); + + const labels = { + 'num-players': t('players'), + 'num-impostors': t('impostors'), + 'game-time': t('gameTime'), + 'deliberation-time': t('deliberationTime') + }; + + Object.entries(labels).forEach(([id, text]) => { + const label = document.querySelector(`label[for="${id}"]`); + if (label) label.textContent = text + ':'; + }); + + // Pools screen + const poolsTitle = document.querySelector('#pools-screen h1'); + if (poolsTitle) poolsTitle.textContent = t('poolsSelection'); + + // Names screen + const namesTitle = document.querySelector('#names-screen h1'); + if (namesTitle) namesTitle.textContent = t('playerNames'); + + // Pre-reveal screen + const preRevealTitle = document.querySelector('#pre-reveal-screen h1'); + if (preRevealTitle) preRevealTitle.textContent = t('readyToReveal'); + + // Reveal screen + const revealTitle = document.querySelector('#reveal-screen h1'); + if (revealTitle) revealTitle.textContent = t('revelation'); + + // Game screen + const gameTitle = document.querySelector('#game-screen h1'); + 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.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.textContent = t('secretVoting'); + + // Results screen + const resultsTitle = document.querySelector('#results-screen h1'); + if (resultsTitle) resultsTitle.textContent = t('results'); + + // Buttons + const backButtons = document.querySelectorAll('button.ghost'); + backButtons.forEach(btn => { + if (btn.textContent.includes('Volver') || btn.textContent.includes('Back')) { + btn.textContent = `← ${t('back')}`; + } + }); + + // Update all other buttons based on their onclick or content + document.querySelectorAll('button').forEach(btn => { + 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') + ' →'; + else if (btn.id === 'start-game-btn') btn.textContent = t('startMatch'); + else if (btn.getAttribute('onclick') === 'skipToDeliberation()') btn.textContent = t('skipToDeliberation') + ' →'; + else if (btn.getAttribute('onclick') === 'skipToVoting()') btn.textContent = t('goToVoting') + ' →'; + else if (btn.id === 'confirm-vote-btn') btn.textContent = t('confirmVote'); + else if (btn.getAttribute('onclick') === 'newMatch()') btn.textContent = t('newMatch'); + }); + + // Exit game button + const exitText = document.querySelector('.exit-text'); + if (exitText) exitText.textContent = t('exitGame'); +} + +// Embedded pools with impostor words [civilian_word, impostor_word] +const EMBEDDED_POOLS = [ + // Spanish pools + { id: 'animales_naturaleza', name: 'Animales y Naturaleza', emoji: '🌿', lang: 'es', words: [['Oso','Pez'],['Pavo real','Abanico'],['Camello','Arena'],['Lirio','Rana'],['Lobo','Luna'],['Represa','Castor'],['Elefante','Safari'],['Flamenco','Camarón'],['Búho','Nieve'],['Canguro','Koala'],['Jungla','Serpiente'],['Muerte','Cuervo'],['Delfín','Orca'],['Zorro','Gallina'],['Tortuga','Galápagos'],['León','Sabana'],['Polo Sur','Pingüino'],['Hormiga','Trabajo'],['Abeja','Verano'],['Ballena','Dory'],['Mandíbula','Tiburón'],['Río de Janeiro','Loro'],['Caballo','Libertad'],['Gorila','Plata'],['Murciélago','Fruta'],['Venado','Tambor'],['Misisipi','Águila'],['Cisne','Lago'],['Grillo','Campo'],['Leopardo','Manchas'],['Mascarilla','Mapache'],['Chita','Velocidad'],['Araña','Nueva York'],['Playa','Medusa'],['Glaciar','Oso polar'],['Jirafa','Madagascar'],['Maine','Langosta'],['Pulpo','Pluma'],['Cuervo','Pantera'],['Foca','Rosa'],['Mariposa','Algodoncillo'],['Burro','Santorini'],['Lluvia','Caracol'],['Cangrejo','Araña'],['Rana','Grillo'],['Siberia','Tigre'],['Gaviota','Playa'],['Cocodrilo','Nilo'],['Pingüino','Nueva Zelanda'],['Loro','Gato'],['Cuervo','Bandada'],['Conejo','Agujero'],['Tiburón','Paleozoico'],['Trueno','Júpiter'],['Sol','Playa'],['Océano Atlántico','Huracán'],['Tsunami','Derrumbe'],['Ola','Hawái'],['Papel','Árbol'],['Universo','Energía'],['Vida','Tiempo'],['Océano','Tormenta'],['Lago','Sal'],['Oxígeno','Fuego'],['Biología','Célula'],['Tiza','Hielo'],['Clima','Invierno'],['Planeta','Gas'],['Era de hielo','Bellota'],['Avalancha','Montaña'],['Bisonte','Llanuras'],['Floración','Néctar'],['Cañón','Águila'],['Ardilla listada','Nueces'],['Coral','Arrecife'],['Desierto','Espejismo'],['Ecosistema','Equilibrio'],['Halcón','Picado'],['Luciérnaga','Brillo'],['Gecko','Hoja'],['Colibrí','Azúcar'],['Koala','Eucalipto'],['Meteoro','Cráter'],['Nutria','Río'],['Selva tropical','Dosel'],['Rinoceronte','Cuerno'],['Volcán','Ceniza'],['Naturaleza salvaje','Huellas']] }, + { id: 'objetos_cotidianos', name: 'Objetos Cotidianos', emoji: '🏠', lang: 'es', words: [['Martillo','Tiburón'],['Silla','Espalda'],['Mesa','Café'],['Cuchara','Crema'],['Tenedor','Posidón'],['Cuchillo','Mantequilla'],['Plata','Plato'],['Copa','Campeonato'],['Vidrio','Arena'],['Botella','Aerosol'],['Lata','Boda'],['Teléfono','Radio'],['Laptop','Tarjeta'],['Teclado','Piano'],['Ratón','Laboratorio'],['Marco','Pantalla'],['Control','Satélite'],['Lámpara','Aceite'],['Horno','Bombilla'],['Vela','Corona de flores'],['Carro','Espejo'],['Ventana','Caja'],['Puerta','Armario'],['Llave','Auto'],['Candado','Sello'],['Monedero','Piel'],['Cartera','Etiqueta'],['Mochila','Avión'],['Maleta','Toalla'],['Sombrero','Paja'],['Zapatos','Vela'],['Calcetas','Medida'],['Playera','Algodón'],['Cierre','Pantalón'],['Abrigo','Pelo'],['Paraguas','Ala'],['Vacaciones','Gafas de sol'],['Reloj de pulsera','Monitor'],['Rueda','Anillo'],['Collar','Tiara'],['Manga','Tatuaje'],['Cama','Monstruo'],['Funda','Media'],['Manta','Cuna'],['Colchón','Aire'],['Libro','Pop-up'],['Revista','Diario'],['Periódico','Columna'],['Pluma','Bola'],['Lápiz','Delineador'],['Borrador','Goma'],['Dibujo','Cuaderno'],['Tijeras','Cabello'],['Regla','Parrilla'],['Pegamento','Tubo'],['Cinta adhesiva','Clip'],['Pincel','Escoba'],['Cesto','Arco'],['Caja','Zapato'],['Sobre','Carta'],['Sello','Fecha'],['Calendario','Luna'],['Reloj','Campana'],['Radio','Onda'],['Bocina','Pared'],['DJ','Audífonos'],['Micrófono','Televisión'],['Televisión','Imagen'],['Cámara','Láser'],['Trípode','Pierna'],['Ventilador','Oxígeno'],['Calefactor','Secadora'],['Estufa','Carbón'],['Refrigerador','Leche'],['Congelador','Helado'],['Microondas','Radar'],['Tostadora','Horno'],['Licuadora','Espátula'],['Olla','Sopa'],['Acero','Sartén'],['Tetera','Cobre'],['Esponja','Gelatina'],['Jabón','Barra'],['Toalla','Ducha'],['Cepillo de dientes','Lengua'],['Pasta de dientes','Gel'],['Marfil','Peine'],['Cepillo','Rastrillo'],['Navaja','Jabón'],['Champú','Sábila'],['Acondicionador','Espuma'],['Loción','Seda'],['Balde','Tierra'],['Trapeador','Piso'],['Escoba','Avión'],['Recogedor','Nube'],['Basurero','Camión'],['Reciclaje','Papel'],['Escalera','Cuerda']] }, + + // English pools + { id: 'animals_nature_en', name: 'Animals and Nature', emoji: '🌿', lang: 'en', words: [['Bear','Fish'],['Peacock','Fan'],['Camel','Sand'],['Lily','Frog'],['Wolf','Moon'],['Dam','Beaver'],['Elephant','Safari'],['Flamingo','Shrimp'],['Owl','Snow'],['Kangaroo','Koala'],['Jungle','Snake'],['Death','Crow'],['Dolphin','Orca'],['Fox','Chicken'],['Turtle','Galapagos'],['Lion','Savanna'],['South Pole','Penguin'],['Ant','Work'],['Bee','Summer'],['Whale','Dory'],['Jaw','Shark'],['Rio','Parrot'],['Horse','Freedom'],['Gorilla','Silver'],['Bat','Fruit'],['Deer','Drum'],['Mississippi','Eagle'],['Swan','Lake'],['Cricket','Field'],['Leopard','Spots'],['Mask','Raccoon'],['Cheetah','Speed'],['Spider','New York'],['Beach','Jellyfish'],['Glacier','Polar bear'],['Giraffe','Madagascar'],['Maine','Lobster'],['Octopus','Feather'],['Raven','Panther'],['Seal','Rose'],['Butterfly','Milkweed'],['Donkey','Santorini'],['Rain','Snail'],['Crab','Spider'],['Frog','Cricket'],['Siberia','Tiger'],['Seagull','Beach'],['Crocodile','Nile'],['Penguin','New Zealand'],['Parrot','Cat'],['Crow','Flock'],['Rabbit','Hole'],['Shark','Paleozoic'],['Thunder','Jupiter'],['Sun','Beach'],['Atlantic','Hurricane'],['Tsunami','Landslide'],['Wave','Hawaii'],['Paper','Tree'],['Universe','Energy'],['Life','Time'],['Ocean','Storm'],['Lake','Salt'],['Oxygen','Fire'],['Biology','Cell'],['Chalk','Ice'],['Climate','Winter'],['Planet','Gas'],['Ice age','Acorn'],['Avalanche','Mountain'],['Bison','Plains'],['Bloom','Nectar'],['Canyon','Eagle'],['Chipmunk','Nuts'],['Coral','Reef'],['Desert','Mirage'],['Ecosystem','Balance'],['Falcon','Dive'],['Firefly','Glow'],['Gecko','Leaf'],['Hummingbird','Sugar'],['Koala','Eucalyptus'],['Meteor','Crater'],['Otter','River'],['Rainforest','Canopy'],['Rhino','Horn'],['Volcano','Ash'],['Wilderness','Tracks']] }, + { id: 'everyday_objects_en', name: 'Everyday Objects', emoji: '🏠', lang: 'en', words: [['Hammer','Shark'],['Chair','Back'],['Table','Coffee'],['Spoon','Cream'],['Fork','Poseidon'],['Knife','Butter'],['Silver','Plate'],['Cup','Championship'],['Glass','Sand'],['Bottle','Spray'],['Can','Wedding'],['Phone','Radio'],['Laptop','Card'],['Keyboard','Piano'],['Mouse','Lab'],['Frame','Screen'],['Remote','Satellite'],['Lamp','Oil'],['Oven','Bulb'],['Candle','Wreath'],['Car','Mirror'],['Window','Box'],['Door','Closet'],['Key','Car'],['Lock','Seal'],['Wallet','Leather'],['Purse','Tag'],['Backpack','Airplane'],['Suitcase','Towel'],['Hat','Straw'],['Shoes','Sail'],['Socks','Measure'],['Shirt','Cotton'],['Zipper','Pants'],['Coat','Hair'],['Umbrella','Wing'],['Vacation','Sunglasses'],['Watch','Monitor'],['Wheel','Ring'],['Necklace','Tiara'],['Sleeve','Tattoo'],['Bed','Monster'],['Pillowcase','Stocking'],['Blanket','Cradle'],['Mattress','Air'],['Book','Pop-up'],['Magazine','Journal'],['Newspaper','Column'],['Pen','Ball'],['Pencil','Eyeliner'],['Eraser','Rubber'],['Notebook','Drawing'],['Scissors','Hair'],['Ruler','Grill'],['Glue','Tube'],['Tape','Clip'],['Brush','Broom'],['Basket','Arc'],['Box','Shoe'],['Envelope','Letter'],['Stamp','Date'],['Calendar','Moon'],['Clock','Bell'],['Radio','Wave'],['Speaker','Wall'],['DJ','Headphones'],['Microphone','TV'],['Television','Picture'],['Camera','Laser'],['Tripod','Leg'],['Fan','Oxygen'],['Heater','Dryer'],['Stove','Coal'],['Fridge','Milk'],['Freezer','Ice cream'],['Microwave','Radar'],['Toaster','Oven'],['Blender','Spatula'],['Pot','Soup'],['Pan','Steel'],['Kettle','Copper'],['Sponge','Jelly'],['Soap','Bar'],['Towel','Shower'],['Toothbrush','Tongue'],['Toothpaste','Gel'],['Comb','Ivory'],['Brush','Rake'],['Razor','Soap'],['Shampoo','Aloe'],['Conditioner','Foam'],['Lotion','Silk'],['Bucket','Earth'],['Mop','Floor'],['Broom','Airplane'],['Dustpan','Cloud'],['Trash can','Truck'],['Recycling','Paper'],['Ladder','Rope']] } +]; + +let availablePools = []; +let poolsCache = {}; + +let state = { + phase: 'setup', + numPlayers: 6, + numImpostors: 1, + gameTime: 180, + deliberationTime: 60, + playerNames: [], + roles: [], + civilianWord: '', + impostorWord: '', + currentReveal: 0, + startPlayer: 0, + turnDirection: 'horario', + revealOrder: [], + timerEndAt: null, + timerPhase: null, + votes: {}, + votingPlayer: 0, + selections: [], + executed: [], + selectedPools: [], // Now it's an array for multiple pools, will be populated based on language + votingPool: null, + isTiebreak: false, + tiebreakCandidates: [], + votingMode: 'individual' // 'individual' or 'raisedHand' +}; + +const saveState = () => localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +const loadState = () => { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return false; + try { state = JSON.parse(raw); return true; } catch { return false; } +}; +const clearState = () => localStorage.removeItem(STORAGE_KEY); + +const loadPoolsCache = () => { + try { poolsCache = JSON.parse(localStorage.getItem(POOLS_CACHE_KEY) || '{}'); } catch { poolsCache = {}; } +}; +const savePoolsCache = () => localStorage.setItem(POOLS_CACHE_KEY, JSON.stringify(poolsCache)); + +// ---------- Default values ---------- +function defaultImpostors(nPlayers) { + const capped = Math.min(Math.max(nPlayers, MIN_PLAYERS), MAX_PLAYERS); + let impostors = 1; + if (capped > 7) impostors = 3; + else if (capped > 5) impostors = 2; + const halfCap = Math.max(1, Math.floor(capped / 2)); + return Math.min(impostors, halfCap); +} + +function defaultGameTime(nPlayers) { + const capped = Math.min(Math.max(nPlayers, MIN_PLAYERS), MAX_PLAYERS); + if (capped <= 4) return 300; + if (capped >= 10) return 900; + const extraPlayers = capped - 4; + const seconds = 300 + extraPlayers * 100; + return Math.round(seconds / 30) * 30; +} + +function defaultDeliberation(gameSeconds) { + return Math.max(30, Math.round(gameSeconds / 3)); +} + +// ---------- Word Pools ---------- +async function loadPoolsList() { + loadPoolsCache(); + + // Start with embedded pools (always available) + let embeddedList = EMBEDDED_POOLS.map(p => ({ + id: p.id, + name: p.name, + emoji: p.emoji, + count: p.words.length, + lang: p.lang + })); + + // Try to load external pools from manifest + let externalList = []; + try { + const res = await fetch(POOLS_MANIFEST_URL); + if (res.ok) { + const manifest = await res.json(); + if (Array.isArray(manifest)) { + externalList = manifest; + } + } + } catch (e) { + console.log('Failed to load manifest:', e); + } + + // Combine pools, avoiding duplicates (prefer embedded version over manifest) + const embeddedIds = new Set(embeddedList.map(p => p.id)); + const uniqueExternal = externalList.filter(p => !embeddedIds.has(p.id)); + const allPools = [...embeddedList, ...uniqueExternal]; + + // Filter pools by current language (only show pools matching current language) + availablePools = allPools.filter(p => p.lang === currentLanguage); + + // Check if selected pools are valid for current language + const validSelectedPools = (state.selectedPools || []).filter(id => + availablePools.some(p => p.id === id) + ); + + // If no valid pools or pools don't match current language, reset to defaults + if (validSelectedPools.length === 0) { + const defaultPools = availablePools.slice(0, 2).map(p => p.id); + state.selectedPools = defaultPools.length > 0 ? defaultPools : []; + saveState(); + } else { + state.selectedPools = validSelectedPools; + saveState(); + } + + renderPoolButtons(); +} + +function parseWordsFile(text) { + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#')); + return lines.map(line => { + // Format: civilian_word|impostor_word + if (line.includes('|')) { + const [civil, impostor] = line.split('|').map(s => s.trim()); + return [civil, impostor]; + } + // Fallback: if no pipe, use the same word for both + return [line, line]; + }); +} + +async function pickWords() { + const selectedIds = state.selectedPools && state.selectedPools.length > 0 ? state.selectedPools : ['animales_naturaleza']; + let allWords = []; + + // Collect words from all selected pools + for (const poolId of selectedIds) { + let words = []; + + // Search embedded pools first + const embeddedPool = EMBEDDED_POOLS.find(p => p.id === poolId); + if (embeddedPool) { + words = embeddedPool.words; + } else if (poolsCache[poolId]?.words) { + words = poolsCache[poolId].words; + } else { + try { + const res = await fetch(`word-pools/${poolId}.txt`); + if (res.ok) { + const text = await res.text(); + words = parseWordsFile(text); + poolsCache[poolId] = { words, ts: Date.now() }; + savePoolsCache(); + } + } catch (_) {} + } + + allWords = allWords.concat(words); + } + + if (allWords.length === 0) { + // Fallback to embedded pool + allWords = EMBEDDED_POOLS[0].words; + } + + const shuffled = [...allWords].sort(() => Math.random() - 0.5); + const wordPair = shuffled[0]; + + // wordPair is [civilian_word, impostor_word] + return { civilian: wordPair[0], impostor: wordPair[1] }; +} + +// ---------- Voting Mode ---------- +function setVotingMode(mode) { + state.votingMode = mode; + saveState(); + updateVotingModeButtons(); +} + +function updateVotingModeButtons() { + const individualBtn = document.getElementById('voting-mode-individual'); + const raisedHandBtn = document.getElementById('voting-mode-raisedHand'); + + if (individualBtn && raisedHandBtn) { + individualBtn.classList.toggle('selected', state.votingMode === 'individual'); + raisedHandBtn.classList.toggle('selected', state.votingMode === 'raisedHand'); + + // Update text based on language + individualBtn.querySelector('.voting-mode-name').textContent = t('individualVoting'); + raisedHandBtn.querySelector('.voting-mode-name').textContent = t('raisedHandVoting'); + } + + // Update label + const label = document.getElementById('voting-mode-label'); + if (label) { + label.textContent = t('votingMode') + ':'; + } +} + +function renderPoolButtons() { + const container = document.getElementById('pool-buttons'); + if (!container) return; + container.innerHTML = ''; + + // Ensure selectedPools is an array and contains valid pools for current language + if (!Array.isArray(state.selectedPools)) { + state.selectedPools = [state.selectedPools || 'animales_naturaleza']; + } + + // Filter out pools that don't exist in current language + const validSelectedPools = state.selectedPools.filter(id => + availablePools.some(p => p.id === id) + ); + + // If no valid pools selected, select first 2 available + if (validSelectedPools.length === 0 && availablePools.length > 0) { + state.selectedPools = availablePools.slice(0, 2).map(p => p.id); + saveState(); + } else { + state.selectedPools = validSelectedPools; + } + + availablePools.forEach(pool => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'pool-btn'; + btn.textContent = `${pool.emoji || '🎲'} ${pool.name || pool.id}`; + if (state.selectedPools.includes(pool.id)) btn.classList.add('selected'); + btn.onclick = () => { + // Toggle multiple selection + if (state.selectedPools.includes(pool.id)) { + state.selectedPools = state.selectedPools.filter(id => id !== pool.id); + // Ensure at least one is selected + if (state.selectedPools.length === 0) { + state.selectedPools = [pool.id]; + } + } else { + state.selectedPools.push(pool.id); + } + saveState(); + renderPoolButtons(); + }; + container.appendChild(btn); + }); +} + +// ---------- Setup and player names ---------- +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)); + let nImpostors = parseInt(document.getElementById('num-impostors').value) || defaultImpostors(nPlayers); + nImpostors = Math.min(Math.max(1, nImpostors), maxImpostors); + let gTime = parseInt(document.getElementById('game-time').value) || defaultGameTime(nPlayers); + gTime = Math.min(Math.max(gTime, 60), 900); + 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; + showScreen('pools-screen'); +} + +function goToNames() { + buildNameInputs(); + showScreen('names-screen'); +} + +function buildNameInputs() { + const list = document.getElementById('player-names-list'); + list.innerHTML = ''; + for (let i = 0; i < state.numPlayers; i++) { + const div = document.createElement('div'); + div.className = 'player-name-item'; + const playerLabel = `${t('player')} ${i+1}`; + div.innerHTML = `${playerLabel}:`; + list.appendChild(div); + } +} + +// ---------- Game start ---------- +function startGame() { + state.playerNames = []; + for (let i = 0; i < state.numPlayers; i++) { + const val = document.getElementById(`player-name-${i}`).value.trim(); + state.playerNames.push(val || `Jugador ${i+1}`); + } + pickWords().then(({civilian, impostor}) => { + state.civilianWord = civilian; + state.impostorWord = impostor; + finalizeStart(); + }).catch(() => { + const fallback = EMBEDDED_POOLS[0].words; + const shuffled = [...fallback].sort(() => Math.random() - 0.5); + const wordPair = shuffled[0]; + state.civilianWord = wordPair[0]; + state.impostorWord = wordPair[1]; + finalizeStart(); + }); +} + +function finalizeStart() { + state.roles = Array(state.numPlayers - state.numImpostors).fill('CIVIL').concat(Array(state.numImpostors).fill('IMPOSTOR')).sort(() => Math.random()-0.5); + state.startPlayer = Math.floor(Math.random() * state.numPlayers); + state.turnDirection = Math.random() < 0.5 ? 'horario' : 'antihorario'; + const step = state.turnDirection === 'horario' ? 1 : -1; + state.revealOrder = Array.from({length: state.numPlayers}, (_, k) => (state.startPlayer + step * k + state.numPlayers) % state.numPlayers); + state.currentReveal = 0; state.phase = 'pre-reveal'; state.votes = {}; state.votingPlayer = 0; state.selections = []; state.executed = []; state.timerEndAt = null; state.timerPhase = null; + state.votingPool = null; state.isTiebreak = false; state.tiebreakCandidates = []; + saveState(); + renderSummary(); + showScreen('pre-reveal-screen'); +} + +// Adjust defaults when player count is edited +document.getElementById('num-players').addEventListener('change', () => { + let nPlayers = parseInt(document.getElementById('num-players').value) || MIN_PLAYERS; + nPlayers = Math.min(Math.max(nPlayers, MIN_PLAYERS), MAX_PLAYERS); + document.getElementById('num-players').value = nPlayers; + const imp = defaultImpostors(nPlayers); + const gTime = defaultGameTime(nPlayers); + const dTime = defaultDeliberation(gTime); + document.getElementById('num-impostors').max = Math.max(1, Math.floor(nPlayers / 2)); + document.getElementById('num-impostors').value = imp; + document.getElementById('game-time').value = gTime; + document.getElementById('deliberation-time').value = dTime; +}); + +function renderSummary() { + const el = document.getElementById('config-summary'); + const fmt = secs => `${Math.floor(secs/60)}:${(secs%60).toString().padStart(2,'0')}`; + const startName = state.playerNames[state.startPlayer] || `${t('player')} ${state.startPlayer+1}`; + + // Generate list of selected pools + const selectedIds = Array.isArray(state.selectedPools) ? state.selectedPools : [state.selectedPools || 'animales_naturaleza']; + const poolsText = selectedIds.map(id => { + const pool = availablePools.find(p => p.id === id) || EMBEDDED_POOLS.find(p => p.id === id); + return pool ? `${pool.emoji || '🎲'} ${pool.name || pool.id}` : id; + }).join(', '); + + el.innerHTML = ` +
${t('players')}: ${state.numPlayers}
+${t('impostors')}: ${state.numImpostors}
+${t('gameTime')}: ${fmt(state.gameTime)}
+${t('deliberationTime')}: ${fmt(state.deliberationTime)}
+${t('poolsLabel')}: ${poolsText}
+${t('starts')}: ${startName} · ${t('order')}: ${state.turnDirection === 'horario' ? t('clockwise') : t('counterclockwise')}
+ `; +} + +// ---------- 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); + } + const idx = state.revealOrder[state.currentReveal]; + const name = state.playerNames[idx]; + document.getElementById('current-player-name').textContent = name; + + // Update curtain text + const revealText = document.querySelector('#reveal-screen .info-text'); + if (revealText) { + revealText.innerHTML = `${t('turnOf')}: ${name}`; + } + + const curtainText = document.querySelector('.curtain-cover div:last-child'); + if (curtainText) { + curtainText.textContent = t('liftCurtain'); + } + + // Reset curtain state + curtainState.isRevealed = false; + const coverEl = document.getElementById('curtain-cover'); + coverEl.style.transform = 'translateY(0)'; + coverEl.style.transition = ''; + + document.getElementById('next-player-btn').style.display = 'none'; + document.getElementById('start-game-btn').style.display = 'none'; +} + +function liftCurtain() { + const cover = document.getElementById('curtain-cover'); + if (cover.classList.contains('lifted')) return; + + // Restore CSS transition and use the class + cover.style.transition = ''; + cover.style.transform = ''; + cover.classList.add('lifted'); + + const idx = state.revealOrder[state.currentReveal]; + const role = state.roles[idx]; + const word = role === 'CIVIL' ? state.civilianWord : state.impostorWord; + document.getElementById('role-text').textContent = role; + document.getElementById('role-text').className = 'role ' + (role === 'CIVIL' ? 'civil' : 'impostor'); + document.getElementById('word-text').textContent = word; + setTimeout(() => { + if (state.currentReveal + 1 < state.numPlayers) document.getElementById('next-player-btn').style.display = 'block'; + else document.getElementById('start-game-btn').style.display = 'block'; + }, 700); +} + +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'); + if (!curtain) return; + + // Function to get Y position from event (touch or mouse) + const getY = (e) => { + return e.touches ? e.touches[0].clientY : e.clientY; + }; + + // Start function (touch and mouse) + const handleStart = (e) => { + curtainDragState.startY = getY(e); + curtainDragState.isDragging = true; + curtainDragState.currentTranslateY = 0; + if (e.type === 'mousedown') { + e.preventDefault(); // Prevent text selection on desktop + } + }; + + // Move function (touch and mouse) + const handleMove = (e) => { + if (curtainDragState.startY === null || !curtainDragState.isDragging) return; + const currentY = getY(e); + const dy = currentY - curtainDragState.startY; + const coverEl = document.getElementById('curtain-cover'); + if (!coverEl) return; + + // Calculate displacement: negative = up, positive = down + // 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(${curtainDragState.currentTranslateY}px)`; + coverEl.style.transition = 'none'; + + // If lifted enough, show content + if (curtainDragState.currentTranslateY < -80 && !curtainState.isRevealed) { + curtainState.isRevealed = true; + const idx = state.revealOrder[state.currentReveal]; + const role = state.roles[idx]; + const word = role === 'CIVIL' ? state.civilianWord : state.impostorWord; + const roleText = t(role.toLowerCase()); + document.getElementById('role-text').textContent = roleText; + document.getElementById('role-text').className = 'role ' + (role === 'CIVIL' ? 'civil' : 'impostor'); + document.getElementById('word-text').textContent = word; + } + + e.preventDefault(); // Prevent selection + }; + + // End function (touch and mouse) + const handleEnd = (e) => { + 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'; + coverEl.style.transform = 'translateY(0)'; + + // If content was revealed, show button after it falls + if (curtainState.isRevealed) { + setTimeout(() => { + if (state.currentReveal + 1 < state.numPlayers) { + document.getElementById('next-player-btn').style.display = 'block'; + } else { + document.getElementById('start-game-btn').style.display = 'block'; + } + }, 400); + } + + curtainDragState.startY = null; + curtainDragState.isDragging = false; + curtainDragState.currentTranslateY = 0; + }; + + // Touch events (mobile) + 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) - start on curtain only + curtain.addEventListener('mousedown', handleStart); + + // 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; +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; + saveState(); + const el = document.getElementById(elementId); + const tick = () => { + const remaining = Math.max(0, Math.round((state.timerEndAt - Date.now())/1000)); + updateTimerDisplay(el, remaining); + if (remaining <= 0) { + clearInterval(timerInterval); + releaseWakeLock(); // Release wake lock when timer ends + playBeep(); + onEnd(); + } + }; + tick(); + timerInterval = setInterval(tick, 1000); +} + +function resumeTimerIfNeeded() { + if (!state.timerEndAt || !state.timerPhase) return; + const remaining = Math.round((state.timerEndAt - Date.now())/1000); + if (remaining <= 0) { state.timerEndAt = null; saveState(); return; } + if (state.timerPhase === 'game') { showScreen('game-screen'); startPhaseTimer('game', remaining, 'game-timer', startDeliberationPhase); } + else if (state.timerPhase === 'deliberation') { showScreen('deliberation-screen'); startPhaseTimer('deliberation', remaining, 'deliberation-timer', startVotingPhase); } +} + +function updateTimerDisplay(el, remaining) { + const minutes = Math.floor(remaining/60); const secs = remaining%60; + el.textContent = `${minutes}:${secs.toString().padStart(2,'0')}`; + el.className = 'timer'; + if (remaining <= 10) el.classList.add('danger'); else if (remaining <= 30) el.classList.add('warning'); +} + +function playBeep() { + // Play alarm sound - 3 ascending beeps pattern repeated twice + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + 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 = {}; + state.selections = []; + state.votingPool = candidates; + state.isTiebreak = isTiebreak; + saveState(); + renderVoting(); + showScreen('voting-screen'); +} +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; + saveState(); + showScreen('deliberation-screen'); + startPhaseTimer('deliberation', 60, 'deliberation-timer', () => startVotingPhase(candidates, true)); +} + +// ---------- Secret voting ---------- +function renderVoting() { + const pool = state.votingPool || Array.from({length: state.numPlayers}, (_, i) => i); + + // Check if we're in raised hand mode + if (state.votingMode === 'raisedHand') { + renderRaisedHandVoting(pool); + return; + } + + // Individual voting mode + const voter = state.playerNames[state.votingPlayer]; + document.getElementById('voter-name').textContent = voter; + document.getElementById('votes-needed').textContent = state.numImpostors; + + // Update voting instruction text + const votingInfo = document.querySelector('#voting-screen .info-text'); + if (votingInfo) { + votingInfo.innerHTML = `${t('passMobileTo')} ${voter}. ${t('chooseSuspects')} ${state.numImpostors} ${t('suspect')}.`; + } + + // Update title + const votingTitle = document.querySelector('#voting-screen h1'); + if (votingTitle) { + votingTitle.textContent = t('secretVoting'); + } + + state.selections = state.selections || []; + const list = document.getElementById('vote-list'); list.innerHTML = ''; + 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 += `${t('votes')}: ${state.votes[i]}`; + if (state.selections.includes(i)) item.classList.add('selected'); + + if (i !== state.votingPlayer) { + item.onclick = () => toggleSelection(i, item); + } + + list.appendChild(item); + }); + updateConfirmButton(); +} + +function renderRaisedHandVoting(pool) { + // Update title + const votingTitle = document.querySelector('#voting-screen h1'); + if (votingTitle) { + votingTitle.textContent = t('groupVotingTitle'); + } + + // Update voting instruction text + const votingInfo = document.querySelector('#voting-screen .info-text'); + if (votingInfo) { + votingInfo.innerHTML = `${t('groupVotingInstruction')} ${state.numImpostors} ${t('groupVotingSuspects')}.`; + } + + state.selections = state.selections || []; + const list = document.getElementById('vote-list'); + list.innerHTML = ''; + + pool.forEach(i => { + const item = document.createElement('div'); + item.className = 'player-item'; + item.textContent = state.playerNames[i]; + + if (state.selections.includes(i)) { + item.classList.add('selected'); + } + + item.onclick = () => toggleRaisedHandSelection(i); + list.appendChild(item); + }); + + updateConfirmButton(); +} + +function toggleRaisedHandSelection(idx) { + if (state.selections.includes(idx)) { + state.selections = state.selections.filter(x => x !== idx); + } else { + if (state.selections.length >= state.numImpostors) return; + state.selections.push(idx); + } + saveState(); + renderVoting(); +} + +function toggleSelection(idx, el) { + if (idx === state.votingPlayer) return; + if (state.selections.includes(idx)) state.selections = state.selections.filter(x => x !== idx); + else { + if (state.selections.length >= state.numImpostors) return; + state.selections.push(idx); + } + saveState(); + renderVoting(); +} + +function updateConfirmButton() { + const btn = document.getElementById('confirm-vote-btn'); + btn.disabled = state.selections.length !== state.numImpostors; +} + +function confirmCurrentVote() { + // Raised hand mode: direct execution + if (state.votingMode === 'raisedHand') { + state.executed = [...state.selections]; + showResults(); + return; + } + + // Individual mode: count votes + state.selections.forEach(t => { state.votes[t] = (state.votes[t] || 0) + 1; }); + state.votingPlayer++; + state.selections = []; + saveState(); + if (state.votingPlayer >= state.numPlayers) { handleVoteOutcome(); return; } + renderVoting(); +} + +// ---------- Vote resolution ---------- +function handleVoteOutcome() { + const pool = state.votingPool || Array.from({length: state.numPlayers}, (_, i) => i); + const counts = pool.map(idx => ({ idx, votes: state.votes[idx] || 0 })); + counts.sort((a, b) => b.votes - a.votes); + + let slots = state.numImpostors; + const executed = []; + for (let i = 0; i < counts.length && slots > 0; ) { + const currentVotes = counts[i].votes; + const group = []; + let j = i; + while (j < counts.length && counts[j].votes === currentVotes) { group.push(counts[j].idx); j++; } + if (group.length <= slots) { + executed.push(...group); + slots -= group.length; + i = j; + } else { + // Tie for remaining slots + if (state.isTiebreak) { + // Second tie: impostors win + state.executed = []; + showResults(true); + return; + } + startTiebreakDeliberation(group); + return; + } + } + + state.executed = executed; + showResults(); +} + +// ---------- 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++; }); + const winner = impostorsAlive > 0 ? 'IMPOSTORES' : 'CIVILES'; + const results = document.getElementById('results-content'); + const winText = winner === 'CIVILES' ? `✅ ${t('civiliansWin')}` : `❌ ${t('impostorsWin')}`; + results.innerHTML = ` +${t('executed')}: ${executed.length ? executed.map(i => state.playerNames[i]).join(', ') : t('nobody')}
+${t('votes')}: ${Object.keys(state.votes).length ? '' : t('noVotes')}
+