388 lines
12 KiB
JavaScript
388 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));
|
|
|
|
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');
|
|
}
|
|
});
|
|
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();
|
|
});
|