import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toColor, toCss } from '../utils/Color.js'; import { fontStack, themeColor } from '../utils/Theme.js'; import { Rng } from '../utils/Rng.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { formatSystemReport } from '../galaxy/SystemReport.js'; import { Discovery } from '../galaxy/Discovery.js'; import { Ship } from '../entities/Ship.js'; import { Planet } from '../entities/Planet.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { Starfield } from '../visuals/Starfield.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; import { TetherField } from '../tether/TetherField.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); const BODY_FONT = () => fontStack('body', FONT_FALLBACK); /** 5120 → "5.1K", 640 → "640" — compact range readout for the HUD. */ function fmtRange(v) { if (v >= 1000) { const k = (v / 1000).toFixed(1).replace(/\.0$/, ''); return `${k}K`; } return String(Math.round(v)); } /** * The game world (v0.3: the current system — the player's home world at the * origin plus the system's other worlds scattered around it, in open space). * Click anywhere to fly there. * * Discovery: come within discovery distance (data/game.json) of a world's * edge and it is DISCOVERED (state in this.discovery). Discovered worlds * that are off-screen get a themed compass arrow on the screen edge * (this.compass) pointing the way back — and clicking its name tag * autopilots the ship there (GameScene.autopilotTo: it targets the * keep-out rim on the side the ship is approaching from). */ export class GameScene extends Phaser.Scene { constructor() { super({ key: 'GameScene' }); } preload() { // The planet spritesheet: frameWidth×frameHeight frames; the first // frames are Terran worlds (see data/planets.json → frames). this.load.spritesheet( Planet.TEXTURE_KEY, config.get('planets.texture', 'assets/images/planets.png'), { frameWidth: config.get('planets.frameWidth', 1024), frameHeight: config.get('planets.frameHeight', 1024), }, ); // The player's ship spritesheet (data/ship.json → texture). If it // isn't configured or the file is missing, Ship falls back to its // built-in procedural dart (a console note says so in create()). const shipTexture = config.get('ship.texture', ''); if (shipTexture) { this.load.spritesheet( Ship.TEXTURE_KEY, shipTexture, { frameWidth: config.get('ship.frameWidth', 256), frameHeight: config.get('ship.frameHeight', 256), }, ); } // The asteroid spritesheet (data/asteroids.json → texture): 128×128 // rock frames, one per cluster member (the generator picks frames). const asteroidTexture = config.get('asteroids.texture', ''); if (asteroidTexture && config.get('asteroids.enabled', true) !== false) { this.load.spritesheet( AsteroidCluster.TEXTURE_KEY, asteroidTexture, { frameWidth: config.get('asteroids.frameWidth', 128), frameHeight: config.get('asteroids.frameHeight', 128), }, ); } } create() { // Camera: smoothly follows the ship (updateCamera below). This motion // is what drives the parallax starfield — ship flies, view trails. this.cameraFollowShip = config.get('game.camera.followShip', true); this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s // We are, after all, in a system. Establish the galaxy (shared // registry), the current system record, and its lazy contents — the // dossier below and the world's other planets both read them. this.ensureGalaxy(); this.systemRecord = this.galaxy.currentSystem(); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); // The home world's name — dealt from the planet name bank by the // generator (the starting system is the only one with a home world), so // it reads as a real place rather than a generic "Terra". Fallback keeps // the old configured name if the bank is ever unavailable. this.homeWorldName = this.systemContent.homeName || config.get('planets.homeName', 'Terra'); this.createSystemHud(); // The home planet — the player's Terran world, always present in the // system they start in. It sits at the world origin. Which Terran face // it shows is a seed-deterministic pick from the terran pool, so the // same galaxy always yields the same home world. const homeName = config.get('planets.homePlanet', 'terran'); const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home'); this.planet = new Planet(this, 0, 0, Planet.frameFor(homeName, homeRng), homeName); this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10) // The rest of the solar system — the generated worlds, placed by the // generator (data/planets.json → solarSystem) on orbits around the // home world. Same solid-disc rules as home: fly close, never through. this.systemPlanets = []; if (config.get('planets.solarSystem.enabled', true)) { for (const rec of this.systemContent.planets ?? []) { if (typeof rec.x !== 'number' || typeof rec.y !== 'number') continue; const kind = rec.class || 'rocky'; const frame = Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name)); const tint = config.get(`planets.classTint.${kind}`); const p = new Planet(this, rec.x, rec.y, frame, kind, { scale: rec.scale ?? 1, tint: tint === undefined ? undefined : toColor(tint), }); p.setDepth(5); p.discoveryId = rec.name; // unique within the system p.discoveryName = rec.name; this.systemPlanets.push(p); } } // Asteroid clusters — loose groups of slowly tumbling rocks scattered // through the system's void (data/asteroids.json). Every cluster is a // DISCOVERABLE object (the compass knows it once found) and a SOLID one // (the ship can park at a rock's rim, never fly through it) — the same // contract as the worlds, at rock scale. Mining them comes later. this.asteroidClusters = []; if (config.get('asteroids.enabled', true) !== false) { for (const rec of this.systemContent.asteroids ?? []) { this.asteroidClusters.push(new AsteroidCluster(this, rec, { depth: 5 })); } } // Every solid in the system — worlds first (their keep-out circles are // disjoint), then the clusters. Ship constraint, click-to-fly clamping // and autopilot all run against this list. this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters]; this.planet.discoveryId = 'home'; this.planet.discoveryName = this.homeWorldName; // The ship — a short hop (~150 px, edge-to-edge) from the home world's // rim, in a seed-derived direction: same galaxy ⇒ same start. if (config.get('ship.texture', '') && !this.textures.exists(Ship.TEXTURE_KEY)) { console.warn( `[orbit] ship spritesheet "${config.get('ship.texture')}" did not load — using the built-in dart.`, ); } this.ship = new Ship(this, 0, 0); this.ship.setDepth(10); const spawn = this.planet.edgePoint( Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2), config.get('planets.spawnDistanceFromEdge', 150), this.ship.radius, ); this.ship.setPosition(spawn.x, spawn.y); // Center the camera on the ship from the very first frame. this.cameras.main.setScroll( this.ship.x - this.scale.width / 2, this.ship.y - this.scale.height / 2, ); // Background (stars are placed around the current camera view). this.starfield = new Starfield(this); this.starfield.create(); // The TETHER — the player's range. Starts as one level-1 tether anchored // on the home world (data/tether.json): the ship may fly anywhere within // its rim (level 1 = 5120 px from the planet's center); the rim is a // hard barrier drawn as a thick glitchy dotted line. The field owns the // tether list (add/remove/setLevel are the seam the build system will // use later); where multiple tethers' zones overlap there is no line // and no wall — the union is the player's space. this.tetherHudTexts = []; this.tetherField = new TetherField(this, { depth: 6, // above planets (5), below the ship (10) onChange: () => this.refreshTetherHud(), }); this.tetherField.add( config.get('tether.homeId', 'home'), 0, 0, // home world's center (it sits at the system origin) config.get('tether.homeLevel', 1), config.get('tether.homeLabel', '') || this.homeWorldName, ); this.tetherToastAt = null; this.refreshTetherHud(); // Discovery: which objects the player has found (within discovery // distance of an edge — data/game.json), tracked per system. Kept in // the shared registry so it survives scene restarts; serializable for // saves later (Discovery.toJSON). The compass turns the discovered set // into screen-edge arrows for the objects currently out of sight. this.discovery = this.registry.get('discovery') ?? null; if (!this.discovery) { this.discovery = new Discovery(config.get('game.discovery.distance', 540)); this.registry.set('discovery', this.discovery); } // The command deck's bottom strip (config: data/actionbar.json) — the // compass lays out its arrows/name tags ABOVE it so a tag is never // buried under the deck, and the hint sits above it too. const deckEnabled = config.get('actionbar.enabled', true) === true; const deckReserve = deckEnabled ? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12) : 0; this.compass = new DiscoveryCompass(this, { // Autopilot: clicking a compass name tag sends the ship to that // discovered object (GameScene.autopilotTo below). onSelect: (id) => this.autopilotTo(id), reserveBottom: deckReserve, }); // The command deck — the cyberpunk action bar across the bottom of the // screen (config: data/actionbar.json). Six evenly spaced slots: // Research, Build, Ship, two reserved, Menu. The slots are the seams // for the player's loop — research (time-based, one at a time, see // data/research.json) and building (credits + minerals, see // data/builds.json) — whose behavior and panels come next. this.actionBar = deckEnabled ? new ActionBar(this, { onAction: (id) => { // TODO(command deck): 'research' → research panel, 'build' → build // panel, 'ship' → ship screen, 'menu' → main menu. console.info(`[orbit] command deck: ${id}`); }, }) : null; // Hint (pinned just ABOVE the command deck, not under it) this.hint = this.add .text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), { fontFamily: BODY_FONT(), fontSize: '14px', color: '#54608a', letterSpacing: 1, }) .setOrigin(0.5) .setScrollFactor(0); // UI: pinned to the screen, not the world // Input: click = fly there. A click ON the command deck is deck // business, and a click on a compass name tag is an autopilot (it // already retargeted the ship) — neither is a fly-here. // A click inside a planet clamps to that planet's keep-out rim — the // ship can stop at the clearance, never inside. (Worlds don't // overlap, so sequential clamping is exact.) A click BEYOND the // player's tether range clamps to the union boundary — the target // marker lands on the barrier line itself. this.input.on('pointerdown', (pointer) => { if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return; if (this.compass.contains(pointer.x, pointer.y)) return; let aim = { x: pointer.worldX, y: pointer.worldY }; for (const s of this.solids) { aim = s.aimPoint(aim.x, aim.y, this.ship.radius); } aim = this.tetherField.clampPoint(aim.x, aim.y); this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); this.hideHint(); }); // The solid keep-outs run AFTER the arcade world has integrated the // ship's motion (the scene's 'postupdate' fires after the physics step // has moved the sprite). Constrained in update() instead, the body // re-applies this frame's inward velocity right after the push-out — // the ship would dip a frame-deep into every rim it meets at speed. this.events.on('postupdate', this.onPostUpdate, this); } /** * Post-physics constraints: every solid in the system (worlds AND * asteroid clusters) holds the ship back at its clearance — fly close, * never through — and the tether holds the player's range (clamped to * the union boundary, the line shudders where it was hit). */ onPostUpdate(_time, delta) { for (const s of this.solids) { s.constrainShip(this.ship, this.ship.radius); } const contact = this.tetherField.constrainShip(this.ship); if (contact) this.onTetherContact(contact); } /** * The galaxy comes from the shared registry (built by the menu from the * chosen seed). Dev boots that skip the menu (dev/test-game.html) get a * fresh dev galaxy so the scene always works standalone — with a * deterministic seed when one is injected (`globalThis.__ORBIT_DEV_SEED`, * set by dev/smoke-game.mjs from ?seed=…). */ ensureGalaxy() { this.galaxy = this.registry.get('galaxy') ?? null; if (!this.galaxy) { const devSeed = typeof globalThis !== 'undefined' ? globalThis.__ORBIT_DEV_SEED : null; const seed = devSeed && String(devSeed).length > 0 ? String(devSeed) : Rng.randomSeedString(8); this.galaxy = Galaxy.create(seed); this.registry.set('galaxy', this.galaxy); this.registry.set('seed', seed); if (!devSeed) console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`); } } /** * Top-left HUD: the current system's dossier — name, identity, and what *'s ALREADY THERE: colonies, mining stations, cloud bases, stations adrift * in open space (or "charted · unclaimed" when nobody's settled here). * Formatted by the pure SystemReport helper; this method only renders. * * The system's CONTENTS were ensured in create() (ensureGalaxy + * ensureContent) — the first touch of the lazy level-2 generation. * (Roster/positions were fixed at the menu's New Game click; this is * just the "build the room" part.) */ createSystemHud() { const report = formatSystemReport(this.systemContent); const fam = BODY_FONT(); const famHeader = HEADER_FONT(); const current = this.systemRecord; let y = 14; const line = (text, style) => { this.add .text(16, y, text, style) .setOrigin(0, 0) .setScrollFactor(0) // UI: pinned to the screen, not the world .setDepth(30); y += 20 + (style.fontSize === '17px' ? 6 : 0); }; line(report.title, { fontFamily: famHeader, fontSize: '17px', // v4 quirk: text colors must be CSS strings (see toCss, utils/Color.js). color: toCss(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'), letterSpacing: 2, }); line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#8fa0c9', letterSpacing: 1 }); y += 2; for (const s of report.settlements) { line(s.text, { fontFamily: fam, fontSize: '13px', color: toCss(s.color, '#8fa0c9') }); } line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' }); line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' }); this.hudEndY = y + 6; // the tether readout (refreshTetherHud) continues below } /** * Under the dossier: one line per tether — level, range, anchor. Rebuilt * whenever the field changes (TetherField.onChange), so a future * "tether upgrade" build shows up here for free. */ refreshTetherHud() { if (this.hudEndY === undefined || !this.tetherField) return; for (const g of this.tetherHudTexts) g.destroy(); this.tetherHudTexts = []; const fam = BODY_FONT(); const neon = toCss(themeColor('neon', 0x00e5ff)); let y = this.hudEndY; for (const t of this.tetherField.tethers) { const label = t.label ? ` · ${String(t.label).toUpperCase()}` : ''; this.tetherHudTexts.push( this.add .text(16, y, `TETHER LV ${t.level} · RANGE ${fmtRange(t.radius)} PX${label}`, { fontFamily: fam, fontSize: '12px', color: neon, letterSpacing: 1, }) .setOrigin(0, 0) .setScrollFactor(0) // UI — pinned to the screen .setDepth(30), ); y += 18; } } update(_time, delta) { // Phaser v4 (Giedi): the engine does not step the scene's TimeClock or // TweenManager — drive them here or delayedCall/addEvent/tweens never // fire. (Same quirk as MenuScene.update; verified Sept 2026.) this.time.update(_time, delta); this.tweens.update(); this.ship.update(_time, delta); // The clusters are ALIVE: each rock tumbles, the loose group drifts, // the dust orbits. (The keep-out constraint runs in onPostUpdate, // after the physics step has moved the ship.) for (const c of this.asteroidClusters) c.update(_time); this.updateCamera(delta); this.starfield.update(); // after the camera, so it sees this frame's motion this.tetherField.tick(_time, delta); // glitch/pulse lifecycle this.tetherField.draw(_time); // the barrier (on-screen dots only) this.actionBar?.update(_time, delta); // the deck's living details this.updateDiscovery(_time, delta); // last: sees this frame's final camera view } /** * The ship met the tether boundary: a small camera kick (impact), and a * console warning — throttled so a held push against the line doesn't * spam it. The line itself already shudders locally (TetherField pulse). */ onTetherContact(contact) { const cam = this.cameras.main; if (typeof cam.shake === 'function') { cam.shake(80, 0.003 + Math.min(0.004, (contact.vn ?? 0) * 0.00002)); } const now = this.time.now; const cooldown = config.get('tether.contact.toastCooldown', 4000); if (this.tetherToastAt !== null && now - this.tetherToastAt < cooldown) return; this.tetherToastAt = now; this.tetherToast(); } /** Top-center console warning (below the discovery toast, magenta). */ tetherToast() { if (Array.isArray(this.tetherToastG)) { for (const g of this.tetherToastG) g.destroy(); } const fam = BODY_FONT(); const g1 = this.add .text(0, 0, '\u26a0', { fontFamily: fam, fontSize: '13px', color: toCss(themeColor('neon2', 0xff2d6f)) }) .setOrigin(0, 0.5) .setScrollFactor(0); const g2 = this.add .text(0, 0, 'TETHER BARRIER — RANGE LIMIT · UPGRADE THE TETHER TO EXTEND IT', { fontFamily: fam, fontSize: '11px', color: toCss(themeColor('dim', 0x7d92c4)), letterSpacing: 2, }) .setOrigin(0, 0.5) .setScrollFactor(0); const total = g1.width + 10 + g2.width; const x0 = this.scale.width / 2 - total / 2; g1.setPosition(x0, 54).setDepth(45).setAlpha(0); g2.setPosition(x0 + g1.width + 10, 54).setDepth(45).setAlpha(0); this.tetherToastG = [g1, g2]; this.tweens.add({ targets: this.tetherToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' }); this.time.delayedCall(2600, () => { if (!Array.isArray(this.tetherToastG)) return; const [a, b] = this.tetherToastG; this.tetherToastG = null; this.tweens.add({ targets: [a, b], alpha: 0, duration: 350, onComplete: () => { a.destroy(); b.destroy(); }, }); }); } /** * The camera chases the ship with a frame-rate-independent ease: * scroll += (shipCenter - scroll) * (1 - e^(-rate·dt)) * While the ship flies, the camera trails it and the starfield streams * past in the opposite direction (real parallax). When the ship stops, * the camera keeps easing until it is centered on the ship again — a * slow recenter (≈1.5 s at followRate 3) with the stars parallaxing * along for the ride. */ updateCamera(delta) { if (!this.cameraFollowShip || !this.ship) return; const rate = this.cameraFollowRate; if (!(rate > 0)) return; const cam = this.cameras.main; const k = 1 - Math.exp(-rate * (Math.min(delta, 64) / 1000)); cam.setScroll( Phaser.Math.Linear(cam.scrollX, this.ship.x - this.scale.width / 2, k), Phaser.Math.Linear(cam.scrollY, this.ship.y - this.scale.height / 2, k), ); } /** * Discovery, every frame (a handful of distance tests — cheap): * 1. Ship within discovery distance of a world's edge → DISCOVERED * (once — state lives in this.discovery), with a rim ping + toast. * 2. Discovered worlds that are off-screen feed the compass, which * draws the themed screen-edge arrows pointing back to them. */ updateDiscovery(time, delta) { const objects = this.discoverableObjects(); const sysId = this.systemRecord.id; const fresh = this.discovery.check(sysId, this.ship.x, this.ship.y, objects); for (const o of fresh) this.celebrateDiscovery(o); // Which discovered objects are NOT on screen right now? (The camera // never zooms, so the world view is scroll + canvas size.) const cam = this.cameras.main; const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height }; const offscreen = []; for (const o of objects) { if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) { offscreen.push(o); } } this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta); } /** The discoverable objects of the system, with compass metadata. */ discoverableObjects() { const out = []; out.push({ id: 'home', x: this.planet.x, y: this.planet.y, radius: this.planet.radius, typeLabel: config.get('planets.homeTypeLabel', 'Home World'), name: this.homeWorldName, }); for (const p of this.systemPlanets) { out.push({ id: p.discoveryId, x: p.x, y: p.y, radius: p.radius, typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name), name: p.discoveryName, }); } // Asteroid clusters count as objects too: discoverable, compass arrows, // autopilot — at the scale of their extent (bound). for (const c of this.asteroidClusters) { out.push({ id: c.discoveryId, x: c.x, y: c.y, radius: c.bound, typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'), // The compass shows clusters in their own light gray (not the // worlds' neon cyan) — the arrow + name tag pick it up. color: config.get('asteroids.compassColor', '#c8d2e0'), name: c.discoveryName, }); } return out; } /** * Autopilot — the player clicked a compass name tag (any UI could wire * to this): send the ship to a discovered object. It flies to the * keep-out rim — the clearance point on the side the ship is coming * from — and arrives to a stop, exactly like a click-to-fly onto the * rim. Works for worlds AND asteroid clusters (the solid behind the * discovery entry). Clicking elsewhere retargets the same way (last * click wins). */ autopilotTo(id) { const o = this.discoverableObjects().find((v) => v.id === id); if (!o) return; // The solid body behind this discovery entry (a world or a cluster). const solid = this.solids.find((s) => s.discoveryId === id) ?? this.planet; // Approach point: on the rim (clearance + hull), on the side the ship // is approaching from (center → ship direction) — it ends up facing // the object. If the object is outside the player's tether range, the // aim clamps to the union boundary — the ship flies as far as its // tether lets it (and rests on the line) until the tether grows. const dx = this.ship.x - o.x; const dy = this.ship.y - o.y; let aim = solid.edgePoint(Math.atan2(dy, dx), solid.clearance, this.ship.radius); // Belt and braces: no other solid may own this point either. for (const s of this.solids) aim = s.aimPoint(aim.x, aim.y, this.ship.radius); aim = this.tetherField.clampPoint(aim.x, aim.y); this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); this.hideHint(); } /** The "new object" moment: a rim ping at the world + a HUD toast. */ celebrateDiscovery(o) { const neon = themeColor('neon', 0x00e5ff); // Expanding ring at the world's rim (world space). const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6); this.tweens.add({ targets: ring, scale: 1.12, alpha: 0, duration: 750, ease: 'Sine.easeOut', onComplete: () => ring.destroy(), }); // Top-center HUD toast, in the console language. if (Array.isArray(this.toast)) { for (const g of this.toast) g.destroy(); } const fam = BODY_FONT(); const type = (o.typeLabel ?? 'OBJECT').toUpperCase(); const name = o.name ? String(o.name).toUpperCase() : null; const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`; const g1 = this.add .text(0, 0, '\u25b8', { fontFamily: fam, fontSize: '12px', color: toCss(neon) }) .setOrigin(0, 0.5) .setScrollFactor(0); // UI — pinned to the screen, not the world const g2 = this.add .text(0, 0, label, { fontFamily: fam, fontSize: '11px', color: toCss(themeColor('dim', 0x7d92c4)), letterSpacing: 2, }) .setOrigin(0, 0.5) .setScrollFactor(0); // UI — pinned to the screen, not the world const total = g1.width + 10 + g2.width; const x0 = this.scale.width / 2 - total / 2; g1.setPosition(x0, 26).setDepth(45).setAlpha(0); g2.setPosition(x0 + g1.width + 10, 26).setDepth(45).setAlpha(0); this.toast = [g1, g2]; this.tweens.add({ targets: this.toast, alpha: 1, duration: 180, ease: 'Sine.easeOut' }); this.time.delayedCall(2400, () => { if (!Array.isArray(this.toast)) return; const [a, b] = this.toast; this.toast = null; this.tweens.add({ targets: [a, b], alpha: 0, duration: 350, onComplete: () => { a.destroy(); b.destroy(); }, }); }); } showTargetMarker(x, y) { const color = toColor(config.get('game.markerColor', '#41c7ff')); const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5); this.tweens.add({ targets: marker, scale: 2.4, alpha: 0, duration: 450, ease: 'Sine.easeOut', onComplete: () => marker.destroy(), }); } hideHint() { if (!this.hint || !this.hint.active) return; this.tweens.add({ targets: this.hint, alpha: 0, duration: 400, onComplete: () => { this.hint.destroy(); this.hint = null; }, }); } shutdown() { this.starfield?.destroy(); this.compass?.destroy(); this.actionBar?.destroy(); this.tetherField?.destroy(); } }