175 lines
5.4 KiB
JavaScript
175 lines
5.4 KiB
JavaScript
// Master of Vega — the star map's nebula backdrop.
|
|
//
|
|
// A fullscreen Phaser Shader GameObject running domain-warped fBm, seeded from
|
|
// the galaxy seed so every game has its own sky. Modelled on
|
|
// src/games/balatro/BalatroSwirlPipeline.js, including its discipline: the
|
|
// Canvas path returns an object with the SAME API, so callers never branch on
|
|
// the renderer.
|
|
|
|
import * as Phaser from 'phaser';
|
|
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
|
|
|
|
const FRAG = `
|
|
precision mediump float;
|
|
|
|
uniform float time;
|
|
uniform vec2 resolution;
|
|
uniform vec3 uColorA;
|
|
uniform vec3 uColorB;
|
|
uniform vec3 uColorC;
|
|
uniform float uSeed;
|
|
uniform float uDensity;
|
|
uniform float uShape; // 0 = even cloud, 1 = spiral arms, 2 = ring
|
|
|
|
varying vec2 outTexCoord;
|
|
|
|
float hash(vec2 p) {
|
|
p = fract(p * vec2(123.34, 456.21) + uSeed);
|
|
p += dot(p, p + 45.32);
|
|
return fract(p.x * p.y);
|
|
}
|
|
|
|
float vnoise(vec2 p) {
|
|
vec2 i = floor(p);
|
|
vec2 f = fract(p);
|
|
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
float a = hash(i);
|
|
float b = hash(i + vec2(1.0, 0.0));
|
|
float c = hash(i + vec2(0.0, 1.0));
|
|
float d = hash(i + vec2(1.0, 1.0));
|
|
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
|
}
|
|
|
|
float fbm(vec2 p) {
|
|
float total = 0.0;
|
|
float amp = 0.5;
|
|
for (int i = 0; i < 5; i++) {
|
|
total += vnoise(p) * amp;
|
|
p *= 2.02;
|
|
amp *= 0.5;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
void main() {
|
|
vec2 uv = outTexCoord;
|
|
vec2 p = (uv - 0.5) * vec2(resolution.x / resolution.y, 1.0) * 2.2;
|
|
|
|
float t = time * 0.012;
|
|
|
|
// Domain warp: noise displacing the lookup of more noise. This is what turns
|
|
// bland cloud into something with filaments and structure.
|
|
vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, 1.3) - t * 0.7));
|
|
vec2 r = vec2(fbm(p + 4.0 * q + vec2(1.7, 9.2) + t * 0.4),
|
|
fbm(p + 4.0 * q + vec2(8.3, 2.8) - t * 0.3));
|
|
float f = fbm(p + 4.0 * r);
|
|
|
|
// The nebula is generated to agree with the galaxy's own topology, so the gas
|
|
// sits where the stars do rather than fighting the layout.
|
|
float radius = length(p);
|
|
float mask = 1.0;
|
|
if (uShape > 1.5) {
|
|
mask = smoothstep(0.35, 0.95, radius) * (1.0 - smoothstep(1.5, 2.1, radius));
|
|
} else if (uShape > 0.5) {
|
|
float ang = atan(p.y, p.x);
|
|
float arms = cos(ang * 2.0 - radius * 2.6) * 0.5 + 0.5;
|
|
mask = mix(0.35, 1.0, arms) * (1.0 - smoothstep(0.7, 2.0, radius));
|
|
} else {
|
|
mask = 1.0 - smoothstep(0.5, 1.9, radius);
|
|
}
|
|
|
|
float v = clamp(f * 1.5 * uDensity * mask, 0.0, 1.0);
|
|
|
|
vec3 col = mix(uColorA, uColorB, clamp(f * f * 2.4, 0.0, 1.0));
|
|
col = mix(col, uColorC, clamp(length(r) * 0.75, 0.0, 1.0));
|
|
col *= v;
|
|
|
|
// A faint deep-space floor so the screen is never pure black.
|
|
col += uColorA * 0.16;
|
|
|
|
gl_FragColor = vec4(col, 1.0);
|
|
}
|
|
`;
|
|
|
|
// Palettes chosen so empire colours and the range overlay stay readable on top.
|
|
export const NEBULA_PALETTES = {
|
|
violet: [0x141026, 0x5a2f8c, 0x2b6fa8],
|
|
ember: [0x1a1010, 0x8c3a2f, 0xc07a2a],
|
|
teal: [0x0c1a1e, 0x1f6b78, 0x54c0a8],
|
|
rose: [0x1a0f18, 0x8c3060, 0x4a3ba0],
|
|
gold: [0x191408, 0x8a6a1e, 0xa83a2a],
|
|
};
|
|
const PALETTE_ORDER = Object.keys(NEBULA_PALETTES);
|
|
|
|
const SHAPE_ID = { elliptical: 0, cluster: 0, spiral: 1, ring: 2 };
|
|
|
|
const vec = (hex) => ({
|
|
x: ((hex >> 16) & 255) / 255,
|
|
y: ((hex >> 8) & 255) / 255,
|
|
z: (hex & 255) / 255,
|
|
});
|
|
|
|
/**
|
|
* Build the nebula backdrop into `layer`.
|
|
* Returns { setDensity, destroy } on both renderer paths.
|
|
*/
|
|
export function makeNebula(scene, layer, { seed = 1, shapeId = 'spiral', density = 1 } = {}) {
|
|
const paletteName = PALETTE_ORDER[Math.abs(seed) % PALETTE_ORDER.length];
|
|
const [a, b, c] = NEBULA_PALETTES[paletteName];
|
|
const usingWebGL = scene.renderer && scene.renderer.type === Phaser.WEBGL;
|
|
|
|
if (usingWebGL) {
|
|
const base = new Phaser.Display.BaseShader('vega-nebula', FRAG, undefined, {
|
|
uColorA: { type: '3f', value: vec(a) },
|
|
uColorB: { type: '3f', value: vec(b) },
|
|
uColorC: { type: '3f', value: vec(c) },
|
|
uSeed: { type: '1f', value: (Math.abs(seed) % 1000) / 1000 },
|
|
uDensity: { type: '1f', value: density },
|
|
uShape: { type: '1f', value: SHAPE_ID[shapeId] ?? 0 },
|
|
});
|
|
const go = scene.add.shader(base, GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT);
|
|
layer.add(go);
|
|
return {
|
|
paletteName,
|
|
setDensity(v) { go.setUniform('uDensity.value', v); },
|
|
destroy() { go.destroy(); },
|
|
};
|
|
}
|
|
|
|
// Canvas fallback: a vertical gradient plus a few huge soft blobs drifting.
|
|
// Not the same picture, but the same job — the map is never bare black.
|
|
const g = scene.add.graphics();
|
|
g.fillGradientStyle(a, a, 0x05060a, 0x05060a, 1);
|
|
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
|
layer.add(g);
|
|
|
|
const blobs = [];
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const blob = scene.add.graphics();
|
|
blob.fillStyle(i % 2 === 0 ? b : c, 0.10);
|
|
blob.fillCircle(0, 0, 260 + i * 90);
|
|
blob.setPosition(
|
|
(GAME_WIDTH / 6) * (i + 0.5) + (i % 2) * 180,
|
|
GAME_HEIGHT * (0.3 + 0.12 * (i % 3)),
|
|
);
|
|
blob.setBlendMode(Phaser.BlendModes.ADD);
|
|
layer.add(blob);
|
|
blobs.push(blob);
|
|
scene.tweens.add({
|
|
targets: blob,
|
|
x: blob.x + 120 - i * 40,
|
|
y: blob.y + 70,
|
|
duration: 18000 + i * 3500,
|
|
yoyo: true,
|
|
repeat: -1,
|
|
ease: 'Sine.easeInOut',
|
|
});
|
|
}
|
|
|
|
return {
|
|
paletteName,
|
|
setDensity(v) { for (const blob of blobs) blob.setAlpha(0.10 * v); },
|
|
destroy() { g.destroy(); for (const blob of blobs) blob.destroy(); },
|
|
};
|
|
}
|