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 { 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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