MTG_Life/script.js
2025-09-09 17:56:15 -04:00

645 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ========= MTG Life Counter =========
// ---------- Config ----------
const STARTING_LIFE = 20;
const POISON_LOSS = 10; // poison counters to lose
const COMMANDER_LOSS = 21; // commander damage from a single opponent to lose
const NAME_MAX_CHARS = 18; // trim player names
// ---------- 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');
// ---------- 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 `
<div class="commander-damage-counter">
<span class="commander-name" data-opponent-index="${oppIndex}">${sanitizeName(opponent.name)}</span>
<div class="commander-controls">
<button class="commander-btn commander-down" data-player-index="${index}" data-opponent-index="${oppIndex}">-</button>
<span class="commander-value" data-player-index="${index}" data-opponent-index="${oppIndex}" data-max="${COMMANDER_LOSS}">${val}</span>
<button class="commander-btn commander-up" data-player-index="${index}" data-opponent-index="${oppIndex}">+</button>
</div>
</div>
`;
}).join('');
}
// Poison row (if enabled)
const poisonCounterHTML = isPoisonMatch ? `
<div class="poison-counter">
<span class="counter-label">Poison:</span>
<button class="poison-btn poison-down" data-player-index="${index}">-</button>
<span class="poison-value" data-max="${POISON_LOSS}">${playerObj.poison}</span>
<button class="poison-btn poison-up" data-player-index="${index}">+</button>
</div>
` : '';
playerDiv.innerHTML = `
<div class="name" contenteditable="true" data-index="${index}" spellcheck="false">${sanitizeName(playerObj.name)}</div>
<div class="life-container">
<div class="life">
<span class="vert">
<div class="life-btn life-up-5" data-amount="5" title="+5"><i class="fas fa-arrow-circle-up"></i></div>
<div class="life-btn life-up-1" data-amount="1" title="+1"><i class="fas fa-chevron-circle-up"></i></div>
</span>
<div class="life-count" title="Life total">${playerObj.life}</div>
<span class="vert">
<div class="life-btn life-down-5" data-amount="-5" title="-5"><i class="fas fa-arrow-circle-down"></i></div>
<div class="life-btn life-down-1" data-amount="-1" title="-1"><i class="fas fa-chevron-circle-down"></i></div>
</span>
</div>
<div class="special-counters">
${poisonCounterHTML}
${commanderDamageHTML}
</div>
</div>
`;
return playerDiv;
};
// ---------- Rendering / Persistence ----------
const renderPlayers = () => {
mainContainer.innerHTML = '';
players.forEach((player, index) => {
const el = createPlayerElement(player, index);
mainContainer.appendChild(el);
});
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();
// After DOM exists, check elimination state for each
players.forEach((_, i) => checkElimination(i));
};
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; // ignore eliminated
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 += amount;
const poisonValue = document.querySelector(`#player${playerIndex + 1} .poison-value`);
if (poisonValue) poisonValue.textContent = players[playerIndex].poison;
checkElimination(playerIndex);
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] += amount;
const valEl = document.querySelector(`.commander-value[data-player-index="${playerIndex}"][data-opponent-index="${oppIndex}"]`);
if (valEl) valEl.textContent = players[playerIndex].commanderDamage[oppIndex];
checkElimination(playerIndex);
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', () => {
// If long-press fired, its handler swallows this click in capture phase.
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';
}
// ---------- D20 no-jump roller with bowl wobble ----------
(() => {
const die = document.querySelector('.die');
if (!die) return;
// Wrap .die in .roller once
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;
// NEW: wrap .roller in .track once (for bowl translation)
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;
// Read duration from CSS var (--roll-ms)
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);
// Set final face FIRST (hidden under spin/wobble)
const face = typeof targetFace === 'number' ? targetFace : randomFace();
setFace(face);
// Randomize direction of bowl wobble
track.classList.toggle('reverse', Math.random() < 0.5);
// Start spin + wobble
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);
}
// Click to roll
(document.querySelector('.content') || roller).addEventListener('click', () => rollOnce());
// Hotkeys (optional)
document.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (k === 'r') rollOnce();
if (k === 'f') {
const v = parseInt(prompt('Roll to face (120):') || '', 10);
if (v) rollOnce(v);
}
});
if (!die.hasAttribute('data-face')) setFace(1);
window.rollTo = (n) => rollOnce(n); // handy for console/testing
})();
// ---------- 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 {}
}
// Restore persisted
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; // prevent the normal click handler after long-press
const willEnable = !document.body.classList.contains('infect-mode');
setInfectMode(willEnable);
vibrate(willEnable ? [20,40,20] : 28);
// Optional confetti hook
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');
}
// Pointer events (mouse & touch)
icon.addEventListener('pointerdown', onDown);
icon.addEventListener('pointermove', onMove);
['pointerup','pointercancel','pointerleave'].forEach(ev => icon.addEventListener(ev, onUp));
// Swallow the click if it immediately follows a successful long-press
icon.addEventListener('click', (e)=>{
if (swallowClick){
e.preventDefault(); e.stopImmediatePropagation();
swallowClick = false;
}
}, true);
// Avoid context menu popups on long-press (Android Chrome etc.)
icon.addEventListener('contextmenu', e => e.preventDefault());
})();
// ---------- Keyboard: small accessibility niceties ----------
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd+F toggles fullscreen (optional)
if (e.code === 'KeyF' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen();
}
});