init commit
This commit is contained in:
parent
1b58dcfa7b
commit
183132c2ae
81
index.html
Normal file
81
index.html
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MTG Life Counter</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<link rel="stylesheet" href="menu/menu.css" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"
|
||||||
|
/>
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script defer src="script.js"></script>
|
||||||
|
|
||||||
|
<div id="navContainer"></div>
|
||||||
|
<div class="buttons1" id="buttonWrapper">
|
||||||
|
<span class="icons">
|
||||||
|
<i class="fa-solid fa-circle-minus"></i>
|
||||||
|
</span>
|
||||||
|
<span class="icons">
|
||||||
|
<i class="fa-solid fa-rotate-left"></i>
|
||||||
|
</span>
|
||||||
|
<span class="icons">
|
||||||
|
<i class="fa-solid fa-circle-plus"></i>
|
||||||
|
</span>
|
||||||
|
<span class="icons">
|
||||||
|
<i class="fa-solid fa-user-group"></i>
|
||||||
|
</span>
|
||||||
|
<span class="icons" id="commander-toggle">
|
||||||
|
<i class="fa-solid fa-crown"></i>
|
||||||
|
</span>
|
||||||
|
<span class="icons" id="poison-toggle">
|
||||||
|
<i class="fa-solid fa-skull-crossbones"></i>
|
||||||
|
</span>
|
||||||
|
<span class="icons">
|
||||||
|
<i class="fa-solid fa-expand"></i>
|
||||||
|
</span>
|
||||||
|
<span class="dice bowl--black">
|
||||||
|
<div class="content">
|
||||||
|
<div class="die">
|
||||||
|
<figure class="face face-1"></figure>
|
||||||
|
<figure class="face face-2"></figure>
|
||||||
|
<figure class="face face-3"></figure>
|
||||||
|
<figure class="face face-4"></figure>
|
||||||
|
<figure class="face face-5"></figure>
|
||||||
|
<figure class="face face-6"></figure>
|
||||||
|
<figure class="face face-7"></figure>
|
||||||
|
<figure class="face face-8"></figure>
|
||||||
|
<figure class="face face-9"></figure>
|
||||||
|
<figure class="face face-10"></figure>
|
||||||
|
<figure class="face face-11"></figure>
|
||||||
|
<figure class="face face-12"></figure>
|
||||||
|
<figure class="face face-13"></figure>
|
||||||
|
<figure class="face face-14"></figure>
|
||||||
|
<figure class="face face-15"></figure>
|
||||||
|
<figure class="face face-16"></figure>
|
||||||
|
<figure class="face face-17"></figure>
|
||||||
|
<figure class="face face-18"></figure>
|
||||||
|
<figure class="face face-19"></figure>
|
||||||
|
<figure class="face face-20"></figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main></main>
|
||||||
|
|
||||||
|
<script defer src="menu/menu.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||||
|
<script
|
||||||
|
src="https://kit.fontawesome.com/17513d4ff3.js"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
644
script.js
Normal file
644
script.js
Normal file
@ -0,0 +1,644 @@
|
|||||||
|
// ========= 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 (1–20):') || '', 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user