Plan 3c: JavaScript frontend (storage.js, grid-editor.js, toolchain). Adds the Node toolchain (ESLint airbnb-base + Prettier) with a CI step, public/js/storage.js (bfStorage module for localStorage pre-fill and the home page's recent-scenarios list), public/js/grid-editor.js (battlefield grid interactions and JSON fetch save), public/assets/archetypes.json (the four ArchetypeTemplate definitions), four placeholder PNGs at public/assets/placeholders/, and updated <script> tags in home.php and team-editor.php and a data-scenario-id attribute + script tag in battlefield-editor.php. Also adds *.js and *.json to .gitattributes to enforce LF line endings on those file types. 196/196 tests pass; PHPStan level 6 clean; ESLint 0 errors; Prettier clean. Two fix commits were made during the per-task review loop: brief's URL parsing slice index in storage.js was off-by-one (slice(-2,-1) returned 'edit' instead of the id), and brief's buildScenarioJson emitted a nested objective shape that didn't match the backend's flat objectiveId/X/Y fields. Both were corrected.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"extends": "airbnb-base"
|
||||
}
|
||||
+3
-1
@@ -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 install --no-interaction --prefer-dist
|
||||
- run: composer check
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run lint
|
||||
- run: npm run format
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
/var/uploads/*
|
||||
!/var/uploads/.htaccess
|
||||
!/var/uploads/index.php
|
||||
/var/secret.key
|
||||
/node_modules/
|
||||
/public/js/*.map
|
||||
.phpunit.result.cache
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
Generated
+3284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "battleforge-battleforge",
|
||||
"description": "BattleForge frontend toolchain.",
|
||||
"private": true,
|
||||
"license": "proprietary",
|
||||
"scripts": {
|
||||
"lint": "eslint public/js",
|
||||
"format": "prettier --check public/js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"prettier": "^3.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
|
||||
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
|
||||
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
|
||||
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 135 B |
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
Binary file not shown.
|
After Width: | Height: | Size: 133 B |
Binary file not shown.
|
After Width: | Height: | Size: 126 B |
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
const STORAGE_PREFIX = 'scenario:';
|
||||
const MATCH_KEY = 'match:current';
|
||||
|
||||
function load(id) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + id);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function save(id, scenario) {
|
||||
const stamped = { ...scenario, lastModified: Date.now() };
|
||||
localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(stamped));
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
localStorage.removeItem(STORAGE_PREFIX + id);
|
||||
}
|
||||
|
||||
function listAll() {
|
||||
const items = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null && key.startsWith(STORAGE_PREFIX)) {
|
||||
const id = key.slice(STORAGE_PREFIX.length);
|
||||
const scenario = load(id);
|
||||
if (scenario !== null) {
|
||||
items.push({
|
||||
id,
|
||||
name: typeof scenario.name === 'string' ? scenario.name : id,
|
||||
lastModified: typeof scenario.lastModified === 'number' ? scenario.lastModified : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => b.lastModified - a.lastModified);
|
||||
return items;
|
||||
}
|
||||
|
||||
function currentMatch() {
|
||||
try {
|
||||
const raw = localStorage.getItem(MATCH_KEY);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentMatch(match) {
|
||||
localStorage.setItem(MATCH_KEY, JSON.stringify(match));
|
||||
}
|
||||
|
||||
function removeCurrentMatch() {
|
||||
localStorage.removeItem(MATCH_KEY);
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
function parsePath(name) {
|
||||
const match = name.match(/^(teamA|teamB)\[units\]\[(\d+)\]\[(\w+)\]$/);
|
||||
if (match) {
|
||||
const [, team, indexStr, key] = match;
|
||||
const teamKey = team === 'teamA' ? 'teamA' : 'teamB';
|
||||
return [teamKey, 'units', Number(indexStr), key];
|
||||
}
|
||||
const simple = [
|
||||
'id',
|
||||
'name',
|
||||
'battlefieldWidth',
|
||||
'battlefieldHeight',
|
||||
'victoryCondition',
|
||||
'holdRoundsRequired',
|
||||
];
|
||||
if (simple.includes(name)) {
|
||||
return [name];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveField(scenario, path) {
|
||||
const parts = parsePath(path);
|
||||
if (parts === null) {
|
||||
return undefined;
|
||||
}
|
||||
let cursor = scenario;
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
if (cursor === null || cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = cursor[parts[i]];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function populateRecentScenarios() {
|
||||
const root = document.getElementById('recent');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
while (root.firstChild) {
|
||||
root.removeChild(root.firstChild);
|
||||
}
|
||||
const items = listAll();
|
||||
if (items.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'bf-empty';
|
||||
p.appendChild(document.createTextNode('No saved scenarios yet.'));
|
||||
root.appendChild(p);
|
||||
return;
|
||||
}
|
||||
const ul = document.createElement('ul');
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('href', `/scenarios/${encodeURIComponent(item.id)}/edit/team`);
|
||||
a.appendChild(document.createTextNode(`${item.name} (${item.id})`));
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
root.appendChild(ul);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async function fetchArchetypes() {
|
||||
const res = await fetch('/assets/archetypes.json');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function prefillTeamEditor() {
|
||||
const form = document.querySelector('form[action*="/edit/team"]');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = decodeURIComponent(window.location.pathname.split('/').slice(-3, -2)[0] || 'new');
|
||||
if (id === 'new') {
|
||||
return;
|
||||
}
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
const fields = form.querySelectorAll('input[name], select[name]');
|
||||
fields.forEach((field) => {
|
||||
const name = field.getAttribute('name');
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const value = resolveField(scenario, name);
|
||||
if (value !== undefined) {
|
||||
Object.assign(field, { value: String(value) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function startMatch() {
|
||||
const id = decodeURIComponent(window.location.pathname.split('/').slice(-3, -2)[0] || 'new');
|
||||
if (id === 'new') {
|
||||
return;
|
||||
}
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
const csrf = getCsrfToken();
|
||||
const res = await fetch(`/scenarios/${encodeURIComponent(id)}/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(scenario),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Start match failed: HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.match) {
|
||||
setCurrentMatch(data.match);
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Start match failed: ${data.errors ? data.errors.join(', ') : 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.bfStorage = {
|
||||
load,
|
||||
save,
|
||||
delete: remove,
|
||||
listAll,
|
||||
currentMatch,
|
||||
setCurrentMatch,
|
||||
removeCurrentMatch,
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRecentScenarios();
|
||||
prefillTeamEditor();
|
||||
const startBtn = document.getElementById('bf-start-match');
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
startMatch();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -18,7 +18,7 @@ render_layout(static function () use ($csrf, $e, $a, $scenarioId, $width, $heigh
|
||||
?>
|
||||
<h1>Battlefield editor</h1>
|
||||
<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) ?>">
|
||||
<fieldset>
|
||||
<legend>Battlefield</legend>
|
||||
|
||||
@@ -82,6 +82,8 @@ render_layout(static function () use ($csrf, $e, $a, $archetypes, $error, $old,
|
||||
<label>Hold rounds: <input type="number" name="holdRoundsRequired" min="1" max="10" value="<?= $e($old['holdRoundsRequired'] ?? '1') ?>"></label>
|
||||
</fieldset>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" id="bf-start-match">Start match</button>
|
||||
</form>
|
||||
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
|
||||
<?php
|
||||
}, $csrf, 'Team editor');
|
||||
|
||||
Reference in New Issue
Block a user