Add offline impostor game with local word pools, voting tiebreaks, and docs

This commit is contained in:
2025-12-27 17:45:44 +01:00
commit 8687cc638a
15 changed files with 1250 additions and 0 deletions

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# Impostor Game (offline)
## What is it?
Role-based impostor-style game for mobile, 100% in the browser with no backend. Uses localStorage so a reload doesnt lose the match.
## Structure
- `index.html`: main UI.
- `styles.css`: mobile-first styles.
- `script.js`: game logic (roles, timers, voting, tiebreaks, word pools).
- `word-pools/`: word pools in `.txt` + `manifest.json`.
## Available pools
- Animals & Nature (🌿) — `animales_naturaleza.txt`
- Daily Life (🏠) — `vida_cotidiana.txt`
- Sports (🏅) — `deportes.txt`
- Brands (🛍️) — `marcas.txt`
- Music (🎵) — `musica.txt`
- Characters (🧙) — `personajes.txt`
`.txt` format: optional first line header `# emoji Name`; the rest is one word/term per line.
## Key rules
- Defaults: up to 10 players; impostors by player count (<=5 →1, 6-7 →2, >7 →3) and never more than half.
- Time defaults: 5 min with 4 or fewer players, up to 15 min with 10; deliberation = 1/3 (rounded).
- No self-voting. Max executions = number of impostors.
- Tie on execution slots: 1 extra minute of deliberation and a new vote only among tied players; if tie persists, impostors win.
- Starting player and direction (clockwise/counter-clockwise) are random.
## How to run (offline)
Use any static file server so `fetch` can read the `.txt` files (needed if opening via `file://`). Example with Python:
```bash
git clone https://git.dariosevilla.es/dasemu/web-imposter-game
cd web-imposter-game
python -m http.server 8000
```
Open in browser: `http://localhost:8000`
## Add new pools
1) Create a `.txt` in `word-pools/` with optional header and one word per line.
2) Add an entry in `word-pools/manifest.json` with `id`, `name`, `emoji`.
3) (Optional) add an embedded version in `script.js` if you want it available without the files.
## Notes
- State is saved in `localStorage` (`impostorGameStateV2`). Use “New match” to clear it.
- Mobile friendly: reveal via swipe or tap; adaptive UI.

108
index.html Normal file
View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="es">
<head>
<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="stylesheet" href="styles.css">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
</head>
<body>
<div class="container">
<!-- Pantalla de configuración -->
<div id="setup-screen" class="screen active">
<h1>🎭 Juego del Impostor</h1>
<div class="form-group">
<label for="num-players">Número de jugadores:</label>
<input type="number" id="num-players" min="3" max="10" value="6">
</div>
<div class="form-group">
<label for="num-impostors">Número de impostores:</label>
<input type="number" id="num-impostors" min="1" max="5" value="1">
</div>
<div class="form-group">
<label for="game-time">Tiempo de partida (segundos):</label>
<input type="number" id="game-time" min="60" max="900" step="30" value="180">
</div>
<div class="form-group">
<label for="deliberation-time">Tiempo de deliberación (segundos):</label>
<input type="number" id="deliberation-time" min="30" max="300" step="10" value="60">
</div>
<div class="form-group">
<label>Pool de palabras:</label>
<div id="pool-buttons" class="pool-buttons"></div>
</div>
<button onclick="goToNames()">Siguiente: nombres</button>
</div>
<!-- Pantalla de nombres -->
<div id="names-screen" class="screen">
<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>
</div>
<!-- Pre revelado -->
<div id="pre-reveal-screen" class="screen">
<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>
<button class="ghost" onclick="showScreen('names-screen')">← Volver</button>
</div>
<!-- Revelación -->
<div id="reveal-screen" class="screen">
<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. Desliza para ver.</small></p>
<div class="curtain" id="curtain">
<div class="curtain-cover" id="curtain-cover">
<div class="curtain-icon">⬆️</div>
<div>DESLIZA PARA REVELAR</div>
</div>
<div class="curtain-content">
<div class="role" id="role-text"></div>
<div class="word" id="word-text"></div>
</div>
</div>
<button id="next-player-btn" style="display:none;" onclick="nextReveal()">Siguiente jugador →</button>
<button id="start-game-btn" style="display:none;" onclick="startGamePhase()">¡Iniciar partida!</button>
</div>
<!-- Partida -->
<div id="game-screen" class="screen">
<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>
</div>
<!-- Deliberación -->
<div id="deliberation-screen" class="screen">
<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>
</div>
<!-- Votación -->
<div id="voting-screen" class="screen">
<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>
</div>
<!-- Resultados -->
<div id="results-screen" class="screen">
<h1>🏆 Resultados</h1>
<div class="results" id="results-content"></div>
<button onclick="newMatch()">Nueva partida</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

