// ========= MTG Life Counter ========= // ---------- Config ---------- const STARTING_LIFE = 20; const POISON_LOSS = 10; const COMMANDER_LOSS = 21; const NAME_MAX_CHARS = 18; // ---------- Global state ---------- let players = []; let playerCount = 1; let isCommanderMatch = false; let isTwoPlayer = false; let isPoisonMatch = false; // ---------- DOM ---------- const mainContainer = document.querySelector('main'); const addPlayerButton = document.querySelector('.fa-circle-plus'); const removePlayerButton = document.querySelector('.fa-circle-minus'); const resetButton = document.querySelector('.fa-rotate-left'); const commanderToggleButton = document.querySelector('#commander-toggle'); const poisonToggleButton = document.querySelector('#poison-toggle'); const twoPlayerToggleButton = document.querySelector('.fa-user-group'); const fullscreenButton = document.querySelector('.fa-expand'); const navContainer = document.getElementById('navContainer'); const diceButton = document.getElementById('dice-button'); const diceModal = document.getElementById('dice-modal'); const closeButton = document.querySelector('.close-button'); // ---------- Utilities ---------- const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const num = (v, d = 0) => (isFinite(+v) ? +v : d); const vibrate = (pattern) => { try { navigator.vibrate?.(pattern); } catch {} }; function sanitizeName(s) { return String(s ?? '').replace(/\n/g, ' ').slice(0, NAME_MAX_CHARS); } // ---------- Player card HTML ---------- const createPlayerElement = (playerObj, index) => { const playerDiv = document.createElement('div'); playerDiv.id = `player${index + 1}`; playerDiv.classList.add('player'); if (isTwoPlayer) playerDiv.classList.add('two-player-mode'); // Commander damage rows (if enabled) let commanderDamageHTML = ''; if (isCommanderMatch) { commanderDamageHTML = players.map((opponent, oppIndex) => { if (index === oppIndex) return ''; const val = playerObj.commanderDamage?.[oppIndex] ?? 0; return `
${sanitizeName(opponent.name)}
${val}
`; }).join(''); } // Poison row (if enabled) const poisonCounterHTML = isPoisonMatch ? `
Poison: ${playerObj.poison}
` : ''; playerDiv.innerHTML = `
${sanitizeName(playerObj.name)}
${playerObj.life}
${poisonCounterHTML} ${commanderDamageHTML}
`; return playerDiv; }; // ---------- Rendering / Persistence ---------- const renderPlayers = () => { const currentPlayers = Array.from(mainContainer.children); // Remove player elements that no longer have a corresponding player object. // This handles when a player is removed. currentPlayers.forEach((playerEl, index) => { if (!players[index]) { playerEl.remove(); } }); // Iterate through the players state and either update existing DOM elements // or create and append new ones. players.forEach((playerObj, index) => { let playerEl = document.getElementById(`player${index + 1}`); if (playerEl) { // Player card exists, update its content instead of re-creating it. playerEl.querySelector('.name').textContent = sanitizeName(playerObj.name); playerEl.querySelector('.life-count').textContent = playerObj.life; setLifeRingOn(playerEl, playerObj.life); const specialCountersDiv = playerEl.querySelector('.special-counters'); const poisonCounterHTML = isPoisonMatch ? `
Poison: ${playerObj.poison}
` : ''; let commanderDamageHTML = ''; if (isCommanderMatch) { commanderDamageHTML = players.map((opponent, oppIndex) => { if (index === oppIndex) return ''; const val = playerObj.commanderDamage?.[oppIndex] ?? 0; return `
${sanitizeName(opponent.name)}
${val}
`; }).join(''); } specialCountersDiv.innerHTML = `${poisonCounterHTML} ${commanderDamageHTML}`; } else { // Player card does not exist, create a new one. const newEl = createPlayerElement(playerObj, index); mainContainer.appendChild(newEl); } checkElimination(index); }); document.body.classList.toggle('is-commander-mode', isCommanderMatch); document.body.classList.toggle('is-two-player-mode', isTwoPlayer); document.body.classList.toggle('is-poison-mode', isPoisonMatch); initLifeRings(); }; const saveState = () => { try { localStorage.setItem('mtgLifeCounterState', JSON.stringify({ players, playerCount, isCommanderMatch, isTwoPlayer, isPoisonMatch, })); } catch {} }; const loadState = () => { let saved = null; try { saved = JSON.parse(localStorage.getItem('mtgLifeCounterState')); } catch {} if (saved?.players?.length) { players = saved.players; playerCount = saved.playerCount; isCommanderMatch = !!saved.isCommanderMatch; isTwoPlayer = !!saved.isTwoPlayer; isPoisonMatch = !!saved.isPoisonMatch; } else { players = [ { name: "Player 1", life: STARTING_LIFE, poison: 0, commanderDamage: {} }, { name: "Player 2", life: STARTING_LIFE, poison: 0, commanderDamage: {} }, ]; playerCount = 2; isTwoPlayer = true; } // Backfill props players.forEach(p => { if (!p || typeof p !== 'object') return; if (typeof p.poison !== 'number') p.poison = 0; if (!p.commanderDamage || typeof p.commanderDamage !== 'object') p.commanderDamage = {}; if (typeof p.life !== 'number') p.life = STARTING_LIFE; if (typeof p.name !== 'string') p.name = "Player"; }); renderPlayers(); }; // ---------- Life Ring helpers ---------- const setLifeRingOn = (playerDiv, life) => { const max = isCommanderMatch ? 40 : STARTING_LIFE; const pct = clamp(life / max, 0, 1); playerDiv.style.setProperty('--life-deg', `${(pct * 360).toFixed(2)}deg`); playerDiv.style.setProperty('--life-pct', pct.toFixed(4)); playerDiv.dataset.lifeZero = String(life <= 0); }; const initLifeRings = () => { Array.from(mainContainer.children).forEach((playerDiv, idx) => { setLifeRingOn(playerDiv, players[idx].life); }); }; // ---------- Elimination ---------- const checkElimination = (playerIndex) => { const p = players[playerIndex]; let reason = null; let fromIndex = null; if (p.life <= 0) reason = 'life'; if (!reason && isPoisonMatch && p.poison >= POISON_LOSS) { reason = 'poison'; } if (!reason && isCommanderMatch && p.commanderDamage) { for (const [opp, dmg] of Object.entries(p.commanderDamage)) { if (dmg >= COMMANDER_LOSS) { reason = 'commander'; fromIndex = Number(opp); break; } } } const card = document.getElementById(`player${playerIndex + 1}`); if (!card) return; const wasOut = card.classList.contains('is-out'); if (reason) { card.classList.add('is-out'); card.setAttribute('data-out-reason', reason); if (reason === 'commander' && fromIndex != null) { card.setAttribute('data-out-from', players[fromIndex]?.name || 'Opponent'); } else { card.removeAttribute('data-out-from'); } if (!wasOut && typeof Swal !== 'undefined') { const msg = reason === 'life' ? `${p.name} has 0 life.` : reason === 'poison' ? `${p.name} reached ${p.poison}/${POISON_LOSS} poison.` : `${p.name} took ${p.commanderDamage[fromIndex]}/${COMMANDER_LOSS} Commander damage from ${players[fromIndex]?.name || 'an opponent'}.`; Swal.fire({ icon: 'error', title: 'Player eliminated', text: msg }); vibrate([30, 60, 30]); } } else { card.classList.remove('is-out'); card.removeAttribute('data-out-reason'); card.removeAttribute('data-out-from'); } }; // ---------- Reset helpers ---------- const resetLife = () => { players.forEach(p => p.life = isCommanderMatch ? 40 : STARTING_LIFE); renderPlayers(); saveState(); }; const resetNames = () => { players.forEach((p, i) => p.name = `Player ${i + 1}`); renderPlayers(); saveState(); }; const resetCommanderDamage = () => { players.forEach(p => { p.commanderDamage = {}; }); renderPlayers(); saveState(); }; // ---------- Add / Remove players ---------- const addPlayer = () => { if (players.length >= 8) return; playerCount++; players.push({ name: `Player ${playerCount}`, life: STARTING_LIFE, poison: 0, commanderDamage: {} }); isTwoPlayer = (players.length === 2); renderPlayers(); saveState(); }; const removePlayer = () => { if (players.length <= 2) return; playerCount--; players.pop(); isTwoPlayer = (players.length === 2); renderPlayers(); saveState(); }; // ---------- 2-player toggle ---------- const toggleTwoPlayerMode = () => { if (isTwoPlayer) return; Swal.fire({ title: "Start a 2-Player Game?", text: "This will reset to 2 players and hide extra counters.", icon: "question", showCancelButton: true, confirmButtonText: "Yes", cancelButtonText: "No", }).then((result) => { if (!result.isConfirmed) return; players = [ { name: "Player 1", life: STARTING_LIFE, poison: 0, commanderDamage: {} }, { name: "Player 2", life: STARTING_LIFE, poison: 0, commanderDamage: {} } ]; playerCount = 2; isTwoPlayer = true; isCommanderMatch = false; isPoisonMatch = false; renderPlayers(); saveState(); }); }; // ---------- Event handlers ---------- const handleLifeChange = (event) => { const btn = event.target.closest('.life-btn'); const card = event.target.closest('.player'); if (!btn || !card) return; if (card.classList.contains('is-out')) return; const playerIndex = Array.from(mainContainer.children).indexOf(card); const amount = num(btn.dataset.amount, 0); players[playerIndex].life = Math.round(players[playerIndex].life + amount); const lifeCountDiv = card.querySelector('.life-count'); if (lifeCountDiv) lifeCountDiv.textContent = players[playerIndex].life; setLifeRingOn(card, players[playerIndex].life); checkElimination(playerIndex); saveState(); }; const handlePoisonChange = (event) => { const btn = event.target.closest('.poison-btn'); if (!btn) return; const playerIndex = num(btn.dataset.playerIndex, -1); if (playerIndex < 0) return; const amount = btn.classList.contains('poison-up') ? 1 : -1; players[playerIndex].poison = clamp(players[playerIndex].poison + amount, 0, POISON_LOSS); renderPlayers(); // Rerender to update the value and check elimination saveState(); }; const handleCommanderChange = (event) => { const btn = event.target.closest('.commander-btn'); if (!btn) return; const playerIndex = num(btn.dataset.playerIndex, -1); const oppIndex = num(btn.dataset.opponentIndex, -1); if (playerIndex < 0 || oppIndex < 0) return; const amount = btn.classList.contains('commander-up') ? 1 : -1; if (!players[playerIndex].commanderDamage[oppIndex]) { players[playerIndex].commanderDamage[oppIndex] = 0; } players[playerIndex].commanderDamage[oppIndex] = clamp(players[playerIndex].commanderDamage[oppIndex] + amount, 0, COMMANDER_LOSS); renderPlayers(); // Rerender to update the value and check elimination saveState(); }; // ---------- Boot ---------- document.addEventListener('DOMContentLoaded', loadState); window.onbeforeunload = saveState; // Toolbar listeners addPlayerButton?.addEventListener('click', addPlayer); removePlayerButton?.addEventListener('click', removePlayer); twoPlayerToggleButton?.addEventListener('click', toggleTwoPlayerMode); // Commander mode toggle commanderToggleButton?.addEventListener('click', () => { if (isCommanderMatch) { Swal.fire({ title: "End Commander Match?", text: "This will revert to a standard game (life reset to 20).", icon: "question", showCancelButton: true, confirmButtonText: "Yes, revert", cancelButtonText: "No, keep Commander", }).then((result) => { if (!result.isConfirmed) return; players.forEach(p => p.life = STARTING_LIFE); isCommanderMatch = false; renderPlayers(); saveState(); Swal.fire("Game Reverted!", "Now playing a standard game.", "success"); }); } else { Swal.fire({ title: "Start Commander Match?", text: "Life will be reset to 40.", icon: "question", showDenyButton: true, confirmButtonText: "Yes", denyButtonText: "No", }).then((result) => { if (!result.isConfirmed) return; players.forEach(p => p.life = 40); isCommanderMatch = true; isTwoPlayer = false; renderPlayers(); saveState(); Swal.fire("Commander Match Started!", "", "success"); }); } }); // Reset menu (Life / Names / Commander) resetButton?.addEventListener('click', async () => { const { value: choice } = await Swal.fire({ title: "What would you like to reset?", input: "radio", inputOptions: { life: "Life", names: "Names", commander: "Commander damage" }, inputValidator: v => (!v ? "Please pick one option" : undefined), confirmButtonText: "Reset", showCancelButton: true, }); if (!choice) return; if (choice === "life") { resetLife(); Swal.fire("Life reset!", "", "success"); } else if (choice === "names") { resetNames(); Swal.fire("All player names reset!", "", "success"); } else if (choice === "commander") { resetCommanderDamage(); Swal.fire("Commander damage reset!", "", "success"); } }); // Poison toggle (normal click) poisonToggleButton?.addEventListener('click', () => { isPoisonMatch = !isPoisonMatch; renderPlayers(); saveState(); Swal.fire(isPoisonMatch ? "Poison Counters Enabled" : "Poison Counters Disabled", "", "info"); }); // Fullscreen fullscreenButton?.addEventListener('click', () => { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); }); // Delegated clicks (life/poison/commander) mainContainer.addEventListener('click', (event) => { handleLifeChange(event); handlePoisonChange(event); handleCommanderChange(event); }); // Name edits with char limit & no newlines mainContainer.addEventListener('beforeinput', (e) => { const nameDiv = e.target.closest?.('.name'); if (!nameDiv) return; if (e.inputType === 'insertLineBreak') { e.preventDefault(); } }); mainContainer.addEventListener('input', (event) => { const nameDiv = event.target.closest('.name'); if (!nameDiv) return; const idx = num(nameDiv.dataset.index, -1); if (idx < 0) return; const clean = sanitizeName(nameDiv.textContent); if (nameDiv.textContent !== clean) { const sel = window.getSelection(); nameDiv.textContent = clean; const range = document.createRange(); range.selectNodeContents(nameDiv); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } players[idx].name = clean; saveState(); }); // PWA hide nav if (window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone) { if (navContainer) navContainer.style.display = 'none'; } // ---------- Die Roller Modal Logic ---------- diceButton?.addEventListener('click', () => { diceModal.style.display = 'flex'; }); closeButton?.addEventListener('click', () => { diceModal.style.display = 'none'; }); window.addEventListener('click', (event) => { if (event.target === diceModal) { diceModal.style.display = 'none'; } }); // ---------- D20 no-jump roller with bowl wobble ---------- (() => { const dieContainer = document.querySelector('.die-container'); if (!dieContainer) return; const die = dieContainer.querySelector('.die'); if (!die) return; if (!die.parentElement.classList.contains('roller')) { const roller = document.createElement('div'); roller.className = 'roller'; die.parentNode.insertBefore(roller, die); roller.appendChild(die); } const roller = die.parentElement; if (!roller.parentElement.classList.contains('track')) { const track = document.createElement('div'); track.className = 'track'; roller.parentNode.insertBefore(track, roller); track.appendChild(roller); } const track = roller.parentElement; const SIDES = 20; let lastFace = null; let timer = null; const msStr = getComputedStyle(document.documentElement).getPropertyValue('--roll-ms').trim(); const ANIM_MS = Number(msStr.replace('ms','')) || 1500; function randomFace() { let n; do { n = 1 + Math.floor(Math.random() * SIDES); } while (n === lastFace); lastFace = n; return n; } function setFace(n) { const f = Math.max(1, Math.min(SIDES, Math.round(n))); die.setAttribute('data-face', String(f)); die.setAttribute('aria-label', `Rolled ${f}`); return f; } function rollOnce(targetFace) { clearTimeout(timer); const face = typeof targetFace === 'number' ? targetFace : randomFace(); setFace(face); track.classList.toggle('reverse', Math.random() < 0.5); roller.classList.add('rolling'); track.classList.add('rolling'); timer = setTimeout(() => { roller.classList.remove('rolling'); track.classList.remove('rolling'); if (navigator.vibrate) navigator.vibrate(20); }, ANIM_MS); } dieContainer.addEventListener('click', (e) => { // Prevent event from bubbling up to close the modal e.stopPropagation(); rollOnce(); }); document.addEventListener('keydown', (e) => { const k = e.key.toLowerCase(); if (k === 'r' && diceModal.style.display === 'flex') { rollOnce(); e.preventDefault(); } }); if (!die.hasAttribute('data-face')) setFace(1); window.rollTo = (n) => rollOnce(n); })(); // ---------- Easter Egg: long-press the skull to toggle Infect Mode ---------- (() => { const icon = document.getElementById('poison-toggle'); if (!icon) return; const HOLD_MS = 1200; const MOVE_CANCEL = 12; let t = null, armed = false, swallowClick = false, startX = 0, startY = 0; function setInfectMode(on){ document.body.classList.toggle('infect-mode', on); try { localStorage.setItem('infectMode', on ? '1' : '0'); } catch {} } try { setInfectMode(localStorage.getItem('infectMode') === '1'); } catch {} function onDown(e){ const p = (e.touches && e.touches[0]) || e; startX = p.clientX; startY = p.clientY; armed = false; clearTimeout(t); icon.classList.add('egg-hold'); t = setTimeout(()=> { armed = true; }, HOLD_MS); } function onMove(e){ if (!t) return; const p = (e.touches && e.touches[0]) || e; if (Math.hypot(p.clientX - startX, p.clientY - startY) > MOVE_CANCEL) cancel(); } function onUp(){ if (!t) return; clearTimeout(t); t = null; icon.classList.remove('egg-hold'); if (armed){ armed = false; swallowClick = true; const willEnable = !document.body.classList.contains('infect-mode'); setInfectMode(willEnable); vibrate(willEnable ? [20,40,20] : 28); const rect = icon.getBoundingClientRect(); const x = rect.left + rect.width/2, y = rect.top + rect.height/2; if (typeof window.confettiBurstAt === 'function') window.confettiBurstAt(x, y, 70); if (typeof Swal !== 'undefined' && Swal?.fire){ Swal.fire({ toast:true, position:'top', timer:1400, showConfirmButton:false, icon: willEnable ? 'success' : 'info', title: willEnable ? 'Infect Mode unlocked' : 'Infect Mode off' }); } } } function cancel(){ clearTimeout(t); t = null; armed = false; icon.classList.remove('egg-hold'); } icon.addEventListener('pointerdown', onDown); icon.addEventListener('pointermove', onMove); ['pointerup','pointercancel','pointerleave'].forEach(ev => icon.addEventListener(ev, onUp)); icon.addEventListener('click', (e)=>{ if (swallowClick){ e.preventDefault(); e.stopImmediatePropagation(); swallowClick = false; } }, true); icon.addEventListener('contextmenu', e => e.preventDefault()); })(); // ---------- Keyboard: small accessibility niceties ---------- document.addEventListener('keydown', (e) => { if (e.code === 'KeyF' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); } }); // --- Fit bowl+die to the toolbar using height and true remaining width --- (() => { const bar = document.getElementById('buttonWrapper'); const dice = bar?.querySelector('.dice'); const toNum = v => { const n = parseFloat(v); return Number.isFinite(n) ? n : 0; }; function resizeDie(){ if (!bar || !dice) return; const root = document.documentElement; const csBar = getComputedStyle(bar); const innerW = bar.clientWidth - toNum(csBar.paddingLeft) - toNum(csBar.paddingRight); const innerH = bar.clientHeight - toNum(csBar.paddingTop) - toNum(csBar.paddingBottom); const gap = toNum(csBar.columnGap || csBar.gap || 0); let usedBefore = 0, countBefore = 0; for (const el of bar.children) { if (el === dice) break; usedBefore += el.getBoundingClientRect().width; countBefore++; } if (countBefore > 0) usedBefore += gap * countBefore; const remainingW = Math.max(0, innerW - usedBefore); const ratio = parseFloat(getComputedStyle(root).getPropertyValue('--bowl-ratio')) || 1.55; const dieFromHeight = innerH / ratio; const dieFromWidth = remainingW / ratio; const die = Math.max(70, Math.min(200, Math.floor(Math.min(dieFromHeight, dieFromWidth)))); root.style.setProperty('--die-size', die + 'px'); } const ro = new ResizeObserver(resizeDie); if (bar) ro.observe(bar); window.addEventListener('resize', resizeDie, { passive:true }); document.addEventListener('visibilitychange', resizeDie); resizeDie(); })();