fertig-classic-games/src/games/mastervega/VegaNebula.js

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(); },
};
}