From 881518417a7bc905a6ba732055c00a19ba32e362 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 12 Jun 2026 14:56:06 +0000 Subject: [PATCH] feat: system view with star, planets, gates, concrete ships --- src/main.js | 26 ++++++- src/render/systemView.js | 159 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 src/render/systemView.js diff --git a/src/main.js b/src/main.js index f73cd26..eb28795 100644 --- a/src/main.js +++ b/src/main.js @@ -1,17 +1,37 @@ import { createWorld, advance } from './sim/world.js'; import { createRenderer } from './render/app.js'; import { GalaxyView } from './render/galaxyView.js'; -import { goTo } from './sim/player.js'; +import { SystemView } from './render/systemView.js'; +import { goTo, playerSystem } from './sim/player.js'; const world = createWorld('neon-1'); const renderer = await createRenderer(document.getElementById('stage')); -const galaxyView = new GalaxyView(renderer, world, { + +const handlers = { onSelectSystem: (id) => console.log('select system', id), 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'; +function setView(name) { + view = name; + galaxyView.setVisible(name === 'galaxy'); + systemView.setVisible(name === 'system'); +} +setView('system'); +window.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + setView(view === 'galaxy' ? 'system' : 'galaxy'); + } }); const DAYS_PER_SEC = 0.25; renderer.app.ticker.add(() => { advance(world, world.t + (renderer.app.ticker.deltaMS / 1000) * DAYS_PER_SEC); - galaxyView.update(); + if (view === 'galaxy') galaxyView.update(); + else if (playerSystem(world) != null) systemView.update(); }); diff --git a/src/render/systemView.js b/src/render/systemView.js new file mode 100644 index 0000000..532a3fe --- /dev/null +++ b/src/render/systemView.js @@ -0,0 +1,159 @@ +import { Container, Graphics, Text } from 'pixi.js'; +import { shipsIn } from '../sim/npc.js'; +import { playerSystem } from '../sim/player.js'; +import { otherEnd } from '../sim/galaxy.js'; + +const GATE_RADIUS = 290; + +export class SystemView { + constructor(renderer, world, { onGotoPlanet, onJump }) { + this.renderer = renderer; + this.world = world; + this.onGotoPlanet = onGotoPlanet; + this.onJump = onJump; + this.container = new Container(); + this.labelContainer = new Container(); + renderer.world.addChild(this.container); + renderer.labels.addChild(this.labelContainer); + this.staticG = new Graphics(); + this.shipG = new Graphics(); + this.container.addChild(this.staticG, this.shipG); + this.hitNodes = new Container(); + this.container.addChild(this.hitNodes); + this.builtFor = null; + } + + center() { + return { x: this.renderer.app.renderer.width / 2, y: this.renderer.app.renderer.height / 2 }; + } + + planetXY(planet) { + const c = this.center(); + return { x: c.x + Math.cos(planet.orbit.a) * planet.orbit.r, y: c.y + Math.sin(planet.orbit.a) * planet.orbit.r }; + } + + gateXY(lane, systemId) { + const { galaxy } = this.world; + const here = galaxy.systems[systemId]; + 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 }; + } + + build(systemId) { + this.builtFor = systemId; + const { galaxy } = this.world; + const sys = galaxy.systems[systemId]; + const c = this.center(); + const g = this.staticG; + g.clear(); + this.labelContainer.removeChildren(); + this.hitNodes.removeChildren(); + + g.circle(c.x, c.y, 30).fill({ color: 0xffe9a8 }); // the star, brightest thing on screen + + for (const planetId of sys.planets) { + const planet = galaxy.planets[planetId]; + const p = this.planetXY(planet); + g.circle(c.x, c.y, planet.orbit.r).stroke({ width: 1, color: 0x1b3a55, alpha: 0.6 }); + g.circle(p.x, p.y, 10).fill({ color: 0x7fb4ff }); + const label = new Text({ + text: `${planet.name} (${planet.kind})`, + style: { fill: '#7fa8c9', fontSize: 11, fontFamily: 'system-ui' }, + }); + label.position.set(p.x + 14, p.y - 6); + this.labelContainer.addChild(label); + const hit = new Graphics().circle(0, 0, 18).fill({ color: 0xffffff, alpha: 0.001 }); + hit.position.set(p.x, p.y); + hit.eventMode = 'static'; + hit.cursor = 'pointer'; + hit.on('pointertap', () => this.onGotoPlanet(planetId)); + this.hitNodes.addChild(hit); + } + + for (const laneId of sys.lanes) { + const lane = galaxy.lanes[laneId]; + const gp = this.gateXY(lane, systemId); + g.poly([gp.x, gp.y - 8, gp.x + 8, gp.y, gp.x, gp.y + 8, gp.x - 8, gp.y]).fill({ color: 0x9fd8ff }); + const name = galaxy.systems[otherEnd(lane, systemId)].name; + const label = new Text({ text: `→ ${name}`, style: { fill: '#7fa8c9', fontSize: 11, fontFamily: 'system-ui' } }); + label.position.set(gp.x + 10, gp.y - 6); + this.labelContainer.addChild(label); + const hit = new Graphics().circle(0, 0, 16).fill({ color: 0xffffff, alpha: 0.001 }); + hit.position.set(gp.x, gp.y); + hit.eventMode = 'static'; + hit.cursor = 'pointer'; + hit.on('pointertap', () => this.onJump(laneId)); + this.hitNodes.addChild(hit); + } + } + + // A ship triangle pointing along its heading. + drawShip(g, x, y, ang, color) { + const cos = Math.cos(ang), sin = Math.sin(ang); + const pt = (dx, dy) => [x + dx * cos - dy * sin, y + dx * sin + dy * cos]; + g.poly([...pt(10, 0), ...pt(-7, 6), ...pt(-7, -6)]).fill({ color }); + } + + update() { + const sim = this.world; + const sysId = playerSystem(sim); + if (sysId == null) return; + if (this.builtFor !== sysId) this.build(sysId); + const { galaxy } = sim; + const c = this.center(); + const g = this.shipG; + g.clear(); + + // Concrete NPC ships. + for (const entry of shipsIn(sim, sysId)) { + if (entry.kind === 'docked') { + const p = this.planetXY(galaxy.planets[entry.planetId]); + this.drawShip(g, p.x + 18, p.y - 14, -Math.PI / 2, 0xffa94d); + } else if (entry.kind === 'arriving' || entry.kind === 'departing') { + const lane = galaxy.lanes[entry.laneId]; + const gp = this.gateXY(lane, sysId); + const dest = entry.npc.plan ? galaxy.planets[entry.npc.plan.destPlanet] : null; + const target = dest && dest.systemId === sysId ? this.planetXY(dest) : c; + const f = entry.kind === 'arriving' ? entry.progress : 1 - entry.progress; + const x = gp.x + (target.x - gp.x) * f * 0.5; + const y = gp.y + (target.y - gp.y) * f * 0.5; + const ang = entry.kind === 'arriving' + ? Math.atan2(target.y - gp.y, target.x - gp.x) + : Math.atan2(gp.y - target.y, gp.x - target.x); + this.drawShip(g, x, y, ang, 0xffa94d); + } else if (entry.kind === 'local-hop') { + const from = this.planetXY(galaxy.planets[entry.npc.plan.originPlanet]); + const to = this.planetXY(galaxy.planets[entry.npc.plan.destPlanet]); + const x = from.x + (to.x - from.x) * entry.progress; + const y = from.y + (to.y - from.y) * entry.progress; + this.drawShip(g, x, y, Math.atan2(to.y - from.y, to.x - from.x), 0xffa94d); + } + } + + // The player. + const loc = sim.player.loc; + if (loc.kind === 'docked') { + const p = this.planetXY(galaxy.planets[loc.planetId]); + this.drawShip(g, p.x - 18, p.y - 14, -Math.PI / 2, 0x4dffd2); + } else if (loc.kind === 'at-gate') { + const gp = this.gateXY(galaxy.lanes[loc.laneId], sysId); + this.drawShip(g, gp.x, gp.y - 16, -Math.PI / 2, 0x4dffd2); + } else if (loc.kind === 'insys-leg') { + const resolve = (d) => d.type === 'planet' + ? this.planetXY(galaxy.planets[d.id]) + : this.gateXY(galaxy.lanes[d.laneId], sysId); + const from = resolve(loc.from), to = resolve(loc.to); + const f = Math.min(1, (sim.t - loc.departT) / (loc.arriveT - loc.departT)); + const x = from.x + (to.x - from.x) * f; + const y = from.y + (to.y - from.y) * f; + this.drawShip(g, x, y, Math.atan2(to.y - from.y, to.x - from.x), 0x4dffd2); + } + } + + setVisible(on) { + this.container.visible = on; + this.labelContainer.visible = on; + } +}