// ========= 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)}
${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();
})();