/**
* MTG Commander Simulator — ui.js
* All UI rendering functions adapted for the 4-quadrant battlefield layout.
* This file overrides/extends DOM-touching functions from engine.js.
* ============================================================
*/
'use strict';
// ============================================================
// GLOBAL UI STATE
// ============================================================
let zoneModalPlayerId = null;
let activeZoneTab = 'graveyard';
let _zoneCardCtx = null;
let undoHistoryVisible = false;
let aiTurnActive = false;
let floatingPreviewTimeout = null;
// ============================================================
// LOG SYSTEM (override engine stub)
// ============================================================
const _logEntries = [];
function addLogEntry(text, category) {
const entry = { text, category: category || 'general', ts: Date.now() };
_logEntries.push(entry);
// Keep last 500 entries
if (_logEntries.length > 500) _logEntries.shift();
// Show as floating log entry on the battlefield
addFloatingLogEntry(text, category);
}
function addFloatingLogEntry(text, category) {
const floatingLog = document.getElementById('floating-log');
if (!floatingLog) return;
const entry = document.createElement('div');
entry.className = 'floating-log-entry';
entry.innerHTML = text;
floatingLog.appendChild(entry);
// Remove old entries
while (floatingLog.children.length > 5) {
floatingLog.removeChild(floatingLog.firstChild);
}
// Fade out after 4 seconds
setTimeout(() => {
entry.style.opacity = '0';
entry.style.transition = 'opacity 0.6s';
setTimeout(() => { if (entry.parentNode) entry.remove(); }, 700);
}, 4000);
}
// ============================================================
// LIFE COUNTER RENDERING (4-quadrant)
// ============================================================
function updateLifeCounterDisplay(playerId) {
if (!gameState) return;
const player = gameState.players[playerId];
if (!player) return;
const el = document.getElementById('life-' + playerId);
if (el) el.textContent = player.life;
// Also update poison
const poisonEl = document.getElementById('poison-' + playerId);
if (poisonEl) {
poisonEl.textContent = player.poison > 0 ? player.poison + ' poison' : '';
poisonEl.style.display = player.poison > 0 ? 'block' : 'none';
}
// Update player label with commander name
const cmdNameEl = document.getElementById('cmd-name-' + playerId);
if (cmdNameEl && player.commanderCardName) {
cmdNameEl.textContent = player.commanderCardName;
}
}
// Called by engine's setLife
function setLife(playerId, newLife) {
if (!gameState) return;
pushUndoState();
const player = gameState.players[playerId];
if (!player) return;
const oldLife = player.life;
player.life = newLife;
updateLifeCounterDisplay(playerId);
const diff = newLife - oldLife;
const sign = diff > 0 ? '+' : '';
addLogEntry('' + player.name + ' life: ' + oldLife + ' → ' + newLife + ' (' + sign + diff + ')');
if (diff > 0) fireBattlefieldTriggers('life_gain');
else if (diff < 0) fireBattlefieldTriggers('life_loss');
if (newLife <= 0 && !player.eliminated) eliminatePlayer(playerId, 'life total reached 0');
checkStateBasedActions();
// Highlight active player quadrant
updateActivePlayerHighlight();
}
// ============================================================
// ACTIVE PLAYER HIGHLIGHT
// ============================================================
function updateActivePlayerHighlight() {
if (!gameState) return;
for (let i = 0; i < 4; i++) {
const area = document.querySelector('.player-area.p' + (i + 1));
if (area) {
area.classList.toggle('active-player', gameState.currentPlayerIndex === i);
}
}
// Update active player indicator in top bar
const indi = document.getElementById('active-player-indicator');
if (indi && gameState.players[gameState.currentPlayerIndex]) {
const p = gameState.players[gameState.currentPlayerIndex];
indi.innerHTML =
' ' +
p.name + "'s Turn";
}
// Update turn display
const turnEl = document.getElementById('turn-counter');
if (turnEl) turnEl.innerHTML = 'Turn ' + (gameState.turnNumber || 1) + ' ';
}
// ============================================================
// RENDER PLAYER PANELS (adapted for 4-quadrant — updates zones/labels)
// ============================================================
function renderPlayerPanels() {
if (!gameState) return;
gameState.players.forEach((player, idx) => {
updateLifeCounterDisplay(idx);
updateZoneCountsForPlayer(idx);
renderCommanderZone(idx);
renderHandZone(idx);
});
updateActivePlayerHighlight();
}
function updateManaPoolBadge(playerIdx) {
// Future: show mini mana pool indicators on each player's area
}
function updateZoneCountsForPlayer(playerIdx) {
if (!gameState) return;
const player = gameState.players[playerIdx];
if (!player) return;
const pNum = playerIdx + 1;
// Update library count
const libCount = document.getElementById('lib-count-' + pNum);
if (libCount) libCount.textContent = player.zones.library.length;
// Update exile count
const exCount = document.getElementById('exile-count-' + pNum);
if (exCount) {
exCount.textContent = player.zones.exile.length;
exCount.style.display = player.zones.exile.length > 0 ? 'block' : 'none';
}
// Update graveyard count
const gvCount = document.getElementById('gv-count-' + pNum);
if (gvCount) {
gvCount.textContent = player.zones.graveyard.length;
gvCount.style.display = player.zones.graveyard.length > 0 ? 'block' : 'none';
}
// Update hand count
const handCount = document.getElementById('hand-count-' + pNum);
if (handCount) handCount.textContent = player.zones.hand.length;
}
function updatePlayerZones() {
if (!gameState) return;
gameState.players.forEach((_, idx) => {
updateZoneCountsForPlayer(idx);
renderHandZone(idx);
renderCommanderZone(idx);
});
}
// ============================================================
// COMMANDER ZONE RENDERING
// ============================================================
function renderCommanderZone(playerIdx) {
if (!gameState) return;
const player = gameState.players[playerIdx];
if (!player) return;
const pNum = playerIdx + 1;
const zoneEl = document.getElementById('cmd-zone-' + pNum);
if (!zoneEl) return;
const cmdCards = player.zones.command || [];
if (cmdCards.length > 0) {
const cmdCard = cmdCards[0];
if (cmdCard.imageUrl) {
zoneEl.innerHTML =
' ';
// Count badge
if (player.commanderCastCount > 0) {
zoneEl.innerHTML += '' + (player.commanderCastCount) + 'x ';
}
} else {
zoneEl.innerHTML =
'👑 ' +
'Commander ' +
'' + cmdCards.length + ' ';
}
} else {
zoneEl.innerHTML =
'👑 ' +
'Commander ';
}
}
// ============================================================
// HAND ZONE RENDERING (thumbnails in zone.hand)
// ============================================================
function renderHandZone(playerIdx) {
if (!gameState) return;
const player = gameState.players[playerIdx];
if (!player) return;
const pNum = playerIdx + 1;
const handEl = document.getElementById('hand-zone-' + pNum);
if (!handEl) return;
const hand = player.zones.hand || [];
// Update count badge
const countEl = document.getElementById('hand-count-' + pNum);
if (countEl) countEl.textContent = hand.length;
if (hand.length === 0) {
handEl.innerHTML =
'🃏 ' +
'Hand (0) ';
return;
}
// Render thumbnails
let thumbsHtml = '';
const maxShow = Math.min(hand.length, 7); // limit thumbnails
for (let i = 0; i < maxShow; i++) {
const card = hand[i];
const imgSrc = card.imageUrl || '';
const escapedName = (card.name || '').replace(/'/g, "\\'");
const escapedType = (card.typeLine || '').replace(/'/g, "\\'");
if (imgSrc) {
thumbsHtml +=
'
' +
'
' +
'
';
} else {
thumbsHtml += '' +
(card.name || '?').substring(0, 4) + '
';
}
}
if (hand.length > maxShow) {
thumbsHtml += '+' + (hand.length - maxShow) + '
';
}
handEl.innerHTML = thumbsHtml;
}
// ============================================================
// RENDER BATTLEFIELD CARD (adapted for quadrant battle-zones)
// ============================================================
function renderBattlefieldCard(cardData) {
// Target the player's battle-zone div
const pNum = cardData.ownerIndex + 1;
const battleZone = document.getElementById('battle-zone-' + pNum);
if (!battleZone) {
// Fallback: try old-style battlefield
const bf = document.getElementById('battlefield');
if (!bf) return;
}
const container = battleZone || document.getElementById('battlefield');
// Remove existing card element if re-rendering
const existing = document.getElementById('bc-' + cardData.id);
if (existing) existing.remove();
const card = document.createElement('div');
card.className = 'battlefield-card';
card.id = 'bc-' + cardData.id;
card.style.left = (cardData.x || 10) + 'px';
card.style.top = (cardData.y || 10) + 'px';
const playerColor = (gameState.players[cardData.ownerIndex] || {}).color || 'transparent';
card.style.borderColor = playerColor;
if (cardData.tapped) card.classList.add('tapped');
if (cardData.faceDown) card.classList.add('face-down');
if (typeof hasSummoningSickness === 'function' && hasSummoningSickness(cardData)) {
card.classList.add('summoning-sick');
}
if (cardData.isToken) card.classList.add('token-glow');
// Keyword badges HTML
let kwBadgeHtml = '';
if (typeof getKeywordBadges === 'function') {
const kwBadges = getKeywordBadges(cardData);
if (kwBadges && kwBadges.length > 0) {
kwBadgeHtml = '';
kwBadges.forEach(b => {
kwBadgeHtml += '' + b.icon + ' ';
});
kwBadgeHtml += '
';
}
}
// Protection badge
let protBadge = '';
if (typeof getBattlefieldCardProtectionBadge === 'function') {
protBadge = getBattlefieldCardProtectionBadge(cardData) || '';
}
const imgUrl = cardData.imageUrl || '';
const altName = (cardData.name || 'Card').replace(/"/g, '"');
card.innerHTML =
protBadge +
' ' +
'
' +
kwBadgeHtml;
// Counter badges
if (cardData.counters) {
const cTypes = Object.keys(cardData.counters).filter(k => cardData.counters[k] > 0);
if (cTypes.length > 0) {
const strip = document.createElement('div');
strip.className = 'card-counters-strip';
cTypes.forEach(cType => {
const cCount = cardData.counters[cType];
const ct = (typeof COUNTER_TYPES !== 'undefined' && COUNTER_TYPES[cType]) ||
{ icon: '\u25CF', color: '#9ca3af', label: cType };
const badge = document.createElement('span');
badge.className = 'card-counter-badge';
badge.style.cssText = 'background:' + ct.color + ';color:#fff;';
badge.title = cCount + ' ' + ct.label;
if (cType === '+1/+1') badge.textContent = '+' + cCount + '/+' + cCount;
else if (cType === '-1/-1') badge.textContent = '-' + cCount + '/-' + cCount;
else badge.textContent = ct.icon + cCount;
strip.appendChild(badge);
});
card.appendChild(strip);
}
}
// Damage badge
if (cardData.damageMarked && cardData.damageMarked > 0) {
const dmgBadge = document.createElement('div');
dmgBadge.className = 'damage-badge';
dmgBadge.textContent = cardData.damageMarked;
card.appendChild(dmgBadge);
}
// Event listeners — use both for compatibility (Comet: inline onclick already on container)
card.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (cardData.attachedTo) return;
e.preventDefault();
isDraggingBattlefieldCard = true;
draggedCardId = cardData.id;
const rect = card.getBoundingClientRect();
dragOffset.x = e.clientX - rect.left;
dragOffset.y = e.clientY - rect.top;
card.style.zIndex = '100';
card.style.transition = 'none';
});
card.addEventListener('dblclick', e => {
e.preventDefault();
toggleTap(cardData.id);
});
card.addEventListener('contextmenu', e => {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, cardData.id);
});
card.addEventListener('click', e => {
if (!gameState) return;
if (typeof stackPickMode !== 'undefined' && stackPickMode) {
handleStackPickClick(cardData.id);
e.stopPropagation();
return;
}
if (typeof equipPickMode !== 'undefined' && equipPickMode) {
handleEquipPickClick(cardData.id);
e.stopPropagation();
return;
}
// Combat attacker/blocker clicks
const combat = gameState.combat;
if (combat && combat.phase === 'declaring-attackers') {
if (cardData.ownerIndex === gameState.currentPlayerIndex && !cardData.tapped) {
toggleAttacker(cardData.id);
e.stopPropagation();
}
} else if (combat && combat.phase === 'declaring-blockers') {
if (!cardData.tapped && combat.pendingBlockerFor !== null) {
toggleBlocker(cardData.id, combat.pendingBlockerFor);
e.stopPropagation();
} else {
const asAttacker = combat.attackers && combat.attackers.find(a => a.cardId === cardData.id);
if (asAttacker) {
combat.pendingBlockerFor = cardData.id;
refreshCombatOverlay();
e.stopPropagation();
}
}
}
});
// Long press for context menu (touch)
let _lpTimer = null;
card.addEventListener('touchstart', e => {
_lpTimer = setTimeout(() => {
const t = e.touches[0];
showContextMenu(t.clientX, t.clientY, cardData.id);
}, 600);
});
card.addEventListener('touchend', () => clearTimeout(_lpTimer));
card.addEventListener('touchmove', () => clearTimeout(_lpTimer));
card.addEventListener('mouseenter', e => {
if (!isDraggingBattlefieldCard) {
if (cardData.stackGroupId && typeof isStackLeader === 'function' && isStackLeader(cardData)) {
fanOutStack(cardData.stackGroupId);
}
showFloatingCardPreview(cardData.imageUrl, e, {
name: cardData.name,
typeLine: cardData.typeLine,
oracleText: cardData.oracleText,
manaCost: cardData.manaCost || '',
power: cardData.power,
toughness: cardData.toughness,
pt: cardData.pt,
loyalty: cardData.loyalty,
_bfCard: cardData
});
}
});
card.addEventListener('mousemove', e => {
if (!isDraggingBattlefieldCard) positionFloatingPreview(e);
});
card.addEventListener('mouseleave', () => {
hideFloatingCardPreview();
});
container.appendChild(card);
}
// Drag tracking for battlefield cards
document.addEventListener('mousemove', e => {
if (!isDraggingBattlefieldCard || draggedCardId === null) return;
hideFloatingCardPreview();
const cardData = gameState && gameState.battlefieldCards.find(c => c.id === draggedCardId);
if (!cardData) return;
const pNum = cardData.ownerIndex + 1;
const battleZone = document.getElementById('battle-zone-' + pNum);
if (!battleZone) return;
const rect = battleZone.getBoundingClientRect();
let newX = e.clientX - rect.left - dragOffset.x;
let newY = e.clientY - rect.top - dragOffset.y;
newX = Math.max(0, Math.min(rect.width - 80, newX));
newY = Math.max(0, Math.min(rect.height - 105, newY));
cardData.x = newX;
cardData.y = newY;
const cardEl = document.getElementById('bc-' + draggedCardId);
if (cardEl) {
cardEl.style.left = newX + 'px';
cardEl.style.top = newY + 'px';
}
if (cardData.attachments && cardData.attachments.length > 0) {
moveAttachmentsWithCreature(cardData.id);
}
});
document.addEventListener('mouseup', e => {
if (!isDraggingBattlefieldCard) return;
isDraggingBattlefieldCard = false;
const droppedId = draggedCardId;
const cardEl = document.getElementById('bc-' + droppedId);
if (cardEl) {
cardEl.style.zIndex = '';
cardEl.style.transition = '';
}
draggedCardId = null;
// Shift+drop stacking
if (e.shiftKey && droppedId && gameState) {
const droppedCard = gameState.battlefieldCards.find(c => c.id === droppedId);
if (droppedCard && typeof createStack === 'function') {
const threshold = 40;
let closestCard = null, closestDist = Infinity;
gameState.battlefieldCards.forEach(other => {
if (other.id === droppedId) return;
if (other.stackGroupId && !isStackLeader(other)) return;
const dx = (other.x || 0) - (droppedCard.x || 0);
const dy = (other.y || 0) - (droppedCard.y || 0);
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < threshold && dist < closestDist) {
closestDist = dist;
closestCard = other;
}
});
if (closestCard) createStack(closestCard.id, droppedId);
}
}
});
// ============================================================
// PHASE TRACKER
// ============================================================
function renderPhaseTracker() {
if (!gameState) return;
const track = document.getElementById('phase-track');
if (!track) return;
const currentPhase = PHASES[gameState.currentPhaseIndex] || PHASES[0];
let html = '';
PHASES.forEach((phase, idx) => {
const isActive = idx === gameState.currentPhaseIndex;
const isDone = idx < gameState.currentPhaseIndex;
let cls = 'phase-step';
if (isActive) cls += ' active';
if (isDone) cls += ' done';
let style = '';
if (isActive) {
style = 'style="background:' + hexToRgba(phase.color || '#5b8dd9', 0.25) + ';box-shadow:inset 0 0 0 1px ' + hexToRgba(phase.color || '#5b8dd9', 0.4) + '"';
}
// If in combat and this is the combat phase, show current combat sub-phase
let label = phase.short;
if (idx === 2 && gameState.inCombat) {
const combatPhase = COMBAT_PHASES[gameState.combatPhaseIndex] || COMBAT_PHASES[0];
label = combatPhase.short;
}
html += '';
html += '' + (phase.icon || '') + ' ';
html += '' + label + ' ';
html += '
';
if (idx < PHASES.length - 1) {
html += '
';
}
});
track.innerHTML = html;
}
function handlePhaseClick(phaseIdx) {
// Just provide info about the phase, don't jump to it
if (!gameState) return;
const phase = PHASES[phaseIdx];
if (phase) addLogEntry('Phase info: ' + phase.label + ' — ' + phase.tip);
}
function updateTurnDisplay() {
if (!gameState) return;
const turnEl = document.getElementById('turn-counter');
if (turnEl) turnEl.innerHTML = 'Turn ' + (gameState.turnNumber || 1) + ' ';
updateActivePlayerHighlight();
renderPhaseTracker();
}
function updateStormCounter() {
if (!gameState) return;
const storm = gameState.stormCount || 0;
const badge = document.getElementById('storm-badge');
if (badge) {
badge.textContent = 'Storm: ' + storm;
badge.style.display = storm > 0 ? 'block' : 'none';
}
}
// ============================================================
// MANA POOL DISPLAY
// ============================================================
function renderManaPool(playerIdx) {
if (!gameState) return;
const pool = getManaPool(playerIdx !== undefined ? playerIdx : gameState.currentPlayerIndex);
const display = document.getElementById('mana-pool-display');
if (!display) return;
const colors = ['W', 'U', 'B', 'R', 'G', 'C'];
let html = 'MANA: ';
let hasAny = false;
colors.forEach(c => {
if (pool[c] > 0) {
hasAny = true;
html += '' + pool[c] + ' ';
}
});
if (!hasAny) {
html += 'Empty ';
}
html += 'x ';
display.innerHTML = html;
display.style.display = gameState.inGame ? 'flex' : 'none';
}
// ============================================================
// CONTEXT MENU
// ============================================================
let contextMenuTargetId = null;
function showContextMenu(x, y, cardId) {
contextMenuTargetId = cardId;
const menu = document.getElementById('context-menu');
if (!menu) return;
menu.classList.add('visible');
const cardData = gameState && gameState.battlefieldCards.find(c => c.id === cardId);
const _isEquip = cardData && typeof isEquipment === 'function' && isEquipment(cardData);
const _isAuraCard = cardData && typeof isAura === 'function' && isAura(cardData);
const _isPlaneswalker = cardData && cardData.typeLine && /planeswalker/i.test(cardData.typeLine);
const hasAbilities = cardData && typeof parseActivatedAbilities === 'function' && parseActivatedAbilities(cardData).length > 0;
menu.innerHTML =
'' +
'' +
'' +
'' +
'' +
(hasAbilities ? '' : '') +
(_isPlaneswalker ? '' : '') +
(_isEquip ? '' : '') +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'';
// Position
const maxX = window.innerWidth - 200;
const maxY = window.innerHeight - 360;
menu.style.left = Math.min(x, maxX) + 'px';
menu.style.top = Math.min(y, maxY) + 'px';
}
function hideContextMenu() {
const menu = document.getElementById('context-menu');
if (menu) menu.classList.remove('visible');
}
// Close context menu on outside click
document.addEventListener('click', e => {
const menu = document.getElementById('context-menu');
if (menu && !menu.contains(e.target)) hideContextMenu();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
hideContextMenu();
if (typeof cancelStackPickMode === 'function') cancelStackPickMode();
if (typeof cancelEquipPickMode === 'function') cancelEquipPickMode();
closeZoneModal();
}
});
// ============================================================
// FLOATING CARD PREVIEW
// ============================================================
function showFloatingCardPreview(imgSrc, e, cardInfo) {
if (!imgSrc) return;
const preview = document.getElementById('card-preview');
if (!preview) return;
if (floatingPreviewTimeout) {
clearTimeout(floatingPreviewTimeout);
floatingPreviewTimeout = null;
}
const img = preview.querySelector('img');
if (img) img.src = imgSrc;
const infoDiv = preview.querySelector('.card-preview-info');
if (infoDiv && cardInfo) {
const nameEl = preview.querySelector('.card-preview-name');
const typeEl = preview.querySelector('.card-preview-type');
const oracleEl = preview.querySelector('.card-preview-oracle');
const ptEl = preview.querySelector('.card-preview-pt');
if (nameEl) nameEl.textContent = cardInfo.name || '';
if (typeEl) typeEl.textContent = cardInfo.typeLine || '';
if (oracleEl) oracleEl.textContent = cardInfo.oracleText || '';
if (ptEl) {
const pt = cardInfo.pt || (cardInfo.power && cardInfo.toughness ? cardInfo.power + '/' + cardInfo.toughness : '');
ptEl.textContent = pt;
}
if (cardInfo.name || cardInfo.typeLine || cardInfo.oracleText) {
infoDiv.style.display = 'block';
} else {
infoDiv.style.display = 'none';
}
}
preview.style.display = 'block';
positionFloatingPreview(e);
}
function positionFloatingPreview(e) {
const preview = document.getElementById('card-preview');
if (!preview || preview.style.display === 'none') return;
const pw = preview.offsetWidth || 220;
const ph = preview.offsetHeight || 340;
const ww = window.innerWidth;
const wh = window.innerHeight;
let px = e.clientX + 16;
let py = e.clientY - 20;
if (px + pw > ww - 10) px = e.clientX - pw - 16;
if (py + ph > wh - 10) py = wh - ph - 10;
if (py < 50) py = 50;
preview.style.left = px + 'px';
preview.style.top = py + 'px';
}
function hideFloatingCardPreview() {
floatingPreviewTimeout = setTimeout(() => {
const preview = document.getElementById('card-preview');
if (preview) preview.style.display = 'none';
}, 80);
}
// ============================================================
// ZONE MODAL
// ============================================================
function openZoneModal(playerId, zone) {
zoneModalPlayerId = playerId;
activeZoneTab = zone || 'graveyard';
renderZoneModal();
const modal = document.getElementById('zone-modal');
if (modal) modal.classList.add('visible');
}
function closeZoneModal() {
const modal = document.getElementById('zone-modal');
if (modal) modal.classList.remove('visible');
zoneModalPlayerId = null;
if (_zoneCardCtx) _zoneCardCtx.classList.remove('visible');
}
function switchZoneTab(zone) {
activeZoneTab = zone;
renderZoneModal();
}
function renderZoneModal() {
if (zoneModalPlayerId === null || !gameState) return;
const player = gameState.players[zoneModalPlayerId];
if (!player) return;
const titleEl = document.getElementById('zone-modal-title');
if (titleEl) titleEl.textContent = player.name + "'s Zones";
// Render tabs
const tabsEl = document.getElementById('zone-modal-tabs');
if (tabsEl) {
const zones = [
{ key: 'graveyard', icon: '☠', label: 'Graveyard' },
{ key: 'exile', icon: '⬡', label: 'Exile' },
{ key: 'command', icon: '👑', label: 'Command' },
{ key: 'hand', icon: '✋', label: 'Hand' },
{ key: 'library', icon: '📚', label: 'Library' },
];
tabsEl.innerHTML = zones.map(z => {
const count = (player.zones[z.key] || []).length;
const isActive = z.key === activeZoneTab;
return '' +
z.icon + ' ' + z.label + ' (' + count + ') ';
}).join('');
}
const body = document.getElementById('zone-modal-body');
if (!body) return;
const cards = player.zones[activeZoneTab] || [];
let actionButtons = '';
if (activeZoneTab === 'library') {
actionButtons =
'🔀 Shuffle ' +
'📤 Draw ' +
'🔮 Scry ' +
'Mill ';
}
if (cards.length === 0) {
body.innerHTML =
(actionButtons ? '' + actionButtons + '
' : '') +
'No cards in ' + activeZoneTab + '.
';
return;
}
let gridHtml = '';
cards.forEach((card, idx) => {
const imgSrc = card.imageUrl || '';
const safeName = (card.name || 'Unknown').replace(/'/g, "\\'").replace(/"/g, '"');
const safeType = (card.typeLine || '').replace(/'/g, "\\'");
gridHtml +=
'
' +
(imgSrc ? '
' :
'
' + safeName + '
') +
'
' + (card.name || 'Unknown') + '
' +
'
';
});
gridHtml += '
';
const infoBar = '' +
'
' + cards.length + ' card' + (cards.length !== 1 ? 's' : '') + '' +
'
' + actionButtons + '
' +
'
';
body.innerHTML = infoBar + gridHtml;
}
// Zone card context menu
function showZoneCardCtx(x, y, playerId, zone, cardIndex) {
if (_zoneCardCtx) _zoneCardCtx.classList.remove('visible');
if (!_zoneCardCtx) {
_zoneCardCtx = document.createElement('div');
_zoneCardCtx.className = 'zone-card-ctx';
_zoneCardCtx.id = 'zone-card-ctx';
_zoneCardCtx.style.cssText =
'position:fixed;z-index:1100;background:var(--color-surface-raised);' +
'border:1px solid var(--color-border-strong);border-radius:var(--radius-lg);' +
'padding:6px;box-shadow:var(--shadow-lg);min-width:180px;';
document.body.appendChild(_zoneCardCtx);
}
const player = gameState && gameState.players[playerId];
const card = player && player.zones[zone] ? player.zones[zone][cardIndex] : null;
if (!card) return;
const items = [
{ icon: '⚔', label: 'Play to Battlefield', action: 'battlefield' },
{ icon: '✋', label: 'Return to Hand', action: 'hand', skip: zone === 'hand' },
{ divider: true },
{ icon: '☠', label: 'To Graveyard', action: 'graveyard', skip: zone === 'graveyard' },
{ icon: '⬡', label: 'To Exile', action: 'exile', skip: zone === 'exile' },
{ icon: '👑', label: 'To Command Zone', action: 'command', skip: zone === 'command' },
{ divider: true },
{ icon: '📚', label: 'Top of Library', action: 'library-top' },
{ icon: '📚', label: 'Bottom of Library', action: 'library-bottom' },
{ divider: true },
{ icon: '✕', label: 'Remove from Game', action: 'remove', danger: true },
];
_zoneCardCtx.innerHTML =
'' +
(card.name || 'Card') + '
';
items.forEach(item => {
if (item.skip) return;
if (item.divider) {
_zoneCardCtx.innerHTML += '
';
return;
}
_zoneCardCtx.innerHTML +=
'';
});
_zoneCardCtx.style.left = Math.min(x, window.innerWidth - 200) + 'px';
_zoneCardCtx.style.top = Math.min(y, window.innerHeight - 300) + 'px';
_zoneCardCtx.classList.add('visible');
_zoneCardCtx.style.display = 'block';
}
function hideZoneCardCtxMenu() {
if (_zoneCardCtx) { _zoneCardCtx.classList.remove('visible'); _zoneCardCtx.style.display = 'none'; }
}
function handleZmCtxAction(playerId, zone, cardIndex, action) {
pushUndoState();
const player = gameState && gameState.players[playerId];
if (!player) return;
const cards = player.zones[zone];
if (!cards || cardIndex < 0 || cardIndex >= cards.length) return;
const card = cards[cardIndex];
if (action === 'battlefield') {
cards.splice(cardIndex, 1);
if (zone === 'command' && gameState.rulesEngine && gameState.rulesEngine.commanderTaxEnabled) {
player.commanderCastCount = (player.commanderCastCount || 0) + 1;
}
addCardToBattlefield(card.name, card.imageUrl, card.typeLine, playerId, undefined, undefined, card.oracleText || '',
{ manaCost: card.manaCost || '', power: card.power || '', toughness: card.toughness || '', pt: card.pt || '', cmc: card.cmc || 0, loyalty: card.loyalty || '' });
addLogEntry('' + card.name + ' moved from ' + zone + ' to battlefield');
} else if (action === 'hand') {
cards.splice(cardIndex, 1);
player.zones.hand.push(card);
addLogEntry('' + card.name + ' returned to ' + player.name + "'s hand");
} else if (['graveyard', 'exile', 'command'].includes(action)) {
cards.splice(cardIndex, 1);
player.zones[action].push(card);
addLogEntry('' + card.name + ' moved to ' + action);
} else if (action === 'library-top') {
cards.splice(cardIndex, 1);
player.zones.library.unshift(card);
addLogEntry('' + card.name + ' put on top of library');
} else if (action === 'library-bottom') {
cards.splice(cardIndex, 1);
player.zones.library.push(card);
addLogEntry('' + card.name + ' put on bottom of library');
} else if (action === 'remove') {
cards.splice(cardIndex, 1);
addLogEntry('' + card.name + ' removed from game');
}
updatePlayerZones();
renderZoneModal();
renderHandArea();
}
// ============================================================
// HAND AREA — full hand display (used in hand-area section, NOT the zone thumbnails)
// For the new layout, hand is rendered as thumbnails in the zone.hand element
// ============================================================
function renderHandArea() {
if (!gameState) return;
// In the quadrant layout, hand is shown as thumbnails in each player's zone
gameState.players.forEach((_, idx) => renderHandZone(idx));
}
// ============================================================
// SETUP SCREEN
// ============================================================
function initSetupScreen() {
let selectedPlayerCount = 4;
let startingLife = 40;
const playerColors = [
{ color: '#c0392b', name: 'P1 (Crimson)' },
{ color: '#2980b9', name: 'P2 (Sapphire)' },
{ color: '#27ae60', name: 'P3 (Emerald)' },
{ color: '#d4930d', name: 'P4 (Gold)' },
];
const setupEl = document.getElementById('setup-screen');
if (!setupEl) return;
setupEl.innerHTML =
'' +
'
Commander
' +
'
4-Player Battlefield Simulator
' +
'
' +
'
Players
' +
'
' +
[2, 3, 4].map(n =>
'' + n + ' '
).join('') +
'
' +
'
' +
'
' +
'
Player Names
' +
'
' +
playerColors.map((pc, i) =>
'
'
).join('') +
'
' +
'
' +
'
' +
'
AI Opponents
' +
'
' +
'
' +
'Difficulty: ' +
'Easy ' +
'Normal ' +
'Hard ' +
'
' +
'
' +
'
' +
'
Starting Life
' +
'
' +
'- ' +
'40 ' +
'+ ' +
'
' +
'
' +
'
' +
'
Decks (Optional)
' +
'
' +
'
Browse Commander Precons ' +
'
' +
'
Begin Battle ' +
'
';
// Init deck assignment grid
updateDeckAssignmentGrid(4);
// Expose helpers to window
// Track AI selections locally
var _localAiSet = new Set();
var _localAiDiff = 'easy';
function _renderAiToggles() {
var grid = document.getElementById('ai-toggle-grid');
if (!grid) return;
grid.innerHTML = '';
for (var i = 0; i < selectedPlayerCount; i++) {
(function(idx) {
var isAi = _localAiSet.has(idx);
var btn = document.createElement('button');
btn.style.cssText = 'display:flex;align-items:center;gap:5px;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px;' +
'border:1px solid ' + (isAi ? 'rgba(91,141,217,0.4)' : 'rgba(255,255,255,0.1)') + ';' +
'background:' + (isAi ? 'rgba(91,141,217,0.15)' : 'transparent') + ';' +
'color:' + (isAi ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.5)') + ';';
btn.innerHTML = ' ' +
(isAi ? '🤖 AI' : '👤 Human') + ' P' + (idx + 1) + ' ';
btn.setAttribute('onclick', '(function(){})()');
btn.addEventListener('click', function() {
if (!_localAiSet.has(idx)) {
var humanCount = selectedPlayerCount - _localAiSet.size;
if (humanCount <= 1) return;
_localAiSet.add(idx);
var nameInp = document.getElementById('player-name-' + idx);
if (nameInp && (!nameInp.value.trim() || nameInp.value.startsWith('Player '))) {
nameInp.value = 'AI Bot ' + (idx + 1);
}
} else {
_localAiSet.delete(idx);
var nameInp2 = document.getElementById('player-name-' + idx);
if (nameInp2 && nameInp2.value.startsWith('AI Bot')) nameInp2.value = '';
}
_renderAiToggles();
});
grid.appendChild(btn);
})(i);
}
var diffRow = document.getElementById('ai-difficulty-row');
if (diffRow) diffRow.style.display = _localAiSet.size > 0 ? 'flex' : 'none';
}
window._setAiDiff = function(el, diff) {
_localAiDiff = diff;
document.querySelectorAll('.ai-diff-btn').forEach(function(b) {
b.style.background = b.getAttribute('data-diff') === diff ? 'rgba(91,141,217,0.2)' : 'transparent';
b.style.color = b.getAttribute('data-diff') === diff ? 'rgba(255,255,255,0.7)' : 'rgba(255,255,255,0.5)';
b.classList.toggle('active', b.getAttribute('data-diff') === diff);
});
};
window.selectPlayerCount = function(n) {
selectedPlayerCount = n;
document.querySelectorAll('.player-count-btn').forEach((btn, i) => {
btn.classList.toggle('active', [2, 3, 4][i] === n);
});
// Hide/show player fields
for (let i = 0; i < 4; i++) {
const field = document.getElementById('player-field-' + i);
if (field) field.style.display = i < n ? '' : 'none';
}
// Clear AI selections for removed players
_localAiSet.forEach(function(idx) { if (idx >= n) _localAiSet.delete(idx); });
_renderAiToggles();
updateDeckAssignmentGrid(n);
};
window.adjustSetupLife = function(delta) {
startingLife = Math.max(10, Math.min(999, startingLife + delta));
const el = document.getElementById('starting-life-display');
if (el) el.textContent = startingLife;
};
window.startGame = function() {
const playerNames = [];
for (let i = 0; i < selectedPlayerCount; i++) {
const inp = document.getElementById('player-name-' + i);
playerNames.push(inp ? inp.value.trim() || ((_localAiSet.has(i)) ? 'AI Bot ' + (i + 1) : 'Player ' + (i + 1)) : 'Player ' + (i + 1));
}
// Sync AI config to engine globals
setupAiPlayers = _localAiSet;
setupAiDifficulty = _localAiDiff;
initGame(selectedPlayerCount, playerNames, startingLife);
};
// Trigger display update for player count
window.selectPlayerCount(4);
}
function updateDeckAssignmentGrid(playerCount) {
const grid = document.getElementById('deck-assignment-grid');
if (!grid) return;
const preconList = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA : (typeof PRECONS !== 'undefined') ? PRECONS : null;
if (!preconList || preconList.length === 0) {
grid.innerHTML = 'Import deck in the Deck Manager after starting.
';
return;
}
// Pick random unique precons for each player slot
var shuffled = preconList.slice();
for (var si = shuffled.length - 1; si > 0; si--) {
var sj = Math.floor(Math.random() * (si + 1));
var tmp = shuffled[si]; shuffled[si] = shuffled[sj]; shuffled[sj] = tmp;
}
var defaults = shuffled.slice(0, playerCount);
grid.innerHTML = '';
for (let i = 0; i < playerCount; i++) {
var defaultId = defaults[i] ? defaults[i].id : '';
const row = document.createElement('div');
row.className = 'deck-select-row';
var optionsHtml = '— No deck (empty) — ';
preconList.forEach(function(p) {
var sel = (p.id === defaultId) ? ' selected' : '';
optionsHtml += '' + p.name + (p.commander ? ' (' + p.commander + ')' : '') + ' ';
});
row.innerHTML =
'P' + (i + 1) + ': ' +
'' +
optionsHtml +
' ';
grid.appendChild(row);
}
}
// ============================================================
// GAME INITIALIZATION
// ============================================================
function initGame(playerCount, playerNames, startingLife) {
gameState = createInitialState(playerCount, playerNames, startingLife);
gameState.inGame = true;
// Hide setup screen
const setup = document.getElementById('setup-screen');
if (setup) setup.classList.add('hidden');
// Show battlefield
const bf = document.getElementById('game-battlefield');
if (bf) bf.style.display = 'block';
// Update player labels and life counters in the quadrant layout
for (let i = 0; i < 4; i++) {
const pNum = i + 1;
const player = gameState.players[i];
const area = document.querySelector('.player-area.p' + pNum);
// Update player label
const labelEl = area && area.querySelector('.player-label');
if (labelEl) {
labelEl.textContent = player ? player.name : '';
labelEl.style.display = player ? '' : 'none';
}
// Show/hide based on player count
if (area) {
area.style.display = i < playerCount ? '' : 'none';
}
if (player) {
// Set life counter
const lifeEl = document.getElementById('life-' + i);
if (lifeEl) lifeEl.textContent = player.life;
}
}
// Assign precon decks if selected
for (let i = 0; i < playerCount; i++) {
const sel = document.getElementById('deck-sel-' + i);
if (sel && sel.value) {
const preconList2 = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA : (typeof PRECONS !== 'undefined') ? PRECONS : [];
const precon = preconList2.find(p => p.id === sel.value);
if (precon) loadPreconDeck(i, precon);
}
}
// Mark AI players
setupAiPlayers.forEach(function(idx) {
if (gameState.players[idx]) {
gameState.players[idx].isAI = true;
}
});
gameState.aiDifficulty = setupAiDifficulty;
// Initialize phase tracker
renderPhaseTracker();
updateTurnDisplay();
renderPlayerPanels();
renderManaPool(0);
var aiCount = 0;
gameState.players.forEach(function(p) { if (p.isAI) aiCount++; });
addLogEntry('🎮 Game started! ' + playerCount + ' players, ' + startingLife + ' starting life.' + (aiCount > 0 ? ' (' + aiCount + ' AI on ' + setupAiDifficulty + ')' : ''));
addLogEntry('' + gameState.players[0].name + ' begins the game.');
// Build section areas for each battle zone
buildQuadrantBattleZones();
// Show mana pool display
const manaDisplay = document.getElementById('mana-pool-display');
if (manaDisplay) manaDisplay.style.display = 'flex';
// If first player is AI, start their turn after a short delay
if (gameState.players[0] && gameState.players[0].isAI && typeof runAiTurn === 'function') {
setTimeout(function() { runAiTurn(); }, 1200);
}
}
function buildQuadrantBattleZones() {
// Each player's battle-zone div is already in the HTML
// Just ensure they have position:relative and overflow:hidden
for (let i = 0; i < 4; i++) {
const pNum = i + 1;
const bz = document.getElementById('battle-zone-' + pNum);
if (bz) {
bz.style.position = 'relative';
bz.style.overflow = 'hidden';
}
}
}
function loadPreconDeck(playerIdx, precon) {
if (!gameState || !precon) return;
const player = gameState.players[playerIdx];
if (!player) return;
player.commanderCardName = precon.commander || '';
// Parse decklist text if available (PRECON_DATA format)
const deckText = precon.decklist || '';
const cards = precon.cards || [];
// Parse from decklist text
const lines = deckText.split('\n');
const parsedCards = [];
let commanderName = precon.commander || '';
let inCommanderSection = false;
lines.forEach(line => {
line = line.trim();
if (!line || line.startsWith('//')) {
inCommanderSection = line.toLowerCase().includes('commander');
return;
}
const match = line.match(/^(\d+)\s+(.+)$/);
if (match) {
const qty = parseInt(match[1]);
const name = match[2].trim();
if (inCommanderSection || name === commanderName) {
commanderName = name;
inCommanderSection = false;
} else {
for (let q = 0; q < qty; q++) parsedCards.push(name);
}
}
});
// Fall back to precon.cards array
const cardNames = parsedCards.length > 0 ? parsedCards : cards.filter(c => c !== commanderName);
if (cardNames.length > 0 || commanderName) {
// Commander to command zone
if (commanderName) {
player.zones.command.push({
name: commanderName,
imageUrl: '',
typeLine: 'Legendary Creature',
oracleText: '',
});
}
// Library
cardNames.forEach(cardName => {
player.zones.library.push({
name: cardName,
imageUrl: '',
typeLine: '',
oracleText: '',
});
});
// Shuffle library
for (let i = player.zones.library.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[player.zones.library[i], player.zones.library[j]] = [player.zones.library[j], player.zones.library[i]];
}
// Draw opening hand (7 cards)
for (let d = 0; d < 7 && player.zones.library.length > 0; d++) {
player.zones.hand.push(player.zones.library.shift());
}
updateZoneCountsForPlayer(playerIdx);
renderCommanderZone(playerIdx);
renderHandZone(playerIdx);
addLogEntry('' + player.name + ' loaded deck: ' + precon.name + ' (' + commanderName + ')');
}
}
// ============================================================
// SEARCH SIDEBAR
// ============================================================
let searchDebounceTimer = null;
function initSearchSidebar() {
const input = document.getElementById('card-search-input');
if (!input) return;
input.addEventListener('input', () => {
clearTimeout(searchDebounceTimer);
const q = input.value.trim();
if (q.length < 2) {
setSearchStatus('Type to search cards...');
document.getElementById('card-results').innerHTML = '';
return;
}
searchDebounceTimer = setTimeout(() => searchCards(q), 300);
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
clearTimeout(searchDebounceTimer);
const q = input.value.trim();
if (q.length >= 1) searchCards(q);
}
});
}
function setSearchStatus(text) {
const el = document.getElementById('search-status');
if (el) el.textContent = text;
}
function showSearchSpinner(show) {
const el = document.getElementById('card-results');
if (!el) return;
if (show) {
el.innerHTML = '';
setSearchStatus('Searching...');
}
}
function searchCards(query) {
if (!query) return;
showSearchSpinner(true);
const url = 'https://api.scryfall.com/cards/search?q=' + encodeURIComponent(query) + '&unique=cards&order=name&page=1';
fetch(url)
.then(r => r.json())
.then(data => {
if (data.data) {
renderSearchResults(data.data.slice(0, 20));
setSearchStatus(data.total_cards + ' results');
} else {
setSearchStatus('No results found');
document.getElementById('card-results').innerHTML = 'No cards found for "' + query + '"
';
}
})
.catch(() => {
setSearchStatus('Search failed');
document.getElementById('card-results').innerHTML = 'Search failed. Check internet connection.
';
});
}
function getCardImage(card) {
if (card.image_uris) return card.image_uris.normal || card.image_uris.small;
if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) {
return card.card_faces[0].image_uris.normal || card.card_faces[0].image_uris.small;
}
return '';
}
function renderSearchResults(cards) {
const container = document.getElementById('card-results');
if (!container) return;
container.innerHTML = '';
cards.forEach(card => {
const imgUrl = getCardImage(card);
const item = document.createElement('div');
item.className = 'search-card-item';
item.innerHTML =
(imgUrl ? ' ' :
'' + card.name + '
') +
'' +
'
' + card.name + '
' +
'
' + (card.type_line || '') + '
' +
'
' +
'+ ';
// Add to battlefield on click
const addCard = () => {
if (!gameState) return;
const ownerIdx = gameState.currentPlayerIndex;
addCardToBattlefield(
card.name, imgUrl, card.type_line || '',
ownerIdx, undefined, undefined,
card.oracle_text || card.card_faces?.[0]?.oracle_text || '',
{
manaCost: card.mana_cost || '',
power: card.power || '',
toughness: card.toughness || '',
pt: (card.power && card.toughness) ? card.power + '/' + card.toughness : '',
cmc: card.cmc || 0,
loyalty: card.loyalty || '',
}
);
};
item.querySelector('.search-card-add-btn').onclick = e => { e.stopPropagation(); addCard(); };
item.addEventListener('mouseenter', e => {
if (imgUrl) showFloatingCardPreview(imgUrl, e, {
name: card.name,
typeLine: card.type_line || '',
oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '',
manaCost: card.mana_cost || '',
power: card.power, toughness: card.toughness,
});
});
item.addEventListener('mousemove', positionFloatingPreview);
item.addEventListener('mouseleave', hideFloatingCardPreview);
container.appendChild(item);
});
}
// ============================================================
// TRIGGER BADGE & STACK PANEL
// ============================================================
function updateTriggerBadge() {
if (!gameState) return;
const pending = (gameState.triggerStack || []).filter(t => !t.resolved);
const btn = document.getElementById('btn-triggers');
if (!btn) return;
btn.textContent = pending.length > 0 ? '❇ ' + pending.length : '❇';
btn.classList.toggle('has-triggers', pending.length > 0);
}
function openTriggerStack() {
const panel = document.getElementById('trigger-stack-panel');
if (panel) { panel.classList.add('visible'); renderTriggerStack(); }
}
function closeTriggerStack() {
const panel = document.getElementById('trigger-stack-panel');
if (panel) panel.classList.remove('visible');
}
function toggleTriggerStack() {
const panel = document.getElementById('trigger-stack-panel');
if (panel) {
if (panel.classList.contains('visible')) closeTriggerStack();
else openTriggerStack();
}
}
function renderTriggerStack() {
const body = document.getElementById('trigger-stack-body');
if (!body || !gameState) return;
const pending = (gameState.triggerStack || []).filter(t => !t.resolved);
if (pending.length === 0) {
body.innerHTML = 'No pending triggers.
';
return;
}
body.innerHTML = pending.map((trigger, idx) => {
const typeIcon = (typeof TRIGGER_TYPE_ICONS !== 'undefined' && TRIGGER_TYPE_ICONS[trigger.type]) || '❇';
const typeLabel = (typeof TRIGGER_TYPE_LABELS !== 'undefined' && TRIGGER_TYPE_LABELS[trigger.type]) || trigger.type;
const ownerColor = (gameState.players[trigger.ownerIndex] || {}).color || '#888';
return '' +
'
' + typeIcon + '
' +
'
' +
'
' + (trigger.cardName || 'Unknown') + '
' +
'
' + typeLabel + '
' +
'
' + (trigger.oracleSnippet || '') + '
' +
'
' +
'
' +
'
✕ ' +
'
';
}).join('');
updateTriggerBadge();
}
// ============================================================
// UNDO/REDO BUTTONS
// ============================================================
function updateUndoRedoButtons() {
const undoBtn = document.getElementById('btn-undo');
const redoBtn = document.getElementById('btn-redo');
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
if (undoHistoryVisible) renderUndoHistory();
}
function renderUndoHistory() {
const panel = document.getElementById('undo-history-panel');
if (!panel) return;
// Simple render of last 20 undo states
const items = undoStack.slice(-20).reverse();
panel.innerHTML =
'' +
'Undo History ✕
' +
'' +
items.map((snap, i) => '
' +
(snap._description || 'State #' + (undoStack.length - i)) + '
').join('') +
'
';
}
function closeUndoHistory() {
undoHistoryVisible = false;
const panel = document.getElementById('undo-history-panel');
if (panel) panel.classList.remove('visible');
}
// ============================================================
// DICE MODAL
// ============================================================
function openDiceModal() {
const modal = document.getElementById('dice-modal');
if (modal) modal.classList.add('visible');
}
function closeDiceModal() {
const modal = document.getElementById('dice-modal');
if (modal) modal.classList.remove('visible');
}
function rollDice(sides) {
const result = Math.floor(Math.random() * sides) + 1;
const display = document.getElementById('dice-result-display');
const label = document.getElementById('dice-label');
if (display) {
display.textContent = result;
display.classList.remove('rolling');
void display.offsetWidth; // Reflow
display.classList.add('rolling');
}
if (label) label.textContent = 'd' + sides + ' Roll';
addLogEntry('🎲 Rolled d' + sides + ': ' + result + ' ');
}
function flipCoin() {
const result = Math.random() < 0.5 ? 'Heads' : 'Tails';
const display = document.getElementById('dice-result-display');
const label = document.getElementById('dice-label');
if (display) { display.textContent = result === 'Heads' ? 'H' : 'T'; display.classList.remove('rolling'); void display.offsetWidth; display.classList.add('rolling'); }
if (label) label.textContent = 'Coin Flip';
addLogEntry('🪙 Coin flip: ' + result + ' ');
}
// ============================================================
// TOAST NOTIFICATIONS
// ============================================================
function showToast(message) {
const toast = document.createElement('div');
toast.className = 'toast-message';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.4s';
setTimeout(() => toast.remove(), 450);
}, 2800);
}
// ============================================================
// MANA POOL DISPLAY BUTTON
// ============================================================
function showManaPool() {
const display = document.getElementById('mana-pool-display');
if (display) {
display.style.display = display.style.display === 'none' ? 'flex' : 'none';
}
}
// ============================================================
// SHORTCUT HINT
// ============================================================
let _shortcutHintTimeout = null;
function showShortcutHint() {
const hint = document.querySelector('.shortcut-hint');
if (!hint) return;
const hints = [
'N next phase T next turn Space tap/untap',
'U undo D draw card S search cards',
'G dice roll Esc close panels',
];
hint.innerHTML = hints[Math.floor(Math.random() * hints.length)];
hint.classList.add('show');
clearTimeout(_shortcutHintTimeout);
_shortcutHintTimeout = setTimeout(() => hint.classList.remove('show'), 4000);
}
// ============================================================
// KEYBOARD SHORTCUTS
// ============================================================
function setupKeyboardShortcuts() {
document.addEventListener('keydown', e => {
// Don't fire when typing in inputs
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (!gameState || !gameState.inGame) return;
switch (e.key.toLowerCase()) {
case 'n': if (!e.ctrlKey && !e.metaKey) { nextPhase(); e.preventDefault(); } break;
case 't': if (!e.ctrlKey && !e.metaKey) { nextTurn(); e.preventDefault(); } break;
case 'u': if (!e.ctrlKey) { undo(); e.preventDefault(); } break;
case 'y': if (!e.ctrlKey) { redo(); e.preventDefault(); } break;
case 'd': drawCard(gameState.currentPlayerIndex); break;
case 's': {
const sidebar = document.getElementById('card-search-sidebar');
if (sidebar) {
sidebar.classList.toggle('collapsed');
const inp = document.getElementById('card-search-input');
if (inp && !sidebar.classList.contains('collapsed')) { inp.focus(); }
}
break;
}
case 'g': openDiceModal(); break;
case 'escape': {
hideContextMenu();
closeZoneModal();
closeDiceModal();
if (typeof cancelStackPickMode === 'function') cancelStackPickMode();
if (typeof cancelEquipPickMode === 'function') cancelEquipPickMode();
break;
}
}
if (e.ctrlKey && e.key === 'z') { undo(); e.preventDefault(); }
if (e.ctrlKey && e.key === 'y') { redo(); e.preventDefault(); }
});
}
// ============================================================
// COMBAT OVERLAY (SVG arrows)
// ============================================================
function refreshCombatOverlay() {
const svg = document.getElementById('combat-arrows-svg');
if (!svg || !gameState || !gameState.combat) { return; }
svg.innerHTML = '';
const combat = gameState.combat;
if (!combat.attackers || combat.attackers.length === 0) return;
// Draw arrows from attacker cards to the target player quadrant
combat.attackers.forEach(atk => {
const attackerEl = document.getElementById('bc-' + atk.cardId);
if (!attackerEl) return;
const targetPNum = atk.targetPlayerId + 1;
const targetArea = document.querySelector('.player-area.p' + targetPNum);
if (!targetArea) return;
const svgRect = svg.getBoundingClientRect();
const atkRect = attackerEl.getBoundingClientRect();
const tgtRect = targetArea.getBoundingClientRect();
const x1 = atkRect.left + atkRect.width / 2 - svgRect.left;
const y1 = atkRect.top + atkRect.height / 2 - svgRect.top;
const x2 = tgtRect.left + tgtRect.width / 2 - svgRect.left;
const y2 = tgtRect.top + tgtRect.height / 2 - svgRect.top;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
line.setAttribute('stroke', 'rgba(211,32,42,0.6)');
line.setAttribute('stroke-width', '2');
line.setAttribute('stroke-dasharray', '6,3');
line.setAttribute('marker-end', 'url(#arrowhead)');
svg.appendChild(line);
});
}
function updateCombatArrows() { refreshCombatOverlay(); }
function showCombatOverlay() { refreshCombatOverlay(); }
// ============================================================
// SETTINGS PANEL
// ============================================================
function toggleSettingsPanel() {
const panel = document.getElementById('settings-panel');
if (panel) panel.classList.toggle('visible');
if (panel && panel.classList.contains('visible')) renderSettingsPanel();
}
function renderSettingsPanel() {
if (!gameState) return;
const body = document.getElementById('settings-body');
if (!body) return;
const re = gameState.rulesEngine || {};
body.innerHTML =
'Auto Untap ' + renderToggle('autoUntap', re.autoUntap) + '
' +
'Auto Draw ' + renderToggle('autoDraw', re.autoDraw) + '
' +
'SBA Checks ' + renderToggle('sbaChecks', re.sbaChecks) + '
' +
'Commander Tax ' + renderToggle('commanderTaxEnabled', re.commanderTaxEnabled) + '
' +
'Auto Resolve Triggers ' + renderToggle('autoResolveTriggers', re.autoResolveTriggers) + '
' +
'Enforce Hand Size ' + renderToggle('enforceHandSize', re.enforceHandSize) + '
' +
'Auto Eliminate on SBA ' + renderToggle('autoEliminateOnSBA', re.autoEliminateOnSBA) + '
' +
'Mana Auto-Clear ' + renderToggle('manaAutoClear', gameState.manaAutoClear) + '
';
}
function renderToggle(key, value) {
return ' ';
}
function toggleSetting(key) {
if (!gameState) return;
if (key === 'manaAutoClear') {
gameState.manaAutoClear = !gameState.manaAutoClear;
} else if (gameState.rulesEngine && gameState.rulesEngine[key] !== undefined) {
gameState.rulesEngine[key] = !gameState.rulesEngine[key];
}
renderSettingsPanel();
addLogEntry('Setting ' + key + ' toggled');
}
// ============================================================
// PLACEHOLDER STUBS for engine-called UI functions
// (engine.js calls these — we provide no-op or simple implementations)
// ============================================================
function renderPlayerPanelsNoOp() { renderPlayerPanels(); }
function showManaShortfallWarning(cardName, shortfall, playerIndex) {
showToast('Mana shortfall for ' + cardName + ': need ' + shortfall + ' more mana');
}
function closeManaShortfallModal() {}
function renderMulliganUI() {} // Mulligan handled by engine - UI prompt via showToast
function openDeckView(playerId) { openZoneModal(playerId, 'library'); }
function closeDeckView() { closeZoneModal(); }
function renderDeckView() { renderZoneModal(); }
function openFullLogModal() { showToast('Full log: ' + _logEntries.length + ' entries'); }
function closeFullLogModal() {}
function renderFullLog() {}
function renderTimerDisplay() {}
function updateTimerDisplay(playerId) {}
function renderAllTimerDisplays() {}
function flashTimerWarning(playerId) {}
function renderBattlefieldZoneLabels() {}
function renderZonePanel() { updatePlayerZones(); }
function renderZonePanelBody() { renderZoneModal(); }
function showZpContextMenu(x, y, player, zone, cardIndex) { showZoneCardCtx(x, y, player, zone, cardIndex); }
function showHandContextMenu(x, y, playerId, handIndex) {
// Show context menu for hand card
if (!gameState) return;
const player = gameState.players[playerId];
if (!player || !player.zones.hand[handIndex]) return;
showZoneCardCtx(x, y, playerId, 'hand', handIndex);
}
function renderDeckStats() {}
function updateDeckSummary(playerId) {}
function updateDeckImportProgress(loaded, total, message) {}
function showPreconDetail(preconId) {}
function closePreconDetail() {}
function showManaPickerModal() {}
function closeManaPickerModal() {}
function updatePlayerNameFields() {}
function updateAiToggleGrid() {}
function renderDeckSelectionGrid() {}
function renderTrainingUI() {}
function openAiTrainingModal() {}
function closeAiTrainingModal() {}
function renderDeckTesterProgress() {}
function renderDeckTesterResults() {}
function openDeckTester() {}
function closeDeckTester() {}
function renderDeckTesterSetup() {}
function updateMonarchBadges() { renderPlayerPanels(); }
function openShortcutsModal() {}
function closeShortcutsModal() {}
function renderShortcutsContent() {}
function openGuideModal() {}
function closeGuideModal() {}
function renderAiThoughtBubble(playerIdx, thought) {}
function showAiCastPopup(card, aiPlayerIdx, handIndex, aiTarget) {}
function closeSaveLoadModal() {}
function renderCollectionGrid() {}
function renderWishlistGrid() {}
function openCollectionAddModal() {}
function closeCollectionAddModal() {}
function renderDbResultGrid(cards) {}
function addCardToDeck(card) {}
function closeDbCtxMenu() {}
function renderDbCommanderSlot() {}
function renderDbList() {}
function updateDbCountDisplay() {}
function renderDbStats() {}
function renderDbCurve() {}
function renderDbColors() {}
function renderDbTypes() {}
function updateCardLegalityDots() {}
function renderDbSavedDecks() {}
function openDeckBuilderImport() {}
function closeDbImportModal() {}
function showImportProgress(current, total, text) {}
function renderSettingsRulesTab() { renderSettingsPanel(); }
function renderSettingsDisplayTab() {}
function renderSettingsGameplayTab() {}
function renderSettingsAITab() {}
function updateDisplaySetting(key, value) {}
function updateGameplaySetting(key, value) { toggleSetting(key); }
function updateAIDifficulty(diff) {}
function updateAISetting(key, value) {}
function renderStackPanel() {}
function updateUnifiedStack() {}
function openPriorityWindow(reason, onAllPass) {}
function closePriorityWindow() {}
function renderPriorityBanner() {}
function showPriorityActionMode() {}
function hidePriorityBanner() {}
function renderLegalActionsBar() {}
function showStackBlockedWarning() {
addLogEntry('⚠ Resolve all stack items before advancing.');
}
function isPriorityBlocking() { return false; }
function isPriorityEnabled() {
return gameState && gameState.rulesEngine && gameState.rulesEngine.useStack;
}
function showTargetingWarningBadge(targetCardId, sourceOwnerId) {}
function showColorIdentityWarning(cardName, violations) {
showToast('Color identity warning: ' + cardName);
}
function showCommanderZonePrompt(cardId, cardData, intendedZone) {
// Auto-send to command zone
if (typeof moveCardToZone === 'function') moveCardToZone(cardId, 'command');
}
function showDiscardPrompt(playerId, count) {
addLogEntry('' + (gameState.players[playerId] || {}).name + ' must discard ' + count + ' card(s).');
}
function closeDiscardPrompt() {}
function toggleRulesPanel() { toggleSettingsPanel(); }
function closeRulesPanel() { document.getElementById('settings-panel') && document.getElementById('settings-panel').classList.remove('visible'); }
function renderTurnHistory() {}
function updateBattlefieldSectionCounts() {}
function buildBattlefieldSections() {}
function buildOwnerFilterButtons() {}
function applyBattlefieldFilter() {}
function autoArrangeBattlefield() {
if (!gameState) return;
gameState.battlefieldCards.forEach((card, idx) => {
const pNum = card.ownerIndex + 1;
const bz = document.getElementById('battle-zone-' + pNum);
if (!bz) return;
const cols = Math.ceil(Math.sqrt(gameState.battlefieldCards.filter(c => c.ownerIndex === card.ownerIndex).length));
const col = idx % cols;
const row = Math.floor(idx / cols);
card.x = col * 110 + 5;
card.y = row * 145 + 5;
const el = document.getElementById('bc-' + card.id);
if (el) { el.style.left = card.x + 'px'; el.style.top = card.y + 'px'; }
});
}
function setupBattlefieldDrop() {}
function showDamagePreview() {}
function renderDamagePreviewContent() {}
function showFloatingDamage(elementId, amount, type) {
const el = document.getElementById(elementId);
if (!el) return;
const damage = document.createElement('div');
damage.className = 'floating-damage';
damage.textContent = (type === 'loss' ? '-' : '+') + amount;
damage.style.color = type === 'loss' ? '#e74c3c' : '#2ecc71';
damage.style.left = (el.offsetLeft + el.offsetWidth / 2) + 'px';
damage.style.top = (el.offsetTop - 20) + 'px';
el.parentNode.appendChild(damage);
setTimeout(() => damage.remove(), 1200);
}
function updateDamageBadge(cardData) {
const el = document.getElementById('bc-' + cardData.id);
if (!el) return;
let badge = el.querySelector('.damage-badge');
if (cardData.damageMarked > 0) {
if (!badge) { badge = document.createElement('div'); badge.className = 'damage-badge'; el.appendChild(badge); }
badge.textContent = cardData.damageMarked;
badge.style.display = 'flex';
} else if (badge) {
badge.style.display = 'none';
}
}
function showCombatRecap(logLines) {
if (logLines && logLines.length > 0) {
logLines.forEach(line => addLogEntry(line));
}
}
function showPlaneswalkerAbilities(cardId) {}
function closePlaneswalkerAbilities() {}
function openScryModal(playerId, count) { showScryDialog(playerId); }
function closeScryModal() {}
function renderScryZones() {}
function renderScryZone(containerId, cards, zone) {}
function openTokenModal(playerId) {
const modal = document.getElementById('token-modal');
if (modal) modal.classList.add('visible');
}
function closeTokenModal() {
const modal = document.getElementById('token-modal');
if (modal) modal.classList.remove('visible');
}
function updateTokenPreview() {}
function addToTokenHistory(entry) {}
function renderTokenHistory() {}
function renderTokenBrowserResults(tokens) {}
function updateTokenSummaryBar() {}
function openDeckModal(playerId) {
const modal = document.getElementById('deck-modal');
if (modal) {
modal.classList.add('visible');
renderDeckModalContent(playerId);
}
}
function closeDeckModal() {
const modal = document.getElementById('deck-modal');
if (modal) modal.classList.remove('visible');
}
function renderDeckModalContent(playerId) {
const body = document.getElementById('deck-modal-body');
if (!body || !gameState) return;
const player = gameState.players[playerId];
if (!player) return;
body.innerHTML =
'' + player.name + "'s Deck Manager " +
'' +
'
Commander: ' + (player.commanderCardName || 'Not set') + '
' +
'
Library: ' + player.zones.library.length + ' cards | Hand: ' + player.zones.hand.length + '
' +
'
' +
'' +
'Import Deck (one card per line): ' +
'' +
'
' +
'' +
'📥 Import Deck ' +
'Close ' +
'
';
}
window.importDeckFromTextarea = function(playerId) {
const textarea = document.getElementById('deck-import-text-' + playerId);
if (!textarea || !gameState) return;
if (typeof importDeckList === 'function') {
importDeckList(playerId, textarea.value);
} else {
showToast('Deck import processing...');
}
};
function openPreconBrowser() {
showToast('Precon browser: select a deck from the setup screen dropdown.');
}
// ============================================================
// DOM UTILITY — updateCardCounterBadge
// ============================================================
function updateCardCounterBadge(cardId) {
if (!gameState) return;
const card = gameState.battlefieldCards.find(c => c.id === cardId);
if (!card) return;
const el = document.getElementById('bc-' + cardId);
if (!el) return;
// Re-render counter badges
const existingStrip = el.querySelector('.card-counters-strip');
if (existingStrip) existingStrip.remove();
if (card.counters) {
const cTypes = Object.keys(card.counters).filter(k => card.counters[k] > 0);
if (cTypes.length > 0) {
const strip = document.createElement('div');
strip.className = 'card-counters-strip';
cTypes.forEach(cType => {
const cCount = card.counters[cType];
const ct = (typeof COUNTER_TYPES !== 'undefined' && COUNTER_TYPES[cType]) || { icon: '\u25CF', color: '#9ca3af', label: cType };
const badge = document.createElement('span');
badge.className = 'card-counter-badge';
badge.style.cssText = 'background:' + ct.color + ';color:#fff;';
if (cType === '+1/+1') badge.textContent = '+' + cCount + '/+' + cCount;
else if (cType === '-1/-1') badge.textContent = '-' + cCount + '/-' + cCount;
else badge.textContent = ct.icon + cCount;
strip.appendChild(badge);
});
el.appendChild(strip);
}
}
}
// ============================================================
// DOM — toggleTap visual update
// ============================================================
function _refreshCardEl(cardId) {
if (!gameState) return;
const card = gameState.battlefieldCards.find(c => c.id === cardId);
if (!card) return;
const el = document.getElementById('bc-' + cardId);
if (!el) return;
if (card.tapped) el.classList.add('tapped');
else el.classList.remove('tapped');
}
// ============================================================
// DOMContentLoaded — wire up everything
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
initSetupScreen();
initSearchSidebar();
setupKeyboardShortcuts();
// Sidebar toggle
const sidebarTab = document.getElementById('sidebar-edge-tab');
if (sidebarTab) {
sidebarTab.onclick = () => {
const sidebar = document.getElementById('card-search-sidebar');
if (sidebar) sidebar.classList.toggle('collapsed');
};
}
// Close modal clicks
document.getElementById('zone-modal') && document.getElementById('zone-modal').addEventListener('click', e => {
if (e.target.id === 'zone-modal') closeZoneModal();
});
document.getElementById('dice-modal') && document.getElementById('dice-modal').addEventListener('click', e => {
if (e.target.id === 'dice-modal') closeDiceModal();
});
document.getElementById('token-modal') && document.getElementById('token-modal').addEventListener('click', e => {
if (e.target.id === 'token-modal') closeTokenModal();
});
document.getElementById('deck-modal') && document.getElementById('deck-modal').addEventListener('click', e => {
if (e.target.id === 'deck-modal') closeDeckModal();
});
});