468
script.js Normal file
View File

@@ -0,0 +1,468 @@
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 EMBEDDED_POOLS = [
{ id: 'animales_naturaleza', name: 'Animales y naturaleza', emoji: '🌿', words: ['Perro','Gato','Lobo','Zorro','Oso','Tigre','León','Pantera','Jaguar','Puma','Guepardo','Elefante','Rinoceronte','Hipopótamo','Jirafa','Cebra','Camello','Dromedario','Canguro','Koala','Panda','Mapache','Nutria','Castor','Foca','Morsa','Delfín','Ballena','Tiburón','Orca','Pulpo','Calamar','Medusa','Tortuga','Lagarto','Cocodrilo','Serpiente','Anaconda','Iguana','Rana','Sapo','Búho','Halcón','Águila','Cóndor','Gaviota','Loro','Flamenco','Pingüino','Avestruz','Gallina','Pato','Ganso','Cisne','Abeja','Hormiga','Mariquita','Libélula','Mariposa','Escarabajo','Grillo','Saltamontes','Araña','Escorpión','Lombriz','Caracol','Estrella de mar','Coral','Musgo','Helecho','Pino','Roble','Encina','Palmera','Cactus','Bambú','Rosa','Tulipán','Girasol','Lavanda','Montaña','Río','Lago','Mar','Playa','Desierto','Selva','Bosque','Pradera','Glaciar','Volcán'] },
{ id: 'vida_cotidiana', name: 'Vida cotidiana', emoji: '🏠', words: ['Pan','Leche','Café','Té','Agua','Jugo','Refresco','Cerveza','Vino','Pizza','Hamburguesa','Sándwich','Taco','Burrito','Pasta','Arroz','Paella','Sushi','Ramen','Ensalada','Sopa','Croqueta','Tortilla','Empanada','Arepa','Queso','Jamón','Chorizo','Pollo','Carne','Cerdo','Pescado','Marisco','Patata','Tomate','Cebolla','Ajo','Pimiento','Zanahoria','Lechuga','Brócoli','Coliflor','Manzana','Plátano','Naranja','Pera','Uva','Fresa','Mango','Piña','Melón','Sandía','Yogur','Galletas','Chocolate','Helado','Cereales','Mantequilla','Aceite','Sal','Pimienta','Azúcar','Harina','Huevo','Cuchara','Tenedor','Cuchillo','Plato','Vaso','Taza','Olla','Sartén','Microondas','Horno','Nevera','Mesa','Silla','Sofá','Cama','Almohada','Sábana','Toalla','Ducha','Jabón','Champú','Cepillo','Pasta de dientes'] },
{ id: 'deportes', name: 'Deportes', emoji: '🏅', words: ['Fútbol','Baloncesto','Tenis','Pádel','Bádminton','Voleibol','Béisbol','Rugby','Hockey hielo','Hockey césped','Golf','Boxeo','MMA','Judo','Karate','Taekwondo','Esgrima','Tiro con arco','Halterofilia','Crossfit','Atletismo','Maratón','Triatlón','Ciclismo ruta','Ciclismo montaña','BMX','Natación','Waterpolo','Surf','Vela','Remo','Piragüismo','Esquí','Snowboard','Patinaje artístico','Patinaje velocidad','Curling','Escalada','Senderismo','Trail running','Parkour','Gimnasia artística','Gimnasia rítmica','Trampolín','Skate','Breakdance','Carreras coches','Fórmula 1','Rally','Karting','Motociclismo','Enduro','Motocross','Equitación','Polo','Críquet','Billar','Dardos','Petanca','Pickleball','Ultimate frisbee','Paintball','Airsoft','eSports'] },
{ id: 'marcas', name: 'Marcas', emoji: '🛍️', words: ['Apple','Samsung','Google','Microsoft','Amazon','Meta','Tesla','Toyota','Honda','Ford','BMW','Mercedes','Audi','Volkswagen','Porsche','Ferrari','Lamborghini','Maserati','McLaren','Chevrolet','Nissan','Kia','Hyundai','Peugeot','Renault','Volvo','Jaguar','Land Rover','Fiat','Alfa Romeo','Ducati','Yamaha','Canon','Nikon','Sony','Panasonic','LG','Philips','Siemens','Bosch','Whirlpool','Ikea','Zara','H&M','Uniqlo','Nike','Adidas','Puma','Reebok','New Balance','Under Armour','Converse','Vans','Patagonia','The North Face','Columbia','Levis','Calvin Klein','Gucci','Prada','Louis Vuitton','Chanel','Hermès','Dior','Rolex','Omega','Casio','Pepsi','Coca-Cola','Fanta','Red Bull','Monster','Starbucks','Nespresso','Nestlé','Danone','Kelloggs','Oreo','Intel','AMD','Nvidia','Qualcomm','TikTok','Netflix','Disney','Warner Bros','HBO','Spotify','Airbnb','Uber','Booking'] },
{ id: 'musica', name: 'Música', emoji: '🎵', words: ['Guitarra','Piano','Violín','Batería','Bajo','Saxofón','Trompeta','Flauta','Clarinete','Acordeón','Ukelele','Arpa','Sintetizador','DJ','Micrófono','Altavoz','Concierto','Festival','Vinilo','Rock','Pop','Punk','Metal','Heavy','Thrash','Death metal','Jazz','Blues','Soul','Funk','R&B','Rap','Hip hop','Trap','Reggaetón','Salsa','Bachata','Merengue','Cumbia','Vallenato','Flamenco','Rumba','Bossa nova','Samba','Tango','Country','EDM','Techno','House','Trance','Dubstep','Drum and bass','Lo-fi','Reggae','Ska','K-pop','J-pop','Indie','Gospel','Ópera','Sinfonía','Orquesta','Coro','Cantautor','Balada','Bolero','Ranchera','Corrido','Mariachi'] },
{ id: 'personajes', name: 'Personajes', emoji: '🧙', words: ['Sherlock Holmes','Harry Potter','Hermione Granger','Ron Weasley','Albus Dumbledore','Voldemort','Frodo Bolsón','Sam Gamyi','Gandalf','Aragorn','Legolas','Gimli','Gollum','Bilbo Bolsón','Katniss Everdeen','Peeta Mellark','Batman','Bruce Wayne','Joker','Harley Quinn','Superman','Clark Kent','Lois Lane','Wonder Woman','Diana Prince','Flash','Barry Allen','Aquaman','Arthur Curry','Spider-Man','Peter Parker','Iron Man','Tony Stark','Capitán América','Steve Rogers','Black Widow','Natasha Romanoff','Hulk','Bruce Banner','Thor','Loki','Thanos','Doctor Strange','Wanda Maximoff','Vision','Star-Lord','Gamora','Groot','Rocket','Drax','Deadpool','Wolverine','Magneto','Professor X','Storm','Cyclops','Jean Grey','Mystique','Darth Vader','Luke Skywalker','Leia Organa','Han Solo','Chewbacca','Yoda','Obi-Wan Kenobi','Anakin Skywalker','Rey','Kylo Ren','R2-D2','C-3PO','Indiana Jones','Lara Croft','James Bond','Mario','Luigi','Princesa Peach','Bowser','Link','Zelda','Geralt de Rivia','Ciri','Yennefer','Kratos','Atreus','Ellie','Joel Miller','Nathan Drake','Master Chief','Cortana','Sonic','Tails','Ash Ketchum','Pikachu','Goku','Vegeta','Naruto','Sasuke','Luffy','Zoro','Nami','Tanjiro','Nezuko','Saitama','Light Yagami','L Lawliet'] }
];
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: [],
selectedPool: 'animales_naturaleza',
votingPool: null,
isTiebreak: false,
tiebreakCandidates: []
};
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));
// ---------- Defaults ----------
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));
}
// ---------- Pools ----------
async function loadPoolsList() {
loadPoolsCache();
let list = [];
try {
const res = await fetch(POOLS_MANIFEST_URL);
if (res.ok) list = await res.json();
} catch (_) {}
if (!Array.isArray(list) || list.length === 0) {
list = EMBEDDED_POOLS.map(p => ({ id: p.id, name: p.name, emoji: p.emoji, count: p.words.length }));
}
availablePools = list;
renderPoolButtons();
}
function parseWordsFile(text) {
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (!lines.length) return [];
if (lines[0].startsWith('#')) return lines.slice(1);
return lines;
}
async function pickWords() {
const poolId = state.selectedPool || 'default';
let words = [];
if (poolsCache[poolId]?.words) {
words = poolsCache[poolId].words;
} else if (poolId !== 'default') {
const res = await fetch(`word-pools/${poolId}.txt`);
if (!res.ok) throw new Error('No se pudo cargar el pool');
const text = await res.text();
words = parseWordsFile(text);
poolsCache[poolId] = { words, ts: Date.now() }; savePoolsCache();
} else {
words = EMBEDDED_POOLS[0].words;
}
const shuffled = [...words].sort(() => Math.random() - 0.5);
return { civilian: shuffled[0], impostor: shuffled[1] };
}
function renderPoolButtons() {
const container = document.getElementById('pool-buttons');
if (!container) return;
container.innerHTML = '';
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.selectedPool === pool.id) btn.classList.add('selected');
btn.onclick = () => { state.selectedPool = pool.id; saveState(); renderPoolButtons(); };
container.appendChild(btn);
});
}
// ---------- Configuración y nombres ----------
function goToNames() {
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('Impostores debe ser menor que jugadores'); return; }
state.numPlayers = nPlayers; state.numImpostors = nImpostors; state.gameTime = gTime; state.deliberationTime = dTime;
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';
div.innerHTML = `<span>Jugador ${i+1}:</span><input id="player-name-${i}" value="${state.playerNames[i] || 'Jugador '+(i+1)}" />`;
list.appendChild(div);
}
}
// ---------- Inicio de partida ----------
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);
state.civilianWord = shuffled[0];
state.impostorWord = shuffled[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');
}
// Ajustar defaults cuando se edita el nº de jugadores
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] || `Jugador ${state.startPlayer+1}`;
const poolMeta = availablePools.find(p => p.id === state.selectedPool) || EMBEDDED_POOLS[0];
el.innerHTML = `
<p><strong>Jugadores:</strong> ${state.numPlayers}</p>
<p><strong>Impostores:</strong> ${state.numImpostors}</p>
<p><strong>Tiempo de partida:</strong> ${fmt(state.gameTime)}</p>
<p><strong>Tiempo de deliberación:</strong> ${fmt(state.deliberationTime)}</p>
<p><strong>Pool:</strong> ${poolMeta.emoji || '🎲'} ${poolMeta.name || poolMeta.id}</p>
<p><strong>Empieza:</strong> ${startName} · <strong>Orden:</strong> ${state.turnDirection === 'horario' ? 'Horario' : 'Antihorario'}</p>
`;
}
// ---------- Revelación ----------
function loadCurrentReveal() {
state.phase = 'reveal'; saveState();
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;
document.getElementById('curtain-cover').classList.remove('lifted');
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;
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(); }
// swipe support
(() => {
const curtain = document.getElementById('curtain');
let startY = null;
curtain.addEventListener('touchstart', e => { startY = e.touches[0].clientY; }, {passive:true});
curtain.addEventListener('touchmove', e => { if (startY === null) return; const dy = e.touches[0].clientY - startY; if (dy < -40) { liftCurtain(); startY = null; } }, {passive:true});
curtain.addEventListener('click', liftCurtain);
})();
// ---------- Timers ----------
let timerInterval = null;
function startPhaseTimer(phase, seconds, elementId, onEnd) {
if (timerInterval) clearInterval(timerInterval);
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); 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() {
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);
}
// ---------- Fases ----------
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) {
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); startDeliberationPhase(); }
function skipToVoting() { if (timerInterval) clearInterval(timerInterval); startVotingPhase(); }
function startTiebreakDeliberation(candidates) {
state.phase = 'deliberation';
state.tiebreakCandidates = candidates;
saveState();
showScreen('deliberation-screen');
startPhaseTimer('deliberation', 60, 'deliberation-timer', () => startVotingPhase(candidates, true));
}
// ---------- Votación secreta ----------
function renderVoting() {
const pool = state.votingPool || Array.from({length: state.numPlayers}, (_, i) => i);
const voter = state.playerNames[state.votingPlayer];
document.getElementById('voter-name').textContent = voter;
document.getElementById('votes-needed').textContent = state.numImpostors;
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.votes[i]) item.innerHTML += `<span class="vote-count">Votos: ${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);
}
list.appendChild(item);
});
updateConfirmButton();
}
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() {
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();
}
// ---------- Resolución de voto ----------
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) {
// segunda vez empatados: ganan impostores
state.executed = [];
showResults(true);
return;
}
startTiebreakDeliberation(group);
return;
}
}
state.executed = executed;
showResults();
}
// ---------- Resultados ----------
function showResults(isTiebreak = false) {
state.phase = 'results'; saveState();
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');
results.innerHTML = `
<h2>${winner === 'CIVILES' ? '✅ ¡GANAN LOS CIVILES!' : '❌ ¡GANAN LOS IMPOSTORES!'}</h2>
<p><strong>Ejecutados:</strong> ${executed.length ? executed.map(i => state.playerNames[i]).join(', ') : 'Nadie'}</p>
<p><strong>Votos:</strong> ${Object.keys(state.votes).length ? '' : 'Sin votos'}</p>
<h3 style="margin-top:18px;">Roles revelados</h3>
${state.roles.map((role,i) => {
const word = role === 'CIVIL' ? state.civilianWord : state.impostorWord;
const killed = executed.includes(i) ? 'executed' : '';
return `<div class="role-reveal ${role === 'CIVIL' ? 'civil-reveal' : 'impostor-reveal'} ${killed}"><strong>${state.playerNames[i]}:</strong> ${role} — "${word}" ${killed ? '☠️' : ''}</div>`;
}).join('')}
`;
showScreen('results-screen');
}
// ---------- Utilidades ----------
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
state.phase = id.replace('-screen','');
saveState();
}
function newMatch() { clearState(); state = { ...state, phase:'setup', timerEndAt:null, timerPhase:null, votingPool:null, isTiebreak:false, tiebreakCandidates:[] }; location.reload(); }
// ---------- Rehidratación ----------
(function init() {
const restored = loadState();
showScreen('setup-screen');
loadPoolsList();
if (!state.turnDirection) state.turnDirection = 'horario';
if (typeof state.startPlayer !== 'number') state.startPlayer = 0;
switch (state.phase) {
case 'setup': showScreen('setup-screen'); break;
case 'names': buildNameInputs(); showScreen('names-screen'); break;
case 'pre-reveal': renderSummary(); showScreen('pre-reveal-screen'); break;
case 'reveal': showScreen('reveal-screen'); loadCurrentReveal(); break;
case 'game': showScreen('game-screen'); resumeTimerIfNeeded(); break;
case 'deliberation': showScreen('deliberation-screen'); resumeTimerIfNeeded(); break;
case 'voting': showScreen('voting-screen'); renderVoting(); break;
case 'results': showResults(); break;
default: showScreen('setup-screen');
}
})();

51
styles.css Normal file
View File

@@ -0,0 +1,51 @@
/* Estilos móviles y UI principal */
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; justify-content: center; align-items: center; padding: 20px; color: white; }
.container { width: 100%; max-width: 520px; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border-radius: 20px; padding: 28px; box-shadow: 0 8px 32px rgba(31, 38, 135, 0.37); }
h1 { text-align: center; margin-bottom: 22px; font-size: 1.9em; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); }
h2 { text-align: center; margin: 14px 0; font-size: 1.35em; }
.screen { display: none; animation: fadeIn 0.25s ease; }
.screen.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.form-group { margin-bottom: 16px; }
label { display: block; margin-bottom: 6px; font-weight: 700; font-size: 1.02em; }
input { width: 100%; padding: 12px 14px; border: none; border-radius: 10px; font-size: 1em; background: rgba(255, 255, 255, 0.92); color: #333; }
input:focus { outline: 2px solid #f5576c; }
button { width: 100%; padding: 16px; border: none; border-radius: 10px; font-size: 1.12em; font-weight: 800; cursor: pointer; background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; box-shadow: 0 4px 15px rgba(0,0,0,0.2); transition: transform 0.15s, box-shadow 0.15s; margin-top: 10px; }
button:active { transform: scale(0.98); box-shadow: 0 2px 10px rgba(0,0,0,0.2); }
button.secondary { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); color: #333; }
button.ghost { background: rgba(255,255,255,0.12); color: #fff; font-weight: 600; }
.player-names-list { max-height: 300px; overflow-y: auto; margin-bottom: 10px; }
.player-name-item { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; background: rgba(255,255,255,0.18); padding: 10px; border-radius: 9px; }
.player-name-item span { font-weight: 700; min-width: 86px; }
.player-name-item input { flex: 1; padding: 10px; margin: 0; }
.curtain { position: relative; width: 100%; height: 340px; background: #2d3748; border-radius: 16px; overflow: hidden; margin: 18px 0; box-shadow: 0 4px 15px rgba(0,0,0,0.3); }
.curtain-cover { position: absolute; inset: 0; background: linear-gradient(135deg, #ffa585 0%, #ffeda0 100%); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; font-size: 1.24em; font-weight: 800; color: #333; transition: transform 0.5s ease; z-index: 10; }
.curtain-cover.lifted { transform: translateY(-100%); }
.curtain-icon { font-size: 2.6em; }
.curtain-content { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; padding: 20px; text-align: center; }
.role { font-size: 2.4em; font-weight: 800; padding: 12px 24px; border-radius: 15px; text-transform: uppercase; border: 3px solid transparent; }
.role.civil { background: rgba(74, 222, 128, 0.26); color: #4ade80; border-color: #4ade80; }
.role.impostor { background: rgba(239, 68, 68, 0.28); color: #ef4444; border-color: #ef4444; }
.word { font-size: 2em; font-weight: 800; background: rgba(255,255,255,0.16); padding: 22px 32px; border-radius: 12px; border: 2px solid rgba(255,255,255,0.28); }
.timer { font-size: 3.4em; font-weight: 800; text-align: center; margin: 24px 0; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); padding: 18px; background: rgba(0,0,0,0.22); border-radius: 14px; }
.timer.warning { color: #fbbf24; animation: pulse 1s infinite; }
.timer.danger { color: #ef4444; animation: pulse 0.55s infinite; }
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.05); } }
.info-text { text-align: center; margin: 16px 0; font-size: 1.05em; line-height: 1.6; background: rgba(0,0,0,0.2); padding: 12px; border-radius: 10px; }
.player-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; margin: 18px 0; max-height: 360px; overflow-y: auto; }
.player-item { padding: 18px 12px; background: rgba(255,255,255,0.2); border-radius: 12px; text-align: center; cursor: pointer; transition: transform 0.18s, background 0.18s, border 0.18s; font-weight: 700; font-size: 1.04em; border: 2px solid transparent; }
.player-item:active { transform: scale(0.96); }
.player-item.selected { background: #f5576c; border-color: #fff; box-shadow: 0 0 18px rgba(245, 87, 108, 0.6); }
.player-item .vote-count { display: block; font-size: 0.9em; margin-top: 6px; opacity: 0.88; }
.results { background: rgba(255,255,255,0.16); border-radius: 12px; padding: 20px; margin: 18px 0; }
.role-reveal { background: rgba(0,0,0,0.3); padding: 12px; border-radius: 10px; margin: 7px 0; border-left: 4px solid transparent; }
.role-reveal.civil-reveal { border-left-color: #4ade80; }
.role-reveal.impostor-reveal { border-left-color: #ef4444; }
.role-reveal.executed { opacity: 0.6; background: rgba(0,0,0,0.45); }
.tag { display: inline-block; padding: 6px 10px; border-radius: 8px; background: rgba(255,255,255,0.18); margin: 4px 0; font-weight: 700; }
.pool-buttons { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; margin: 10px 0 0; }
.pool-btn { padding: 10px 12px; border-radius: 10px; border: 2px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.16); color: #fff; font-weight: 700; cursor: pointer; transition: transform 0.12s, border 0.12s, background 0.12s; }
.pool-btn:hover { transform: translateY(-1px); }
.pool-btn.selected { border-color: #f093fb; background: rgba(240,147,251,0.2); box-shadow: 0 0 10px rgba(240,147,251,0.4); }

12
word-pools/animales.txt Normal file
View File

@@ -0,0 +1,12 @@
# 🐾 Animales
Perro
Gato
Conejo
Hamster
Pajaro
Tortuga
Caballo
Vaca
Oveja
Cerdo

View File

@@ -0,0 +1,93 @@
# 🌿 Animales y naturaleza
Perro
Gato
Lobo
Zorro
Oso
Tigre
León
Pantera
Jaguar
Puma
Guepardo
Elefante
Rinoceronte
Hipopótamo
Jirafa
Cebra
Camello
Dromedario
Canguro
Koala
Panda
Mapache
Nutria
Castor
Foca
Morsa
Delfín
Ballena
Tiburón
Orca
Pulpo
Calamar
Medusa
Tortuga
Lagarto
Cocodrilo
Serpiente
Anaconda
Iguana
Rana
Sapo
Búho
Halcón
Águila
Cóndor
Gaviota
Loro
Flamenco
Pingüino
Avestruz
Gallina
Pato
Ganso
Cisne
Abeja
Hormiga
Mariquita
Libélula
Mariposa
Escarabajo
Grillo
Saltamontes
Araña
Escorpión
Lombriz
Caracol
Estrella de mar
Coral
Musgo
Helecho
Pino
Roble
Encina
Palmera
Cactus
Bambú
Rosa
Tulipán
Girasol
Lavanda
Montaña
Río
Lago
Mar
Playa
Desierto
Selva
Bosque
Pradera
Glaciar
Volcán

12
word-pools/ciudades.txt Normal file
View File

@@ -0,0 +1,12 @@
# 🏙️ Ciudades
Paris
Londres
Tokio
Nueva York
Roma
Madrid
Lisboa
Berlin
Mexico
Buenos Aires

12
word-pools/colores.txt Normal file
View File

@@ -0,0 +1,12 @@
# 🌈 Colores
Rojo
Azul
Verde
Amarillo
Morado
Naranja
Negro
Blanco
Gris
Rosa

12
word-pools/comida.txt Normal file
View File

@@ -0,0 +1,12 @@
# 🍔 Comida
Pizza
Hamburguesa
Sushi
Tacos
Pasta
Ramen
Paella
Burrito
Arepa
Ensalada

66
word-pools/deportes.txt Normal file
View File

@@ -0,0 +1,66 @@
# 🏅 Deportes
Fútbol
Baloncesto
Tenis
Pádel
Bádminton
Voleibol
Béisbol
Rugby
Hockey hielo
Hockey césped
Golf
Boxeo
MMA
Judo
Karate
Taekwondo
Esgrima
Tiro con arco
Halterofilia
Crossfit
Atletismo
Maratón
Triatlón
Ciclismo ruta
Ciclismo montaña
BMX
Natación
Waterpolo
Surf
Vela
Remo
Piragüismo
Esquí
Snowboard
Patinaje artístico
Patinaje velocidad
Curling
Escalada
Senderismo
Trail running
Parkour
Gimnasia artística
Gimnasia rítmica
Trampolín
Skate
Breakdance
Carreras coches
Fórmula 1
Rally
Karting
Motociclismo
Enduro
Motocross
Equitación
Polo
Críquet
Billar
Dardos
Petanca
Pickleball
Ultimate frisbee
Paintball
Airsoft
eSports

9
word-pools/manifest.json Normal file
View File

@@ -0,0 +1,9 @@
[
{ "id": "animales_naturaleza", "name": "Animales y naturaleza", "emoji": "🌿" },
{ "id": "vida_cotidiana", "name": "Vida cotidiana", "emoji": "🏠" },
{ "id": "deportes", "name": "Deportes", "emoji": "🏅" },
{ "id": "marcas", "name": "Marcas", "emoji": "🛍️" },
{ "id": "musica", "name": "Música", "emoji": "🎵" },
{ "id": "personajes", "name": "Personajes", "emoji": "🧙" }
]

93
word-pools/marcas.txt Normal file
View File

@@ -0,0 +1,93 @@
# 🛍️ Marcas
Apple
Samsung
Google
Microsoft
Amazon
Meta
Tesla
Toyota
Honda
Ford
BMW
Mercedes
Audi
Volkswagen
Porsche
Ferrari
Lamborghini
Maserati
McLaren
Chevrolet
Nissan
Kia
Hyundai
Peugeot
Renault
Volvo
Jaguar
Land Rover
Fiat
Alfa Romeo
Ducati
Yamaha
Canon
Nikon
Sony
Panasonic
LG
Philips
Siemens
Bosch
Whirlpool
Ikea
Zara
H&M
Uniqlo
Nike
Adidas
Puma
Reebok
New Balance
Under Armour
Converse
Vans
Patagonia
The North Face
Columbia
Levis
Calvin Klein
Gucci
Prada
Louis Vuitton
Chanel
Hermès
Dior
Rolex
Omega
Casio
Pepsi
Coca-Cola
Fanta
Red Bull
Monster
Starbucks
Nespresso
Nestlé
Danone
Kelloggs
Oreo
Intel
AMD
Nvidia
Qualcomm
TikTok
Netflix
Disney
Warner Bros
HBO
Spotify
Airbnb
Uber
Booking

71
word-pools/musica.txt Normal file
View File

@@ -0,0 +1,71 @@
# 🎵 Música
Guitarra
Piano
Violín
Batería
Bajo
Saxofón
Trompeta
Flauta
Clarinete
Acordeón
Ukelele
Arpa
Sintetizador
DJ
Micrófono
Altavoz
Concierto
Festival
Vinilo
Rock
Pop
Punk
Metal
Heavy
Thrash
Death metal
Jazz
Blues
Soul
Funk
R&B
Rap
Hip hop
Trap
Reggaetón
Salsa
Bachata
Merengue
Cumbia
Vallenato
Flamenco
Rumba
Bossa nova
Samba
Tango
Country
EDM
Techno
House
Trance
Dubstep
Drum and bass
Lo-fi
Reggae
Ska
K-pop
J-pop
Indie
Gospel
Ópera
Sinfonía
Orquesta
Coro
Cantautor
Balada
Bolero
Ranchera
Corrido
Mariachi

107
word-pools/personajes.txt Normal file
View File

@@ -0,0 +1,107 @@
# 🧙 Personajes
Sherlock Holmes
Harry Potter
Hermione Granger
Ron Weasley
Albus Dumbledore
Voldemort
Frodo Bolsón
Sam Gamyi
Gandalf
Aragorn
Legolas
Gimli
Gollum
Bilbo Bolsón
Katniss Everdeen
Peeta Mellark
Batman
Bruce Wayne
Joker
Harley Quinn
Superman
Clark Kent
Lois Lane
Wonder Woman
Diana Prince
Flash
Barry Allen
Aquaman
Arthur Curry
Spider-Man
Peter Parker
Iron Man
Tony Stark
Capitán América
Steve Rogers
Black Widow
Natasha Romanoff
Hulk
Bruce Banner
Thor
Loki
Thanos
Doctor Strange
Wanda Maximoff
Vision
Star-Lord
Gamora
Groot
Rocket
Drax
Deadpool
Wolverine
Magneto
Professor X
Storm
Cyclops
Jean Grey
Mystique
Darth Vader
Luke Skywalker
Leia Organa
Han Solo
Chewbacca
Yoda
Obi-Wan Kenobi
Anakin Skywalker
Rey
Kylo Ren
R2-D2
C-3PO
Indiana Jones
Lara Croft
James Bond
Mario
Luigi
Princesa Peach
Bowser
Link
Zelda
Geralt de Rivia
Ciri
Yennefer
Kratos
Atreus
Ellie
Joel Miller
Nathan Drake
Master Chief
Cortana
Sonic
Tails
Ash Ketchum
Pikachu
Goku
Vegeta
Naruto
Sasuke
Luffy
Zoro
Nami
Tanjiro
Nezuko
Saitama
Light Yagami
L Lawliet

View File

@@ -0,0 +1,89 @@
# 🏠 Vida cotidiana
Pan
Leche
Café
Agua
Jugo
Refresco
Cerveza
Vino
Pizza
Hamburguesa
Sándwich
Taco
Burrito
Pasta
Arroz
Paella
Sushi
Ramen
Ensalada
Sopa
Croqueta
Tortilla
Empanada
Arepa
Queso
Jamón
Chorizo
Pollo
Carne
Cerdo
Pescado
Marisco
Patata
Tomate
Cebolla
Ajo
Pimiento
Zanahoria
Lechuga
Brócoli
Coliflor
Manzana
Plátano
Naranja
Pera
Uva
Fresa
Mango
Piña
Melón
Sandía
Yogur
Galletas
Chocolate
Helado
Cereales
Mantequilla
Aceite
Sal
Pimienta
Azúcar
Harina
Huevo
Cuchara
Tenedor
Cuchillo
Plato
Vaso
Taza
Olla
Sartén
Microondas
Horno
Nevera
Mesa
Silla
Sofá
Cama
Almohada
Sábana
Toalla
Ducha
Jabón
Champú
Cepillo
Pasta de dientes