feat: add battlefield grid editor and CI lint step
This commit is contained in:
@@ -1 +1,3 @@
|
|||||||
*.php text eol=lf
|
*.php text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ jobs:
|
|||||||
- run: composer validate --strict
|
- run: composer validate --strict
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer check
|
- run: composer check
|
||||||
|
- run: npm ci --no-audit --no-fund
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run format
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ render_layout(static function () use ($csrf, $e, $a, $scenarioId, $width, $heigh
|
|||||||
?>
|
?>
|
||||||
<h1>Battlefield editor</h1>
|
<h1>Battlefield editor</h1>
|
||||||
<p>Scenario: <strong><?= $e($scenarioId) ?></strong></p>
|
<p>Scenario: <strong><?= $e($scenarioId) ?></strong></p>
|
||||||
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded">
|
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
|
||||||
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
|
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Battlefield</legend>
|
<legend>Battlefield</legend>
|
||||||
|
|||||||
Reference in New Issue
Block a user