Compare commits

...

2 Commits

Author SHA1 Message Date
79de99c8ff add style 2025-09-09 17:56:44 -04:00
183132c2ae init commit 2025-09-09 17:56:15 -04:00
3 changed files with 1272 additions and 0 deletions

81
index.html Normal file
View 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
View 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 (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();
}
});

547
style.css Normal file
View File

@ -0,0 +1,547 @@
/*
MTG Life Counter Full CSS
- modern player cards with depleting ring
- contained die slot with wooden (or black) bowl
- no duplicate rules / cleaned media queries
*/
/* ===== Base / Resets =================================================== */
*,
*::before,
*::after { box-sizing: border-box; }
html, body { height: 100%; }
html { min-height: 100%; margin: 0; }
body {
background: radial-gradient(circle at 50% 40%, #792f22 0%, #4f644f 47%, #6c7f83 100%);
margin: 0;
color: #333;
user-select: none;
font-family: 'Lucida Grande','Lucida Sans Unicode','Lucida Sans',Geneva,Verdana,sans-serif;
}
/* Animatable property for the ring sweep */
@property --life-deg {
syntax: '<angle>';
inherits: true;
initial-value: 360deg;
}
/* ===== Theme Variables ================================================= */
:root{
/* layout */
--border-radius: 30px;
/* dice / animation */
--roll-ms: 1500ms; /* change to slow/fast the roll */
--die-size: 96px; /* change to scale die & bowl together */
/* cards */
--card-border: hsl(0 0% 100% / 0.16);
--ring-thickness: 10px;
/* bowl (wood theme) */
--wood-h: 30; /* hue 2040 = warm brown */
--wood-s: 55%; /* saturation 4070% */
--wood-l: 35%; /* lightness 3045% */
--wood-sheen: .05; /* tiny linear grain */
--wood-rim: .18; /* rim highlight strength */
}
/* ===== Main Layout ===================================================== */
main{
display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: center;
width: 100vw;
min-height: 100vh;
padding-top: 100px; /* space for toolbar */
gap: clamp(18px, 4vw, 44px); /* gap between cards */
}
/* ===== Toolbar ========================================================= */
#buttonWrapper{
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
width: min(92%, 760px);
display: flex;
justify-content: space-evenly;
align-items: center;
padding: 6px;
border-radius: 20px;
background: #eee;
opacity: .9;
box-shadow: 0 4px 8px rgba(0,0,0,.2);
z-index: 10;
}
.icons{
font-size: clamp(18px, 1.6vw, 26px);
padding: 10px;
color: #697069;
cursor: pointer;
transition: color .2s, transform .2s;
}
.icons:hover { color: #333; transform: scale(1.08); }
.icons:active { transform: scale(0.94); }
/* ===== Dice slot + bowl =============================================== */
/* Dice hitbox no visual box */
.dice{
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--bowl-size);
height: var(--bowl-size);
background: none !important;
border: none !important;
box-shadow: none !important;
}
/* 3D canvas for die (sized by --die-size) */
.dice .content{
position: relative;
width: var(--die-size);
height: var(--die-size);
perspective: calc(var(--die-size) * 15);
}
/* --- Wooden bowl (default) --- */
.dice .content::before{
content:"";
position: absolute; inset: 6px; border-radius: 50%;
z-index: 0;
background:
/* concentric rings */
repeating-conic-gradient(
at 50% 52%,
hsl(var(--wood-h) var(--wood-s) calc(var(--wood-l) + 7%)) 0deg 5deg,
hsl(var(--wood-h) var(--wood-s) calc(var(--wood-l) - 7%)) 5deg 10deg
),
/* subtle straight grain */
repeating-linear-gradient(
14deg,
rgba(255,255,255,var(--wood-sheen)) 0 2px,
rgba(0,0,0,var(--wood-sheen)) 2px 4px
),
/* concave shading */
radial-gradient(120% 120% at 50% 42%,
rgba(255,255,255,.18) 0%,
rgba(255,255,255,.10) 30%,
rgba(0,0,0,.22) 70%,
rgba(0,0,0,.34) 100%);
background-blend-mode: multiply, overlay, normal;
box-shadow:
inset 0 12px 26px rgba(0,0,0,.45),
inset 0 -8px 18px rgba(255,255,255,.08),
0 2px 8px rgba(0,0,0,.28);
}
/* rim highlight */
.dice .content::after{
content:"";
position: absolute; inset: 0; border-radius: 50%;
z-index: 0; pointer-events: none;
background:
radial-gradient(80% 80% at 50% 25%,
rgba(255,255,255,var(--wood-rim)) 0%,
rgba(255,255,255,0) 60%);
box-shadow:
0 0 0 2px hsl(var(--wood-h) var(--wood-s) calc(var(--wood-l) - 4%) / .45),
inset 0 0 18px rgba(255,255,255,.08);
}
/* --- Black bowl variant (add class .bowl--black to .dice) --- */
.content::before{
content:"";
position:absolute; inset:6px; / inner lip */
border-radius:50%;
z-index:0;
background:
radial-gradient(circle at 50% 45%, #4a4a4a 0%, #2a2a2a 62%, #111 80%, #000 100%);
box-shadow:
inset 0 10px 24px rgba(255,255,255,.08),
inset 0 -12px 24px rgba(0,0,0,.65),
0 2px 10px rgba(0,0,0,.55);
}
/* Subtle rim highlight */
.content::after{
content:"";
position:absolute; inset:0;
border-radius:50%;
z-index:0;
background: radial-gradient(circle at 50% 30%, rgba(255,255,255,.14) 0%, rgba(0,0,0,0) 60%);
box-shadow:
0 0 0 2px rgba(255,255,255,.08),
inset 0 0 20px rgba(0,0,0,.6);
pointer-events:none;
}
/* ===== Die (D20) ====================================================== */
/* wrapper that owns the animation */
.roller{
position: absolute;
inset: 0;
transform-style: preserve-3d;
z-index: 1; /* above bowl */
}
/* elliptical path that eases back to center */
@keyframes orbit-wobble {
0% { transform: translate3d(0,0,2px) rotateZ(0deg); }
15% { transform: translate3d(16px,10px,-4px) rotateZ(.6deg); }
35% { transform: translate3d(-14px,12px,-2px) rotateZ(-.7deg); }
55% { transform: translate3d(12px,-10px,1px) rotateZ(.5deg); }
75% { transform: translate3d(-6px,4px,0) rotateZ(-.25deg); }
100% { transform: translate3d(0,0,0) rotateZ(0deg); }
}
/* clean 0→100% spin */
@keyframes roll {
0% { transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg); }
25% { transform: rotateX(180deg) rotateY(360deg) rotateZ(0deg); }
50% { transform: rotateX(360deg) rotateY(720deg) rotateZ(0deg); }
75% { transform: rotateX(540deg) rotateY(1080deg) rotateZ(0deg); }
100% { transform: rotateX(720deg) rotateY(1440deg) rotateZ(0deg); }
}
/* run both animations together */
.roller.rolling{
animation:
orbit-wobble var(--roll-ms) ease-in-out,
roll var(--roll-ms) linear;
}
.die{
position: absolute;
inset: 0;
transform-style: preserve-3d;
transition: transform .5s ease-out;
transform-origin: 50% 50% 10px;
cursor: pointer;
/* FIX: reset a local counter so numbering starts at 1 here */
counter-reset: d20num 0;
}
.die:not([data-face]) { transform: rotateX(-53deg) rotateY(0deg); }
.die[data-face="1"] { transform: rotateX(-53deg) rotateY(0deg); }
.die[data-face="2"] { transform: rotateX(-53deg) rotateY(72deg); }
.die[data-face="3"] { transform: rotateX(-53deg) rotateY(144deg); }
.die[data-face="4"] { transform: rotateX(-53deg) rotateY(216deg); }
.die[data-face="5"] { transform: rotateX(-53deg) rotateY(288deg); }
.die[data-face="16"] { transform: rotateX(127deg) rotateY(-72deg); }
.die[data-face="17"] { transform: rotateX(127deg) rotateY(-144deg); }
.die[data-face="18"] { transform: rotateX(127deg) rotateY(-216deg); }
.die[data-face="19"] { transform: rotateX(127deg) rotateY(-288deg); }
.die[data-face="20"] { transform: rotateX(127deg) rotateY(-360deg); }
.die[data-face="6"] { transform: rotateX(11deg) rotateZ(180deg) rotateY(0deg); }
.die[data-face="7"] { transform: rotateX(11deg) rotateZ(180deg) rotateY(72deg); }
.die[data-face="8"] { transform: rotateX(11deg) rotateZ(180deg) rotateY(144deg); }
.die[data-face="9"] { transform: rotateX(11deg) rotateZ(180deg) rotateY(216deg); }
.die[data-face="10"] { transform: rotateX(11deg) rotateZ(180deg) rotateY(288deg); }
.die[data-face="11"] { transform: rotateX(11deg) rotateY(-252deg); }
.die[data-face="12"] { transform: rotateX(11deg) rotateY(-324deg); }
.die[data-face="13"] { transform: rotateX(11deg) rotateY(-396deg); }
.die[data-face="14"] { transform: rotateX(11deg) rotateY(-468deg); }
.die[data-face="15"] { transform: rotateX(11deg) rotateY(-540deg); }
.die .face{
position: absolute;
left: 50%;
top: 0;
margin: 0 -12.5px;
border-left: 12.5px solid transparent;
border-right: 12.5px solid transparent;
border-bottom: 21.5px solid rgba(94,134,91,0.9);
width: 0; height: 0;
transform-style: preserve-3d;
backface-visibility: hidden;
/* FIX: unique counter to avoid clashes (was steps) */
counter-increment: d20num 1;
}
.die .face::before{
content: counter(d20num);
position: absolute;
top: 5.375px;
left: -25px;
width: 50px; height: 21.5px;
color: #fff;
text-shadow: 1px 1px 3px #000;
font-size: 10.75px;
text-align: center; line-height: 19.35px;
transform: translateZ(0.1px); /* avoid z-fighting */
}
/* Face placement */
.die .face:nth-child(1) { transform: rotateY(0deg) translateZ(8.375px) translateY(-3.225px) rotateX(53deg); }
.die .face:nth-child(2) { transform: rotateY(-72deg) translateZ(8.375px) translateY(-3.225px) rotateX(53deg); }
.die .face:nth-child(3) { transform: rotateY(-144deg) translateZ(8.375px) translateY(-3.225px) rotateX(53deg); }
.die .face:nth-child(4) { transform: rotateY(-216deg) translateZ(8.375px) translateY(-3.225px) rotateX(53deg); }
.die .face:nth-child(5) { transform: rotateY(-288deg) translateZ(8.375px) translateY(-3.225px) rotateX(53deg); }
.die .face:nth-child(16) { transform: rotateY(-108deg) translateZ(8.375px) translateY(30.315px) rotateZ(180deg) rotateX(53deg); }
.die .face:nth-child(17) { transform: rotateY(-36deg) translateZ(8.375px) translateY(30.315px) rotateZ(180deg) rotateX(53deg); }
.die .face:nth-child(18) { transform: rotateY(36deg) translateZ(8.375px) translateY(30.315px) rotateZ(180deg) rotateX(53deg); }
.die .face:nth-child(19) { transform: rotateY(108deg) translateZ(8.375px) translateY(30.315px) rotateZ(180deg) rotateX(53deg); }
.die .face:nth-child(20) { transform: rotateY(180deg) translateZ(8.375px) translateY(30.315px) rotateZ(180deg) rotateX(53deg); }
.die .face:nth-child(6) { transform: rotateY(360deg) translateZ(18.75px) translateY(13.545px) rotateZ(180deg) rotateX(-11deg); }
.die .face:nth-child(7) { transform: rotateY(288deg) translateZ(18.75px) translateY(13.545px) rotateZ(180deg) rotateX(-11deg); }
.die .face:nth-child(8) { transform: rotateY(216deg) translateZ(18.75px) translateY(13.545px) rotateZ(180deg) rotateX(-11deg); }
.die .face:nth-child(9) { transform: rotateY(144deg) translateZ(18.75px) translateY(13.545px) rotateZ(180deg) rotateX(-11deg); }
.die .face:nth-child(10) { transform: rotateY(72deg) translateZ(18.75px) translateY(13.545px) rotateZ(180deg) rotateX(-11deg); }
.die .face:nth-child(11) { transform: rotateY(252deg) translateZ(18.75px) translateY(13.545px) rotateX(-11deg); }
.die .face:nth-child(12) { transform: rotateY(324deg) translateZ(18.75px) translateY(13.545px) rotateX(-11deg); }
.die .face:nth-child(13) { transform: rotateY(396deg) translateZ(18.75px) translateY(13.545px) rotateX(-11deg); }
.die .face:nth-child(14) { transform: rotateY(468deg) translateZ(18.75px) translateY(13.545px) rotateX(-11deg); }
.die .face:nth-child(15) { transform: rotateY(540deg) translateZ(18.75px) translateY(13.545px) rotateX(-11deg); }
/* Optional micro-alignment of face 1 (tweak if needed) */
:root{
--face1-ry: 0deg;
--face1-dy: 32px;
--face1-rz: .6deg;
}
.die .face:nth-child(1){
transform:
rotateY(calc(0deg + var(--face1-ry)))
translateZ(8.375px)
translateY(calc(-3.225px + var(--face1-dy)))
rotateX(53deg)
rotateZ(var(--face1-rz));
}
/* ===== Player Cards ==================================================== */
/* varied accents */
main .player:nth-child(6n+1){ --accent: hsl(200 100% 65%); }
main .player:nth-child(6n+2){ --accent: hsl(280 100% 70%); }
main .player:nth-child(6n+3){ --accent: hsl(150 95% 54%); }
main .player:nth-child(6n+4){ --accent: hsl(20 100% 65%); }
main .player:nth-child(6n+5){ --accent: hsl(45 100% 62%); }
main .player:nth-child(6n+6){ --accent: hsl(325 100% 68%); }
/* 2-player: distinct */
body.is-two-player-mode main .player:nth-child(1){ --accent: #45c5ff; }
body.is-two-player-mode main .player:nth-child(2){ --accent: #b86bff; }
.player{
position: relative;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 18px 16px;
border-radius: 20px;
background: linear-gradient(180deg, hsl(0 0% 100% / .10), hsl(0 0% 100% / .05)) border-box;
border: 1px solid var(--card-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 10px 30px hsl(0 0% 0% / .25), inset 0 1px 0 hsl(0 0% 100% / .15);
transition: transform .18s, box-shadow .18s;
max-width: clamp(540px, 46vw, 960px);
}
.player:hover{
transform: translateY(-2px);
box-shadow: 0 16px 40px hsl(0 0% 0% / .33), inset 0 1px 0 hsl(0 0% 100% / .18);
}
/* accent edge glow */
.player::after{
content:"";
position:absolute; inset:-1px; border-radius: 20px; pointer-events:none;
background: linear-gradient(135deg, transparent 20%, color-mix(in oklab, var(--accent), white 25%) 50%, transparent 80%);
opacity:.25;
}
/* name pill */
.name{
width: 100%;
max-width: 360px;
padding: 10px 16px;
border-radius: 14px;
background: hsl(0 0% 100% / .08);
border: 1px solid hsl(0 0% 100% / .18);
color: #fff; text-align: center;
font-size: clamp(16px, 2.2vw, 20px);
outline: none; caret-color: var(--accent);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
transition: box-shadow .18s, border-color .18s, background .18s;
}
.name:focus{
background: hsl(0 0% 100% / .12);
border-color: color-mix(in oklab, var(--accent), white 30%);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent), transparent 80%);
}
/* life area */
.life{
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
background: transparent;
border: 0;
padding: 6px 0;
}
.vert{ display:flex; flex-direction:column; gap: 8px; align-items:center; }
/* ringed number */
.life-count{
position: relative;
width: clamp(96px, 14vw, 142px);
height: clamp(96px, 14vw, 142px);
border-radius: 50%;
display: grid; place-items: center;
color: #fff; font-weight: 800;
text-shadow: 0 1px 0 hsl(0 0% 0% / .35);
font-size: clamp(36px, 8vh, 64px);
isolation: isolate;
transition: --life-deg 280ms linear;
}
.life-count::before{
content:""; position:absolute; inset:0; border-radius: inherit; z-index:-2;
background: conic-gradient(from -90deg, var(--accent) 0 var(--life-deg, calc(var(--life-pct,1)*1turn)), hsl(0 0% 100% / .10) 0 360deg);
filter: drop-shadow(0 4px 10px color-mix(in oklab, var(--accent), black 70%));
}
.life-count::after{
content:""; position:absolute; inset: var(--ring-thickness); border-radius: inherit; z-index:-1;
background: hsl(0 0% 100% / .06);
border: 1px solid hsl(0 0% 100% / .12);
}
/* life buttons */
.life-btn{
width: 44px; height: 44px; border-radius: 50%;
display:grid; place-items:center;
color:#fff; font-size: 20px; cursor: pointer;
background: linear-gradient(180deg, hsl(0 0% 100% / .18), hsl(0 0% 100% / .06));
border: 1px solid hsl(0 0% 100% / .18);
box-shadow: 0 6px 16px hsl(0 0% 0% / .25), inset 0 1px 0 hsl(0 0% 100% / .25);
transition: transform .12s, box-shadow .12s, background .12s;
}
.life-btn:hover{
transform: translateY(-1px);
box-shadow: 0 10px 20px hsl(0 0% 0% / .35), inset 0 1px 0 hsl(0 0% 100% / .30);
}
.life-btn:active{ transform: translateY(0) scale(.97); }
.life-up-1,.life-up-5{ color: color-mix(in oklab, var(--accent), white 10%); }
.life-down-1,.life-down-5{ color: hsl(0 90% 60%); }
/* special counters */
.special-counters{
width: 100%; max-width: 520px; padding-top: 10px; background: none; gap: 10px;
}
.poison-counter,
.commander-damage-counter{
display:flex; align-items:center; gap:10px;
background: hsl(0 0% 100% / .07);
border: 1px solid hsl(0 0% 100% / .14);
border-radius: 12px;
padding: 8px 10px;
color:#fff;
font-size: clamp(12px, 1.6vw, 16px);
}
.commander-name{
flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:.9;
}
.commander-controls{ display:inline-flex; align-items:center; gap:6px; }
.poison-btn, .commander-btn{
width:28px; height:28px; min-width:28px; min-height:28px;
display:grid; place-items:center;
font-size:14px; line-height:1; padding:0; border-radius:8px;
color:#fff; cursor:pointer;
background: hsl(0 0% 100% / .12);
border: 1px solid hsl(0 0% 100% / .18);
transition: transform .1s, background .1s;
}
.poison-btn:hover, .commander-btn:hover{ transform: translateY(-1px); }
.poison-value, .commander-value{ min-width: 1.8ch; text-align:center; color:#fff; }
/* poison: label left, controls dock right */
.poison-counter .counter-label{ margin-right: 8px; }
.poison-counter .poison-down{ margin-left: auto; }
.poison-counter .poison-value{ min-width:2ch; }
/* commander panel show/hide */
body.is-commander-mode .commander-damage { display: block; }
.commander-damage { display: none; }
/* zero-life style */
.player[data-life-zero="true"] .life-count{
color: hsl(0 100% 70%);
filter: drop-shadow(0 0 18px hsl(0 100% 60% / .45));
}
.player[data-life-zero="true"] .life-count::before{
background: conic-gradient(hsl(0 100% 62%) 360deg, transparent 0);
}
/* ===== Responsive ====================================================== */
@media (orientation: landscape) and (max-height: 500px){
.player{ padding: 20px; }
.life{ gap: 12px; }
.life-count{ width:150px; height:150px; font-size: clamp(44px,5vw,78px); --ring-thickness:12px; }
.life-btn{ width:52px; height:52px; font-size:24px; }
.name{ font-size: clamp(16px, 2.4vw, 22px); }
}
@media (min-width: 768px){
.player{ padding: 22px; border-radius: 22px; }
.life{ gap: 14px; }
.life-count{ width:180px; height:180px; font-size: clamp(52px,4.6vw,86px); --ring-thickness:14px; }
.life-btn{ width:58px; height:58px; font-size:26px; }
.special-counters{ max-width: 640px; }
.poison-counter,.commander-damage-counter{ font-size:16px; }
.poison-btn,.commander-btn{ width:32px; height:32px; font-size:16px; }
}
@media (min-width: 1200px){
.life-count{ width:220px; height:220px; font-size: clamp(64px,3.6vw,96px); --ring-thickness:16px; }
.life-btn{ width:64px; height:64px; font-size:30px; }
}
@media (min-width: 1600px){
.life-count{ width:260px; height:260px; font-size: clamp(72px,3vw,112px); --ring-thickness:18px; }
.life-btn{ width:70px; height:70px; font-size:32px; }
}
/* ===== Infect Mode (secret) ===== */
body.infect-mode {
filter: hue-rotate(-25deg) saturate(1.25);
}
body.infect-mode .life-count::before{
background:
conic-gradient(
from -90deg,
#35ff96 0 var(--life-deg, calc(var(--life-pct, 1)*1turn)),
rgba(255,255,255,0.10) 0 360deg
);
filter: drop-shadow(0 6px 16px rgba(11, 232, 129, 0.55));
}
body.infect-mode .poison-counter{
border-color: rgba(53, 255, 150, 0.35);
box-shadow: 0 0 12px rgba(53, 255, 150, 0.25) inset;
}
body.infect-mode .poison-btn{
background: linear-gradient(180deg, rgba(53,255,150,.22), rgba(53,255,150,.12));
border-color: rgba(53,255,150,.45);
}
/* Skull hint while holding */
#poison-toggle.egg-hold{
animation: skull-hold 1.2s linear;
filter: drop-shadow(0 0 10px rgba(53,255,150,.45));
}
@keyframes skull-hold{
0%{ transform: scale(1); }
90%{ transform: scale(1.12); }
100%{ transform: scale(1.08); }
}