diff --git a/src/main.js b/src/main.js index eb28795..8edce7e 100644 --- a/src/main.js +++ b/src/main.js @@ -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); + } }); diff --git a/src/render/systemView.js b/src/render/systemView.js index 532a3fe..68b7f62 100644 --- a/src/render/systemView.js +++ b/src/render/systemView.js @@ -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) { diff --git a/src/ui/panel.js b/src/ui/panel.js new file mode 100644 index 0000000..60c1b0e --- /dev/null +++ b/src/ui/panel.js @@ -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 = ` +

Probability

+
+
+

Navigation

+ +

Market

+
Dock at a planet to trade.
+

Cargo

+
+

Intel

+
Click a system on the galaxy map.
+

Log

+
+ `; + 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)} — ${Math.floor(p.credits)} cr
` + + `${place} — cargo ${cargoUsed(p)}/${p.capacity}` + + (p.won ? '
VICTORY' : '') + (!p.alive ? '
SHIP LOST' : ''); + + 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) => `
${l}
`).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 = 'In transit…'; + 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 = 'Dock at a planet to trade.'; + return; + } + const rows = market.map((r) => ` + + ${r.good}${r.stock}${r.price} + + + + + + + `).join(''); + this.refs.market.innerHTML = + `${rows}
goodstockprice
`; + 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('
') + : 'empty'; + } + + updateIntel() { + const w = this.world; + if (this.selectedSystem == null) return; + const sys = w.galaxy.systems[this.selectedSystem]; + let html = `${sys.name}`; + for (const planetId of sys.planets) { + const planet = w.galaxy.planets[planetId]; + const known = playerKnownPrices(w, planetId); + html += `
${planet.name} (${planet.kind})`; + if (!known) { html += '
no data'; continue; } + const age = known.ageDays < 0.5 ? 'live' : `${known.ageDays.toFixed(0)}d old`; + html += ` [${age}]
` + + GOODS.map((g) => `${g} ${known.prices[g]}`).join(', '); + } + this.refs.intel.innerHTML = html; + } +}