188 lines
6.5 KiB
JavaScript
188 lines
6.5 KiB
JavaScript
/**
|
||
* binary companion star — a distant "other star" wandering slowly in the sky.
|
||
*
|
||
* Content layer (two world-space sprites: a bright core + a soft halo),
|
||
* NOT a filter: Canvas-safe and immune to the HUD. It is a FAR, distant
|
||
* light source: very low parallax (~0.08), so it barely drifts as you
|
||
* fly, and it wanders a slow elliptical arc seeded per system (each
|
||
* binary's companion walks its own path). Both sprites use additive
|
||
* blend — it is a source of light, so it glows, never occludes.
|
||
* Depth 3 keeps it behind the art (planets 5, ship 10).
|
||
*
|
||
* Placement (the fix for "it was off-screen"): the star is ANCHORED TO
|
||
* THE CAMERA VIEW, not to a fixed box near the system star. Each frame its
|
||
* view-space position is (home + wander + drift), wrapped back into the
|
||
* view (Starfield pattern) so it stays in the sky no matter how far the
|
||
* ship flies — then converted to world space as camera + offset. The
|
||
* drift trails the camera at (1 − parallax), so on screen it slowly
|
||
* wanders opposite to travel — a distant star, never a lens sticker.
|
||
*
|
||
* Config: data/systems.json → types.binary.effect.wanderer.
|
||
*/
|
||
import Phaser from '../vendor/phaser.js';
|
||
import { canvasTexture } from '../utils/Textures.js';
|
||
import { toColor } from '../utils/Color.js';
|
||
|
||
const DEPTH = 3; // behind the art, in front of the starfield
|
||
const MARGIN = 160; // keep the star's center this far inside the view, each side
|
||
|
||
/** A tight, bright core — the star's body. White (tint gives the color). */
|
||
function drawCore(ctx, size) {
|
||
const c = size / 2;
|
||
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,1.0)');
|
||
g.addColorStop(0.5, 'rgba(255,255,255,0.9)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, size, size);
|
||
}
|
||
|
||
/** A wide, soft halo — the star's corona. White (tint gives the color). */
|
||
function drawHalo(ctx, size) {
|
||
const c = size / 2;
|
||
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
|
||
g.addColorStop(0.0, 'rgba(255,255,255,0.55)');
|
||
g.addColorStop(0.4, 'rgba(255,255,255,0.28)');
|
||
g.addColorStop(1.0, 'rgba(255,255,255,0.0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, size, size);
|
||
}
|
||
|
||
/**
|
||
* The companion star (core + halo) wandering a slow seeded arc, anchored
|
||
* to the current camera view.
|
||
*
|
||
* @param {object} scene Phaser scene
|
||
* @param {string} color star color (CSS hex)
|
||
* @param {object} cfg data/systems.json → effect.wanderer
|
||
* @param {{phase?:number, angle?:number, drift?:number}} fx per-system seeded 0..1 variation
|
||
*/
|
||
export class WandererStar {
|
||
constructor(scene, color, cfg, fx = {}) {
|
||
this.scene = scene;
|
||
this.color = toColor(color, 0x9fd0ff);
|
||
this.cfg = cfg ?? {};
|
||
this.fx = fx ?? {};
|
||
this.core = null;
|
||
this.halo = null;
|
||
this.tSec = 0;
|
||
this.driftX = 0;
|
||
this.driftY = 0;
|
||
this.lastScrollX = 0;
|
||
this.lastScrollY = 0;
|
||
}
|
||
|
||
/** Build the core + halo (idempotent). */
|
||
create() {
|
||
const scene = this.scene;
|
||
if (this.core) return;
|
||
const {
|
||
coreSize = [60, 90],
|
||
haloSize = [200, 300],
|
||
parallax = 0.08,
|
||
speed = [3, 8],
|
||
} = this.cfg;
|
||
|
||
const coreKey = canvasTexture(scene, 'fx-wanderer-core', 64, 64, (ctx) => drawCore(ctx, 64));
|
||
const haloKey = canvasTexture(scene, 'fx-wanderer-halo', 128, 128, (ctx) => drawHalo(ctx, 128));
|
||
|
||
// Seeded HOME point in VIEW space (upper third of the view — a star in
|
||
// the sky) and a slow elliptical WANDER around it.
|
||
const fx = this.fx;
|
||
const phase = fx.phase ?? 0.5;
|
||
const angle = fx.angle ?? 0.5;
|
||
const drift = fx.drift ?? 0.5;
|
||
const w = scene.scale.width;
|
||
const h = scene.scale.height;
|
||
const homeX = w * (0.2 + 0.6 * phase);
|
||
const homeY = h * (0.12 + 0.3 * angle);
|
||
const ampX = w * (0.05 + 0.12 * drift);
|
||
const ampY = h * (0.04 + 0.1 * drift);
|
||
const omega = ((speed[0] + (speed[1] - speed[0]) * drift) / w) * Math.PI * 2; // rad/s (slow)
|
||
|
||
this.homeX = homeX;
|
||
this.homeY = homeY;
|
||
this.ampX = ampX;
|
||
this.ampY = ampY;
|
||
this.omega = omega;
|
||
this.phase0 = angle * Math.PI * 2;
|
||
this.p = parallax;
|
||
|
||
// Start at the home point in world space (camera + view offset).
|
||
const cam = scene.cameras.main;
|
||
const x0 = (cam?.scrollX ?? 0) + homeX;
|
||
const y0 = (cam?.scrollY ?? 0) + homeY;
|
||
|
||
this.core = scene.add
|
||
.image(x0, y0, coreKey)
|
||
.setDepth(DEPTH)
|
||
.setTint(this.color)
|
||
.setAlpha(1.0)
|
||
.setBlendMode(Phaser.BlendModes.ADD);
|
||
const cs = coreSize[0] + (coreSize[1] - coreSize[0]) * drift;
|
||
this.core.setScale(cs / 64);
|
||
|
||
this.halo = scene.add
|
||
.image(x0, y0, haloKey)
|
||
.setDepth(DEPTH)
|
||
.setTint(this.color)
|
||
.setAlpha(0.85)
|
||
.setBlendMode(Phaser.BlendModes.ADD);
|
||
const hs = haloSize[0] + (haloSize[1] - haloSize[0]) * drift;
|
||
this.halo.setScale(hs / 128);
|
||
|
||
this.lastScrollX = cam?.scrollX ?? 0;
|
||
this.lastScrollY = cam?.scrollY ?? 0;
|
||
}
|
||
|
||
/**
|
||
* Per-frame: anchor to the current view, add the slow elliptical wander,
|
||
* trail the camera at (1 − parallax), and wrap back into the view so the
|
||
* star never escapes the sky. Call after the camera has moved.
|
||
*/
|
||
update(nowMs, dtMs) {
|
||
if (!this.core) return;
|
||
const cam = this.scene.cameras.main;
|
||
const camX = cam?.scrollX ?? 0;
|
||
const camY = cam?.scrollY ?? 0;
|
||
|
||
// The camera delta; the star trails it at (1 − p), so on screen it
|
||
// drifts by −p of the motion (opposite to travel, like Starfield).
|
||
const dx = camX - this.lastScrollX;
|
||
const dy = camY - this.lastScrollY;
|
||
this.lastScrollX = camX;
|
||
this.lastScrollY = camY;
|
||
this.driftX -= dx * this.p;
|
||
this.driftY -= dy * this.p;
|
||
|
||
this.tSec += (dtMs || 0) / 1000;
|
||
const a = this.omega * this.tSec + this.phase0;
|
||
const w = this.scene.scale.width;
|
||
const h = this.scene.scale.height;
|
||
|
||
// View-space position, wrapped into the view (+ margin) — a distant
|
||
// star that wanders, but stays in the sky.
|
||
const vx = wrapIn(this.homeX + this.ampX * Math.cos(a) + this.driftX, -MARGIN, w + 2 * MARGIN);
|
||
const vy = wrapIn(this.homeY + this.ampY * Math.sin(a) + this.driftY, -MARGIN, h + 2 * MARGIN);
|
||
|
||
// World = camera + view offset.
|
||
const x = camX + vx;
|
||
const y = camY + vy;
|
||
this.core.setPosition(x, y);
|
||
this.halo.setPosition(x, y);
|
||
}
|
||
|
||
/** Tear down. */
|
||
destroy() {
|
||
this.core?.destroy();
|
||
this.halo?.destroy();
|
||
this.core = null;
|
||
this.halo = null;
|
||
}
|
||
}
|
||
|
||
/** Maps any coordinate into [left, left + span) — safe for huge deltas too. */
|
||
function wrapIn(v, left, span) {
|
||
return left + ((((v - left) % span) + span) % span);
|
||
}
|