[object Object]

← back to Atmos Game

Add AI 3D-item pipeline (FLUX->HF image-to-3D) + GLTFLoader loader with primitive fallbacks; plane.glb via Shap-E

8de8aab005abef8805a78b68ab4b4b6b599d4e30 · 2026-07-23 15:31:27 -0700 · Steve

Files touched

Diff

commit 8de8aab005abef8805a78b68ab4b4b6b599d4e30
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 23 15:31:27 2026 -0700

    Add AI 3D-item pipeline (FLUX->HF image-to-3D) + GLTFLoader loader with primitive fallbacks; plane.glb via Shap-E
---
 assets/gen/bakeoff.py    |  47 ++++
 assets/gen/build.py      |  47 ++--
 assets/gen/sf3d_test.py  |  27 +++
 assets/gen/sf3d_try.py   |  24 ++
 assets/models/README.md  |  10 +
 assets/models/plane.glb  | Bin 0 -> 673620 bytes
 assets/ref/balloon.png   | Bin 0 -> 231058 bytes
 assets/ref/plane_rgb.png | Bin 0 -> 114863 bytes
 index.html               | 597 ++++++++++++++++++++++-------------------------
 9 files changed, 416 insertions(+), 336 deletions(-)

diff --git a/assets/gen/bakeoff.py b/assets/gen/bakeoff.py
new file mode 100644
index 0000000..5502b90
--- /dev/null
+++ b/assets/gen/bakeoff.py
@@ -0,0 +1,47 @@
+"""Corrected image-to-3D bake-off: TRELLIS (quality) then Shap-E (reliable).
+First GLB wins; prints which backend to standardize on."""
+from gradio_client import Client, handle_file
+import shutil, os, sys
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+img = os.path.join(ROOT, "assets", "ref", "plane_rgb.png")
+out = os.path.join(ROOT, "assets", "models", "plane.glb")
+
+def save(glb):
+    if isinstance(glb,(list,tuple)): glb = glb[-1]        # download_glb is last for TRELLIS
+    if isinstance(glb, dict): glb = glb.get("path") or glb.get("value")
+    if not glb or not str(glb).endswith((".glb",".gltf")): return None
+    shutil.copy(glb, out); return os.path.getsize(out)
+
+def trellis(cl):
+    try: cl.predict(api_name="/start_session")
+    except Exception as e: print("   start_session", str(e)[:100], flush=True)
+    r = cl.predict(
+        image=handle_file(img), multiimages=[], seed=0,
+        ss_guidance_strength=7.5, ss_sampling_steps=12,
+        slat_guidance_strength=3.0, slat_sampling_steps=12,
+        multiimage_algo="stochastic", mesh_simplify=0.95, texture_size=1024,
+        api_name="/generate_and_extract_glb")
+    # returns (video, litmodel3d_glb, download_glb) — take the glb file, not the video
+    for cand in ([r[2], r[1]] if isinstance(r,(list,tuple)) and len(r)>=3 else [r]):
+        s = save(cand)
+        if s: return s
+    return None
+
+def shape(cl):
+    r = cl.predict(image=handle_file(img), seed=0, guidance_scale=3.0,
+                   num_inference_steps=64, api_name="/image-to-3d")
+    return save(r)
+
+SPACES = [("trellis-community/TRELLIS", trellis), ("hysts/Shap-E", shape)]
+for nm, fn in SPACES:
+    print("="*58, "\nSPACE:", nm, flush=True)
+    try:
+        cl = Client(nm, verbose=False)
+        sz = fn(cl)
+        if sz:
+            print(f"  PASS -> {out} ({sz//1024} KB)  BACKEND={nm}", flush=True)
+            print("WINNER:", nm, flush=True); sys.exit(0)
+        print("  no glb returned", flush=True)
+    except Exception as e:
+        print("  FAIL", type(e).__name__, str(e)[:220], flush=True)
+print("ALL FAILED", flush=True); sys.exit(2)
diff --git a/assets/gen/build.py b/assets/gen/build.py
index 2769dc7..9a7ed7b 100644
--- a/assets/gen/build.py
+++ b/assets/gen/build.py
@@ -7,9 +7,15 @@ Checkpointing is by file existence: re-running skips whatever already landed,
 so a ZeroGPU quota stop is recoverable (add HF_TOKEN and re-run).
 Usage: python build.py [--only id1,id2] [--steps N]
 """
-import os, sys, shutil, argparse, traceback
+import os, sys, shutil, argparse, traceback, subprocess
 from gradio_client import Client, handle_file
 
+def to_true_png(path):
+    # FLUX returns WebP-in-.png; re-encode to real RGB PNG via macOS sips
+    try: subprocess.run(["sips","-s","format","png",path,"--out",path],
+                        capture_output=True, check=False)
+    except Exception: pass
+
 ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 REF  = os.path.join(ROOT, "assets", "ref")
 MODELS = os.path.join(ROOT, "assets", "models")
@@ -29,18 +35,27 @@ def t2i(client, prompt, out, steps=4):
     r = client.predict(prompt=prompt, seed=7, randomize_seed=False,
                        width=768, height=768, num_inference_steps=steps, api_name="/infer")
     img = r[0] if isinstance(r,(list,tuple)) else r
-    shutil.copy(img, out); return out
+    shutil.copy(img, out); to_true_png(out); return out
+
+BACKEND = os.environ.get("I2G_BACKEND", "shape")  # 'shape' (Shap-E, anon) or 'trellis' (needs token)
+SPACE = {"shape": "hysts/Shap-E", "trellis": "trellis-community/TRELLIS"}[BACKEND]
 
-def img2glb(client, img, out, steps=30, octree=256):
-    r = client.predict(
-        image=handle_file(img), mv_image_front=None, mv_image_back=None,
-        mv_image_left=None, mv_image_right=None, steps=steps, guidance_scale=5.0,
-        seed=1234, octree_resolution=octree, check_box_rembg=True,
-        num_chunks=8000, randomize_seed=True, api_name="/generation_all")
-    # returns (textured_glb, white_glb, html, stats, seed) — first is textured
-    glb = r[0] if isinstance(r,(list,tuple)) else r
-    if isinstance(glb, dict):  # some gradio versions wrap file outputs
-        glb = glb.get("path") or glb.get("value")
+def img2glb(client, img, out):
+    if BACKEND == "shape":
+        r = client.predict(image=handle_file(img), seed=0, guidance_scale=3.0,
+                           num_inference_steps=64, api_name="/image-to-3d")
+        glb = r
+    else:  # trellis, one-call textured GLB (requires HF_TOKEN quota)
+        try: client.predict(api_name="/start_session")
+        except Exception: pass
+        r = client.predict(image=handle_file(img), multiimages=[], seed=0,
+                          ss_guidance_strength=7.5, ss_sampling_steps=12,
+                          slat_guidance_strength=3.0, slat_sampling_steps=12,
+                          multiimage_algo="stochastic", mesh_simplify=0.95,
+                          texture_size=1024, api_name="/generate_and_extract_glb")
+        glb = r[-1] if isinstance(r,(list,tuple)) else r  # download_glb
+    if isinstance(glb,(list,tuple)): glb = glb[0]
+    if isinstance(glb, dict): glb = glb.get("path") or glb.get("value")
     shutil.copy(glb, out); return out
 
 def main():
@@ -66,11 +81,11 @@ def main():
         # Stage 2
         if a.stage in ("all","img2glb") and os.path.exists(ref) and not os.path.exists(glb):
             if hun is None:
-                print("[hunyuan] connecting…", flush=True)
-                hun = Client("tencent/Hunyuan3D-2.1", verbose=False)
-            print(f"[img2glb] {i} … (this is the slow ZeroGPU step)", flush=True)
+                print(f"[i2g:{BACKEND}] connecting {SPACE}…", flush=True)
+                hun = Client(SPACE, verbose=False)
+            print(f"[img2glb] {i} … (Stable-Fast-3D GPU step)", flush=True)
             try:
-                img2glb(hun, ref, glb, steps=a.steps)
+                img2glb(hun, ref, glb)
                 sz = os.path.getsize(glb)
                 print(f"[img2glb] {i} OK -> {glb} ({sz//1024} KB)", flush=True)
             except Exception as e:
diff --git a/assets/gen/sf3d_test.py b/assets/gen/sf3d_test.py
new file mode 100644
index 0000000..d90a53a
--- /dev/null
+++ b/assets/gen/sf3d_test.py
@@ -0,0 +1,27 @@
+from gradio_client import Client, handle_file
+import shutil, os
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+cands = ["stabilityai/stable-fast-3d", "stabilityai/stable-point-aware-3d"]
+img = os.path.join(ROOT, "assets", "ref", "plane.png")
+for c in cands:
+    print("="*60, "\nSPACE:", c)
+    try:
+        cl = Client(c, verbose=False)
+        api = cl.view_api(return_format="dict")
+        for name, info in api.get("named_endpoints", {}).items():
+            ps = [f"{p.get('parameter_name') or p.get('label','')}" for p in info.get("parameters", [])]
+            print("  EP", name, "params=", ps)
+    except Exception as e:
+        print("  CONNECT_ERR", type(e).__name__, str(e)[:160]); continue
+    # try the most likely endpoint
+    for ep in ["/run_button", "/run", "/predict", "/generate", "/image_to_3d"]:
+        try:
+            r = cl.predict(handle_file(img), 0.85, api_name=ep) if ep in ("/run","/predict","/run_button") else None
+            if r is None: continue
+            glb = r[0] if isinstance(r,(list,tuple)) else r
+            if isinstance(glb, dict): glb = glb.get("path") or glb.get("value")
+            out = os.path.join(ROOT, "assets", "models", "plane.glb")
+            shutil.copy(glb, out); print("  OK via", ep, "->", out, os.path.getsize(out)//1024, "KB")
+            break
+        except Exception as e:
+            print("  try", ep, "FAIL", type(e).__name__, str(e)[:140])
diff --git a/assets/gen/sf3d_try.py b/assets/gen/sf3d_try.py
new file mode 100644
index 0000000..bea16b7
--- /dev/null
+++ b/assets/gen/sf3d_try.py
@@ -0,0 +1,24 @@
+from gradio_client import Client, handle_file
+import shutil, os, sys
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+img = os.path.join(ROOT, "assets", "ref", "plane_rgb.png")
+out = os.path.join(ROOT, "assets", "models", "plane.glb")
+cl = Client("stabilityai/stable-fast-3d", verbose=False)
+
+attempts = [
+    ("/requires_bg_remove", dict(image=handle_file(img), fr=0.85)),
+    ("/run_button", dict(input_image=handle_file(img), foreground_ratio=0.85,
+                         remesh_option="None", vertex_count=-1, texture_size=1024)),
+]
+for ep, kw in attempts:
+    try:
+        print("TRY", ep, flush=True)
+        r = cl.predict(api_name=ep, **kw)
+        glb = r[1] if isinstance(r,(list,tuple)) and len(r)>1 else (r[0] if isinstance(r,(list,tuple)) else r)
+        if isinstance(glb, dict): glb = glb.get("path") or glb.get("value")
+        shutil.copy(glb, out)
+        print("OK via", ep, "->", out, os.path.getsize(out)//1024, "KB", flush=True)
+        sys.exit(0)
+    except Exception as e:
+        print("FAIL", ep, type(e).__name__, str(e)[:220], flush=True)
+sys.exit(2)
diff --git a/assets/models/README.md b/assets/models/README.md
new file mode 100644
index 0000000..4105fdc
--- /dev/null
+++ b/assets/models/README.md
@@ -0,0 +1,10 @@
+# Generated 3D items
+
+GLB models here are produced by `assets/gen/build.py` (FLUX reference image → HF image-to-3D).
+They are loaded by `index.html` via `GLTFLoader` and **normalized on load** (recenter + uniform
+scale to a target size + per-item rotation offset in the `ITEM_MODELS` table).
+
+If a model is missing, the game falls back to the original primitive-geometry version of that item,
+so the game always runs even before/without the 3D assets.
+
+Regenerate a single item:  `assets/gen/.venv/bin/python assets/gen/build.py --only plane`
diff --git a/assets/models/plane.glb b/assets/models/plane.glb
new file mode 100644
index 0000000..1da746a
Binary files /dev/null and b/assets/models/plane.glb differ
diff --git a/assets/ref/balloon.png b/assets/ref/balloon.png
new file mode 100644
index 0000000..25f68bb
Binary files /dev/null and b/assets/ref/balloon.png differ
diff --git a/assets/ref/plane_rgb.png b/assets/ref/plane_rgb.png
new file mode 100644
index 0000000..2e7f87e
Binary files /dev/null and b/assets/ref/plane_rgb.png differ
diff --git a/index.html b/index.html
index e14157d..19cd987 100644
--- a/index.html
+++ b/index.html
@@ -5,432 +5,389 @@
 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
 <title>ATMOS — Skyfire</title>
 <style>
-  :root { --ink:#0c1b2a; --sky:#8ec9e8; }
+  :root { --sky:#8ec9e8; }
   * { margin:0; padding:0; box-sizing:border-box; }
   html,body { height:100%; overflow:hidden; background:var(--sky); font-family:"Helvetica Neue",Arial,sans-serif; color:#fff; }
   #canvas { position:fixed; inset:0; display:block; }
-
-  /* ---- HUD ---- */
   #hud { position:fixed; inset:0; pointer-events:none; z-index:10; }
   .corner { position:absolute; padding:16px 20px; text-shadow:0 1px 3px rgba(8,20,32,.45); }
   #score  { top:0; left:0; font-size:14px; letter-spacing:.18em; }
-  #score b { display:block; font-size:34px; font-weight:600; letter-spacing:.02em; margin-top:2px; }
+  #score b { display:block; font-size:34px; font-weight:600; margin-top:2px; }
   #right  { top:0; right:0; text-align:right; font-size:12px; letter-spacing:.18em; opacity:.85; }
   #hpwrap { position:absolute; bottom:26px; left:50%; transform:translateX(-50%); width:min(360px,60vw); }
   #hpwrap span { font-size:11px; letter-spacing:.25em; opacity:.85; }
   #hpbar { height:8px; margin-top:6px; border-radius:6px; background:rgba(255,255,255,.25); overflow:hidden; }
   #hpfill { height:100%; width:100%; border-radius:6px; background:linear-gradient(90deg,#ffd36e,#ff7a59); transition:width .18s ease; }
-
-  /* ---- Overlays ---- */
-  .overlay { position:fixed; inset:0; z-index:20; display:flex; flex-direction:column;
-             align-items:center; justify-content:center; text-align:center; gap:18px;
-             background:radial-gradient(120% 90% at 50% 20%, rgba(142,201,232,.25), rgba(12,27,42,.72));
+  #assets { position:absolute; bottom:8px; left:14px; font-size:10px; letter-spacing:.14em; opacity:.6; }
+  .overlay { position:fixed; inset:0; z-index:20; display:flex; flex-direction:column; align-items:center; justify-content:center;
+             text-align:center; gap:16px; background:radial-gradient(120% 90% at 50% 20%, rgba(142,201,232,.25), rgba(12,27,42,.72));
              backdrop-filter:blur(2px); -webkit-backdrop-filter:blur(2px); transition:opacity .5s ease; }
   .overlay h1 { font-size:clamp(42px,9vw,96px); font-weight:200; letter-spacing:.42em; margin-left:.42em; }
   .overlay p  { font-size:14px; letter-spacing:.28em; opacity:.9; text-transform:uppercase; }
-  .keys { display:flex; gap:26px; margin-top:6px; font-size:12px; letter-spacing:.14em; opacity:.85; }
-  .keys div span { display:inline-block; min-width:34px; padding:5px 9px; margin-right:8px; border:1px solid rgba(255,255,255,.5);
-                   border-radius:6px; font-weight:600; letter-spacing:.05em; }
-  .cta { margin-top:14px; font-size:13px; letter-spacing:.3em; opacity:.75; animation:pulse 2.2s ease-in-out infinite; }
+  .keys { display:flex; gap:24px; margin-top:6px; font-size:12px; letter-spacing:.14em; opacity:.85; flex-wrap:wrap; justify-content:center; }
+  .keys span { display:inline-block; min-width:30px; padding:5px 9px; margin-right:6px; border:1px solid rgba(255,255,255,.5); border-radius:6px; font-weight:600; }
+  .cta { margin-top:12px; font-size:13px; letter-spacing:.3em; opacity:.75; animation:pulse 2.2s ease-in-out infinite; }
   @keyframes pulse { 0%,100%{opacity:.45} 50%{opacity:.95} }
-  #gameover { opacity:0; pointer-events:none; }
-  #gameover.show { opacity:1; pointer-events:auto; }
-  .stat { font-size:16px; letter-spacing:.16em; }
-  .stat b { font-size:26px; font-weight:600; }
+  #gameover { opacity:0; pointer-events:none; } #gameover.show { opacity:1; pointer-events:auto; }
+  .stat { font-size:16px; letter-spacing:.16em; } .stat b { font-size:26px; font-weight:600; }
   .hidden { opacity:0 !important; pointer-events:none !important; }
   .credit { position:fixed; bottom:10px; right:14px; z-index:15; font-size:10px; letter-spacing:.16em; opacity:.5; }
 </style>
 </head>
 <body>
   <canvas id="canvas"></canvas>
-
   <div id="hud">
     <div id="score" class="corner">SCORE<b>0</b></div>
     <div id="right" class="corner">ALT <span id="alt">32,000</span> FT<br/>DIST <span id="dist">0</span> KM</div>
     <div id="hpwrap"><span>HULL INTEGRITY</span><div id="hpbar"><div id="hpfill"></div></div></div>
+    <div id="assets">loading 3D items…</div>
   </div>
-
   <div id="start" class="overlay">
     <p>Leeroy's Playground · unofficial tribute</p>
     <h1>ATMOS</h1>
     <p>Skyfire</p>
     <div class="keys">
-      <div><span>◀ ▶</span>steer &nbsp; <span>▲ ▼</span>climb / dive</div>
+      <div><span>◀ ▶</span>steer <span>▲ ▼</span>climb/dive</div>
       <div><span>SPACE</span>fire missiles</div>
     </div>
     <div class="cta">click or press space to begin the journey</div>
   </div>
-
   <div id="gameover" class="overlay">
     <p>flight terminated</p>
     <h1 style="letter-spacing:.2em;margin-left:.2em;font-size:clamp(34px,7vw,72px)">DOWN</h1>
     <div class="stat">FINAL SCORE <b id="finalScore">0</b></div>
-    <div class="stat" style="opacity:.8">DISTANCE <b id="finalDist">0</b> KM · BIRDS DOWNED <b id="finalKills">0</b></div>
+    <div class="stat" style="opacity:.8">DIST <b id="finalDist">0</b> KM · BIRDS <b id="finalKills">0</b></div>
     <div class="cta">click or press space to fly again</div>
   </div>
-
-  <div class="credit">original © leeroy — atmos.leeroy.ca · this is an original clone</div>
+  <div class="credit">original © leeroy — atmos.leeroy.ca · original clone</div>
 
 <script type="importmap">
-{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
+{ "imports": {
+    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
+    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
+} }
 </script>
 
 <script type="module">
 import * as THREE from 'three';
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
 
-/* ============================================================================
-   ATMOS · Skyfire  — an original Three.js homage to atmos.leeroy.ca
-   The original is a scroll-driven cloud "journey". This turns that aesthetic
-   into a playable arcade flyer: fly the plane, FIRE MISSILES, DODGE BIRDS.
-   ========================================================================== */
+/* ===========================================================================
+   ATMOS · Skyfire — Three.js homage to atmos.leeroy.ca, now with AI-generated
+   3D items (FLUX reference -> Hugging Face image-to-3D -> GLB), loaded here via
+   GLTFLoader with graceful primitive fallbacks. Fly, FIRE MISSILES, DODGE BIRDS
+   (+ hot-air balloons and the occasional blimp).
+   ========================================================================= */
 
-// ---- Tunables (one place to feel the game) --------------------------------
 const CFG = {
-  worldFar:   -260,   // z where obstacles spawn (deep into the scene)
-  worldNear:    16,   // z past the camera where they get recycled
-  baseSpeed:    60,   // forward world speed (units/sec) — the "journey" pace
-  steerX:       15,   // horizontal fly bounds (+/-)
-  steerYmin:   -8,
-  steerYmax:    9,
-  missileSpeed:220,
-  fireCooldown: 0.16, // seconds between shots
-  birdMax:      14,   // active birds in the pool
-  cloudCount:  120,
-  hitRadiusBird:2.6,  // plane<->bird collision
-  hitRadiusShot:3.4,  // missile<->bird collision
-  birdDamage:   26,
+  worldFar:-260, worldNear:16, baseSpeed:60, steerX:15, steerYmin:-8, steerYmax:9,
+  missileSpeed:220, fireCooldown:0.16,
 };
 
-// ---- Renderer / scene / camera --------------------------------------------
+// ---- renderer / scene / camera ----
 const canvas   = document.getElementById('canvas');
 const renderer = new THREE.WebGLRenderer({ canvas, antialias:true });
-renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+renderer.setPixelRatio(Math.min(devicePixelRatio,2));
 renderer.setSize(innerWidth, innerHeight);
+renderer.outputColorSpace = THREE.SRGBColorSpace;
 
 const scene = new THREE.Scene();
-const SKY_TOP = new THREE.Color(0x2f7fb5);
-const SKY_BOT = new THREE.Color(0xbfe6f5);
+const SKY_TOP = new THREE.Color(0x2f7fb5), SKY_BOT = new THREE.Color(0xbfe6f5);
 scene.background = SKY_BOT.clone();
-scene.fog = new THREE.Fog(0xbfe6f5, 60, 250);   // depth haze — the ATMOS mood
+scene.fog = new THREE.Fog(0xbfe6f5, 60, 250);
 
 const camera = new THREE.PerspectiveCamera(62, innerWidth/innerHeight, 0.1, 600);
-camera.position.set(0, 2.5, 14);
-camera.lookAt(0, 1, -30);
+camera.position.set(0,2.5,14); camera.lookAt(0,1,-30);
 
-// ---- Lights ----------------------------------------------------------------
-scene.add(new THREE.HemisphereLight(0xffffff, 0x6f97b0, 1.05));
-const sun = new THREE.DirectionalLight(0xfff4e0, 1.15);
-sun.position.set(-8, 14, 6);
-scene.add(sun);
+scene.add(new THREE.HemisphereLight(0xffffff, 0x6f97b0, 1.1));
+const sun = new THREE.DirectionalLight(0xfff4e0, 1.25); sun.position.set(-8,14,6); scene.add(sun);
 
-// ---- Gradient sky dome (backdrop behind the fog) ---------------------------
+// ---- gradient sky dome ----
 {
-  const skyGeo = new THREE.SphereGeometry(400, 24, 12);
-  const skyMat = new THREE.ShaderMaterial({
-    side: THREE.BackSide, depthWrite:false, fog:false,
+  const m = new THREE.Mesh(new THREE.SphereGeometry(400,24,12), new THREE.ShaderMaterial({
+    side:THREE.BackSide, depthWrite:false, fog:false,
     uniforms:{ top:{value:SKY_TOP}, bot:{value:SKY_BOT} },
     vertexShader:`varying vec3 vp; void main(){ vp=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
     fragmentShader:`varying vec3 vp; uniform vec3 top; uniform vec3 bot;
       void main(){ float h=clamp(vp.y/400.0*0.5+0.5,0.0,1.0); gl_FragColor=vec4(mix(bot,top,pow(h,0.8)),1.0);}`
-  });
-  scene.add(new THREE.Mesh(skyGeo, skyMat));
+  }));
+  scene.add(m);
 }
 
-// ---- Soft cloud sprite texture (drawn once to a canvas) --------------------
-function makePuffTexture(){
-  const s=128, c=document.createElement('canvas'); c.width=c.height=s;
-  const g=c.getContext('2d');
+// ---- cloud sprites ----
+function puffTexture(){
+  const s=128, c=document.createElement('canvas'); c.width=c.height=s; const g=c.getContext('2d');
   const grd=g.createRadialGradient(s/2,s/2,4,s/2,s/2,s/2);
-  grd.addColorStop(0,'rgba(255,255,255,0.95)');
-  grd.addColorStop(0.5,'rgba(255,255,255,0.55)');
-  grd.addColorStop(1,'rgba(255,255,255,0)');
-  g.fillStyle=grd; g.beginPath(); g.arc(s/2,s/2,s/2,0,Math.PI*2); g.fill();
-  const t=new THREE.CanvasTexture(c); t.needsUpdate=true; return t;
+  grd.addColorStop(0,'rgba(255,255,255,.95)'); grd.addColorStop(.5,'rgba(255,255,255,.55)'); grd.addColorStop(1,'rgba(255,255,255,0)');
+  g.fillStyle=grd; g.beginPath(); g.arc(s/2,s/2,s/2,0,7); g.fill();
+  return new THREE.CanvasTexture(c);
 }
-const puffTex = makePuffTexture();
-
-// ---- Cloud field: recycled billboard sprites -------------------------------
-const clouds = [];
-for (let i=0;i<CFG.cloudCount;i++){
-  const m = new THREE.Sprite(new THREE.SpriteMaterial({
-    map:puffTex, transparent:true, depthWrite:false, fog:true,
-    opacity:0.55+Math.random()*0.35, color:0xffffff
-  }));
-  resetCloud(m, true);
-  scene.add(m); clouds.push(m);
+const puffTex = puffTexture();
+const clouds=[];
+for(let i=0;i<120;i++){
+  const m=new THREE.Sprite(new THREE.SpriteMaterial({map:puffTex,transparent:true,depthWrite:false,fog:true,opacity:.55+Math.random()*.35}));
+  resetCloud(m,true); scene.add(m); clouds.push(m);
 }
-function resetCloud(m, initial){
-  const sc = 14 + Math.random()*46;
-  m.scale.set(sc, sc*(0.55+Math.random()*0.3), 1);
-  m.position.set(
-    (Math.random()*2-1)*70,
-    -14 + Math.random()*40,
-    initial ? CFG.worldNear - Math.random()*(CFG.worldNear-CFG.worldFar)
-            : CFG.worldFar - Math.random()*40
-  );
-  m.userData.spin = (Math.random()*2-1)*0.05;
+function resetCloud(m,init){
+  const sc=14+Math.random()*46; m.scale.set(sc,sc*(.55+Math.random()*.3),1);
+  m.position.set((Math.random()*2-1)*70,-14+Math.random()*40,
+     init? CFG.worldNear-Math.random()*(CFG.worldNear-CFG.worldFar) : CFG.worldFar-Math.random()*40);
+  m.userData.spin=(Math.random()*2-1)*.05;
 }
 
-// ---- The player plane (stylized low-poly, built from primitives) -----------
-const plane = new THREE.Group();
-{
-  const body = new THREE.Mesh(
-    new THREE.CylinderGeometry(0.5,0.28,4.4,16),
-    new THREE.MeshStandardMaterial({color:0xf5f7fa, metalness:0.2, roughness:0.55}));
-  body.rotation.x = Math.PI/2; plane.add(body);
-
-  const nose = new THREE.Mesh(new THREE.ConeGeometry(0.5,1.1,16),
-    new THREE.MeshStandardMaterial({color:0xff7a59, metalness:0.1, roughness:0.5}));
-  nose.rotation.x = -Math.PI/2; nose.position.z=-2.5; plane.add(nose);
-
-  const wingMat = new THREE.MeshStandardMaterial({color:0xdfe6ee, metalness:0.15, roughness:0.6});
-  const wing = new THREE.Mesh(new THREE.BoxGeometry(7.4,0.14,1.5), wingMat);
-  wing.position.set(0,0,0.2); plane.add(wing);
-
-  const tailWing = new THREE.Mesh(new THREE.BoxGeometry(2.8,0.12,0.9), wingMat);
-  tailWing.position.set(0,0,1.9); plane.add(tailWing);
-
-  const fin = new THREE.Mesh(new THREE.BoxGeometry(0.12,1.3,1.0), wingMat);
-  fin.position.set(0,0.6,2.0); plane.add(fin);
+/* ===========================================================================
+   MODEL LOADING — normalize any GLB (recenter + uniform-scale + rot offset),
+   fall back to a primitive builder if the GLB is missing/broken.
+   ========================================================================= */
+const loader = new GLTFLoader();
+const MODELS = {};   // id -> normalized THREE.Object3D (template to clone), or null
+
+// per-item: target max-dimension size, rotation offset (radians), and a fallback builder
+const ITEM = {
+  plane:   { size:5.2, rot:[0, Math.PI, 0] },
+  bird:    { size:2.6, rot:[0, 0, 0] },
+  balloon: { size:7.0, rot:[0, 0, 0] },
+  blimp:   { size:13,  rot:[0, Math.PI/2, 0] },
+};
 
-  const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.55,0.02,4.2),
-    new THREE.MeshStandardMaterial({color:0xff7a59}));
-  stripe.position.set(0,0.5,0.1); plane.add(stripe);
-}
-plane.position.set(0,0,2);
-scene.add(plane);
-
-// ---- Missile pool ----------------------------------------------------------
-const missiles = [];
-const missileGeo = new THREE.CapsuleGeometry(0.16,0.7,4,8);
-const missileMat = new THREE.MeshStandardMaterial({color:0xffca3a, emissive:0xff7a00, emissiveIntensity:0.8});
-for (let i=0;i<40;i++){
-  const m = new THREE.Mesh(missileGeo, missileMat);
-  m.rotation.x = Math.PI/2; m.visible=false; m.userData.alive=false;
-  scene.add(m); missiles.push(m);
+function normalize(obj, size, rot){
+  const wrap = new THREE.Group();
+  obj.traverse(o=>{ if(o.isMesh){ o.castShadow=false; if(o.geometry && !o.geometry.attributes.normal) o.geometry.computeVertexNormals(); if(o.material){ o.material.side=THREE.DoubleSide; } } });
+  const box = new THREE.Box3().setFromObject(obj);
+  const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3());
+  const k = size / Math.max(s.x, s.y, s.z || 1e-3);
+  obj.position.sub(c);                 // recenter to origin
+  const inner = new THREE.Group(); inner.add(obj); inner.scale.setScalar(k);
+  wrap.add(inner); wrap.rotation.set(rot[0], rot[1], rot[2]);
+  return wrap;
 }
-function fireMissile(){
-  // two barrels, one per wingtip-ish
-  for (const dx of [-1.1, 1.1]){
-    const m = missiles.find(x=>!x.userData.alive);
-    if (!m) return;
-    m.position.set(plane.position.x+dx, plane.position.y, plane.position.z-2.4);
-    m.visible=true; m.userData.alive=true;
-  }
+function loadModel(id){
+  const url = `assets/models/${id}.glb`;
+  return loader.loadAsync(url).then(g=>{
+    MODELS[id] = normalize(g.scene, ITEM[id].size, ITEM[id].rot);
+    console.log('loaded model', id);
+    if(id==='plane') applyPlaneModel();
+  }).catch(()=>{ MODELS[id]=null; console.log('no model for', id, '- using primitive'); });
 }
 
-// ---- Bird pool (dark low-poly, flapping) -----------------------------------
-const birds = [];
-function makeBird(){
+/* ===========================================================================
+   PLAYER PLANE — primitive by default; swap to GLB when it loads.
+   ========================================================================= */
+const plane = new THREE.Group();
+const planePrim = buildPlanePrimitive();
+plane.add(planePrim);
+plane.position.set(0,0,2); scene.add(plane);
+
+function buildPlanePrimitive(){
   const g = new THREE.Group();
-  const mat = new THREE.MeshStandardMaterial({color:0x243242, roughness:0.9, metalness:0.0});
-  const wl = new THREE.Mesh(new THREE.BoxGeometry(2.2,0.12,0.9), mat);
-  const wr = wl.clone();
-  wl.position.x = -1.15; wr.position.x = 1.15;
-  const bodyB = new THREE.Mesh(new THREE.SphereGeometry(0.42,10,8), mat);
-  g.add(wl,wr,bodyB);
-  g.userData = {wl, wr, alive:false, flap:Math.random()*6, sway:Math.random()*6, swayAmp:2+Math.random()*4};
+  const body = new THREE.Mesh(new THREE.CylinderGeometry(.5,.28,4.4,16), new THREE.MeshStandardMaterial({color:0xf5f7fa,metalness:.2,roughness:.55}));
+  body.rotation.x=Math.PI/2; g.add(body);
+  const nose = new THREE.Mesh(new THREE.ConeGeometry(.5,1.1,16), new THREE.MeshStandardMaterial({color:0xff7a59,roughness:.5}));
+  nose.rotation.x=-Math.PI/2; nose.position.z=-2.5; g.add(nose);
+  const wm = new THREE.MeshStandardMaterial({color:0xdfe6ee,roughness:.6});
+  const wing=new THREE.Mesh(new THREE.BoxGeometry(7.4,.14,1.5),wm); wing.position.z=.2; g.add(wing);
+  const tw=new THREE.Mesh(new THREE.BoxGeometry(2.8,.12,.9),wm); tw.position.z=1.9; g.add(tw);
+  const fin=new THREE.Mesh(new THREE.BoxGeometry(.12,1.3,1),wm); fin.position.set(0,.6,2); g.add(fin);
   return g;
 }
-for (let i=0;i<CFG.birdMax;i++){ const b=makeBird(); b.visible=false; scene.add(b); birds.push(b); }
-function spawnBird(b){
-  b.position.set((Math.random()*2-1)*CFG.steerX, CFG.steerYmin + Math.random()*(CFG.steerYmax-CFG.steerYmin), CFG.worldFar - Math.random()*30);
-  b.userData.alive=true; b.visible=true;
-  b.userData.baseX = b.position.x;
-  b.userData.sway = Math.random()*6;
+let planeModel=null;
+function applyPlaneModel(){
+  if(!MODELS.plane) return;
+  planePrim.visible=false;
+  planeModel = MODELS.plane.clone(true);
+  plane.add(planeModel);
 }
 
-// ---- Explosion puff pool (sprites that expand + fade) ----------------------
-const puffs = [];
-for (let i=0;i<24;i++){
-  const p = new THREE.Sprite(new THREE.SpriteMaterial({map:puffTex, transparent:true, color:0xffce7a, depthWrite:false}));
-  p.visible=false; p.userData.alive=false; scene.add(p); puffs.push(p);
-}
-function boom(x,y,z,color){
-  for (let i=0;i<5;i++){
-    const p = puffs.find(q=>!q.userData.alive); if(!p) break;
-    p.position.set(x+(Math.random()*2-1), y+(Math.random()*2-1), z);
-    p.material.color.set(color); p.material.opacity=0.95;
-    p.userData.alive=true; p.userData.life=0; p.userData.max=0.45+Math.random()*0.2;
-    p.userData.grow=6+Math.random()*8; p.visible=true; p.scale.set(2,2,1);
-  }
+/* ===========================================================================
+   MISSILES
+   ========================================================================= */
+const missiles=[];
+const mGeo=new THREE.CapsuleGeometry(.16,.7,4,8);
+const mMat=new THREE.MeshStandardMaterial({color:0xffca3a,emissive:0xff7a00,emissiveIntensity:.8});
+for(let i=0;i<40;i++){ const m=new THREE.Mesh(mGeo,mMat); m.rotation.x=Math.PI/2; m.visible=false; m.userData.alive=false; scene.add(m); missiles.push(m); }
+function fire(){ for(const dx of [-1.1,1.1]){ const m=missiles.find(x=>!x.userData.alive); if(!m)return; m.position.set(plane.position.x+dx,plane.position.y,plane.position.z-2.4); m.visible=true; m.userData.alive=true; } }
+
+/* ===========================================================================
+   OBSTACLE SYSTEM — typed items (bird / balloon / blimp). GLB if available,
+   else a primitive. Each type: pool size, collision radius, closing-speed
+   bonus, damage to hull, points when shot, and whether it bobs.
+   ========================================================================= */
+const TYPES = {
+  bird:    { pool:12, r:2.6, speed:22, dmg:24, pts:100, weight:0.72, bob:.0,  flap:true },
+  balloon: { pool:6,  r:3.8, speed:5,  dmg:34, pts:160, weight:0.24, bob:.6,  flap:false },
+  blimp:   { pool:2,  r:5.6, speed:9,  dmg:55, pts:320, weight:0.04, bob:.25, flap:false, big:true },
+};
+const obstacles=[];   // {group,type,alive,...}
+
+function birdPrimitive(){
+  const g=new THREE.Group(), mat=new THREE.MeshStandardMaterial({color:0x243242,roughness:.9});
+  const wl=new THREE.Mesh(new THREE.BoxGeometry(2.2,.12,.9),mat), wr=wl.clone();
+  wl.position.x=-1.15; wr.position.x=1.15;
+  g.add(wl,wr,new THREE.Mesh(new THREE.SphereGeometry(.42,10,8),mat));
+  g.userData.wl=wl; g.userData.wr=wr; return g;
 }
-
-// ---- Input -----------------------------------------------------------------
-const keys = {};
-const target = { x:0, y:1 };   // where the plane wants to be
-addEventListener('keydown', e=>{
-  keys[e.code]=true;
-  if (e.code==='Space'){ e.preventDefault(); if(state==='play') firing=true; else beginOrRestart(); }
-});
-addEventListener('keyup', e=>{ keys[e.code]=false; if(e.code==='Space') firing=false; });
-// pointer steering (mouse / touch)
-function pointerMove(cx, cy){
-  const nx = (cx/innerWidth)*2-1;
-  const ny = (cy/innerHeight)*2-1;
-  target.x = nx*CFG.steerX;
-  target.y = THREE.MathUtils.clamp(-ny*CFG.steerYmax, CFG.steerYmin, CFG.steerYmax);
+function balloonPrimitive(){
+  const g=new THREE.Group();
+  const hue=Math.random(); const col=new THREE.Color().setHSL(hue,.7,.55);
+  const env=new THREE.Mesh(new THREE.SphereGeometry(3,20,16), new THREE.MeshStandardMaterial({color:col,roughness:.5}));
+  env.scale.y=1.25; env.position.y=1.5; g.add(env);
+  const basket=new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshStandardMaterial({color:0x7a4a22,roughness:.9}));
+  basket.position.y=-2.4; g.add(basket);
+  return g;
 }
-addEventListener('mousemove', e=>pointerMove(e.clientX, e.clientY));
-addEventListener('touchmove', e=>{ const t=e.touches[0]; pointerMove(t.clientX,t.clientY); firing=true; }, {passive:true});
-addEventListener('touchend',  ()=>{ firing=false; });
-canvas.addEventListener('click', ()=>{ if(state!=='play') beginOrRestart(); });
-
-// ---- Game state ------------------------------------------------------------
-let state='start';               // 'start' | 'play' | 'over'
-let firing=false, cooldown=0;
-let score=0, kills=0, dist=0, hp=100;
-const startEl=document.getElementById('start');
-const overEl =document.getElementById('gameover');
-const hpFill =document.getElementById('hpfill');
-const scoreEl=document.querySelector('#score b');
-const distEl =document.getElementById('dist');
-const altEl  =document.getElementById('alt');
-
-function beginOrRestart(){
-  if (state==='play') return;
-  score=0; kills=0; dist=0; hp=100; firing=false; cooldown=0;
-  target.x=0; target.y=1; plane.position.set(0,0,2);
-  birds.forEach(b=>{ b.userData.alive=false; b.visible=false; });
-  missiles.forEach(m=>{ m.userData.alive=false; m.visible=false; });
-  state='play';
-  startEl.classList.add('hidden');
-  overEl.classList.remove('show');
+function blimpPrimitive(){
+  const g=new THREE.Group();
+  const body=new THREE.Mesh(new THREE.SphereGeometry(3,24,16), new THREE.MeshStandardMaterial({color:0xdad6cf,metalness:.2,roughness:.5}));
+  body.scale.set(3,1,1); g.add(body);
+  const fin=new THREE.Mesh(new THREE.BoxGeometry(.2,2,1.4), new THREE.MeshStandardMaterial({color:0xff7a59}));
+  fin.position.x=-8; g.add(fin);
+  const gon=new THREE.Mesh(new THREE.BoxGeometry(2,.8,.8), new THREE.MeshStandardMaterial({color:0x333}));
+  gon.position.y=-2.6; g.add(gon);
+  return g;
 }
-function endGame(){
-  state='over';
-  document.getElementById('finalScore').textContent=score;
-  document.getElementById('finalDist').textContent=Math.floor(dist/100);
-  document.getElementById('finalKills').textContent=kills;
-  overEl.classList.add('show');
+const PRIM = { bird:birdPrimitive, balloon:balloonPrimitive, blimp:blimpPrimitive };
+
+function buildObstacle(type){
+  const group=new THREE.Group();
+  let visual;
+  if(MODELS[type]) visual = MODELS[type].clone(true);
+  else visual = PRIM[type]();
+  group.add(visual);
+  const o={ group, type, visual, alive:false, ...TYPES[type] };
+  group.visible=false; scene.add(group); obstacles.push(o); return o;
 }
+for(const t of Object.keys(TYPES)) for(let i=0;i<TYPES[t].pool;i++) buildObstacle(t);
 
-// ---- Resize ----------------------------------------------------------------
-addEventListener('resize', ()=>{
-  camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
-  renderer.setSize(innerWidth,innerHeight);
-});
+function pickType(){
+  const r=Math.random(); let a=0;
+  for(const t of Object.keys(TYPES)){ a+=TYPES[t].weight; if(r<=a) return t; }
+  return 'bird';
+}
+function spawnObstacle(){
+  const type=pickType();
+  const o=obstacles.find(x=>!x.alive && x.type===type) || obstacles.find(x=>!x.alive);
+  if(!o) return;
+  o.alive=true; o.group.visible=true;
+  o.group.position.set((Math.random()*2-1)*CFG.steerX, CFG.steerYmin+Math.random()*(CFG.steerYmax-CFG.steerYmin), CFG.worldFar-Math.random()*30);
+  o.baseX=o.group.position.x; o.baseY=o.group.position.y; o.sway=Math.random()*6; o.phase=Math.random()*6;
+  o.group.rotation.y=(o.type==='bird')?0:Math.random()*6;
+}
 
-// ---- Main loop -------------------------------------------------------------
-const clock = new THREE.Clock();
-let flash=0;                       // red damage flash amount
-let spawnTimer=0;
+/* ---- explosion puffs ---- */
+const puffs=[];
+for(let i=0;i<28;i++){ const p=new THREE.Sprite(new THREE.SpriteMaterial({map:puffTex,transparent:true,depthWrite:false})); p.visible=false; p.userData.alive=false; scene.add(p); puffs.push(p); }
+function boom(x,y,z,color,n=5){
+  for(let i=0;i<n;i++){ const p=puffs.find(q=>!q.userData.alive); if(!p)break;
+    p.position.set(x+(Math.random()*2-1),y+(Math.random()*2-1),z); p.material.color.set(color); p.material.opacity=.95;
+    p.userData.alive=true; p.userData.life=0; p.userData.max=.45+Math.random()*.2; p.userData.grow=6+Math.random()*8; p.visible=true; p.scale.set(2,2,1); }
+}
 
+/* ===========================================================================
+   INPUT / STATE / LOOP
+   ========================================================================= */
+const keys={}, target={x:0,y:1};
+let state='start', firing=false, cooldown=0, score=0, kills=0, dist=0, hp=100, flash=0, spawnTimer=0;
+addEventListener('keydown',e=>{ keys[e.code]=true; if(e.code==='Space'){ e.preventDefault(); if(state==='play')firing=true; else begin(); } });
+addEventListener('keyup',e=>{ keys[e.code]=false; if(e.code==='Space')firing=false; });
+function pointer(cx,cy){ target.x=((cx/innerWidth)*2-1)*CFG.steerX; target.y=THREE.MathUtils.clamp(-((cy/innerHeight)*2-1)*CFG.steerYmax,CFG.steerYmin,CFG.steerYmax); }
+addEventListener('mousemove',e=>pointer(e.clientX,e.clientY));
+addEventListener('touchmove',e=>{const t=e.touches[0];pointer(t.clientX,t.clientY);firing=true;},{passive:true});
+addEventListener('touchend',()=>firing=false);
+canvas.addEventListener('click',()=>{ if(state!=='play') begin(); });
+const startEl=document.getElementById('start'), overEl=document.getElementById('gameover');
+const hpFill=document.getElementById('hpfill'), scoreEl=document.querySelector('#score b'), distEl=document.getElementById('dist'), altEl=document.getElementById('alt');
+
+function begin(){ if(state==='play')return; score=kills=dist=0; hp=100; firing=false; cooldown=0; target.x=0; target.y=1; plane.position.set(0,0,2);
+  obstacles.forEach(o=>{o.alive=false;o.group.visible=false;}); missiles.forEach(m=>{m.userData.alive=false;m.visible=false;});
+  state='play'; startEl.classList.add('hidden'); overEl.classList.remove('show'); }
+function endGame(){ state='over'; document.getElementById('finalScore').textContent=score; document.getElementById('finalDist').textContent=Math.floor(dist/100); document.getElementById('finalKills').textContent=kills; overEl.classList.add('show'); }
+
+addEventListener('resize',()=>{ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth,innerHeight); });
+
+const clock=new THREE.Clock();
 function tick(){
   requestAnimationFrame(tick);
-  const dt = Math.min(clock.getDelta(), 0.05);
-  const t  = clock.elapsedTime;
-
-  // world always drifts (even on menus) so the sky feels alive
-  const worldSpeed = (state==='play') ? CFG.baseSpeed : CFG.baseSpeed*0.35;
-
-  // clouds
-  for (const c of clouds){
-    c.position.z += worldSpeed*dt;
-    c.material.rotation += c.userData.spin*dt;
-    if (c.position.z > CFG.worldNear) resetCloud(c, false);
+  const dt=Math.min(clock.getDelta(),.05), t=clock.elapsedTime;
+  const worldSpeed=(state==='play')?CFG.baseSpeed:CFG.baseSpeed*.35;
+
+  for(const c of clouds){ c.position.z+=worldSpeed*dt; c.material.rotation+=c.userData.spin*dt; if(c.position.z>CFG.worldNear) resetCloud(c,false); }
+
+  if(state==='play'){
+    if(keys['ArrowLeft']||keys['KeyA']) target.x-=42*dt;
+    if(keys['ArrowRight']||keys['KeyD']) target.x+=42*dt;
+    if(keys['ArrowUp']||keys['KeyW']) target.y+=30*dt;
+    if(keys['ArrowDown']||keys['KeyS']) target.y-=30*dt;
+    target.x=THREE.MathUtils.clamp(target.x,-CFG.steerX,CFG.steerX); target.y=THREE.MathUtils.clamp(target.y,CFG.steerYmin,CFG.steerYmax);
+    const px=plane.position.x;
+    plane.position.x+=(target.x-plane.position.x)*Math.min(1,6*dt);
+    plane.position.y+=(target.y-plane.position.y)*Math.min(1,6*dt);
+    const dx=plane.position.x-px;
+    plane.rotation.z+=((-dx*4)-plane.rotation.z)*Math.min(1,8*dt);
+    plane.rotation.x+=((-(target.y-plane.position.y)*.06)-plane.rotation.x)*Math.min(1,6*dt);
+    plane.position.y+=Math.sin(t*2.2)*.004;
+
+    cooldown-=dt; if(firing&&cooldown<=0){ fire(); cooldown=CFG.fireCooldown; }
+    dist+=worldSpeed*dt; score+=Math.floor(worldSpeed*dt*.1);
+
+    spawnTimer-=dt;
+    if(spawnTimer<=0 && obstacles.filter(o=>o.alive).length < 16){ spawnObstacle(); spawnTimer=.5+Math.random()*.7; }
   }
 
-  if (state==='play'){
-    // ---- steer plane toward target (keyboard nudges the pointer target) ----
-    if (keys['ArrowLeft']||keys['KeyA'])  target.x -= 42*dt;
-    if (keys['ArrowRight']||keys['KeyD']) target.x += 42*dt;
-    if (keys['ArrowUp']||keys['KeyW'])    target.y += 30*dt;
-    if (keys['ArrowDown']||keys['KeyS'])  target.y -= 30*dt;
-    target.x = THREE.MathUtils.clamp(target.x, -CFG.steerX, CFG.steerX);
-    target.y = THREE.MathUtils.clamp(target.y, CFG.steerYmin, CFG.steerYmax);
-
-    const prevX = plane.position.x;
-    plane.position.x += (target.x - plane.position.x)*Math.min(1, 6*dt);
-    plane.position.y += (target.y - plane.position.y)*Math.min(1, 6*dt);
-    // bank into the turn + pitch with climb — reads as real flight
-    const dx = plane.position.x - prevX;
-    plane.rotation.z += ((-dx*4) - plane.rotation.z)*Math.min(1,8*dt);
-    plane.rotation.x += ((-(target.y-plane.position.y)*0.06) - plane.rotation.x)*Math.min(1,6*dt);
-    plane.position.y += Math.sin(t*2.2)*0.004;  // subtle idle bob
-
-    // ---- firing ----
-    cooldown -= dt;
-    if (firing && cooldown<=0){ fireMissile(); cooldown=CFG.fireCooldown; }
-
-    // ---- distance / score-over-time ----
-    dist  += worldSpeed*dt;
-    score += Math.floor(worldSpeed*dt*0.1);
-
-    // ---- spawn birds ----
-    spawnTimer -= dt;
-    const activeBirds = birds.filter(b=>b.userData.alive).length;
-    if (spawnTimer<=0 && activeBirds < CFG.birdMax){
-      const b = birds.find(x=>!x.userData.alive);
-      if (b) spawnBird(b);
-      spawnTimer = 0.5 + Math.random()*0.7;   // difficulty knob
-    }
-  }
-
-  // ---- missiles fly forward, cull, and hit birds ----
-  for (const m of missiles){
-    if (!m.userData.alive) continue;
-    m.position.z -= CFG.missileSpeed*dt;
-    if (m.position.z < CFG.worldFar-20){ m.userData.alive=false; m.visible=false; continue; }
-    for (const b of birds){
-      if (!b.userData.alive) continue;
-      if (m.position.distanceTo(b.position) < CFG.hitRadiusShot){
-        boom(b.position.x,b.position.y,b.position.z,0xffce7a);
-        b.userData.alive=false; b.visible=false;
-        m.userData.alive=false; m.visible=false;
-        kills++; score+=100;
-        break;
+  // missiles
+  for(const m of missiles){ if(!m.userData.alive)continue; m.position.z-=CFG.missileSpeed*dt;
+    if(m.position.z<CFG.worldFar-20){ m.userData.alive=false; m.visible=false; continue; }
+    for(const o of obstacles){ if(!o.alive)continue;
+      if(m.position.distanceTo(o.group.position) < o.r+1){
+        boom(o.group.position.x,o.group.position.y,o.group.position.z,0xffce7a, o.big?9:5);
+        o.alive=false; o.group.visible=false; m.userData.alive=false; m.visible=false; kills++; score+=o.pts; break;
       }
     }
   }
 
-  // ---- birds fly toward the player, flap, and can hit the plane ----
-  for (const b of birds){
-    if (!b.userData.alive) continue;
-    b.position.z += (worldSpeed + 22)*dt;              // birds close faster than clouds
-    b.userData.sway += dt;
-    b.position.x = b.userData.baseX + Math.sin(b.userData.sway*1.3)*b.userData.swayAmp*0.15;
-    const flap = Math.sin(t*16 + b.userData.flap)*0.7; // wing beat
-    b.userData.wl.rotation.z =  flap;
-    b.userData.wr.rotation.z = -flap;
-    if (state==='play' && b.position.distanceTo(plane.position) < CFG.hitRadiusBird){
-      boom(plane.position.x,plane.position.y,plane.position.z,0xff5a4d);
-      b.userData.alive=false; b.visible=false;
-      hp -= CFG.birdDamage; flash=1;
-      if (hp<=0){ hp=0; endGame(); }
+  // obstacles
+  for(const o of obstacles){ if(!o.alive)continue;
+    o.group.position.z+=(worldSpeed+o.speed)*dt;
+    o.sway+=dt;
+    o.group.position.x=o.baseX+Math.sin(o.sway*1.3)*(o.type==='bird'?2.2:1.0)*.15*ITEMscale(o);
+    if(o.bob) o.group.position.y=o.baseY+Math.sin(t*1.5+o.phase)*o.bob;
+    if(o.flap && !MODELS.bird && o.visual.userData.wl){ const f=Math.sin(t*16+o.phase)*.7; o.visual.userData.wl.rotation.z=f; o.visual.userData.wr.rotation.z=-f; }
+    if(o.type!=='bird') o.group.rotation.y+=dt*.2;
+    if(state==='play' && o.group.position.distanceTo(plane.position) < o.r){
+      boom(plane.position.x,plane.position.y,plane.position.z,0xff5a4d, o.big?9:5);
+      o.alive=false; o.group.visible=false; hp-=o.dmg; flash=1; if(hp<=0){ hp=0; endGame(); }
     }
-    if (b.position.z > CFG.worldNear){ b.userData.alive=false; b.visible=false; }  // dodged — recycle
-  }
-
-  // ---- explosion puffs ----
-  for (const p of puffs){
-    if (!p.userData.alive) continue;
-    p.userData.life += dt;
-    const k = p.userData.life/p.userData.max;
-    const s = 2 + p.userData.grow*k;
-    p.scale.set(s,s,1);
-    p.material.opacity = 0.95*(1-k);
-    if (k>=1){ p.userData.alive=false; p.visible=false; }
+    if(o.group.position.z>CFG.worldNear){ o.alive=false; o.group.visible=false; }
   }
 
-  // ---- camera gently trails the plane ----
-  camera.position.x += (plane.position.x*0.5 - camera.position.x)*Math.min(1,3*dt);
-  camera.position.y += ((plane.position.y*0.4+2.5) - camera.position.y)*Math.min(1,3*dt);
-  camera.lookAt(plane.position.x*0.6, plane.position.y*0.5+1, -30);
+  for(const p of puffs){ if(!p.userData.alive)continue; p.userData.life+=dt; const k=p.userData.life/p.userData.max; const s=2+p.userData.grow*k; p.scale.set(s,s,1); p.material.opacity=.95*(1-k); if(k>=1){p.userData.alive=false;p.visible=false;} }
 
-  // ---- damage flash via background tint ----
-  if (flash>0){ flash=Math.max(0,flash-dt*2.2); scene.background.copy(SKY_BOT).lerp(new THREE.Color(0xff4433), flash*0.5); }
-  else scene.background.copy(SKY_BOT);
+  camera.position.x+=(plane.position.x*.5-camera.position.x)*Math.min(1,3*dt);
+  camera.position.y+=((plane.position.y*.4+2.5)-camera.position.y)*Math.min(1,3*dt);
+  camera.lookAt(plane.position.x*.6,plane.position.y*.5+1,-30);
 
-  // ---- HUD ----
-  hpFill.style.width = hp+'%';
-  scoreEl.textContent = score;
-  distEl.textContent  = Math.floor(dist/100);
-  altEl.textContent   = (32000 + Math.round(plane.position.y*140)).toLocaleString();
+  if(flash>0){ flash=Math.max(0,flash-dt*2.2); scene.background.copy(SKY_BOT).lerp(new THREE.Color(0xff4433),flash*.5); } else scene.background.copy(SKY_BOT);
 
-  renderer.render(scene, camera);
+  hpFill.style.width=hp+'%'; scoreEl.textContent=score; distEl.textContent=Math.floor(dist/100); altEl.textContent=(32000+Math.round(plane.position.y*140)).toLocaleString();
+  renderer.render(scene,camera);
 }
+function ITEMscale(o){ return o.type==='bird'?1:.4; }
 tick();
+
+// ---- preload models (non-blocking); rebuild obstacle visuals once GLBs arrive ----
+const assetsEl=document.getElementById('assets');
+Promise.all(Object.keys(ITEM).map(loadModel)).then(()=>{
+  // rebuild any pooled obstacle visuals whose GLB is now available
+  for(const o of obstacles){
+    if(MODELS[o.type]){
+      o.group.remove(o.visual);
+      o.visual=MODELS[o.type].clone(true);
+      o.group.add(o.visual);
+    }
+  }
+  const have=Object.keys(ITEM).filter(k=>MODELS[k]);
+  assetsEl.textContent = have.length ? ('3D items: '+have.join(', ')) : 'primitive items (no GLBs yet)';
+  setTimeout(()=>assetsEl.style.opacity=0, 4000);
+});
 </script>
 </body>
 </html>

← 328159d auto-save: 2026-07-23T15:22:12 (1 files) — assets/  ·  back to Atmos Game  ·  Design-review pass (graphic-designer agent): IBL+ACES tonema 89093ba →