From c42851f09ca0648fdf425ec88329bc30deee5823 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 2 Aug 2026 09:01:43 -0600 Subject: [PATCH] feat: add flowing route previews and fix orrery depth sorting Replace the static destination ring with a flowing dashed line that indicates the fleet's path and direction of travel. Introduce setSelectedFleet to highlight specific fleet markers and track live routes for fleets already underway. Fix depth sorting in the system view (orrery) by grouping bodies and sorting by Y position on every frame. --- src/games/mastervega/MasterOfVegaGame.js | 11 +- src/games/mastervega/VegaStarMap.js | 151 ++++++++++++++++++++++- src/games/mastervega/VegaSystemView.js | 20 ++- 3 files changed, 170 insertions(+), 12 deletions(-) diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 0888d06..dbc9017 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -479,7 +479,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { onViewSystem: (idx) => this.openSystemViewFor(idx), onSelectFleet: (fleet) => this.onFleetClick(fleet), onAcceptOrder: (fleet, toStar, ships) => this.confirmOrder(fleet, toStar, ships), - onCancelOrder: () => this.panel.showFleet(this.selectedFleet), + onCancelOrder: () => { this.map?.clearRoutePreview(); this.panel.showFleet(this.selectedFleet); }, onSelectionLost: () => { this.selectedFleet = null; this.map?.setSelectedStar(-1); }, onClose: () => this.clearSelection(), }); @@ -579,18 +579,21 @@ export default class MasterOfVegaGame extends Phaser.Scene { // something to look at. onStarClick(idx) { if (this.modalOpen || this.busy) return; - this.map.setSelectedStar(idx); // Clicking the system the fleet is already sitting in is not a move order — // it is a request to look at the place, so the selection is dropped and the // star's own panel comes up (which lists the fleet, one click from getting - // it back). + // it back). Clicking anywhere else with a fleet in hand quotes a route: the + // fleet keeps its own ring and a flowing dashed line marks the course + // instead of ringing the destination star. if (this.selectedFleet && this.state.fleets.includes(this.selectedFleet) && this.selectedFleet.starIdx >= 0 && this.selectedFleet.starIdx !== idx) { + this.map.setRoutePreview(this.selectedFleet.starIdx, idx); this.panel.showOrder(idx); return; } + this.map.setSelectedStar(idx); this.selectedFleet = null; this.panel.showStar(idx); } @@ -605,7 +608,7 @@ export default class MasterOfVegaGame extends Phaser.Scene { return; } this.selectedFleet = fleet; - this.map.setSelectedStar(fleet.starIdx); + this.map.setSelectedFleet(fleet); this.panel.showFleet(fleet); } diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js index 292a1e9..b82009d 100644 --- a/src/games/mastervega/VegaStarMap.js +++ b/src/games/mastervega/VegaStarMap.js @@ -58,6 +58,8 @@ export default class VegaStarMap { this.zoomIndex = Math.min(DEFAULT_ZOOM_INDEX, this.zooms.length - 1); this.zoom = this.zooms[this.zoomIndex]; this.selectedStar = -1; + this.selectedFleet = null; + this.routePreview = null; this.hoverStar = -1; this.rangeDirty = true; this.territoryDirty = true; @@ -105,6 +107,8 @@ export default class VegaStarMap { // so a fleet parked over its own star is never hidden by its own highlight. this.selGfx = scene.add.graphics(); this.root.add(this.selGfx); + this.routeGfx = scene.add.graphics(); + this.root.add(this.routeGfx); this.fleetLayer = scene.add.container(0, 0); this.root.add(this.fleetLayer); this.labelLayer = scene.add.container(0, 0); @@ -435,23 +439,159 @@ export default class VegaStarMap { /** Ring the system the command panel is currently talking about. -1 clears. */ setSelectedStar(idx) { this.selectedStar = idx ?? -1; + this.selectedFleet = null; + this.clearRoutePreview(); this.drawSelection(); } + /** + * Ring a specific fleet marker instead of the star it happens to be at. + * A fleet that is already underway (or has a standing order) gets the same + * flowing route drawn to its live destination — tracked by reference so it + * keeps pace with the fleet as it moves, not a snapshot of where it was. + */ + setSelectedFleet(fleet) { + this.selectedFleet = fleet ?? null; + this.selectedStar = -1; + if (fleet && fleet.toStar >= 0) { + this.routePreview = { fleet }; + } else { + this.routePreview = null; + } + this.routeGfx.clear(); + this.drawSelection(); + if (this.routePreview) this.drawRoutePreview(); + } + + /** Flowing dashes from `fromIdx` to `toIdx` — the route a pending order will fly. */ + setRoutePreview(fromIdx, toIdx) { + if (fromIdx == null || toIdx == null || fromIdx < 0 || toIdx < 0) { + this.clearRoutePreview(); + return; + } + this.routePreview = { from: fromIdx, to: toIdx }; + this.drawRoutePreview(); + } + + clearRoutePreview() { + this.routePreview = null; + this.routeGfx.clear(); + } + + /** Resolve the preview's start/end points — either a fixed star pair (a + * pending click-to-order) or a live fleet reference (an underway fleet + * tracked to its destination as it flies). */ + resolveRouteEndpoints() { + const rp = this.routePreview; + if (!rp) return null; + if (rp.fleet) { + const f = rp.fleet; + if (!this.state.fleets.includes(f) || f.toStar < 0) return null; + const dest = this.state.galaxy.stars[f.toStar]; + if (!dest) return null; + let ax; + let ay; + if (f.starIdx >= 0) { + const star = this.state.galaxy.stars[f.starIdx]; + if (!star) return null; + // Match the fleet marker's own offset from the star it sits at. + ax = star.x + 26; + ay = star.y - 22; + } else { + const from = this.state.galaxy.stars[f.fromStar]; + if (!from) return null; + const t = f.total > 0 ? Phaser.Math.Clamp(f.progress / f.total, 0, 1) : 0; + ax = from.x + (dest.x - from.x) * t; + ay = from.y + (dest.y - from.y) * t; + } + return { + ax, ay, bx: dest.x, by: dest.y, + }; + } + const from = this.state.galaxy.stars[rp.from]; + const to = this.state.galaxy.stars[rp.to]; + if (!from || !to) return null; + return { + ax: from.x, ay: from.y, bx: to.x, by: to.y, + }; + } + + drawRoutePreview() { + const g = this.routeGfx; + g.clear(); + const pts = this.resolveRouteEndpoints(); + if (!pts) return; + const a = { x: pts.ax, y: pts.ay }; + const b = { x: pts.bx, y: pts.by }; + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + if (len < 1) return; + const ux = dx / len; + const uy = dy / len; + + // Marching dashes: a fixed dash/gap pattern whose phase slides toward the + // destination, reading as a current flowing along the route rather than a + // static line — the "ship will fly this way" cue the ring around the + // target star used to give less directly. + const dash = 22; + const gap = 16; + const period = dash + gap; + const speed = 90; + // d advances with time (not -phase) so the dashes drift from `a` toward + // `b` — matching the ship's actual direction of travel, not the reverse. + const phase = ((this.time / 1000) * speed) % period; + g.lineStyle(3, 0x9fd8ff, 0.85); + for (let d = phase - period; d < len; d += period) { + const s = Math.max(d, 0); + const e = Math.min(d + dash, len); + if (e <= s) continue; + g.beginPath(); + g.moveTo(a.x + ux * s, a.y + uy * s); + g.lineTo(a.x + ux * e, a.y + uy * e); + g.strokePath(); + } + + // Arrowhead planted on the destination star, pointing along the route. + const ang = Math.atan2(dy, dx); + const ah = 11; + const tip = { x: b.x - ux * 6, y: b.y - uy * 6 }; + g.fillStyle(0x9fd8ff, 0.9); + g.beginPath(); + g.moveTo(tip.x, tip.y); + g.lineTo(tip.x - Math.cos(ang - 2.6) * ah, tip.y - Math.sin(ang - 2.6) * ah); + g.lineTo(tip.x - Math.cos(ang + 2.6) * ah, tip.y - Math.sin(ang + 2.6) * ah); + g.closePath(); + g.fillPath(); + } + drawSelection() { const g = this.selGfx; g.clear(); - const s = this.starSprites?.[this.selectedStar]; - if (!s) return; + let cx; + let cy; + let r; + if (this.selectedFleet) { + const marker = this.fleetMarkers?.find((m) => m.fleet === this.selectedFleet); + if (!marker) return; + cx = marker.container.x; + cy = marker.container.y; + r = 20; + } else { + const s = this.starSprites?.[this.selectedStar]; + if (!s) return; + cx = s.star.x; + cy = s.star.y; + r = s.cls.radius * 2.2 + 20; + } // Four arcs rather than a circle: a broken reticle stays legible on top of // an ownership ring, which a second full circle would not. - const r = s.cls.radius * 2.2 + 20; const spin = (this.time / 1000) * 0.5; g.lineStyle(2, 0x9fd8ff, 0.95); for (let i = 0; i < 4; i += 1) { const a = spin + (i * Math.PI) / 2; g.beginPath(); - g.arc(s.star.x, s.star.y, r, a, a + 0.7); + g.arc(cx, cy, r, a, a + 0.7); g.strokePath(); } } @@ -462,7 +602,8 @@ export default class VegaStarMap { this.time += delta; const t = this.time / 1000; - if (this.selectedStar >= 0) this.drawSelection(); + if (this.selectedStar >= 0 || this.selectedFleet) this.drawSelection(); + if (this.routePreview) this.drawRoutePreview(); for (const s of this.starSprites) { if (s.cls.special === 'pulsar') { diff --git a/src/games/mastervega/VegaSystemView.js b/src/games/mastervega/VegaSystemView.js index b3a7f1f..15bb3ba 100644 --- a/src/games/mastervega/VegaSystemView.js +++ b/src/games/mastervega/VegaSystemView.js @@ -30,10 +30,21 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) { const orreryCY = shell.body.y + 380; // --- star at the centre of the orrery + // + // The star and every planet share one sub-container so they can be + // depth-sorted by screen Y each tick — a Phaser Container renders its + // children in LIST order and ignores .depth entirely, so without an + // explicit sort a planet lower on screen (nearer the "camera" in this + // pseudo-3D tilted view) could render behind one higher up, whichever + // happened to be added first. orbitGfx sits fixed at y=0, below every body + // and the star, so it never needs re-sorting itself. + const orrery = scene.add.container(0, 0); + shell.add(orrery); + const starImg = scene.add.image(orreryCX, orreryCY, art.stars, Math.max(0, starFrame(rules, star.classId))) .setDisplaySize(cls.radius * 9, cls.radius * 9) .setBlendMode(cls.special === 'blackhole' ? Phaser.BlendModes.NORMAL : Phaser.BlendModes.ADD); - shell.add(starImg); + orrery.add(starImg); shell.add(scene.add.text(shell.body.x, shell.body.y, `${cls.name} star — ${cls.desc}`, { fontFamily: FONT, fontSize: '16px', color: '#7f97b3', wordWrap: { width: 700 }, @@ -42,7 +53,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) { // --- orbits and planets const bodies = []; const orbitGfx = scene.add.graphics(); - shell.add(orbitGfx); + orrery.add(orbitGfx); let selected = null; star.planets.forEach((planet, i) => { @@ -53,7 +64,7 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) { 34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18) .setInteractive({ useHandCursor: true }); img.on('pointerup', () => select(i)); - shell.add(img); + orrery.add(img); bodies.push({ planet, img, type, angle: planet.orbitAngle, radius: planet.orbitRadius * 1.35 }); }); @@ -254,6 +265,9 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) { orreryCY + Math.sin(b.angle) * b.radius * 0.55, ); } + // Re-sort every tick: whichever body (or the star) sits lowest on + // screen this frame draws last, i.e. on top. + orrery.sort('y'); }, });