fix
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// The animated backdrop, all on one canvas: drifting, twinkling stars with the
|
||||
// odd shooting star, the striped sun, and a perspective grid floor sliding
|
||||
// towards you. One canvas is one composited layer: no CSS 3D transforms, masks or
|
||||
// animations, which is what made the old CSS floor flicker in mobile Safari.
|
||||
//
|
||||
// Built to survive mobile browsers: star positions are stored as fractions of
|
||||
// the viewport so a resize (iOS fires them constantly as the toolbar collapses)
|
||||
// never re-scatters them; a frame that throws cannot kill the loop; and the loop
|
||||
// is restarted if it is ever found dead (dropped frame, back/forward cache,
|
||||
// throttled tab), so the animation cannot silently stop.
|
||||
|
||||
const STAR_COUNT = 320;
|
||||
const TINTS = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0'];
|
||||
|
||||
export function startBackdrop(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
// Positions are fractions of the viewport (nx, ny in [0, 1)).
|
||||
const stars = Array.from({ length: STAR_COUNT }, () => ({
|
||||
nx: Math.random(),
|
||||
ny: Math.random(),
|
||||
z: 0.25 + Math.random() * 0.75,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.6 + Math.random() * 1.8,
|
||||
tint: TINTS[Math.floor(Math.random() * TINTS.length)],
|
||||
}));
|
||||
let shooters = [];
|
||||
let nextShot = 4000;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let dpr = 1;
|
||||
let horizonY = 0; // where the floor starts
|
||||
let floorH = 0;
|
||||
let staticLayer = null; // sun + floor base, painted once per size
|
||||
let staticTop = 0; // the layer is empty above this row, so only the part below it is drawn
|
||||
let fade = null; // gradient that dissolves the grid into the horizon
|
||||
let shown = 0; // how many stars fit the current viewport
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
let lastFrame = performance.now();
|
||||
let resizeTimer = 0;
|
||||
|
||||
function resize() {
|
||||
// The canvas is a fixed box of 100vh anchored to the bottom (see the CSS), so
|
||||
// it does not change size when a phone's toolbar slides in and out.
|
||||
const nw = canvas.clientWidth;
|
||||
const nh = canvas.clientHeight;
|
||||
if (!nw || !nh) return;
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
if (nw !== w || nh !== h || canvas.width !== Math.round(nw * dpr)) {
|
||||
w = nw;
|
||||
h = nh;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
floorH = Math.round(h * 0.17);
|
||||
horizonY = h - floorH;
|
||||
staticLayer = buildStatic();
|
||||
fade = ctx.createLinearGradient(0, horizonY, 0, horizonY + floorH * 0.55);
|
||||
fade.addColorStop(0, 'rgba(34,9,63,1)');
|
||||
fade.addColorStop(1, 'rgba(34,9,63,0)');
|
||||
}
|
||||
shown = Math.min(STAR_COUNT, Math.round((w * h) / 5500));
|
||||
draw(performance.now());
|
||||
}
|
||||
|
||||
/** The parts that never move, painted once: the striped sun and the floor's base. */
|
||||
function buildStatic() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = canvas.width;
|
||||
c.height = canvas.height;
|
||||
const g = c.getContext('2d');
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const S = Math.max(180, Math.min(400, 0.34 * Math.min(w, h)));
|
||||
const R = S / 2;
|
||||
const cx = w / 2;
|
||||
const cy = horizonY - 0.16 * S; // its lower third is hidden behind the floor
|
||||
staticTop = Math.max(0, Math.floor(cy - R - 60)); // top of the sun's halo
|
||||
|
||||
const halo = g.createRadialGradient(cx, cy, R, cx, cy, R + 60);
|
||||
halo.addColorStop(0, 'rgba(255,58,216,0.30)');
|
||||
halo.addColorStop(1, 'rgba(255,58,216,0)');
|
||||
g.fillStyle = halo;
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, R + 60, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
|
||||
const t = document.createElement('canvas'); // the disc, with its stripes cut out
|
||||
t.width = t.height = Math.ceil(S * dpr);
|
||||
const tg = t.getContext('2d');
|
||||
tg.scale(dpr, dpr);
|
||||
const sun = tg.createLinearGradient(0, 0, 0, S);
|
||||
sun.addColorStop(0, '#fff27a');
|
||||
sun.addColorStop(0.32, '#ffb62e');
|
||||
sun.addColorStop(0.72, '#ff3ad8');
|
||||
sun.addColorStop(1, '#8a2cff');
|
||||
tg.fillStyle = sun;
|
||||
tg.beginPath();
|
||||
tg.arc(R, R, R, 0, Math.PI * 2);
|
||||
tg.fill();
|
||||
tg.globalCompositeOperation = 'destination-out';
|
||||
for (const [from, to] of [[0.46, 0.49], [0.56, 0.6], [0.67, 0.72], [0.79, 0.85], [0.91, 0.98]]) {
|
||||
tg.fillRect(0, from * S, S, (to - from) * S);
|
||||
}
|
||||
g.drawImage(t, cx - R, cy - R, S, S);
|
||||
|
||||
const base = g.createLinearGradient(0, horizonY, 0, h);
|
||||
base.addColorStop(0, '#22093f');
|
||||
base.addColorStop(1, '#0c0420');
|
||||
g.fillStyle = base;
|
||||
g.fillRect(0, horizonY, w, floorH);
|
||||
const glow = g.createLinearGradient(0, horizonY - 28, 0, horizonY);
|
||||
glow.addColorStop(0, 'rgba(40,245,255,0)');
|
||||
glow.addColorStop(1, 'rgba(40,245,255,0.45)');
|
||||
g.fillStyle = glow;
|
||||
g.fillRect(0, horizonY - 28, w, 28);
|
||||
g.fillStyle = '#28f5ff';
|
||||
g.fillRect(0, horizonY - 1, w, 2);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** The grid: lines at even steps in depth, projected onto the floor. */
|
||||
function drawGrid(now) {
|
||||
const Z_FAR = 26;
|
||||
const phase = reduce.matches ? 0 : ((now / 1000) * 0.42) % 1; // depth steps per second
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, horizonY, w, floorH);
|
||||
ctx.clip();
|
||||
ctx.strokeStyle = '#ff3ad8';
|
||||
|
||||
// lines across, sliding towards the viewer
|
||||
for (let j = 0; j < Z_FAR; j++) {
|
||||
const z = 1 + j - phase;
|
||||
const y = horizonY + floorH / z;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.lineWidth = 0.7 + 1.5 / Math.max(z, 1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// lines running into the distance, meeting at the vanishing point
|
||||
const spacing = floorH * 0.55;
|
||||
const n = Math.ceil((w / 2 / spacing) * 6);
|
||||
const cx = w / 2;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = -n; i <= n; i++) {
|
||||
ctx.moveTo(cx + (i * spacing) / Z_FAR, horizonY + floorH / Z_FAR);
|
||||
ctx.lineTo(cx + (i * spacing) / 0.4, horizonY + floorH / 0.4);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// dissolve everything into the horizon so distant lines cannot shimmer
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = fade;
|
||||
ctx.fillRect(0, horizonY, w, floorH * 0.55);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
if (!staticLayer) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const s = stars[i];
|
||||
const tw = 0.5 + 0.5 * Math.sin((now / 1000) * s.speed + s.phase);
|
||||
ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z;
|
||||
ctx.fillStyle = s.tint;
|
||||
const r = 0.4 + s.z * 1.3;
|
||||
ctx.fillRect(s.nx * w, s.ny * h, r, r);
|
||||
}
|
||||
for (const s of shooters) {
|
||||
const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.95)');
|
||||
g.addColorStop(1, 'rgba(255,58,216,0)');
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, s.life));
|
||||
ctx.strokeStyle = g;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
// sun and floor over the stars; only the part of the layer that has anything in it
|
||||
ctx.drawImage(staticLayer, 0, staticTop * dpr, canvas.width, canvas.height - staticTop * dpr, 0, staticTop, w, h - staticTop);
|
||||
drawGrid(now);
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const s = stars[i];
|
||||
s.nx -= (s.z * 0.012 * dt) / w;
|
||||
if (s.nx < 0) {
|
||||
s.nx += 1;
|
||||
s.ny = Math.random();
|
||||
}
|
||||
}
|
||||
nextShot -= dt;
|
||||
if (nextShot <= 0) {
|
||||
nextShot = 7000 + Math.random() * 9000;
|
||||
shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 });
|
||||
}
|
||||
for (const s of shooters) {
|
||||
s.x += (s.vx * dt) / 16;
|
||||
s.y += (s.vy * dt) / 16;
|
||||
s.life -= dt / 900;
|
||||
}
|
||||
shooters = shooters.filter((s) => s.life > 0);
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
raf = 0;
|
||||
try {
|
||||
const dt = Math.max(0, Math.min(64, now - last));
|
||||
last = now;
|
||||
update(dt);
|
||||
draw(now);
|
||||
} catch (e) {
|
||||
console.error('starfield frame failed; continuing', e);
|
||||
}
|
||||
lastFrame = performance.now();
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (raf || reduce.matches || document.visibilityState !== 'visible') return;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
/** (Re)start the loop from a clean state. Safe to call at any time. */
|
||||
function restart() {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
last = lastFrame = performance.now();
|
||||
if (reduce.matches) draw(last);
|
||||
else schedule();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(resize, 120);
|
||||
});
|
||||
window.addEventListener('orientationchange', () => setTimeout(resize, 250));
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') restart(); });
|
||||
window.addEventListener('pageshow', restart); // back/forward cache restore
|
||||
window.addEventListener('focus', restart);
|
||||
reduce.addEventListener('change', restart);
|
||||
|
||||
// Watchdog: a visible page whose loop has gone quiet gets restarted.
|
||||
setInterval(() => {
|
||||
if (!reduce.matches && document.visibilityState === 'visible' && performance.now() - lastFrame > 1500) restart();
|
||||
}, 1000);
|
||||
|
||||
resize();
|
||||
restart();
|
||||
}
|
||||
Reference in New Issue
Block a user