789 lines
30 KiB
JavaScript
789 lines
30 KiB
JavaScript
// Total Annihilation — the Phaser scene.
|
|
//
|
|
// A thin coordinator: it owns the fixed-step loop, input bindings and screen routing, and
|
|
// holds NO game rules. Every state change goes through TALogic.issueOrder, the same entry
|
|
// point the AI uses — which is what lets tools/verifyTotalAnnihilation.js bot the human side
|
|
// and assert a campaign mission is actually winnable.
|
|
//
|
|
// The loop is TALogic.step(state, rules, deltaMs): the accumulator lives inside the headless
|
|
// state (the StarControlLogic.stepMatch idiom), so the verify script drives an identical loop
|
|
// in Node with a synthetic delta and gets identical results.
|
|
|
|
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
|
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
|
import { playSound } from '../../ui/Sounds.js';
|
|
import { api } from '../../services/api.js';
|
|
import { compileRules } from './TARules.js';
|
|
import { generateMap, decodeMap } from './TAMapGen.js';
|
|
import * as Logic from './TALogic.js';
|
|
import { runAI } from './TAAI.js';
|
|
import TAWorldView, { DEPTHS } from './TAWorldView.js';
|
|
import TAFx from './TAFx.js';
|
|
import TAHud from './TAHud.js';
|
|
import * as Screens from './TAScreens.js';
|
|
|
|
const SAVE_KEY = 'totalannihilation-save';
|
|
const SETTINGS_KEY = 'totalannihilation-settings';
|
|
const EDGE_PAN = 26; // screen-edge scroll band, px
|
|
const PAN_SPEED = 1100; // px/s at zoom 1
|
|
const DRAG_MIN = 8; // px before a click becomes a drag-box
|
|
|
|
export default class TotalAnnihilationGame extends Phaser.Scene {
|
|
constructor() { super('TotalAnnihilationGame'); }
|
|
|
|
init(data) {
|
|
this.gameDef = data.game ?? { slug: 'totalannihilation', name: 'Total Annihilation' };
|
|
this.phase = 'menu'; // menu | setup | briefing | playing | paused | result
|
|
this.match = null;
|
|
this.view = null;
|
|
this.hud = null;
|
|
this.fx = null;
|
|
this.screen = null;
|
|
this.selection = new Set();
|
|
this.groups = new Map();
|
|
this.placement = null;
|
|
this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack'
|
|
this.simSpeed = 1;
|
|
this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true });
|
|
this.lastSfxAt = {}; // per-sound-key gate — see _throttledSfx
|
|
}
|
|
|
|
create() {
|
|
try {
|
|
const { tracks, volume } = getGameSoundtrack(this);
|
|
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
|
|
} catch (_) { /* soundtrack is optional */ }
|
|
|
|
this.input.mouse?.disableContextMenu();
|
|
|
|
this.rules = compileRules(this.cache.json.get('totalannihilation-rules'));
|
|
this.art = this.cache.json.get('totalannihilation-artwork');
|
|
this.campaign = this.cache.json.get('totalannihilation-campaign') ?? { missions: [] };
|
|
|
|
this.keys = this.input.keyboard.addKeys(
|
|
'W,A,S,D,X,H,P,G,Q,E,ESC,SPACE,UP,DOWN,LEFT,RIGHT,SHIFT,CTRL',
|
|
);
|
|
this._bindInput();
|
|
|
|
// Commander portraits come from the shared opponent roster; a fetch failure just means
|
|
// the setup screen falls back to name-only cards.
|
|
this.oppById = {};
|
|
fetch('data/opponents.json')
|
|
.then((r) => r.json())
|
|
.then((d) => { for (const o of d.opponents ?? []) this.oppById[o.id] = o; })
|
|
.catch(() => { /* portraits are optional */ });
|
|
|
|
this.events.once('shutdown', () => this._teardown());
|
|
this.showMenu();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Screen routing
|
|
// -------------------------------------------------------------------------
|
|
|
|
swapScreen(next) {
|
|
this.screen?.destroy();
|
|
this.screen = next ?? null;
|
|
}
|
|
|
|
showMenu() {
|
|
this.phase = 'menu';
|
|
this._endMatch();
|
|
this.swapScreen(Screens.mainMenu(this, {
|
|
hasSave: !!localStorage.getItem(SAVE_KEY),
|
|
onSkirmish: () => this.showSkirmishSetup(),
|
|
onCampaign: () => this.showCampaign(),
|
|
onContinue: () => this.loadSave(),
|
|
onLeave: () => this.scene.start('GameMenu'),
|
|
}));
|
|
}
|
|
|
|
showSkirmishSetup() {
|
|
this.phase = 'setup';
|
|
this.swapScreen(Screens.skirmishSetup(this, {
|
|
onBack: () => this.showMenu(),
|
|
onStart: (cfg) => this.startSkirmish(cfg),
|
|
}));
|
|
}
|
|
|
|
showCampaign() {
|
|
this.phase = 'setup';
|
|
api.get(`/puzzles/${this.gameDef.slug}/progress`)
|
|
.then((p) => p?.levelsCompleted ?? 0)
|
|
.catch(() => 0)
|
|
.then((cleared) => {
|
|
this.swapScreen(Screens.campaignList(this, this.campaign, cleared, {
|
|
onBack: () => this.showMenu(),
|
|
onPick: (idx) => this.startMission(idx),
|
|
}));
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Match lifecycle
|
|
// -------------------------------------------------------------------------
|
|
|
|
startSkirmish(cfg) {
|
|
const map = generateMap(this.rules, {
|
|
seed: cfg.seed, size: cfg.size, theme: cfg.theme, symmetry: cfg.symmetry,
|
|
armies: cfg.armies.length,
|
|
});
|
|
const state = Logic.createMatch(this.rules, {
|
|
seed: cfg.seed, map,
|
|
victory: cfg.victory,
|
|
armies: cfg.armies.map((a, i) => ({
|
|
armyId: a.armyId, commanderId: a.commanderId,
|
|
isHuman: i === 0, aiSkill: a.skill,
|
|
aiProfile: this.rules.commanderById?.[a.commanderId]?.aiProfile ?? null,
|
|
})),
|
|
});
|
|
this.meta = { mode: 'skirmish', cfg };
|
|
this._beginMatch(state, 0);
|
|
}
|
|
|
|
startMission(idx) {
|
|
const m = this.campaign.missions[idx];
|
|
if (!m) return this.showMenu();
|
|
const map = decodeMap(this.rules, m.map);
|
|
const state = Logic.createMatch(this.rules, {
|
|
seed: m.seed ?? 1234, map,
|
|
// Missions take the default Commander-kill rule unless one opts out in its JSON.
|
|
victory: m.victory,
|
|
armies: [
|
|
{ armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true },
|
|
...(m.enemies ?? []).map((e) => ({
|
|
armyId: e.army, commanderId: e.commander,
|
|
aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null,
|
|
})),
|
|
],
|
|
});
|
|
(m.startResources ?? []).forEach((r, i) => {
|
|
if (!state.armies[i]) return;
|
|
state.armies[i].mass = r.mass ?? state.armies[i].mass;
|
|
state.armies[i].energy = r.energy ?? state.armies[i].energy;
|
|
});
|
|
this.meta = { mode: 'campaign', missionIdx: idx };
|
|
this.mission = m;
|
|
this._beginMatch(state, 0);
|
|
}
|
|
|
|
_beginMatch(state, playerArmy) {
|
|
this.swapScreen(null);
|
|
this.match = state;
|
|
this.playerArmy = playerArmy;
|
|
this.selection.clear();
|
|
this.groups.clear();
|
|
this.placement = null;
|
|
|
|
this.view = new TAWorldView(this, this.rules, this.art, state, playerArmy);
|
|
this.view.setFogEnabled(this.settings.fog !== false);
|
|
this.fx = new TAFx(this, this.view.worldRoot, DEPTHS);
|
|
this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, {
|
|
onBuildPick: (def) => this._beginPlacement(def),
|
|
onProduce: (def, n) => this._enqueue(def, n),
|
|
onMinimapJump: (x, y) => this.view.centerOn(x, y),
|
|
});
|
|
if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs);
|
|
|
|
const start = state.starts.find((s) => s.army === playerArmy);
|
|
if (start) this.view.centerOn(start.x * state.tileSize, start.y * state.tileSize);
|
|
|
|
this.phase = 'playing';
|
|
this._lastAutosave = this.time.now;
|
|
}
|
|
|
|
_endMatch() {
|
|
this.hud?.destroy(); this.hud = null;
|
|
this.fx?.destroy(); this.fx = null;
|
|
this.view?.destroy(); this.view = null;
|
|
this.match = null;
|
|
this.selection.clear();
|
|
}
|
|
|
|
_teardown() {
|
|
if (this._onBlur) this.game.events.off('blur', this._onBlur);
|
|
this._endMatch();
|
|
this.screen?.destroy();
|
|
this.music?.destroy?.();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The loop
|
|
// -------------------------------------------------------------------------
|
|
|
|
update(time, delta) {
|
|
if (this.phase !== 'playing' || !this.match) return;
|
|
const st = this.match;
|
|
|
|
this._updateCamera(delta);
|
|
|
|
// Releasing the queue modifier ends a chained build run, so the player is not forced to
|
|
// place one more building (or reach for Esc) just to put the cursor down. Only a run that
|
|
// was actually chained with CTRL is cancelled this way — picking a building from the menu
|
|
// and never touching CTRL leaves the placement alone.
|
|
if (this.placement?.chained && !this._queueModDown()) {
|
|
this._cancelPlacement();
|
|
this.hud.toast('Build queue ended');
|
|
}
|
|
|
|
// The AI thinks on sim ticks, not render frames, so it is stepped inside the same
|
|
// fixed-step loop the simulation uses — otherwise its cadence would ride framerate.
|
|
const before = st.tick;
|
|
const events = Logic.step(st, this.rules, delta * this.simSpeed, 4, (s) => {
|
|
for (const a of s.armies) {
|
|
if (!a.alive || a.isHuman) continue;
|
|
runAI(this.rules, s, a.idx, { skill: a.aiSkill, ...(a.aiProfile ?? {}) });
|
|
}
|
|
});
|
|
if (st.tick !== before) this.view.fogDirty = true;
|
|
|
|
for (const ev of events) this._onSimEvent(ev);
|
|
|
|
const { nanoLinks, projectiles } = this.view.render(st.alpha);
|
|
this.fx.draw(delta, nanoLinks, projectiles, time);
|
|
this._syncSelection();
|
|
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null);
|
|
this._drawDragBox();
|
|
|
|
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
|
|
this._lastAutosave = time;
|
|
this.saveGame();
|
|
}
|
|
if (st.over) this._finish(st.over);
|
|
}
|
|
|
|
_onSimEvent(ev) {
|
|
this.fx.onEvent(ev, this.rules);
|
|
// A weapon's fire/impact sound may be a single key or a list of variants (e.g. a weapon
|
|
// that alternates between two clips) — resolve to one key before it reaches the throttle
|
|
// gate, so each variant is throttled independently rather than the array being treated as
|
|
// one key.
|
|
if (ev.t === 'weaponFired' && ev.sound) this._throttledSfx(this._pickSound(ev.sound), 90);
|
|
if (ev.t === 'impact' && ev.sound) this._throttledSfx(this._pickSound(ev.sound), 90);
|
|
if (ev.t === 'unitDestroyed' && !ev.isBuilding) {
|
|
const moveClass = this.rules.defById[ev.defId]?.moveClass;
|
|
if (moveClass) this._throttledSfx(moveClass === 'foot' ? 'sfx-ta-unit-loss' : 'sfx-ta-vehicle-loss', 120);
|
|
}
|
|
if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) {
|
|
// Terrain under a finished structure changes, so its chunk has to be restamped.
|
|
this.view.repaintArea(ev.x - 128, ev.y - 128, ev.x + 128, ev.y + 128);
|
|
}
|
|
if (ev.t === 'armyEliminated') {
|
|
const name = this.rules.armies[ev.army]?.name ?? 'Enemy';
|
|
const lost = ev.reason === 'commanderLost';
|
|
if (ev.army !== this.playerArmy) {
|
|
this.hud.toast(lost ? `${name} Commander destroyed` : `${name} eliminated`, '#9ce6a0');
|
|
} else if (lost) {
|
|
this.hud.toast('Commander lost!', '#ff8a6b');
|
|
}
|
|
}
|
|
if (ev.t === 'noPath' && ev.army === this.playerArmy) {
|
|
this.hud.toast('No route there', '#ff9a6b');
|
|
}
|
|
}
|
|
|
|
/** A sound field is either one key or a list of variants to pick between at random. */
|
|
_pickSound(sound) {
|
|
return Array.isArray(sound) ? sound[Math.floor(Math.random() * sound.length)] : sound;
|
|
}
|
|
|
|
/** Gate repeated plays of the same sound key so a volley of units firing at once doesn't
|
|
* stack a dozen overlapping instances of the same clip. */
|
|
_throttledSfx(key, gapMs) {
|
|
const now = this.time.now;
|
|
if ((this.lastSfxAt[key] ?? -1e9) + gapMs > now) return;
|
|
this.lastSfxAt[key] = now;
|
|
playSound(this, key);
|
|
}
|
|
|
|
_finish(over) {
|
|
this.phase = 'result';
|
|
const won = over.winner === this.playerArmy;
|
|
api.post('/history/single-player', {
|
|
slug: this.gameDef.slug, score: Math.round(this.match.elapsedSec),
|
|
opponentScores: [], result: won ? 'win' : 'loss',
|
|
}).catch(() => { /* best effort */ });
|
|
|
|
if (won && this.meta?.mode === 'campaign') {
|
|
api.post(`/puzzles/${this.gameDef.slug}/complete`, { level: this.meta.missionIdx + 1 })
|
|
.catch(() => { /* best effort */ });
|
|
}
|
|
localStorage.removeItem(SAVE_KEY);
|
|
|
|
const stats = summarise(this.match, this.playerArmy);
|
|
this.swapScreen(Screens.results(this, { won, stats, elapsed: this.match.elapsedSec }, {
|
|
onMenu: () => this.showMenu(),
|
|
onAgain: () => {
|
|
if (this.meta?.mode === 'campaign') this.startMission(this.meta.missionIdx);
|
|
else this.startSkirmish({ ...this.meta.cfg, seed: (this.meta.cfg.seed * 7919 + 13) >>> 0 });
|
|
},
|
|
}));
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Camera
|
|
// -------------------------------------------------------------------------
|
|
|
|
_updateCamera(delta) {
|
|
const k = this.keys;
|
|
const dt = delta / 1000;
|
|
let dx = 0, dy = 0;
|
|
if (k.A.isDown || k.LEFT.isDown) dx -= 1;
|
|
if (k.D.isDown || k.RIGHT.isDown) dx += 1;
|
|
if (k.W.isDown || k.UP.isDown) dy -= 1;
|
|
if (k.S.isDown || k.DOWN.isDown) dy += 1;
|
|
|
|
// Edge scroll stays off until the mouse has actually moved over the canvas. Phaser's
|
|
// activePointer sits at (0,0) until its first event, which is inside the top-left edge
|
|
// band — so without this the camera silently flies off the corner of the map the moment
|
|
// a match starts, taking the player's Commander off screen before they touch anything.
|
|
if (this.settings.edgeScroll !== false && this._pointerLive && !this._dragging) {
|
|
const p = this.input.activePointer;
|
|
if (p.x >= 0 && p.y >= 0 && p.x <= GAME_WIDTH && p.y <= GAME_HEIGHT) {
|
|
if (p.x < EDGE_PAN) dx -= 1;
|
|
if (p.x > GAME_WIDTH - EDGE_PAN) dx += 1;
|
|
if (p.y < EDGE_PAN) dy -= 1;
|
|
if (p.y > GAME_HEIGHT - EDGE_PAN) dy += 1;
|
|
}
|
|
}
|
|
if (dx || dy) {
|
|
const len = Math.hypot(dx, dy) || 1;
|
|
this.view.panBy((dx / len) * PAN_SPEED * dt, (dy / len) * PAN_SPEED * dt);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Input
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Is the queue modifier held right now? Tracked from the DOM events Phaser forwards rather
|
|
* than polled off a Key object, so it covers Cmd as well as either Ctrl key, and reads
|
|
* false again after a focus loss swallows the keyup.
|
|
*/
|
|
_queueModDown() {
|
|
return !!this._queueMod;
|
|
}
|
|
|
|
_bindInput() {
|
|
const trackMod = (ev) => { this._queueMod = !!(ev?.ctrlKey || ev?.metaKey); };
|
|
this.input.keyboard.on('keydown', trackMod);
|
|
this.input.keyboard.on('keyup', trackMod);
|
|
this.input.on('pointermove', (p) => trackMod(p.event));
|
|
this.input.on('pointerdown', (p) => trackMod(p.event));
|
|
// A blurred window never delivers the keyup, which would otherwise strand the cursor.
|
|
this._onBlur = () => { this._queueMod = false; };
|
|
this.game.events.on('blur', this._onBlur);
|
|
|
|
this.input.on('pointermove', () => { this._pointerLive = true; });
|
|
this.input.on('pointerdown', (p) => { this._pointerLive = true; this._onPointerDown(p); });
|
|
this.input.on('pointerup', (p) => this._onPointerUp(p));
|
|
this.input.on('wheel', (p, objs, dx, dy) => {
|
|
if (this.phase !== 'playing') return;
|
|
this.view.zoomBy(dy > 0 ? -1 : 1, p.x, p.y);
|
|
});
|
|
|
|
this.input.keyboard.on('keydown', (ev) => {
|
|
if (this.phase === 'playing') this._onKey(ev);
|
|
});
|
|
}
|
|
|
|
_onKey(ev) {
|
|
const code = ev.key?.toLowerCase();
|
|
if (ev.code === 'Escape') {
|
|
if (this.placement) { this._cancelPlacement(); return; }
|
|
if (this.pendingCommand) { this.pendingCommand = null; return; }
|
|
this.togglePause();
|
|
return;
|
|
}
|
|
// Control groups: Ctrl+N assigns, N recalls, N again centres on the group.
|
|
if (/^[1-9]$/.test(code)) {
|
|
const n = Number(code);
|
|
if (ev.ctrlKey) {
|
|
this.groups.set(n, [...this.selection]);
|
|
this.hud.toast(`Group ${n} set (${this.selection.size})`);
|
|
} else {
|
|
const ids = (this.groups.get(n) ?? []).filter((id) => Logic.entityById(this.match, id));
|
|
if (!ids.length) return;
|
|
const same = ids.length === this.selection.size && ids.every((id) => this.selection.has(id));
|
|
this.selection = new Set(ids);
|
|
if (same) {
|
|
const e = Logic.entityById(this.match, ids[0]);
|
|
if (e) this.view.centerOn(e.x, e.y);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
switch (code) {
|
|
case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break;
|
|
case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break;
|
|
case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break;
|
|
case 'x': this._order({ type: 'stop' }); break;
|
|
case 'h': this._order({ type: 'hold' }); break;
|
|
case 'q': this.view.zoomBy(-1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
|
|
case 'e': this.view.zoomBy(1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
|
|
case ' ': this._jumpToAction(); break;
|
|
case 'delete': case 'backspace': this._deleteQueueSelection(); break;
|
|
default: break;
|
|
}
|
|
if (ev.ctrlKey && code === 'a') this._selectAll();
|
|
}
|
|
|
|
_onPointerDown(p) {
|
|
if (this.phase !== 'playing') return;
|
|
if (this.hud.hitsUi(p.x, p.y)) return;
|
|
|
|
if (p.middleButtonDown()) { this._panAnchor = { x: p.x, y: p.y }; return; }
|
|
|
|
const w = this.view.worldPoint(p.x, p.y);
|
|
// CTRL is the queue modifier, as in the original: hold it and every order appends to the
|
|
// unit's queue instead of replacing it. SHIFT stays on selection (add to selection) and
|
|
// on the factory buttons (x5), so the two never fight over the same click.
|
|
if (p.rightButtonDown()) {
|
|
// Right-clicking an existing queue ghost cancels that order instead of stacking a
|
|
// redundant new one on top of it.
|
|
const hit = this.view.hitTestQueue(w.x, w.y);
|
|
if (hit) { this._cancelQueueOrder(hit); return; }
|
|
this._issueContextual(w, queueHeld(p));
|
|
return;
|
|
}
|
|
|
|
if (this.placement) { this._commitPlacement(w, queueHeld(p)); return; }
|
|
if (this.pendingCommand) { this._applyPendingCommand(w, queueHeld(p)); return; }
|
|
|
|
this._dragStart = { x: p.x, y: p.y, wx: w.x, wy: w.y, add: !!p.event?.shiftKey };
|
|
this._dragging = false;
|
|
}
|
|
|
|
_onPointerUp(p) {
|
|
this._panAnchor = null;
|
|
if (this.phase !== 'playing' || !this._dragStart) return;
|
|
const d = this._dragStart;
|
|
this._dragStart = null;
|
|
const w = this.view.worldPoint(p.x, p.y);
|
|
if (this._dragging) {
|
|
this.view.queueSelection = null;
|
|
this._selectInBox(d.wx, d.wy, w.x, w.y, d.add);
|
|
} else {
|
|
// A plain click landing on a queue ghost just highlights it — it no longer sends the
|
|
// unit straight there / puts it straight to work, which used to be indistinguishable
|
|
// from "the order didn't register."
|
|
const hit = this.view.hitTestQueue(w.x, w.y);
|
|
if (hit) {
|
|
this.view.queueSelection = hit;
|
|
} else {
|
|
this.view.queueSelection = null;
|
|
this._selectAt(w.x, w.y, d.add, !!p.event?.detail && p.event.detail > 1);
|
|
}
|
|
}
|
|
this._dragging = false;
|
|
}
|
|
|
|
_drawDragBox() {
|
|
if (!this._dragBoxG) this._dragBoxG = this.add.graphics().setDepth(2000);
|
|
const g = this._dragBoxG;
|
|
g.clear();
|
|
if (!this._dragStart) return;
|
|
const p = this.input.activePointer;
|
|
if (Math.abs(p.x - this._dragStart.x) + Math.abs(p.y - this._dragStart.y) > DRAG_MIN) this._dragging = true;
|
|
if (!this._dragging) return;
|
|
this.view.uiRoot.add(g);
|
|
const x = Math.min(p.x, this._dragStart.x), y = Math.min(p.y, this._dragStart.y);
|
|
const w = Math.abs(p.x - this._dragStart.x), h = Math.abs(p.y - this._dragStart.y);
|
|
g.fillStyle(0x7dff9b, 0.10); g.fillRect(x, y, w, h);
|
|
g.lineStyle(1.5, 0x7dff9b, 0.9); g.strokeRect(x, y, w, h);
|
|
|
|
if (this._panAnchor) {
|
|
this.view.panBy(this._panAnchor.x - p.x, this._panAnchor.y - p.y);
|
|
this._panAnchor = { x: p.x, y: p.y };
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Selection
|
|
// -------------------------------------------------------------------------
|
|
|
|
_mine() {
|
|
return this.match.entities.filter((e) => !e.dead && e.army === this.playerArmy);
|
|
}
|
|
|
|
_selectedEntities() {
|
|
return [...this.selection].map((id) => Logic.entityById(this.match, id)).filter(Boolean);
|
|
}
|
|
|
|
/** Drop dead entities so the HUD and orders never reference a corpse. */
|
|
_syncSelection() {
|
|
for (const id of [...this.selection]) {
|
|
const e = Logic.entityById(this.match, id);
|
|
if (!e || e.dead) this.selection.delete(id);
|
|
}
|
|
this.view.selection = this.selection;
|
|
|
|
// A highlighted queue entry goes stale the moment its unit leaves the selection, dies, or
|
|
// the order itself is consumed/cancelled out from under it (queue shrinks past its index).
|
|
const qs = this.view.queueSelection;
|
|
if (qs) {
|
|
const e = this.selection.has(qs.unitId) ? Logic.entityById(this.match, qs.unitId) : null;
|
|
if (!e || qs.index >= e.orders.length) this.view.queueSelection = null;
|
|
}
|
|
}
|
|
|
|
_selectAt(x, y, add, double) {
|
|
let best = null, bestD = Infinity;
|
|
for (const e of this.match.entities) {
|
|
if (e.dead) continue;
|
|
if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue;
|
|
const d = Math.hypot(e.x - x, e.y - y);
|
|
const r = e.isBuilding
|
|
? Math.max(e.radius, Math.max(...Object.values(this.rules.defById[e.defId].footprint)) * this.match.tileSize * 0.5)
|
|
: e.radius + 6;
|
|
if (d <= r && d < bestD) { best = e; bestD = d; }
|
|
}
|
|
if (!best) { if (!add) this.selection.clear(); return; }
|
|
if (!add) this.selection.clear();
|
|
if (double && best.army === this.playerArmy) {
|
|
// Double-click grabs every on-screen unit of the same type — the standard RTS idiom.
|
|
const cam = this.cameras.main.worldView;
|
|
for (const e of this._mine()) {
|
|
if (e.defId === best.defId && Phaser.Geom.Rectangle.Contains(cam, e.x, e.y)) this.selection.add(e.id);
|
|
}
|
|
return;
|
|
}
|
|
this.selection.add(best.id);
|
|
}
|
|
|
|
_selectInBox(x0, y0, x1, y1, add) {
|
|
if (!add) this.selection.clear();
|
|
const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) };
|
|
const hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
|
|
// A box prefers mobile units: dragging over a base to grab tanks shouldn't also
|
|
// select the factory they came out of.
|
|
const hits = this._mine().filter((e) => e.x >= lo.x && e.x <= hi.x && e.y >= lo.y && e.y <= hi.y);
|
|
const mobile = hits.filter((e) => !e.isBuilding && !e.site);
|
|
for (const e of (mobile.length ? mobile : hits)) this.selection.add(e.id);
|
|
}
|
|
|
|
_selectAll() {
|
|
this.selection.clear();
|
|
for (const e of this._mine()) if (!e.isBuilding && !e.site) this.selection.add(e.id);
|
|
}
|
|
|
|
_jumpToAction() {
|
|
const e = this._mine().find((u) => u.targetId) ?? this._mine()[0];
|
|
if (e) this.view.centerOn(e.x, e.y);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Orders
|
|
// -------------------------------------------------------------------------
|
|
|
|
_order(order, queue = false) {
|
|
const unitIds = [...this.selection];
|
|
if (!unitIds.length) return { ok: false };
|
|
const r = Logic.issueOrder(this.match, this.rules, {
|
|
army: this.playerArmy, unitIds, order, queue,
|
|
});
|
|
if (!r.ok && r.error) this.hud.toast(r.error, '#ff9a6b');
|
|
return r;
|
|
}
|
|
|
|
/** Right-click: move, attack an enemy, or set a factory's rally point. */
|
|
_issueContextual(w, queue) {
|
|
const sel = this._selectedEntities();
|
|
if (!sel.length) return;
|
|
|
|
const factories = sel.filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
|
if (factories.length === sel.length) {
|
|
for (const f of factories) {
|
|
Logic.issueOrder(this.match, this.rules, {
|
|
army: this.playerArmy, order: { type: 'setRally', factoryId: f.id, x: w.x, y: w.y },
|
|
});
|
|
}
|
|
this.hud.toast('Rally point set');
|
|
return;
|
|
}
|
|
|
|
const target = this._entityAt(w.x, w.y);
|
|
if (target && target.army !== this.playerArmy && this.view.visibleToPlayer(target)) {
|
|
this._order({ type: 'attack', targetId: target.id }, queue);
|
|
return;
|
|
}
|
|
if (target && target.army === this.playerArmy && target.site) {
|
|
this._order({ type: 'assist', targetId: target.id }, queue);
|
|
return;
|
|
}
|
|
// Any unit that can build can also repair, so a future construction unit picks this up
|
|
// with no code change — the capability comes from its def, not from being the Commander.
|
|
if (target && target.army === this.playerArmy && target.hp < target.maxHp
|
|
&& this._selectedEntities().some((e) => this.rules.defById[e.defId].builds?.length && !e.isBuilding)) {
|
|
this._order({ type: 'repair', targetId: target.id }, queue);
|
|
return;
|
|
}
|
|
this._order({ type: 'move', x: w.x, y: w.y }, queue);
|
|
}
|
|
|
|
/** Remove one highlighted/clicked entry from a unit's order queue. */
|
|
_cancelQueueOrder(hit) {
|
|
const r = Logic.cancelOrder(this.match, this.rules, this.playerArmy, hit.unitId, hit.index);
|
|
if (r.ok) this.view.queueSelection = null;
|
|
else if (r.error) this.hud.toast(r.error, '#ff9a6b');
|
|
}
|
|
|
|
_deleteQueueSelection() {
|
|
const qs = this.view.queueSelection;
|
|
if (!qs) return;
|
|
this._cancelQueueOrder(qs);
|
|
}
|
|
|
|
_applyPendingCommand(w, queue) {
|
|
const cmd = this.pendingCommand;
|
|
this.pendingCommand = null;
|
|
if (cmd === 'guard') {
|
|
const t = this._entityAt(w.x, w.y);
|
|
if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue);
|
|
return;
|
|
}
|
|
this._order({ type: cmd, x: w.x, y: w.y }, queue);
|
|
}
|
|
|
|
_entityAt(x, y) {
|
|
let best = null, bestD = Infinity;
|
|
for (const e of this.match.entities) {
|
|
if (e.dead) continue;
|
|
const d = Math.hypot(e.x - x, e.y - y);
|
|
if (d <= e.radius + 8 && d < bestD) { best = e; bestD = d; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
_enqueue(def, count) {
|
|
const f = this._selectedEntities().find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.includes(def.id));
|
|
if (!f) return;
|
|
Logic.issueOrder(this.match, this.rules, {
|
|
army: this.playerArmy,
|
|
order: { type: 'factoryEnqueue', factoryId: f.id, defId: def.id, count },
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Building placement
|
|
// -------------------------------------------------------------------------
|
|
|
|
_beginPlacement(def) {
|
|
const builder = this._selectedEntities().find((e) => !e.isBuilding && this.rules.defById[e.defId].builds?.includes(def.id));
|
|
if (!builder) return;
|
|
this.placement = { def, builderId: builder.id, chained: false };
|
|
this.view.highlightMassSpots(!!def.terrainMultiplier);
|
|
this.input.on('pointermove', this._onPlacementMove, this);
|
|
this._onPlacementMove(this.input.activePointer);
|
|
}
|
|
|
|
_onPlacementMove(p) {
|
|
if (!this.placement) return;
|
|
const w = this.view.worldPoint(p.x, p.y);
|
|
const ts = this.match.tileSize;
|
|
const def = this.placement.def;
|
|
// Centre the footprint on the cursor, then snap to the tile grid.
|
|
const tx = Math.round(w.x / ts - def.footprint.w / 2);
|
|
const ty = Math.round(w.y / ts - def.footprint.h / 2);
|
|
const builder = Logic.entityById(this.match, this.placement.builderId);
|
|
this.placement.tx = tx; this.placement.ty = ty;
|
|
this.view.placement = {
|
|
def, tx, ty,
|
|
legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def),
|
|
builderX: builder?.x, builderY: builder?.y,
|
|
buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0,
|
|
};
|
|
}
|
|
|
|
_commitPlacement(w, queue) {
|
|
const p = this.placement;
|
|
if (!p) return;
|
|
const r = Logic.issueOrder(this.match, this.rules, {
|
|
army: this.playerArmy, unitIds: [p.builderId],
|
|
order: { type: 'build', defId: p.def.id, tx: p.tx, ty: p.ty },
|
|
queue,
|
|
});
|
|
if (!r.ok) { this.hud.toast(r.error ?? 'Cannot build there', '#ff9a6b'); return; }
|
|
// A plain click places one and puts the cursor down. Holding the queue modifier keeps the
|
|
// building loaded so a row of generators is one gesture — and `chained` records that the
|
|
// player got there by holding CTRL, so releasing it can end the run (see update()).
|
|
if (queue) p.chained = true;
|
|
else this._cancelPlacement();
|
|
}
|
|
|
|
_cancelPlacement() {
|
|
this.placement = null;
|
|
this.view.placement = null;
|
|
this.view.highlightMassSpots(false);
|
|
this.input.off('pointermove', this._onPlacementMove, this);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Pause / persistence
|
|
// -------------------------------------------------------------------------
|
|
|
|
togglePause() {
|
|
if (this.phase === 'playing') {
|
|
this.phase = 'paused';
|
|
this.swapScreen(Screens.pause(this, {
|
|
settings: this.settings,
|
|
onResume: () => { this.phase = 'playing'; this.swapScreen(null); },
|
|
onSave: () => { this.saveGame(); this.hud.toast('Saved'); },
|
|
onQuit: () => this.showMenu(),
|
|
onToggleFog: (on) => {
|
|
this.settings.fog = on;
|
|
writeJson(SETTINGS_KEY, this.settings);
|
|
this.view.setFogEnabled(on);
|
|
},
|
|
onToggleEdge: (on) => { this.settings.edgeScroll = on; writeJson(SETTINGS_KEY, this.settings); },
|
|
onSpeed: (v) => { this.simSpeed = v; },
|
|
}));
|
|
} else if (this.phase === 'paused') {
|
|
this.phase = 'playing';
|
|
this.swapScreen(null);
|
|
}
|
|
}
|
|
|
|
saveGame() {
|
|
if (!this.match) return;
|
|
try {
|
|
localStorage.setItem(SAVE_KEY, JSON.stringify({
|
|
v: Logic.SAVE_VERSION, meta: this.meta, state: Logic.serialize(this.match),
|
|
}));
|
|
} catch (_) { /* quota — a failed autosave must not kill the match */ }
|
|
}
|
|
|
|
loadSave() {
|
|
const raw = readJson(SAVE_KEY, null);
|
|
if (!raw || raw.v !== Logic.SAVE_VERSION) { this.hud?.toast('No compatible save'); return; }
|
|
const state = Logic.deserialize(this.rules, raw.state);
|
|
this.meta = raw.meta;
|
|
if (raw.meta?.mode === 'campaign') this.mission = this.campaign.missions[raw.meta.missionIdx];
|
|
this._beginMatch(state, 0);
|
|
}
|
|
}
|
|
|
|
function summarise(state, armyIdx) {
|
|
const a = state.armies[armyIdx];
|
|
return {
|
|
built: a.builtEver, lost: a.lostEver, kills: a.killsEver,
|
|
mass: Math.round(a.mass), energy: Math.round(a.energy),
|
|
};
|
|
}
|
|
|
|
/** Is the queue modifier down for this pointer event? */
|
|
function queueHeld(p) {
|
|
return !!(p.event?.ctrlKey || p.event?.metaKey);
|
|
}
|
|
|
|
function readJson(key, fallback) {
|
|
try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch (_) { return fallback; }
|
|
}
|
|
|
|
function writeJson(key, v) {
|
|
try { localStorage.setItem(key, JSON.stringify(v)); } catch (_) { /* ignore */ }
|
|
}
|