Files
BattleForge/public/js/match.js
T
Keith Solomon d80ceae128 fix: declare grid-template-columns so the battle grid lays out as a grid, not a single column
CSS Grid without an explicit grid-template-columns defaults to a
single-column layout, so the 64-144 tiles stacked vertically and
the user saw a long column instead of an 8x8 (or 10x8 or 12x12)
battlefield. Set the column count via a CSS variable on the grid
root, written by match.js after the match snapshot loads (CSS attr()
for non-content properties is not yet shipped in Safari or Firefox).
2026-07-26 11:21:49 -05:00

393 lines
12 KiB
JavaScript

const CSRF_META = 'meta[name="csrf-token"]';
const ACTIVE_TEAM_LABEL = { alpha: 'Alpha', bravo: 'Bravo' };
function getCsrfToken() {
const meta = document.querySelector(CSRF_META);
return meta ? meta.getAttribute('content') : '';
}
function getEnvelope() {
const raw = localStorage.getItem('match:current');
if (raw === null) return null;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || !parsed.match || !parsed.matchId) return null;
return parsed;
} catch (e) {
return null;
}
}
function setEnvelope(envelope) {
localStorage.setItem('match:current', JSON.stringify(envelope));
}
function removeEnvelope() {
localStorage.removeItem('match:current');
}
function el(tag, attrs = {}, text = null) {
const node = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
if (v !== null && v !== undefined) {
node.setAttribute(k, String(v));
}
});
if (text !== null) {
node.appendChild(document.createTextNode(String(text)));
}
return node;
}
function renderNoMatch(root) {
while (root.firstChild) root.removeChild(root.firstChild);
const empty = el('p', { class: 'bf-empty' }, 'No match in progress.');
const link = el('a', { href: '/' }, 'Return to home');
root.appendChild(empty);
root.appendChild(link);
}
function renderHeader(match) {
const header = document.getElementById('bf-match-header');
const team = document.getElementById('bf-active-team');
const round = document.getElementById('bf-round');
header.setAttribute('data-bf-active-team', match.activeTeamId);
header.setAttribute('data-bf-round', String(match.round));
while (team.firstChild) team.removeChild(team.firstChild);
team.appendChild(
document.createTextNode(ACTIVE_TEAM_LABEL[match.activeTeamId] || match.activeTeamId),
);
while (round.firstChild) round.removeChild(round.firstChild);
round.appendChild(document.createTextNode(String(match.round)));
}
function renderGrid(root, match) {
while (root.firstChild) root.removeChild(root.firstChild);
const { width, height, terrain } = match.battlefield;
root.setAttribute('data-bf-width', String(width));
root.setAttribute('data-bf-height', String(height));
// Set the column count via a CSS variable so the grid-template-columns
// declaration can read it portably (CSS attr() for non-content properties
// is not yet supported in Safari or Firefox as of mid-2026).
root.style.setProperty('--bf-cols', String(width));
const unitsByKey = {};
match.units.forEach((unit) => {
if (unit.health > 0) {
unitsByKey[`${unit.position.x}:${unit.position.y}`] = unit;
}
});
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const key = `${x}:${y}`;
const terrainType = (terrain && terrain[key]) || 'open';
const tile = el('button', {
type: 'button',
class: 'bf-tile',
'data-bf-x': x,
'data-bf-y': y,
'data-bf-terrain': terrainType,
});
const unit = unitsByKey[key];
if (unit) {
const isActive = unit.teamId === match.activeTeamId;
const isWinner = match.winnerTeamId && unit.teamId === match.winnerTeamId;
const cls = ['bf-unit', `bf-unit--${unit.teamId}`];
if (isActive) cls.push('bf-unit--active');
else cls.push('bf-unit--inactive');
if (isWinner) cls.push('bf-unit--winner');
const unitBtn = el(
'button',
{
type: 'button',
class: cls.join(' '),
'data-bf-unit-id': unit.id,
'data-bf-team': unit.teamId,
},
`${unit.id.split('-')[1] || unit.id} (${unit.health})`,
);
tile.appendChild(unitBtn);
}
root.appendChild(tile);
}
}
}
function renderLog(root, match) {
while (root.firstChild) root.removeChild(root.firstChild);
const log = Array.isArray(match.actionLog) ? match.actionLog.slice(-10) : [];
log.forEach((entry) => {
root.appendChild(el('p', { class: 'bf-log-entry' }, entry));
});
}
function renderResult(match) {
const result = document.getElementById('bf-result');
const winner = document.getElementById('bf-winner');
while (winner.firstChild) winner.removeChild(winner.firstChild);
winner.appendChild(
document.createTextNode(ACTIVE_TEAM_LABEL[match.winnerTeamId] || match.winnerTeamId),
);
result.removeAttribute('hidden');
const endBtn = document.getElementById('bf-end-turn');
if (endBtn) endBtn.setAttribute('disabled', 'disabled');
['bf-action-move', 'bf-action-attack', 'bf-action-ability'].forEach((id) => {
const btn = document.getElementById(id);
if (btn) btn.setAttribute('disabled', 'disabled');
});
}
function renderState() {
const envelope = getEnvelope();
const grid = document.getElementById('bf-grid');
if (!envelope) {
renderNoMatch(grid);
return;
}
const { match } = envelope;
renderHeader(match);
renderGrid(grid, match);
renderLog(document.getElementById('bf-log'), match);
if (match.winnerTeamId) {
renderResult(match);
} else {
document.getElementById('bf-result').setAttribute('hidden', 'hidden');
}
}
function toast(message) {
const host = document.getElementById('bf-toast-host');
if (!host) return;
const node = el('div', { class: 'bf-toast' }, message);
host.appendChild(node);
setTimeout(() => node.remove(), 3000);
}
async function dispatch(verb, params) {
const envelope = getEnvelope();
if (!envelope) {
toast('No match in progress.');
return;
}
const body = { matchId: envelope.matchId, match: envelope.match, ...params };
try {
const response = await fetch(`/matches/current/${verb}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
'X-Turn-Token': envelope.turnToken || '',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (response.status === 200) {
setEnvelope({
matchId: envelope.matchId,
match: data.match,
turnToken: data.turnToken,
});
renderState();
return;
}
if (response.status === 409) {
if (data.error === 'turn') {
const fresh = getEnvelope();
if (fresh) {
setEnvelope(fresh);
renderState();
toast('Turn has changed; resynced.');
} else {
renderNoMatch(document.getElementById('bf-grid'));
}
return;
}
if (data.match) {
setEnvelope({
matchId: envelope.matchId,
match: data.match,
turnToken: data.turnToken || envelope.turnToken,
});
renderState();
}
if (data.error) toast(data.error);
return;
}
if (data.error) {
toast(data.error);
} else {
toast(`Action failed: HTTP ${response.status}`);
}
} catch (e) {
toast(`Network error: ${e.message}`);
}
}
let activeMode = 'move';
let selectedUnitId = null;
function updateSelectedUnitIndicator() {
const node = document.getElementById('bf-selected-unit');
if (!node) return;
while (node.firstChild) node.removeChild(node.firstChild);
node.appendChild(document.createTextNode(selectedUnitId || 'none'));
}
function renderAbilityOptions() {
const host = document.getElementById('bf-ability-options');
if (!host) return;
while (host.firstChild) host.removeChild(host.firstChild);
if (activeMode !== 'ability' || !selectedUnitId) {
return;
}
const envelope = getEnvelope();
if (!envelope) return;
const unit = envelope.match.units.find((u) => u.id === selectedUnitId);
if (!unit || !Array.isArray(unit.abilities) || unit.abilities.length === 0) {
host.appendChild(el('p', { class: 'bf-empty' }, 'No abilities.'));
return;
}
unit.abilities.forEach((abilityId) => {
const radio = el('input', {
type: 'radio',
name: 'bf-ability',
value: abilityId,
id: `bf-ability-${abilityId}`,
});
const label = el('label', { for: `bf-ability-${abilityId}` }, abilityId);
const wrap = el('span', {});
wrap.appendChild(radio);
wrap.appendChild(label);
host.appendChild(wrap);
});
}
function setMode(mode) {
activeMode = mode;
['move', 'attack', 'ability'].forEach((m) => {
const btn = document.getElementById(`bf-action-${m}`);
if (btn) {
if (m === mode) btn.classList.add('bf-action--active');
else btn.classList.remove('bf-action--active');
btn.removeAttribute('disabled');
}
});
renderAbilityOptions();
}
function onTileClick(event) {
const tile = event.target.closest('.bf-tile');
if (!tile) return;
const x = Number(tile.getAttribute('data-bf-x'));
const y = Number(tile.getAttribute('data-bf-y'));
const unitBtn = tile.querySelector('.bf-unit');
if (unitBtn) {
const unitId = unitBtn.getAttribute('data-bf-unit-id');
const team = unitBtn.getAttribute('data-bf-team');
const envelope = getEnvelope();
if (!envelope) return;
if (team !== envelope.match.activeTeamId) {
toast('That unit is not on the active team.');
return;
}
if (activeMode === 'attack') {
toast('Pick a friendly unit to attack with, then click an enemy.');
selectedUnitId = unitId;
updateSelectedUnitIndicator();
return;
}
selectedUnitId = unitId;
updateSelectedUnitIndicator();
return;
}
if (activeMode === 'move' && selectedUnitId) {
dispatch('move', { unitId: selectedUnitId, x, y });
return;
}
if (activeMode === 'attack' && selectedUnitId) {
const envelope = getEnvelope();
if (!envelope) return;
const attacker = envelope.match.units.find((u) => u.id === selectedUnitId);
if (!attacker) return;
const target = envelope.match.units.find(
(u) => u.position.x === x && u.position.y === y && u.teamId !== attacker.teamId,
);
if (!target) {
toast('No enemy on that tile.');
return;
}
dispatch('attack', { attackerId: selectedUnitId, targetId: target.id });
return;
}
if (activeMode === 'ability' && selectedUnitId) {
const radio = document.querySelector('input[name="bf-ability"]:checked');
if (!radio) {
toast('Pick an ability first.');
return;
}
dispatch('ability', {
unitId: selectedUnitId,
abilityId: radio.value,
x,
y,
});
}
}
function onUnitClick(event) {
const unitBtn = event.target.closest('.bf-unit');
if (!unitBtn) return;
const unitId = unitBtn.getAttribute('data-bf-unit-id');
const team = unitBtn.getAttribute('data-bf-team');
const envelope = getEnvelope();
if (!envelope) return;
if (activeMode === 'attack' && team !== envelope.match.activeTeamId) {
if (!selectedUnitId) {
toast('Pick a friendly attacker first.');
return;
}
dispatch('attack', { attackerId: selectedUnitId, targetId: unitId });
return;
}
if (team === envelope.match.activeTeamId) {
selectedUnitId = unitId;
updateSelectedUnitIndicator();
return;
}
toast('That unit is not on the active team.');
}
function wireEvents() {
document.getElementById('bf-grid').addEventListener('click', (event) => {
const unitBtn = event.target.closest('.bf-unit');
if (unitBtn) {
onUnitClick(event);
} else {
onTileClick(event);
}
});
['move', 'attack', 'ability'].forEach((mode) => {
const btn = document.getElementById(`bf-action-${mode}`);
if (btn) btn.addEventListener('click', () => setMode(mode));
});
const endBtn = document.getElementById('bf-end-turn');
if (endBtn) {
endBtn.addEventListener('click', () => dispatch('end-turn', {}));
}
const returnHome = document.getElementById('bf-return-home');
if (returnHome) {
returnHome.addEventListener('click', (event) => {
event.preventDefault();
removeEnvelope();
window.location.href = '/';
});
}
}
document.addEventListener('DOMContentLoaded', () => {
wireEvents();
setMode('move');
renderState();
});