feat: add match.js renderer and battle UI styles
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
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();
|
||||
});
|
||||
+60
-2
@@ -188,14 +188,63 @@ async function startMatch() {
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.match) {
|
||||
setCurrentMatch(data.match);
|
||||
window.location.href = '/';
|
||||
setCurrentMatch({ matchId: data.matchId, match: data.match, turnToken: data.turnToken });
|
||||
window.location.href = '/matches/current';
|
||||
} else {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Start match failed: ${data.errors ? data.errors.join(', ') : 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function startBundledMatch(id) {
|
||||
if (typeof id !== 'string' || !/^[a-z0-9-]+$/.test(id)) {
|
||||
return;
|
||||
}
|
||||
const csrf = getCsrfToken();
|
||||
let manifest;
|
||||
try {
|
||||
const res = await fetch(`/scenarios/bundled/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Could not load scenario: HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
manifest = await res.json();
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Network error: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`/scenarios/${encodeURIComponent(id)}/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(manifest),
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Network error: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Start match failed: HTTP ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.match && data.matchId && data.turnToken) {
|
||||
setCurrentMatch({ matchId: data.matchId, match: data.match, turnToken: data.turnToken });
|
||||
window.location.href = '/matches/current';
|
||||
} else {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Start match failed: ${(data.errors || ['unknown']).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.bfStorage = {
|
||||
load,
|
||||
save,
|
||||
@@ -204,6 +253,7 @@ window.bfStorage = {
|
||||
currentMatch,
|
||||
setCurrentMatch,
|
||||
removeCurrentMatch,
|
||||
startBundledMatch,
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -216,4 +266,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
startMatch();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('[data-bf-bundle]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const id = button.getAttribute('data-bf-bundle');
|
||||
if (id) {
|
||||
startBundledMatch(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user