feat: UI panel, time controls, full playable trading loop

- Add src/ui/panel.js: sidebar with status, time controls (⏸/1×/4×/16×/Map-System toggle), navigation buttons, market table with buy/sell, cargo display, intel panel with price age tags, and a 10-entry log (newest first).
- Replace src/main.js: wires Panel + GalaxyView + SystemView together; game clock driven by PixiJS ticker at configurable DAYS_PER_SEC; panel DOM refresh at 4 Hz; VICTORY/SHIP LOST banner on end state; speed/view state via controls closure.
- Amendment A (systemView.js): gateXY uses canvas-proportional radius (min(GATE_RADIUS, min(c.x,c.y)-40)) so gates stay on-screen on small canvases.
- Amendment B (main.js): keydown handler guards e.target !== document.body to avoid hijacking keys from focused panel buttons.
- Amendment C (main.js): renderer resize event rebuilds GalaxyView static geometry and clears SystemView cache so cached transforms don't go stale.
This commit is contained in:
2026-06-12 15:13:58 +00:00
parent 881518417a
commit 7726651c69
3 changed files with 196 additions and 5 deletions
+41 -4
View File
@@ -2,20 +2,36 @@ import { createWorld, advance } from './sim/world.js';
import { createRenderer } from './render/app.js'; import { createRenderer } from './render/app.js';
import { GalaxyView } from './render/galaxyView.js'; import { GalaxyView } from './render/galaxyView.js';
import { SystemView } from './render/systemView.js'; import { SystemView } from './render/systemView.js';
import { Panel } from './ui/panel.js';
import { goTo, playerSystem } from './sim/player.js'; import { goTo, playerSystem } from './sim/player.js';
const world = createWorld('neon-1'); const world = createWorld(new URLSearchParams(location.search).get('seed') ?? 'neon-1');
const renderer = await createRenderer(document.getElementById('stage')); const renderer = await createRenderer(document.getElementById('stage'));
let view = 'system';
let speed = 1;
const controls = {
getSpeed: () => speed,
setSpeed: (v) => { speed = v; },
getView: () => view,
setView: (name) => setView(name),
};
const panel = new Panel(document.getElementById('panel'), world, controls);
const handlers = { const handlers = {
onSelectSystem: (id) => console.log('select system', id), onSelectSystem: (id) => { panel.selectSystem(id); panel.update(); },
onJump: (laneId) => goTo(world, { laneId }), onJump: (laneId) => goTo(world, { laneId }),
onGotoPlanet: (planetId) => goTo(world, { planetId }), onGotoPlanet: (planetId) => goTo(world, { planetId }),
}; };
const galaxyView = new GalaxyView(renderer, world, handlers); const galaxyView = new GalaxyView(renderer, world, handlers);
const systemView = new SystemView(renderer, world, handlers); const systemView = new SystemView(renderer, world, handlers);
let view = 'system'; // Rebuild static geometry when the canvas resizes (review: cached transforms went stale).
renderer.app.renderer.on('resize', () => {
galaxyView.buildStatic();
systemView.builtFor = null;
});
function setView(name) { function setView(name) {
view = name; view = name;
galaxyView.setVisible(name === 'galaxy'); galaxyView.setVisible(name === 'galaxy');
@@ -23,15 +39,36 @@ function setView(name) {
} }
setView('system'); setView('system');
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.target !== document.body) return; // don't hijack keys from panel buttons (review)
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
setView(view === 'galaxy' ? 'system' : 'galaxy'); setView(view === 'galaxy' ? 'system' : 'galaxy');
} }
if (e.key === ' ') {
e.preventDefault();
speed = speed === 0 ? 1 : 0;
}
}); });
const DAYS_PER_SEC = 0.25; const DAYS_PER_SEC = 0.25;
let banner = null;
let uiClock = 0;
renderer.app.ticker.add(() => { renderer.app.ticker.add(() => {
advance(world, world.t + (renderer.app.ticker.deltaMS / 1000) * DAYS_PER_SEC); const dt = renderer.app.ticker.deltaMS / 1000;
if (speed > 0 && world.player.alive && !world.player.won) {
advance(world, world.t + dt * DAYS_PER_SEC * speed);
}
// Switch to system view automatically when arriving somewhere new.
if (view === 'galaxy') galaxyView.update(); if (view === 'galaxy') galaxyView.update();
else if (playerSystem(world) != null) systemView.update(); else if (playerSystem(world) != null) systemView.update();
uiClock += dt;
if (uiClock > 0.25) { uiClock = 0; panel.update(); } // 4 Hz DOM refresh
if (!banner && (world.player.won || !world.player.alive)) {
banner = document.createElement('div');
banner.className = 'banner';
banner.textContent = world.player.won ? 'VICTORY' : 'SHIP LOST';
document.getElementById('stage').appendChild(banner);
}
}); });
+3 -1
View File
@@ -38,7 +38,9 @@ export class SystemView {
const there = galaxy.systems[otherEnd(lane, systemId)]; const there = galaxy.systems[otherEnd(lane, systemId)];
const ang = Math.atan2(there.y - here.y, there.x - here.x); const ang = Math.atan2(there.y - here.y, there.x - here.x);
const c = this.center(); const c = this.center();
return { x: c.x + Math.cos(ang) * GATE_RADIUS, y: c.y + Math.sin(ang) * GATE_RADIUS, ang }; // Keep gates on-screen on small canvases (review: fixed 290 clipped hit targets).
const r = Math.min(GATE_RADIUS, Math.min(c.x, c.y) - 40);
return { x: c.x + Math.cos(ang) * r, y: c.y + Math.sin(ang) * r, ang };
} }
build(systemId) { build(systemId) {
+152
View File
@@ -0,0 +1,152 @@
import { GOODS } from '../sim/constants.js';
import { goTo, buyGood, sellGood, playerSystem, playerMarket, cargoUsed } from '../sim/player.js';
import { playerKnownPrices } from '../sim/knowledge.js';
import { otherEnd } from '../sim/galaxy.js';
export class Panel {
constructor(el, world, controls) {
this.el = el;
this.world = world;
this.controls = controls; // { getSpeed, setSpeed, getView, setView }
this.selectedSystem = null;
el.innerHTML = `
<h2>Probability</h2>
<div id="status"></div>
<div id="timeCtl"></div>
<h2>Navigation</h2>
<div id="nav"></div>
<h2>Market</h2>
<div id="market" class="muted">Dock at a planet to trade.</div>
<h2>Cargo</h2>
<div id="cargo"></div>
<h2>Intel</h2>
<div id="intel" class="muted">Click a system on the galaxy map.</div>
<h2>Log</h2>
<div id="log"></div>
`;
this.refs = Object.fromEntries(
['status', 'timeCtl', 'nav', 'market', 'cargo', 'intel', 'log'].map((id) => [id, el.querySelector('#' + id)])
);
this.renderTimeControls();
}
renderTimeControls() {
const c = this.refs.timeCtl;
c.innerHTML = '';
for (const [label, value] of [['⏸', 0], ['1×', 1], ['4×', 4], ['16×', 16]]) {
const b = document.createElement('button');
b.textContent = label;
b.onclick = () => { this.controls.setSpeed(value); this.update(); };
b.dataset.speed = value;
c.appendChild(b);
}
const v = document.createElement('button');
v.textContent = 'Map / System';
v.onclick = () => this.controls.setView(this.controls.getView() === 'galaxy' ? 'system' : 'galaxy');
c.appendChild(v);
}
selectSystem(systemId) {
this.selectedSystem = systemId;
}
update() {
const w = this.world;
const p = w.player;
const sys = playerSystem(w);
let place = 'in hyperspace';
if (p.loc.kind === 'docked') place = w.galaxy.planets[p.loc.planetId].name;
else if (sys != null) place = `${w.galaxy.systems[sys].name} space`;
this.refs.status.innerHTML =
`Day ${w.t.toFixed(1)} — <b>${Math.floor(p.credits)} cr</b><br>` +
`${place} — cargo ${cargoUsed(p)}/${p.capacity}` +
(p.won ? '<br><b>VICTORY</b>' : '') + (!p.alive ? '<br><b>SHIP LOST</b>' : '');
for (const b of this.refs.timeCtl.querySelectorAll('button[data-speed]')) {
b.classList.toggle('active', Number(b.dataset.speed) === this.controls.getSpeed());
}
this.updateNav(sys);
this.updateMarket();
this.updateCargo();
this.updateIntel();
this.refs.log.innerHTML = w.log.slice(-10).reverse().map((l) => `<div>${l}</div>`).join('');
}
updateNav(sys) {
const w = this.world;
const nav = this.refs.nav;
nav.innerHTML = '';
if (sys == null || w.player.loc.kind === 'insys-leg' || w.player.loc.kind === 'lane') {
nav.innerHTML = '<span class="muted">In transit…</span>';
return;
}
for (const planetId of w.galaxy.systems[sys].planets) {
const b = document.createElement('button');
b.textContent = `Fly to ${w.galaxy.planets[planetId].name}`;
b.onclick = () => goTo(w, { planetId });
nav.appendChild(b);
}
for (const laneId of w.galaxy.systems[sys].lanes) {
const lane = w.galaxy.lanes[laneId];
const name = w.galaxy.systems[otherEnd(lane, sys)].name;
const b = document.createElement('button');
b.textContent = `Jump → ${name} (${lane.days}d, danger ${(lane.hazard * 100).toFixed(0)}%)`;
b.onclick = () => goTo(w, { laneId });
nav.appendChild(b);
}
}
updateMarket() {
const w = this.world;
const market = playerMarket(w);
if (!market) {
this.refs.market.innerHTML = '<span class="muted">Dock at a planet to trade.</span>';
return;
}
const rows = market.map((r) => `
<tr>
<td>${r.good}</td><td>${r.stock}</td><td>${r.price}</td>
<td>
<button data-act="buy" data-good="${r.good}" data-qty="1">+1</button>
<button data-act="buy" data-good="${r.good}" data-qty="10">+10</button>
<button data-act="sell" data-good="${r.good}" data-qty="1">-1</button>
<button data-act="sell" data-good="${r.good}" data-qty="10">-10</button>
</td>
</tr>`).join('');
this.refs.market.innerHTML =
`<table><tr><th>good</th><th>stock</th><th>price</th><th></th></tr>${rows}</table>`;
for (const b of this.refs.market.querySelectorAll('button')) {
b.onclick = () => {
const fn = b.dataset.act === 'buy' ? buyGood : sellGood;
fn(w, b.dataset.good, Number(b.dataset.qty));
this.update();
};
}
}
updateCargo() {
const entries = Object.entries(this.world.player.cargo);
this.refs.cargo.innerHTML = entries.length
? entries.map(([g, q]) => `${g}: ${q}`).join('<br>')
: '<span class="muted">empty</span>';
}
updateIntel() {
const w = this.world;
if (this.selectedSystem == null) return;
const sys = w.galaxy.systems[this.selectedSystem];
let html = `<b>${sys.name}</b>`;
for (const planetId of sys.planets) {
const planet = w.galaxy.planets[planetId];
const known = playerKnownPrices(w, planetId);
html += `<br><b>${planet.name}</b> <span class="muted">(${planet.kind})</span>`;
if (!known) { html += '<br><span class="muted">no data</span>'; continue; }
const age = known.ageDays < 0.5 ? 'live' : `${known.ageDays.toFixed(0)}d old`;
html += ` <span class="muted">[${age}]</span><br>` +
GOODS.map((g) => `${g} ${known.prices[g]}`).join(', ');
}
this.refs.intel.innerHTML = html;
}
}