476 lines
14 KiB
TypeScript
476 lines
14 KiB
TypeScript
/**
|
|
* Background rendering: depth star field + hex grid.
|
|
* Adapted from agent-flow's background-layer.ts (Apache 2.0).
|
|
*/
|
|
|
|
import { COLORS, alphaHex } from '../constants/colors';
|
|
import { BACKGROUND } from '../constants/canvas-constants';
|
|
|
|
// ─── Depth Particle (star) ──────────────────────────────────────────────────
|
|
|
|
export interface DepthParticle {
|
|
x: number;
|
|
y: number;
|
|
size: number;
|
|
brightness: number;
|
|
speed: number;
|
|
depth: number;
|
|
twinkleOffset: number;
|
|
twinkleSpeed: number;
|
|
twinkleAmount: number;
|
|
flickerOffset: number;
|
|
flickerSpeed: number;
|
|
flickerAmount: number;
|
|
haloStrength: number;
|
|
}
|
|
|
|
export function createDepthParticles(w: number, h: number): DepthParticle[] {
|
|
const particles: DepthParticle[] = [];
|
|
for (let i = 0; i < BACKGROUND.starCount; i++) {
|
|
particles.push(createDepthParticle(w, h));
|
|
}
|
|
return particles;
|
|
}
|
|
|
|
function createDepthParticle(w: number, h: number, spawnAbove = false): DepthParticle {
|
|
const depth = Math.random();
|
|
const sizeBias = Math.pow(Math.random(), 2.35);
|
|
const size = 0.34 + sizeBias * 1.18 + (1 - depth) * 0.16;
|
|
const brightness = 0.22 + sizeBias * 0.34 + (1 - depth) * 0.08;
|
|
|
|
return {
|
|
x: Math.random() * w,
|
|
y: spawnAbove ? -8 - Math.random() * Math.max(12, h * 0.12) : Math.random() * h,
|
|
size,
|
|
brightness,
|
|
speed: 0.04 + Math.random() * 0.09 + (1 - depth) * 0.04,
|
|
depth,
|
|
twinkleOffset: Math.random() * Math.PI * 2,
|
|
twinkleSpeed: 0.18 + Math.random() * 0.4,
|
|
twinkleAmount: 0.05 + Math.random() * 0.08,
|
|
flickerOffset: Math.random() * Math.PI * 2,
|
|
flickerSpeed: 0.9 + Math.random() * 2.2,
|
|
flickerAmount: 0.015 + Math.random() * 0.04,
|
|
haloStrength: 0.08 + Math.random() * 0.14,
|
|
};
|
|
}
|
|
|
|
export function updateDepthParticles(
|
|
particles: DepthParticle[],
|
|
w: number,
|
|
h: number,
|
|
dt: number
|
|
): void {
|
|
for (const p of particles) {
|
|
p.y += p.speed * dt * 20;
|
|
if (p.y > h + 5) {
|
|
Object.assign(p, createDepthParticle(w, h, true));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Shooting Stars ────────────────────────────────────────────────────────
|
|
|
|
export interface ShootingStar {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
travel: number;
|
|
maxTravel: number;
|
|
length: number;
|
|
thickness: number;
|
|
brightness: number;
|
|
}
|
|
|
|
export interface ShootingStarField {
|
|
active: ShootingStar[];
|
|
spawnCooldown: number;
|
|
}
|
|
|
|
export function createShootingStarField(): ShootingStarField {
|
|
return {
|
|
active: [],
|
|
spawnCooldown: randomShootingStarCooldown(),
|
|
};
|
|
}
|
|
|
|
export function updateShootingStarField(
|
|
field: ShootingStarField,
|
|
w: number,
|
|
h: number,
|
|
dt: number
|
|
): void {
|
|
field.spawnCooldown -= dt;
|
|
if (field.spawnCooldown <= 0 && field.active.length < 1) {
|
|
field.active.push(createShootingStar(w, h));
|
|
field.spawnCooldown = randomShootingStarCooldown();
|
|
}
|
|
|
|
for (let i = field.active.length - 1; i >= 0; i--) {
|
|
const star = field.active[i];
|
|
star.travel += Math.hypot(star.vx, star.vy) * dt;
|
|
star.x += star.vx * dt;
|
|
star.y += star.vy * dt;
|
|
|
|
if (star.travel >= star.maxTravel) {
|
|
field.active.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createShootingStar(w: number, h: number): ShootingStar {
|
|
const margin = 60;
|
|
const sizeBias = Math.pow(Math.random(), 1.9);
|
|
const sizeScale = 0.68 + sizeBias * 0.92;
|
|
const { x, y, angle } = createShootingStarSpawn(w, h, margin);
|
|
const speed = 58 + sizeScale * 18 + Math.random() * 10;
|
|
const vx = Math.cos(angle) * speed;
|
|
const vy = Math.sin(angle) * speed;
|
|
const maxTravel = speed * computeShootingStarExitTime(x, y, vx, vy, w, h, margin + 28);
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
vx,
|
|
vy,
|
|
travel: 0,
|
|
maxTravel,
|
|
length: 14 + sizeScale * (10 + Math.random() * 8),
|
|
thickness: 0.34 + sizeScale * 0.34,
|
|
brightness: 0.14 + sizeScale * 0.09 + Math.random() * 0.03,
|
|
};
|
|
}
|
|
|
|
function randomShootingStarCooldown(): number {
|
|
return 16 + Math.random() * 14;
|
|
}
|
|
|
|
function createShootingStarSpawn(
|
|
w: number,
|
|
h: number,
|
|
margin: number
|
|
): { x: number; y: number; angle: number } {
|
|
const edgeOffset = Math.random() * Math.max(24, Math.min(w, h) * 0.06);
|
|
const variant = Math.floor(Math.random() * 4);
|
|
|
|
switch (variant) {
|
|
case 0:
|
|
return {
|
|
x: w + margin + edgeOffset,
|
|
y: h * (0.06 + Math.random() * 0.22),
|
|
angle: Math.PI - (0.3 + Math.random() * 0.12),
|
|
};
|
|
case 1:
|
|
return {
|
|
x: -margin - edgeOffset,
|
|
y: h * (0.06 + Math.random() * 0.22),
|
|
angle: 0.3 + Math.random() * 0.12,
|
|
};
|
|
case 2:
|
|
return {
|
|
x: w * (0.08 + Math.random() * 0.34),
|
|
y: -margin - edgeOffset,
|
|
angle: 0.96 + Math.random() * 0.18,
|
|
};
|
|
default:
|
|
return {
|
|
x: w * (0.58 + Math.random() * 0.34),
|
|
y: -margin - edgeOffset,
|
|
angle: Math.PI - (0.96 + Math.random() * 0.18),
|
|
};
|
|
}
|
|
}
|
|
|
|
function computeShootingStarExitTime(
|
|
x: number,
|
|
y: number,
|
|
vx: number,
|
|
vy: number,
|
|
w: number,
|
|
h: number,
|
|
margin: number
|
|
): number {
|
|
const exitTimes: number[] = [];
|
|
|
|
if (vx > 0.001) {
|
|
exitTimes.push((w + margin - x) / vx);
|
|
} else if (vx < -0.001) {
|
|
exitTimes.push((-margin - x) / vx);
|
|
}
|
|
|
|
if (vy > 0.001) {
|
|
exitTimes.push((h + margin - y) / vy);
|
|
} else if (vy < -0.001) {
|
|
exitTimes.push((-margin - y) / vy);
|
|
}
|
|
|
|
const positiveExitTimes = exitTimes.filter((time) => time > 0);
|
|
return Math.max(positiveExitTimes.length > 0 ? Math.min(...positiveExitTimes) : 0.001, 0.001);
|
|
}
|
|
|
|
// ─── Background Drawing ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Draw the space background: void fill + depth stars + optional hex grid.
|
|
*/
|
|
export function drawBackground(
|
|
ctx: CanvasRenderingContext2D,
|
|
w: number,
|
|
h: number,
|
|
particles: DepthParticle[],
|
|
shootingStars: ShootingStarField,
|
|
camera: { x: number; y: number; zoom: number },
|
|
time: number,
|
|
options?: { showHexGrid?: boolean; showStarField?: boolean }
|
|
): void {
|
|
const showStars = options?.showStarField ?? true;
|
|
const showHex = options?.showHexGrid ?? true;
|
|
|
|
// Deep void background
|
|
ctx.fillStyle = COLORS.void;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// Depth star field
|
|
if (showStars) {
|
|
const centerX = w * 0.5;
|
|
const centerY = h * 0.5;
|
|
|
|
for (const p of particles) {
|
|
const parallax = 1.06 - p.depth * 0.5;
|
|
const sx = p.x + camera.x * parallax * 0.068;
|
|
const sy = p.y + camera.y * parallax * 0.068;
|
|
const positionScale = getStarPositionScale(camera.zoom, p.depth);
|
|
const starX = projectZoomedWrappedCoord(sx, w, centerX, positionScale);
|
|
const starY = projectZoomedWrappedCoord(sy, h, centerY, positionScale);
|
|
const primaryTwinkle = Math.sin(time * p.twinkleSpeed + p.twinkleOffset) * p.twinkleAmount;
|
|
const secondaryTwinkle = Math.sin(time * p.flickerSpeed + p.flickerOffset) * p.flickerAmount;
|
|
const twinkle = clamp(1 + primaryTwinkle + secondaryTwinkle, 0.82, 1.22);
|
|
const zoomScale = getStarZoomScale(camera.zoom, p.depth);
|
|
const alpha = p.brightness * twinkle * (0.98 + (zoomScale - 1) * 0.35) * 0.52;
|
|
drawDepthParticle(ctx, starX, starY, p, alpha, zoomScale, twinkle);
|
|
}
|
|
}
|
|
|
|
// Hex grid
|
|
if (showHex) {
|
|
drawHexGrid(ctx, w, h, camera, time);
|
|
}
|
|
|
|
if (showStars) {
|
|
for (const shootingStar of shootingStars.active) {
|
|
drawShootingStar(ctx, shootingStar);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawDepthParticle(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
particle: DepthParticle,
|
|
alpha: number,
|
|
zoomScale: number,
|
|
twinkle: number
|
|
): void {
|
|
const size = Math.max(0.68, particle.size * zoomScale * (0.985 + (twinkle - 1) * 0.22));
|
|
const coreRadius = Math.max(0.48, size * (0.48 + (twinkle - 1) * 0.08));
|
|
const coreAlpha = clamp(Math.min(1, alpha * 1.08 + 0.04), 0.16, 0.72);
|
|
|
|
if (size > 0.8) {
|
|
const glowRadius = size * (1.45 + particle.haloStrength * 0.8);
|
|
const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius);
|
|
glow.addColorStop(0, COLORS.holoHot + alphaHex(coreAlpha * particle.haloStrength * 0.72));
|
|
glow.addColorStop(0.42, COLORS.holoBright + alphaHex(coreAlpha * particle.haloStrength * 0.34));
|
|
glow.addColorStop(1, COLORS.holoBright + alphaHex(0));
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, glowRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.fillStyle = COLORS.holoBright + alphaHex(coreAlpha * 0.12);
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, coreRadius * 1.7, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = COLORS.holoHot + alphaHex(coreAlpha);
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, coreRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
function drawShootingStar(ctx: CanvasRenderingContext2D, shootingStar: ShootingStar): void {
|
|
const progress = clamp01(shootingStar.travel / Math.max(shootingStar.maxTravel, 0.001));
|
|
const fadeIn = clamp01(progress / 0.06);
|
|
const alpha = shootingStar.brightness * fadeIn;
|
|
if (alpha <= 0) return;
|
|
|
|
const speed = Math.hypot(shootingStar.vx, shootingStar.vy) || 1;
|
|
const dirX = shootingStar.vx / speed;
|
|
const dirY = shootingStar.vy / speed;
|
|
const tailX = shootingStar.x - dirX * shootingStar.length;
|
|
const tailY = shootingStar.y - dirY * shootingStar.length;
|
|
|
|
const trailGradient = ctx.createLinearGradient(shootingStar.x, shootingStar.y, tailX, tailY);
|
|
trailGradient.addColorStop(0, COLORS.holoHot + alphaHex(alpha));
|
|
trailGradient.addColorStop(0.24, COLORS.holoBright + alphaHex(alpha * 0.28));
|
|
trailGradient.addColorStop(1, COLORS.holoBright + alphaHex(0));
|
|
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'screen';
|
|
ctx.lineCap = 'round';
|
|
ctx.strokeStyle = COLORS.holoBright + alphaHex(alpha * 0.1);
|
|
ctx.lineWidth = shootingStar.thickness * 2.1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tailX, tailY);
|
|
ctx.lineTo(shootingStar.x, shootingStar.y);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = trailGradient;
|
|
ctx.lineWidth = shootingStar.thickness;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tailX, tailY);
|
|
ctx.lineTo(shootingStar.x, shootingStar.y);
|
|
ctx.stroke();
|
|
|
|
const glowRadius = shootingStar.thickness * 3.4;
|
|
const headGlow = ctx.createRadialGradient(
|
|
shootingStar.x,
|
|
shootingStar.y,
|
|
0,
|
|
shootingStar.x,
|
|
shootingStar.y,
|
|
glowRadius
|
|
);
|
|
headGlow.addColorStop(0, COLORS.holoHot + alphaHex(alpha * 0.34));
|
|
headGlow.addColorStop(0.4, COLORS.holoBright + alphaHex(alpha * 0.12));
|
|
headGlow.addColorStop(1, COLORS.holoBright + alphaHex(0));
|
|
ctx.fillStyle = headGlow;
|
|
ctx.beginPath();
|
|
ctx.arc(shootingStar.x, shootingStar.y, glowRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = COLORS.holoHot + alphaHex(Math.min(1, alpha * 1.3 + 0.06));
|
|
ctx.beginPath();
|
|
ctx.arc(
|
|
shootingStar.x,
|
|
shootingStar.y,
|
|
Math.max(0.52, shootingStar.thickness * 0.7),
|
|
0,
|
|
Math.PI * 2
|
|
);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
function clamp01(value: number): number {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function getStarZoomScale(zoom: number, depth: number): number {
|
|
const zoomDelta = clamp(zoom, 0.45, 2.2) - 1;
|
|
const influence = 0.03 + (1 - depth) * 0.03;
|
|
return clamp(1 + zoomDelta * influence, 0.96, 1.07);
|
|
}
|
|
|
|
function getStarPositionScale(zoom: number, depth: number): number {
|
|
const zoomDelta = clamp(zoom, 0.45, 2.2) - 1;
|
|
const influence = 0.075 + (1 - depth) * 0.075;
|
|
return clamp(1 + zoomDelta * influence, 0.86, 1.18);
|
|
}
|
|
|
|
function projectZoomedWrappedCoord(
|
|
value: number,
|
|
size: number,
|
|
center: number,
|
|
scale: number
|
|
): number {
|
|
const repeatSize = size / Math.max(scale, 0.0001);
|
|
const repeatOffset = center - repeatSize * 0.5;
|
|
const normalized = wrapCoord(value - repeatOffset, repeatSize) + repeatOffset;
|
|
return wrapCoord(center + (normalized - center) * scale, size);
|
|
}
|
|
|
|
function wrapCoord(value: number, size: number): number {
|
|
return ((value % size) + size) % size;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
// ─── Hex Grid ───────────────────────────────────────────────────────────────
|
|
|
|
// Pre-computed hex vertex offsets
|
|
const HEX_OFFSETS: [number, number][] = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = (Math.PI / 3) * i - Math.PI / 6;
|
|
HEX_OFFSETS.push([Math.cos(angle), Math.sin(angle)]);
|
|
}
|
|
|
|
function drawHexGrid(
|
|
ctx: CanvasRenderingContext2D,
|
|
w: number,
|
|
h: number,
|
|
camera: { x: number; y: number; zoom: number },
|
|
time: number
|
|
): void {
|
|
const lodScale =
|
|
camera.zoom < 0.24
|
|
? 0
|
|
: camera.zoom < 0.34
|
|
? 4
|
|
: camera.zoom < 0.46
|
|
? 3
|
|
: camera.zoom < 0.62
|
|
? 2
|
|
: 1;
|
|
if (lodScale === 0) return;
|
|
|
|
const zoomFade = clamp((camera.zoom - 0.22) / 0.4, 0, 1);
|
|
const size = BACKGROUND.hexSize * lodScale;
|
|
const pulse =
|
|
BACKGROUND.hexAlpha * zoomFade * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed));
|
|
|
|
// Visible region in world space (expanded a bit for edge cells)
|
|
const worldX0 = -camera.x / camera.zoom - size * 2;
|
|
const worldY0 = -camera.y / camera.zoom - size * 2;
|
|
const worldX1 = (w - camera.x) / camera.zoom + size * 2;
|
|
const worldY1 = (h - camera.y) / camera.zoom + size * 2;
|
|
|
|
const rowH = size * 1.5;
|
|
const colW = size * Math.sqrt(3);
|
|
|
|
const rowStart = Math.floor(worldY0 / rowH);
|
|
const rowEnd = Math.ceil(worldY1 / rowH);
|
|
const colStart = Math.floor(worldX0 / colW);
|
|
const colEnd = Math.ceil(worldX1 / colW);
|
|
|
|
ctx.save();
|
|
ctx.translate(camera.x, camera.y);
|
|
ctx.scale(camera.zoom, camera.zoom);
|
|
|
|
ctx.strokeStyle = COLORS.hexGrid + alphaHex(pulse);
|
|
ctx.lineWidth = 0.5 / camera.zoom;
|
|
|
|
ctx.beginPath();
|
|
for (let row = rowStart; row <= rowEnd; row++) {
|
|
for (let col = colStart; col <= colEnd; col++) {
|
|
const cx = col * colW + (row % 2 === 0 ? 0 : colW / 2);
|
|
const cy = row * rowH;
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
const [ox, oy] = HEX_OFFSETS[i];
|
|
const px = cx + ox * size;
|
|
const py = cy + oy * size;
|
|
if (i === 0) ctx.moveTo(px, py);
|
|
else ctx.lineTo(px, py);
|
|
}
|
|
ctx.closePath();
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|