221 lines
5.8 KiB
JavaScript
221 lines
5.8 KiB
JavaScript
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// DONAU2SPACE // DEV ENTITY — Canvas Starfield
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
import { random, lerp, clamp } from './utils.js';
|
|
|
|
const STAR_COUNT = 900;
|
|
const LAYER_COUNT = 3;
|
|
const SHOOT_CHANCE = 0.001;
|
|
|
|
let canvas, ctx, W, H;
|
|
let stars = [];
|
|
let shootingStars = [];
|
|
let mouseX = 0.5, mouseY = 0.5;
|
|
let targetParallax = { x: 0, y: 0 };
|
|
let parallax = { x: 0, y: 0 };
|
|
let warpFactor = 0;
|
|
let warpTarget = 0;
|
|
let drift = 0;
|
|
let running = true;
|
|
let nebulae = [];
|
|
|
|
function createStar() {
|
|
const layer = (Math.random() * LAYER_COUNT) | 0;
|
|
const depth = (layer + 1) / LAYER_COUNT;
|
|
return {
|
|
x: Math.random() * 2 - 0.5,
|
|
y: Math.random() * 2 - 0.5,
|
|
size: random(0.3, 1.8) * depth,
|
|
brightness: random(0.3, 1),
|
|
twinkleSpeed: random(0.5, 3),
|
|
twinklePhase: random(0, Math.PI * 2),
|
|
layer,
|
|
depth,
|
|
hue: Math.random() < 0.15 ? random(20, 50) : random(200, 240),
|
|
saturation: Math.random() < 0.8 ? random(0, 15) : random(40, 70),
|
|
};
|
|
}
|
|
|
|
function createNebula() {
|
|
return {
|
|
x: random(0.1, 0.9),
|
|
y: random(0.1, 0.9),
|
|
radius: random(150, 400),
|
|
hue: random(180, 300),
|
|
alpha: random(0.01, 0.03),
|
|
drift: random(-0.00002, 0.00002),
|
|
};
|
|
}
|
|
|
|
function createShootingStar() {
|
|
const angle = random(-0.5, -0.2);
|
|
const speed = random(6, 14);
|
|
return {
|
|
x: random(-0.1, 1.1),
|
|
y: random(-0.1, 0.3),
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * -speed,
|
|
life: 1,
|
|
decay: random(0.015, 0.035),
|
|
length: random(40, 100),
|
|
brightness: random(0.6, 1),
|
|
};
|
|
}
|
|
|
|
export function initStarfield(canvasEl) {
|
|
canvas = canvasEl;
|
|
ctx = canvas.getContext('2d');
|
|
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
document.addEventListener('mousemove', e => {
|
|
mouseX = e.clientX / W;
|
|
mouseY = e.clientY / H;
|
|
});
|
|
|
|
for (let i = 0; i < STAR_COUNT; i++) stars.push(createStar());
|
|
for (let i = 0; i < 4; i++) nebulae.push(createNebula());
|
|
|
|
tick();
|
|
}
|
|
|
|
function resize() {
|
|
W = canvas.width = window.innerWidth;
|
|
H = canvas.height = window.innerHeight;
|
|
}
|
|
|
|
function tick() {
|
|
if (!running) return;
|
|
requestAnimationFrame(tick);
|
|
draw();
|
|
}
|
|
|
|
function draw() {
|
|
const time = performance.now() / 1000;
|
|
drift += 0.0001;
|
|
|
|
// Parallax smoothing
|
|
targetParallax.x = (mouseX - 0.5) * 30;
|
|
targetParallax.y = (mouseY - 0.5) * 20;
|
|
parallax.x = lerp(parallax.x, targetParallax.x, 0.03);
|
|
parallax.y = lerp(parallax.y, targetParallax.y, 0.03);
|
|
|
|
// Warp smoothing
|
|
warpFactor = lerp(warpFactor, warpTarget, 0.02);
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
// Nebulae (subtle colored glow patches)
|
|
for (const neb of nebulae) {
|
|
neb.x += neb.drift;
|
|
const nx = neb.x * W + parallax.x * 0.2;
|
|
const ny = neb.y * H + parallax.y * 0.2;
|
|
const grad = ctx.createRadialGradient(nx, ny, 0, nx, ny, neb.radius);
|
|
grad.addColorStop(0, `hsla(${neb.hue}, 60%, 40%, ${neb.alpha})`);
|
|
grad.addColorStop(1, 'transparent');
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(nx - neb.radius, ny - neb.radius, neb.radius * 2, neb.radius * 2);
|
|
}
|
|
|
|
// Stars
|
|
for (const star of stars) {
|
|
const twinkle = Math.sin(time * star.twinkleSpeed + star.twinklePhase) * 0.3 + 0.7;
|
|
const alpha = star.brightness * twinkle;
|
|
|
|
// Parallax offset based on depth
|
|
const px = parallax.x * star.depth;
|
|
const py = parallax.y * star.depth;
|
|
|
|
// Warp stretch
|
|
const warpStretch = warpFactor * star.depth * 40;
|
|
|
|
let sx = (star.x + drift * star.depth) * W + px;
|
|
let sy = star.y * H + py;
|
|
|
|
// Wrap around
|
|
sx = ((sx % W) + W) % W;
|
|
sy = ((sy % H) + H) % H;
|
|
|
|
const size = star.size * (1 + warpFactor * 2);
|
|
|
|
ctx.globalAlpha = clamp(alpha, 0, 1);
|
|
|
|
if (warpFactor > 0.1) {
|
|
// Warp mode: draw streaks
|
|
ctx.strokeStyle = `hsla(${star.hue}, ${star.saturation}%, 85%, ${alpha})`;
|
|
ctx.lineWidth = size * 0.6;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sx, sy);
|
|
ctx.lineTo(sx - warpStretch, sy);
|
|
ctx.stroke();
|
|
} else {
|
|
// Normal: draw dots with subtle glow
|
|
ctx.fillStyle = `hsla(${star.hue}, ${star.saturation}%, 85%, ${alpha})`;
|
|
ctx.beginPath();
|
|
ctx.arc(sx, sy, size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Glow for brighter stars
|
|
if (star.brightness > 0.7 && star.size > 1) {
|
|
ctx.globalAlpha = alpha * 0.15;
|
|
ctx.beginPath();
|
|
ctx.arc(sx, sy, size * 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Shooting stars
|
|
if (Math.random() < SHOOT_CHANCE && shootingStars.length < 3) {
|
|
shootingStars.push(createShootingStar());
|
|
}
|
|
|
|
for (let i = shootingStars.length - 1; i >= 0; i--) {
|
|
const ss = shootingStars[i];
|
|
ss.x += ss.vx / W;
|
|
ss.y += ss.vy / H;
|
|
ss.life -= ss.decay;
|
|
|
|
if (ss.life <= 0) {
|
|
shootingStars.splice(i, 1);
|
|
continue;
|
|
}
|
|
|
|
const sx = ss.x * W;
|
|
const sy = ss.y * H;
|
|
const ex = sx - (ss.vx / W) * ss.length;
|
|
const ey = sy - (ss.vy / H) * ss.length;
|
|
|
|
const grad = ctx.createLinearGradient(sx, sy, ex, ey);
|
|
grad.addColorStop(0, `rgba(255, 255, 255, ${ss.life * ss.brightness})`);
|
|
grad.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
|
|
|
ctx.strokeStyle = grad;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sx, sy);
|
|
ctx.lineTo(ex, ey);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
export function setWarp(factor) {
|
|
warpTarget = clamp(factor, 0, 1);
|
|
}
|
|
|
|
export function triggerShootingStar() {
|
|
shootingStars.push(createShootingStar());
|
|
}
|
|
|
|
export function pause() { running = false; }
|
|
export function resume() {
|
|
if (!running) { running = true; tick(); }
|
|
}
|
|
|
|
export function setOpacity(val) {
|
|
if (canvas) canvas.style.opacity = clamp(val, 0, 1);
|
|
}
|