diff --git a/.gitattributes b/.gitattributes index 24d11df..4c415e8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ -*.php text eol=lf +*.php text eol=lf +*.js text eol=lf +*.json text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16ebf18..abefa78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,6 @@ jobs: - run: composer validate --strict - run: composer install --no-interaction --prefer-dist - run: composer check + - run: npm ci --no-audit --no-fund + - run: npm run lint + - run: npm run format diff --git a/public/js/grid-editor.js b/public/js/grid-editor.js new file mode 100644 index 0000000..d38cd02 --- /dev/null +++ b/public/js/grid-editor.js @@ -0,0 +1,266 @@ +function getCsrfToken() { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; +} + +function getCurrentMode() { + const checked = document.querySelector('input[name="zoneMode"]:checked'); + return checked ? checked.value : 'terrain'; +} + +function getSelectedPaint() { + const active = document.querySelector('.bf-paint.active'); + if (active) { + return active.getAttribute('data-paint'); + } + return 'forest'; +} + +function loadScenario(id) { + if (window.bfStorage && typeof window.bfStorage.load === 'function') { + return window.bfStorage.load(id); + } + return null; +} + +function saveScenario(id, scenario) { + if (window.bfStorage && typeof window.bfStorage.save === 'function') { + window.bfStorage.save(id, scenario); + } +} + +function applyTile(button, paint, zone, isObjective) { + button.setAttribute('data-paint', paint); + if (zone) { + button.setAttribute('data-zone', zone); + } else { + button.removeAttribute('data-zone'); + } + if (isObjective) { + button.setAttribute('data-objective', '1'); + } else { + button.removeAttribute('data-objective'); + } +} + +function loadGrid(tileMap, deploymentZones, objective) { + document.querySelectorAll('.bf-tile').forEach((button) => { + const x = button.getAttribute('data-x'); + const y = button.getAttribute('data-y'); + const key = `${x}:${y}`; + if (tileMap[key]) { + button.setAttribute('data-paint', tileMap[key]); + } + button.removeAttribute('data-zone'); + button.removeAttribute('data-objective'); + }); + Object.entries(deploymentZones).forEach(([team, positions]) => { + positions.forEach((pos) => { + const selector = `.bf-tile[data-x="${pos.x}"][data-y="${pos.y}"]`; + const button = document.querySelector(selector); + if (button) { + button.setAttribute('data-zone', team); + } + }); + }); + if (objective) { + const selector = `.bf-tile[data-x="${objective.x}"][data-y="${objective.y}"]`; + const button = document.querySelector(selector); + if (button) { + button.setAttribute('data-objective', '1'); + } + } +} + +function readGrid() { + const tileMap = {}; + const deploymentZones = { alpha: [], bravo: [] }; + let objective = null; + document.querySelectorAll('.bf-tile').forEach((button) => { + const x = Number(button.getAttribute('data-x')); + const y = Number(button.getAttribute('data-y')); + const paint = button.getAttribute('data-paint'); + if (paint && paint !== 'open') { + tileMap[`${x}:${y}`] = paint; + } + const zone = button.getAttribute('data-zone'); + if (zone === 'alpha' || zone === 'bravo') { + deploymentZones[zone].push({ x, y }); + } + if (button.getAttribute('data-objective') === '1') { + objective = { x, y }; + } + }); + return { tileMap, deploymentZones, objective }; +} + +function buildScenarioJson(scenarioId, tileMap, deploymentZones, objective) { + const widthInput = document.querySelector('input[name="battlefieldWidth"]'); + const heightInput = document.querySelector('input[name="battlefieldHeight"]'); + const result = { + id: scenarioId, + name: scenarioId, + battlefieldWidth: widthInput ? Number(widthInput.value) : 8, + battlefieldHeight: heightInput ? Number(heightInput.value) : 8, + battlefieldTerrain: tileMap, + teamA: { + units: [], + }, + teamB: { + units: [], + }, + deploymentZones, + objectives: {}, + victoryCondition: 'eliminate_all', + holdRoundsRequired: 1, + }; + if (objective) { + result.victoryCondition = 'hold_objective'; + result.holdRoundsRequired = 3; + result.objectiveId = 'objective-1'; + result.objectiveX = objective.x; + result.objectiveY = objective.y; + } + return result; +} + +function paintTile(button, ev) { + const mode = getCurrentMode(); + if (ev.shiftKey) { + applyTile(button, 'open', null, false); + return; + } + if (mode === 'objective') { + document.querySelectorAll('.bf-tile[data-objective]').forEach((other) => { + other.removeAttribute('data-objective'); + }); + applyTile(button, 'open', null, true); + return; + } + if (mode === 'alpha' || mode === 'bravo') { + const current = button.getAttribute('data-zone'); + if (current === mode) { + button.removeAttribute('data-zone'); + } else { + button.setAttribute('data-zone', mode); + } + return; + } + applyTile(button, getSelectedPaint(), null, false); +} + +function selectPalette(event) { + document.querySelectorAll('.bf-paint').forEach((b) => b.classList.remove('active')); + event.currentTarget.classList.add('active'); +} + +function showToast(message) { + const toast = document.createElement('div'); + toast.className = 'bf-toast'; + toast.appendChild(document.createTextNode(message)); + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 3000); +} + +function showErrors(errors) { + const existing = document.querySelector('.bf-errors'); + if (existing) { + existing.remove(); + } + const div = document.createElement('div'); + div.className = 'bf-errors'; + const ul = document.createElement('ul'); + errors.forEach((msg) => { + const li = document.createElement('li'); + li.appendChild(document.createTextNode(msg)); + ul.appendChild(li); + }); + div.appendChild(ul); + const form = document.getElementById('battlefield-form'); + if (form) { + form.parentNode.insertBefore(div, form); + } +} + +async function save(event) { + event.preventDefault(); + const form = document.getElementById('battlefield-form'); + if (!form) { + return; + } + const id = form.getAttribute('data-scenario-id'); + if (!id) { + return; + } + const { tileMap, deploymentZones, objective } = readGrid(); + const scenario = buildScenarioJson(id, tileMap, deploymentZones, objective); + const csrf = getCsrfToken(); + let res; + try { + res = await fetch(`/scenarios/${encodeURIComponent(id)}/edit/battlefield`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + body: JSON.stringify(scenario), + }); + } catch (e) { + showErrors([`Network error: ${e.message}`]); + return; + } + if (res.ok) { + const data = await res.json(); + if (data.scenario) { + saveScenario(id, data.scenario); + showToast('Saved.'); + } else { + showErrors([(data.errors || ['Unknown error']).join(', ')]); + } + } else if (res.status === 400) { + let data = {}; + try { + data = await res.json(); + } catch (e) { + // body wasn't JSON + } + showErrors(data.errors || ['Validation failed (HTTP 400).']); + } else { + showErrors([`Save failed: HTTP ${res.status}`]); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const form = document.getElementById('battlefield-form'); + if (!form) { + return; + } + const id = form.getAttribute('data-scenario-id'); + if (id) { + const scenario = loadScenario(id); + if (scenario) { + const battlefield = scenario.battlefield || {}; + const terrain = battlefield.terrain || {}; + const rawZones = scenario.deploymentZones || {}; + const deploymentZones = { alpha: [], bravo: [] }; + ['alpha', 'bravo'].forEach((team) => { + if (rawZones[team] && Array.isArray(rawZones[team].positions)) { + deploymentZones[team] = rawZones[team].positions; + } + }); + const objectiveMarker = scenario.objectives ? scenario.objectives['objective-1'] : null; + const objective = objectiveMarker ? objectiveMarker.position : null; + loadGrid(terrain, deploymentZones, objective); + } + } + document.querySelectorAll('.bf-tile').forEach((button) => { + button.addEventListener('click', (ev) => paintTile(button, ev)); + }); + document.querySelectorAll('.bf-paint').forEach((button) => { + button.addEventListener('click', selectPalette); + }); + if (document.querySelector('.bf-paint')) { + document.querySelector('.bf-paint').classList.add('active'); + } + form.addEventListener('submit', save); +}); diff --git a/src/Views/battlefield-editor.php b/src/Views/battlefield-editor.php index e61211d..397613c 100644 --- a/src/Views/battlefield-editor.php +++ b/src/Views/battlefield-editor.php @@ -18,7 +18,7 @@ render_layout(static function () use ($csrf, $e, $a, $scenarioId, $width, $heigh ?>

Battlefield editor

Scenario:

-
+
Battlefield