PageSourceSearch

https://summitvalley.app/js/lemming.js

js summitvalley.app collected 2026-10-02 01:15:10 UTC 7,612 bytes, 180 lines download raw bytes

1// lemming.js — Three.js Chief Lemming for the Summit Valley Landing Page.
2// Scroll-fade: lemming hides when promo-wrap scrolls over its head.
3// Hero events: dispatches sv-hero-hidden / sv-hero-revealed so weather, bubble,
4// and other layers can stagger their reveal sequence.
5
6import * as THREE from './lib/three.module.js';
7import { GLTFLoader } from './lib/GLTFLoader.js';
8
9console.log('[lemming] module loaded, THREE:', !!THREE);
10
11const canvas = document.getElementById('lemming-canvas');
12console.log('[lemming] canvas element:', canvas, 'size:', canvas?.offsetWidth, canvas?.offsetHeight);
13
14const isMobile = window.innerWidth <= 768;
15
16let renderer;
17try {
18  // On mobile: no antialias = much lighter GPU load
19  renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: !isMobile });
20  console.log('[lemming] WebGLRenderer created OK');
21} catch (e) {
22  console.error('[lemming] WebGLRenderer FAILED:', e);
23  throw e;
24}
25// Cap pixel ratio: 1 on mobile, 1.5 on tablets (iPad landscape = dPR 2 → saves ~44% GPU work), full on desktop.
26const isTablet = !isMobile && window.innerWidth <= 1194;
27renderer.setPixelRatio(isMobile ? 1 : isTablet ? Math.min(window.devicePixelRatio, 1.5) : window.devicePixelRatio);
28renderer.outputColorSpace = THREE.SRGBColorSpace;
29renderer.shadowMap.enabled = false;
30
31const scene = new THREE.Scene();
32
33// Mobile: 360px tall canvas — matches CSS height so legs aren't clipped
34function sceneH() {
35  if (isMobile) return 360;
36  return Math.max(280, window.innerWidth * 915 / 1719);
37}
38
39const camera = new THREE.PerspectiveCamera(40, window.innerWidth / sceneH(), 0.1, 100);
40camera.position.set(0, 1.0, isMobile ? 2.8 : 4.5);
41camera.lookAt(0, 0.4, 0);
42
43// Lighting — warm outdoor feel
44const ambient = new THREE.AmbientLight(0xfff8f0, 1.4);
45scene.add(ambient);
46const sun = new THREE.DirectionalLight(0xfff5e0, 2.0);
47sun.position.set(3, 8, 5);
48scene.add(sun);
49const fill = new THREE.DirectionalLight(0xd0e8ff, 0.6);
50fill.position.set(-3, 2, -2);
51scene.add(fill);
52
53// ── State ─────────────────────────────────────────────────────────────────────
54const loader = new GLTFLoader();
55
56let mixer         = null;
57let model         = null;
58let idleAction    = null;
59let charHeadPoint = null;
60
61const promoEl = document.querySelector('.promo-wrap');
62
63// ── Hero visibility ────────────────────────────────────────────────────────────
64// prevCovered: tracks last known state so events fire on change, not every tick.
65// heroActive: pauses Three.js render loop when hero is fully hidden — saves GPU
66//             for work the user literally cannot see.
67let prevCovered = false;
68let heroActive  = true; // hero visible on page load
69
70// ── Scroll-fade: hide lemming when promo-wrap scrolls over its head ───────────
71function checkCoverage() {
72  if (!charHeadPoint || !promoEl) return;
73  const projected   = charHeadPoint.clone().project(camera);
74  const canvasH     = sceneH();
75  const headScreenY = (-projected.y * 0.5 + 0.5) * canvasH;
76  const promoTop    = promoEl.getBoundingClientRect().top;
77  const isCovered   = promoTop <= headScreenY;
78
79  canvas.style.opacity = isCovered ? '0' : '1';
80
81  // Only fire hero events when the covered state actually changes
82  if (isCovered !== prevCovered) {
83    prevCovered = isCovered;
84
85    if (isCovered) {
86      // ── Hero hiding ──────────────────────────────────────────────────────
87      // Dispatch immediately so weather/bubble layers start their fade-out.
88      // Pause Three.js loop AFTER the CSS opacity fade completes (~400ms) so
89      // the canvas is never frozen mid-transition — it fades out smoothly first.
90      window.dispatchEvent(new CustomEvent('sv-hero-hidden'));
91      setTimeout(() => { heroActive = false; }, 420);
92    } else {
93      // ── Hero revealing ───────────────────────────────────────────────────
94      // Resume Three.js FIRST so the canvas has a rendered frame before the
95      // opacity fade-in begins. Weather + bubble layers stagger behind via
96      // their own event listeners (250ms and 500ms delays).
97      heroActive = true;
98      window.dispatchEvent(new CustomEvent('sv-hero-revealed'));
99    }
100  }
101}
102
103window.addEventListener('scroll', checkCoverage, { passive: true });
104
105// ── Load idle GLB ─────────────────────────────────────────────────────────────
106loader.load(
107  'assets/glb/Chief Lemming - Breathing Idle.glb',
108  (gltf) => {
109    model = gltf.scene;
110    model.scale.setScalar(isMobile ? 0.45 : 0.45);
111    model.position.set(-0.35, isMobile ? -0.7 : -1.25, -0.3);
112    scene.add(model);
113
114    // Fix transparency/depth sorting
115    model.traverse((child) => {
116      if (child.isMesh) {
117        child.material.depthWrite = true;
118        child.material.depthTest  = true;
119        if (child.material.opacity >= 0.99) {
120          child.material.transparent = false;
121        }
122        child.renderOrder = 0;
123      }
124    });
125
126    if (gltf.animations.length > 0) {
127      mixer      = new THREE.AnimationMixer(model);
128      idleAction = mixer.clipAction(gltf.animations[0]);
129      idleAction.play();
130    }
131
132    // Compute head point from bounding box top-center (same as original app)
133    const box = new THREE.Box3().setFromObject(model);
134    charHeadPoint = new THREE.Vector3(
135      (box.min.x + box.max.x) / 2,
136      box.max.y,
137      (box.min.z + box.max.z) / 2
138    );
139    checkCoverage();
140
141    // React clip not loaded — no click interaction on landing page.
142  },
143  undefined,
144  (err) => console.error('[lemming] GLB load FAILED:', err)
145);
146
147// No click/hover interaction on the landing page — pointer-events: none in CSS.
148
149// ── Resize — canvas height = scene height (not full viewport) ─────────────────
150function onResize() {
151  const w = window.innerWidth;
152  const h = sceneH();
153  renderer.setSize(w, h, false);
154  camera.aspect = w / h;
155  camera.updateProjectionMatrix();
156}
157onResize();
158window.addEventListener('resize', onResize);
159
160// ── IntersectionObserver — pause render loop when canvas is off-screen ─────────
161// Note: the canvas is position:fixed so it never actually leaves the viewport.
162// heroActive (above) is the primary pause mechanism for the hidden-hero state.
163// This observer is kept as a lightweight safety net for any edge cases.
164let lemmingVisible = true;
165const visObserver = new IntersectionObserver(
166  (entries) => { lemmingVisible = entries[0].isIntersecting; },
167  { threshold: 0 }
168);
169visObserver.observe(canvas);
170
171// ── Render loop ───────────────────────────────────────────────────────────────
172const clock = new THREE.Clock();
173(function animate() {
174  requestAnimationFrame(animate);
175  if (!lemmingVisible) return; // canvas off-screen (edge case safety)
176  if (!heroActive) return;     // hero hidden under promo — GPU freed
177  const delta = clock.getDelta();
178  if (mixer) mixer.update(delta);
179  renderer.render(scene, camera);
180})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.