git / moonbite-network / static/home-film.js · raw

/* ============================================================================   MoonBite CINEMATIC FILM — the whole home page is one continuous, scroll-driven   shot. A sticky stage holds a persistent starfield + a flying moon while eight   "chapters" cross-fade over it as you scroll. No stacked sections.   Self-contained IIFE. Loaded only on / via home.html scripts_extra (defer).   - No JS / reduced motion  -> normal vertical flow, all chapters visible.   - Full motion             -> adds .is-film, drives the flight from scroll.   ========================================================================== */(function () {    "use strict";    var reduce = window.matchMedia &&        window.matchMedia("(prefers-reduced-motion: reduce)").matches;    var film = document.getElementById("film");    var chapters = film ? film.querySelectorAll(".chapter") : [];    var N = chapters.length || 1;    /* ---------------------------------------------------------- STARFIELD */    var canvas = document.getElementById("cineStars");    if (canvas && canvas.getContext) {        var ctx = canvas.getContext("2d");        var stars = [];        var shooters = [];        var w = 0, h = 0, dpr = Math.min(window.devicePixelRatio || 1, 2);        var raf = null;        var mx = 0, my = 0, tmx = 0, tmy = 0;        var flight = 0; // 0..1 scroll progress, fed by the film engine below        function resize() {            w = canvas.clientWidth;            h = canvas.clientHeight;            canvas.width = Math.floor(w * dpr);            canvas.height = Math.floor(h * dpr);            ctx.setTransform(dpr, 0, 0, dpr, 0, 0);            buildStars();        }        function buildStars() {            var count = Math.min(240, Math.floor((w * h) / 6200));            stars = [];            for (var i = 0; i < count; i++) {                var layer = Math.random();                stars.push({                    x: Math.random() * w,                    y: Math.random() * h,                    r: layer * 1.7 + 0.3,                    z: 0.25 + layer * 0.85,           // parallax + flight depth                    tw: Math.random() * Math.PI * 2,                    tws: 0.008 + Math.random() * 0.03,                    hue: Math.random() < 0.18 ? "52,211,255"                        : (Math.random() < 0.2 ? "124,92,255" : "230,235,255")                });            }        }        function spawnShooter() {            if (reduce) return;            var fromLeft = Math.random() < 0.5;            shooters.push({                x: fromLeft ? -40 : w + 40,                y: Math.random() * h * 0.5,                vx: (fromLeft ? 1 : -1) * (6 + Math.random() * 5),                vy: 2 + Math.random() * 2.5,                life: 0,                max: 60 + Math.random() * 30            });        }        function frame() {            ctx.clearRect(0, 0, w, h);            mx += (tmx - mx) * 0.05;            my += (tmy - my) * 0.05;            var cx = w / 2, cy = h * 0.5;            // As we "fly", stars drift outward from the centre + speed up twinkle.            var push = flight * 1.0;            for (var i = 0; i < stars.length; i++) {                var s = stars[i];                s.tw += s.tws;                var a = 0.35 + Math.abs(Math.sin(s.tw)) * 0.65;                var ox = (s.x - cx) * push * s.z * 0.55;                var oy = (s.y - cy) * push * s.z * 0.55;                var px = s.x + ox + mx * s.z * 22;                var py = s.y + oy + my * s.z * 22;                ctx.beginPath();                ctx.arc(px, py, s.r, 0, Math.PI * 2);                ctx.fillStyle = "rgba(" + s.hue + "," + a.toFixed(3) + ")";                ctx.fill();                if (s.r > 1.2) {                    ctx.beginPath();                    ctx.arc(px, py, s.r * 2.6, 0, Math.PI * 2);                    ctx.fillStyle = "rgba(" + s.hue + "," + (a * 0.12).toFixed(3) + ")";                    ctx.fill();                }            }            for (var j = shooters.length - 1; j >= 0; j--) {                var sh = shooters[j];                sh.life++;                sh.x += sh.vx;                sh.y += sh.vy;                var t = 1 - sh.life / sh.max;                if (t <= 0) { shooters.splice(j, 1); continue; }                var tailX = sh.x - sh.vx * 6;                var tailY = sh.y - sh.vy * 6;                var grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);                grad.addColorStop(0, "rgba(255,255,255," + (0.9 * t).toFixed(3) + ")");                grad.addColorStop(1, "rgba(124,92,255,0)");                ctx.strokeStyle = grad;                ctx.lineWidth = 2;                ctx.beginPath();                ctx.moveTo(sh.x, sh.y);                ctx.lineTo(tailX, tailY);                ctx.stroke();            }            raf = requestAnimationFrame(frame);        }        window.addEventListener("resize", resize, { passive: true });        resize();        if (reduce) {            for (var k = 0; k < stars.length; k++) {                var st = stars[k];                ctx.beginPath();                ctx.arc(st.x, st.y, st.r, 0, Math.PI * 2);                ctx.fillStyle = "rgba(" + st.hue + ",0.8)";                ctx.fill();            }        } else {            raf = requestAnimationFrame(frame);            setInterval(spawnShooter, 4200);            document.addEventListener("visibilitychange", function () {                if (document.hidden) { if (raf) cancelAnimationFrame(raf); raf = null; }                else if (!raf) { raf = requestAnimationFrame(frame); }            });            window.addEventListener("mousemove", function (e) {                tmx = (e.clientX / window.innerWidth) - 0.5;                tmy = (e.clientY / window.innerHeight) - 0.5;            }, { passive: true });        }        // Expose so the film engine can feed scroll progress into the starfield.        window.__cineSetFlight = function (p) { flight = p; };    }    /* -------------------------------------------------------- FILM ENGINE */    var moon = film ? film.querySelector(".film-moonwrap") : null;    var cue = film ? film.querySelector(".film-cue") : null;    var progFill = document.querySelector(".cine-progress > span");    var progMoon = document.querySelector(".cine-progress-moon");    var curve = document.querySelector(".cine-curve-line");    var curveLen = 0;    if (curve && curve.getTotalLength) {        curveLen = curve.getTotalLength();        curve.style.strokeDasharray = curveLen;    }    function setProgressBar(p) {        var pct = (p * 100).toFixed(2) + "%";        if (progFill) progFill.style.width = pct;        if (progMoon) progMoon.style.left = pct;    }    // The sticky cross-fade film gives each chapter one full viewport. That only    // works when the viewport is big enough for a chapter's multi-column layout    // to fit; on phones the columns stack and would clip inside the pinned stage,    // so those get the clean, fully-readable vertical fallback instead.    var roomy = window.innerWidth >= 900 && window.innerHeight >= 600;    if (!film || reduce || N < 2 || !roomy) {        // Fallback: normal flow. Reveal everything, draw the curve fully.        for (var f = 0; f < chapters.length; f++) chapters[f].classList.add("is-active");        if (curve) curve.style.strokeDashoffset = 0;        var chart0 = document.querySelector(".cine-chart");        if (chart0) chart0.classList.add("is-drawn");        setProgressBar(0);    } else {        film.classList.add("is-film");        // Give the flight some room: one screen per chapter, plus a little tail.        var track = Math.round(N * 100 + 20);        film.style.setProperty("--film-h", track + "vh");        var ticking = false;        var chart = document.querySelector(".cine-chart");        function render() {            var rect = film.getBoundingClientRect();            var total = film.offsetHeight - window.innerHeight;            if (total <= 0) total = 1;            var scrolled = -rect.top;            scrolled = scrolled < 0 ? 0 : (scrolled > total ? total : scrolled);            var p = scrolled / total;            // Feed the starfield so stars stream past as we fly.            if (window.__cineSetFlight) window.__cineSetFlight(p);            // Persistent moon flies across + shrinks + slowly rotates.            if (moon) {                var tx = (-p * 12).toFixed(2) + "vw";                var ty = (-p * 16).toFixed(2) + "vh";                var sc = (1.15 - p * 0.8).toFixed(3);                var rot = (p * 40).toFixed(2) + "deg";                moon.style.transform =                    "translate3d(" + tx + "," + ty + ",0) scale(" + sc + ") rotate(" + rot + ")";            }            // Cross-fade the chapters. Chapter i peaks at pos == i, so the first            // chapter is fully lit at the very top (p=0) and the last at p=1.            var pos = p * (N - 1);            var activeIdx = 0, activeDist = 1e9;            for (var i = 0; i < N; i++) {                var local = pos - i;          // 0 at this chapter's peak                var ad = Math.abs(local);                var opacity = 1 - ad;         // linear cross-fade, sums to ~1                opacity = opacity < 0 ? 0 : (opacity > 1 ? 1 : opacity);                var translateY = (local * -60).toFixed(1);                var scale = (1 - ad * 0.10).toFixed(3);                var ch = chapters[i];                ch.style.opacity = opacity.toFixed(3);                ch.style.transform =                    "translate3d(0," + translateY + "px,0) scale(" + scale + ")";                if (ad < activeDist) { activeDist = ad; activeIdx = i; }            }            for (var a = 0; a < N; a++) {                if (a === activeIdx) chapters[a].classList.add("is-active");                else chapters[a].classList.remove("is-active");            }            // Emission curve draws while its chapter (index 5) is on screen.            if (curve && curveLen) {                var dp = 1 - Math.min(Math.abs(pos - 5) * 1.4, 1);                dp = dp < 0 ? 0 : dp;                curve.style.strokeDashoffset = (curveLen * (1 - dp)).toFixed(1);                if (chart && dp > 0.06) chart.classList.add("is-drawn");            }            // Fade the scroll cue away after we leave the first chapter.            if (cue) cue.style.opacity = p > 0.04 ? "0" : "1";            setProgressBar(p);            ticking = false;        }        window.addEventListener("scroll", function () {            if (!ticking) { ticking = true; requestAnimationFrame(render); }        }, { passive: true });        window.addEventListener("resize", function () {            if (!ticking) { ticking = true; requestAnimationFrame(render); }        }, { passive: true });        render();    }})();