diff --git a/public/assets/styles.css b/public/assets/styles.css index f443cb8..d0cf731 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -19,3 +19,22 @@ table.bf-grid button[data-zone="alpha"] { outline: 3px solid #00c; } table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; } .bf-errors { color: #c00; } .bf-toast { background: #dfd; padding: 0.5rem 1rem; margin: 0.5rem 0; } +.bf-match { display: flex; gap: 1rem; } +.bf-grid { display: grid; gap: 1px; background: #ddd; padding: 1px; width: max-content; } +.bf-tile { width: 2.5rem; height: 2.5rem; border: 0; background: #fff; padding: 0; position: relative; } +.bf-tile[data-bf-terrain="forest"] { background: #cfc; } +.bf-tile[data-bf-terrain="rough"] { background: #fec; } +.bf-tile[data-bf-terrain="water"] { background: #cce; } +.bf-tile[data-bf-terrain="blocking"] { background: #444; } +.bf-unit { width: 100%; height: 100%; font-size: 0.7rem; border: 0; cursor: pointer; } +.bf-unit--alpha { background: #cce5ff; } +.bf-unit--bravo { background: #ffcccc; } +.bf-unit--inactive { opacity: 0.4; cursor: not-allowed; } +.bf-unit--winner { outline: 3px solid #c00; } +.bf-action--active { font-weight: bold; } +.bf-toast { background: #ffd; padding: 0.5rem 1rem; margin: 0.25rem 0; border: 1px solid #cc9; } +.bf-log { max-height: 12rem; overflow: auto; background: #f4f4f4; padding: 0.5rem; margin-top: 1rem; } +.bf-log-entry { margin: 0; font-family: monospace; font-size: 0.85rem; } +.bf-empty { color: #777; font-style: italic; } +.bf-result { margin-top: 1rem; padding: 1rem; background: #efe; border: 2px solid #393; } +.bf-result[hidden] { display: none; } diff --git a/public/js/match.js b/public/js/match.js new file mode 100644 index 0000000..dca5edc --- /dev/null +++ b/public/js/match.js @@ -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(); +}); diff --git a/public/js/storage.js b/public/js/storage.js index 43f2fc2..6e5a5eb 100644 --- a/public/js/storage.js +++ b/public/js/storage.js @@ -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); + } + }); + }); });