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:
+41
-4
@@ -2,20 +2,36 @@ import { createWorld, advance } from './sim/world.js';
|
||||
import { createRenderer } from './render/app.js';
|
||||
import { GalaxyView } from './render/galaxyView.js';
|
||||
import { SystemView } from './render/systemView.js';
|
||||
import { Panel } from './ui/panel.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'));
|
||||
|
||||
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 = {
|
||||
onSelectSystem: (id) => console.log('select system', id),
|
||||
onSelectSystem: (id) => { panel.selectSystem(id); panel.update(); },
|
||||
onJump: (laneId) => goTo(world, { laneId }),
|
||||
onGotoPlanet: (planetId) => goTo(world, { planetId }),
|
||||
};
|
||||
const galaxyView = new GalaxyView(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) {
|
||||
view = name;
|
||||
galaxyView.setVisible(name === 'galaxy');
|
||||
@@ -23,15 +39,36 @@ function setView(name) {
|
||||
}
|
||||
setView('system');
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.target !== document.body) return; // don't hijack keys from panel buttons (review)
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
setView(view === 'galaxy' ? 'system' : 'galaxy');
|
||||
}
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
speed = speed === 0 ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
const DAYS_PER_SEC = 0.25;
|
||||
let banner = null;
|
||||
let uiClock = 0;
|
||||
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();
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,7 +38,9 @@ export class SystemView {
|
||||
const there = galaxy.systems[otherEnd(lane, systemId)];
|
||||
const ang = Math.atan2(there.y - here.y, there.x - here.x);
|
||||
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) {
|
||||
|
||||
+152
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user