流氷でサーフィングする「IceSurfSafari」

【更新履歴】

 ・2026/7/24 バージョン1.0公開。
 ・2026/7/24 バージョン1.1公開。
 
・2026/7/24 バージョン1.2公開。
 ・2026/7/25 バージョン1.3公開。
 ・2026/7/25 バージョン1.4公開。


画像

《 操作説明 》

・方向キーで移動。
 (上キーで加速。下キーで減速。)

・Zキーで氷を投げる。
 (敵に当たると凍る。
  3回当てると倒せる。
  1回でも当てて凍らせていたら、
  ジャンプで飛び乗って倒せる。)

・Xキーで、近くの流氷に飛び移る。
 (近くの流氷に近寄るだけでも飛び移れる。)

・主人公が乗っかっている流氷は、
 他の流氷にぶつかると
 少しずつ小さくなっていきます。

・主人公が海に落ちるとゲームオーバー。

・光ってる氷をゲットすると、
 主人公が乗ってる流氷が回復します。

・ダウンロードされる方はこちら。↓

https://drive.google.com/drive/folders/1DqFdeKTPar3ovkyevIkrRRmZNLZNJP2w?ths=true

・ソースコードはこちら。↓

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ICE SURF SAFARI 3D - 南極サマーグランプリ</title>
<style>
  :root{
    --deep:#0b5d8a; --mid:#2ea3d8; --foam:#eafaff;
    --sun:#ffd35c; --coral:#ff7a8a; --mint:#8ef0cf;
    --ink:#083048;
  }
  html,body{margin:0;padding:0;overflow:hidden;background:#0b5d8a;font-family:'Segoe UI','Hiragino Sans',sans-serif;user-select:none;}
  #app{position:fixed;inset:0;}
  canvas{display:block;}

  /* ---------- HUD ---------- */
  .hud{position:fixed;inset:0;pointer-events:none;z-index:10;color:var(--ink);}
  #topBar{position:absolute;top:14px;left:18px;right:18px;display:flex;justify-content:space-between;align-items:flex-start;}
  #hpBox{display:flex;gap:5px;background:rgba(255,255,255,0.55);padding:8px 10px;border-radius:14px;backdrop-filter:blur(6px);box-shadow:0 4px 14px rgba(8,48,72,0.18);transition:background .2s;}
  #hpBox.swimming{background:rgba(230,60,80,0.35);}
  .floeChip{width:24px;height:22px;background:linear-gradient(180deg,#eafaff,#8fd8ee);clip-path:polygon(50% 0%,93% 25%,93% 75%,50% 100%,7% 75%,7% 25%);filter:drop-shadow(0 2px 2px rgba(20,90,120,0.35));transition:transform .15s,opacity .15s;border:1px solid rgba(255,255,255,0.8);}
  .floeChip.empty{background:#c9d6de;filter:none;opacity:0.45;}
  #scoreWrap{background:rgba(255,255,255,0.55);padding:8px 16px;border-radius:14px;text-align:right;backdrop-filter:blur(6px);box-shadow:0 4px 14px rgba(8,48,72,0.18);}
  #scoreLabel{font-size:11px;letter-spacing:2px;font-weight:700;opacity:.7;}
  #scoreVal{font-size:26px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums;}
  #comboTag{font-size:13px;font-weight:800;color:#ff5c2e;height:16px;}

  #progressWrap{position:absolute;top:16px;left:50%;transform:translateX(-50%);width:min(420px,44vw);}
  #progressBg{position:relative;height:16px;background:rgba(255,255,255,0.4);border-radius:9px;overflow:hidden;box-shadow:inset 0 2px 4px rgba(8,48,72,0.25);}
  #progressFill{height:100%;width:0%;background:linear-gradient(90deg,var(--mint),var(--sun),var(--coral));transition:width .2s;}
  #progressSurfer{position:absolute;top:-6px;left:0%;font-size:20px;transform:translateX(-50%);transition:left .2s;}
  #progressLabel{text-align:center;font-size:11px;font-weight:700;letter-spacing:2px;margin-top:3px;color:#fff;text-shadow:0 1px 3px rgba(8,48,72,0.6);}

  #speedWrap{position:absolute;right:22px;bottom:24px;text-align:right;color:#fff;text-shadow:0 2px 6px rgba(8,48,72,0.6);}
  #speedVal{font-size:52px;font-weight:900;line-height:.9;font-style:italic;font-variant-numeric:tabular-nums;}
  #speedUnit{font-size:14px;font-weight:700;letter-spacing:2px;opacity:.85;}
  #boostBarBg{width:150px;height:8px;margin-top:6px;margin-left:auto;background:rgba(255,255,255,0.3);border-radius:4px;overflow:hidden;}
  #boostBarFill{height:100%;width:0%;background:linear-gradient(90deg,#ffd35c,#ff7a3c);}


  #lockHint,#dangerHint{position:absolute;left:50%;transform:translateX(-50%);font-weight:800;padding:6px 18px;border-radius:20px;display:none;}
  #lockHint{top:96px;font-size:14px;color:#083048;background:rgba(255,211,92,0.92);box-shadow:0 4px 12px rgba(255,150,0,0.4);}
  #dangerHint{top:140px;font-size:16px;color:#fff;background:rgba(230,40,70,0.9);animation:flash .45s infinite;box-shadow:0 4px 16px rgba(230,40,70,0.5);}
  @keyframes flash{0%,100%{opacity:1}50%{opacity:.35}}

  #popupLayer{position:fixed;inset:0;pointer-events:none;z-index:12;overflow:hidden;}
  .popup{position:absolute;font-weight:900;transform:translate(-50%,-50%);white-space:nowrap;animation:popRise 1s ease-out forwards;text-shadow:0 2px 0 rgba(8,48,72,0.85),0 4px 10px rgba(8,48,72,0.4);}
  .popup.score{font-size:26px;color:#fff566;}
  .popup.combo{font-size:20px;color:#ff9d5c;}
  .popup.nice{font-size:30px;color:#8ef0cf;letter-spacing:2px;}
  @keyframes popRise{0%{opacity:0;transform:translate(-50%,-30%) scale(.5)}15%{opacity:1;transform:translate(-50%,-55%) scale(1.15)}30%{transform:translate(-50%,-60%) scale(1)}100%{opacity:0;transform:translate(-50%,-140%) scale(1)}}

  #speedTint{position:fixed;inset:0;pointer-events:none;z-index:8;opacity:0;background:radial-gradient(ellipse at center,transparent 40%,rgba(120,220,255,0.25) 100%);transition:opacity .3s;}
  #hitFlash{position:fixed;inset:0;pointer-events:none;z-index:13;background:rgba(255,60,80,0.35);opacity:0;transition:opacity .1s;}

  #stageBanner{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%) scale(0.6);z-index:16;pointer-events:none;
    font-size:min(80px,10vw);font-weight:900;font-style:italic;color:#fff;letter-spacing:3px;
    text-shadow:0 0 30px rgba(255,211,92,0.8),0 6px 0 rgba(8,48,72,0.85);opacity:0;text-align:center;}
  #stageBanner.show{animation:stagePop 1.4s cubic-bezier(.2,1.4,.4,1) forwards;}
  @keyframes stagePop{
    0%{opacity:0;transform:translate(-50%,-50%) scale(0.4);}
    18%{opacity:1;transform:translate(-50%,-50%) scale(1.08);}
    30%{transform:translate(-50%,-50%) scale(1);}
    78%{opacity:1;}
    100%{opacity:0;transform:translate(-50%,-58%) scale(1.02);}
  }

  #instructions{position:absolute;bottom:20px;left:20px;font-size:12px;line-height:1.7;background:rgba(255,255,255,0.5);padding:10px 14px;border-radius:12px;color:var(--ink);max-width:250px;backdrop-filter:blur(6px);}
  .key{display:inline-block;background:var(--ink);color:#fff;border-radius:5px;padding:0 7px;font-weight:800;font-size:11px;margin:0 1px;}

  /* ---------- Overlays ---------- */
  #overlay{position:fixed;inset:0;z-index:30;display:flex;flex-direction:column;align-items:center;justify-content:center;
    background:linear-gradient(180deg,rgba(6,26,46,0.55),rgba(6,26,46,0.82));backdrop-filter:blur(5px);color:#fff;}
  #titleArt{font-size:min(78px,9vw);font-weight:900;font-style:italic;letter-spacing:1px;line-height:.95;text-align:center;
    background:linear-gradient(180deg,#fff 20%,#9fe8ff 55%,#2ea3d8 100%);-webkit-background-clip:text;background-clip:text;color:transparent;
    filter:drop-shadow(0 6px 0 rgba(8,48,72,0.85)) drop-shadow(0 14px 24px rgba(0,80,140,0.5));margin:0;}
  #titleSub{font-size:18px;font-weight:700;letter-spacing:6px;color:var(--sun);margin:10px 0 26px;text-shadow:0 2px 8px rgba(255,180,0,0.5);}
  #resultTitle{font-size:min(64px,8vw);font-weight:900;font-style:italic;margin:0 0 8px;text-align:center;}
  #resultTitle.clear{color:var(--sun);text-shadow:0 0 30px rgba(255,211,92,0.7),0 4px 0 rgba(120,80,0,0.5);}
  #resultTitle.over{color:#ff8fa5;text-shadow:0 0 30px rgba(255,90,120,0.5),0 4px 0 rgba(90,10,30,0.5);}
  #resultStats{display:none;gap:26px;margin:6px 0 18px;font-weight:800;text-align:center;}
  .stat .n{font-size:34px;color:#fff;}
  .stat .l{font-size:11px;letter-spacing:2px;opacity:.7;}
  .rules{max-width:620px;font-size:13px;line-height:1.9;background:rgba(255,255,255,0.08);border:1px solid rgba(160,225,255,0.35);border-radius:14px;padding:16px 24px;margin-bottom:24px;}
  .rules b{color:var(--sun);}
  #startBtn{pointer-events:auto;padding:16px 52px;font-size:20px;font-weight:900;letter-spacing:3px;border:none;border-radius:40px;cursor:pointer;
    color:#083048;background:linear-gradient(180deg,#fff,#9fe8ff);box-shadow:0 6px 0 #1a6fa0,0 12px 26px rgba(0,60,110,0.5);transition:transform .08s;}
  #startBtn:hover{transform:translateY(-2px);}
  #startBtn:active{transform:translateY(3px);box-shadow:0 2px 0 #1a6fa0;}
</style>
</head>
<body>
<div id="app"></div>

<div class="hud">
  <div id="topBar">
    <div id="hpBox"></div>
    <div id="scoreWrap">
      <div id="scoreLabel">SCORE</div>
      <div id="scoreVal">0</div>
      <div id="comboTag"></div>
    </div>
  </div>
  <div id="progressWrap">
    <div id="progressBg"><div id="progressFill"></div><div id="progressSurfer">🏄</div></div>
    <div id="progressLabel">STAGE <span id="stageIndicator">1</span>/5 ・ GOAL まで <span id="progressPct">0</span>%</div>
  </div>
  <div id="lockHint">🎯 <span class="key">X</span> ジャンプ!</div>
  <div id="dangerHint">⚠ 海に落ちた!近くの氷へ <span class="key" style="background:#fff;color:#c22;">X</span> !</div>
  <div id="speedWrap">
    <div id="speedVal">0</div>
    <div id="speedUnit">km/h</div>
    <div id="boostBarBg"><div id="boostBarFill"></div></div>
  </div>
  <div id="instructions">
    <span class="key">←</span><span class="key">→</span> カービング <span class="key">↑</span> ブースト <span class="key">↓</span> ブレーキ<br>
    <span class="key">Z</span> 氷弾を当てた敵はロックオン可能に。<span class="key">X</span>でジャンプして踏み割れば即撃破!3発当てると自然に転覆(ドボン)することも<br>
    <span class="key">X</span> ロックオンした流氷/敵へジャンプ(画面に氷が無ければ海面を凍らせる)<br>
    左上は今の流氷のHP。💎氷の結晶を取ると全回復!
  </div>
</div>

<div id="popupLayer"></div>
<div id="stageBanner"></div>

<div id="speedTint"></div>
<div id="hitFlash"></div>

<div id="overlay">
  <h1 id="titleArt">ICE SURF<br>SAFARI</h1>
  <div id="titleSub">— 南極サマーグランプリ 全5ステージ —</div>
  <h2 id="resultTitle" style="display:none;"></h2>
  <div id="resultStats">
    <div class="stat"><div class="n" id="statScore">0</div><div class="l">SCORE</div></div>
    <div class="stat"><div class="n" id="statKills">0</div><div class="l">KNOCKOUTS</div></div>
    <div class="stat"><div class="n" id="statCombo">0</div><div class="l">MAX COMBO</div></div>
  </div>
  <div class="rules" id="rulesBox">
    <b>真夏の南極海を、氷のボードで駆け抜けるハイスピードレース!全5ステージ。</b><br>
    ・<span class="key">←</span><span class="key">→</span>でカービング(体ごと傾いて曲がる)。<span class="key">↑</span>でブースト、<span class="key">↓</span>でブレーキ<br>
    ・30m級のビッグウェーブが次々と押し寄せる。ゲレンデの下り坂を延々と滑り落ちるように、急な下りが長く続くうねりの上を疾走する<br>
    ・ステージごとに世界が一変: ①南極ビーチ ②トロピカルラグーン ③サンセット大波 ④クリスタル氷海 ⑤火山島フィナーレ。敵の姿もステージごとに変わる<br>
    ・<span class="key">Z</span>の氷弾は放物線を描いて飛ぶ。敵は自機めがけて海面を滑ってくるので狙いやすい<br>
    ・氷弾が当たった敵は🎯ロックオン可能になり、<span class="key">X</span>でジャンプして踏み割れば<b>即座に撃破</b>!氷弾を重ねて当てるほど氷のカタマリが増え、<b>3発当てると乗っている流氷ごと自然に転覆(ドボン)</b>することも。敵が乗っていた流氷は、そのままプレイヤーが乗れる普通の流氷になる<br>
    ・凍りきる前の敵に体当たりしても倒せない。弾き飛ばすだけで、自分の氷が削れる<br>
    ・💎<b>氷の結晶</b>を取ると、今乗っている流氷のHPが全回復!<br>
    ・左上のアイコンは今乗っている氷のHP。0になると海に投げ出される<br>
    ・近くの流氷に触れれば自動でジャンプして乗り換え。<span class="key">X</span>で🎯ロックオンした流氷へジャンプ(どんなに遠くてもOK)。画面に流氷が1つも無い時だけ、海面を凍らせて最小の足場を作る<br>
    ・全5ステージを走り抜ければグランプリ制覇!
  </div>
  <button id="startBtn">START</button>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="AudioFX.js" defer></script>
<script src="World.js" defer></script>
<script src="VFX.js" defer></script>
<script src="Characters.js" defer></script>
<script src="Game.js" defer></script>
<script src="Input.js" defer></script>
</body>
</html>




AudioFX.js

/* ============================================================
   AudioFX.js — 合成SE + 軽量トロピカルBGM(外部ファイル不要)
   ============================================================ */
let actx = null;
let bgmTimer = null;
let bgmStep = 0;
let masterGain = null;

function ensureAudio(){
  if(!actx){
    try{
      actx = new (window.AudioContext || window.webkitAudioContext)();
      masterGain = actx.createGain();
      masterGain.gain.value = 0.9;
      masterGain.connect(actx.destination);
    }catch(e){}
  }
  if(actx && actx.state === 'suspended') actx.resume();
}

function tone(freq, dur, type, gain, glideTo, delay){
  if(!actx) return;
  try{
    const osc = actx.createOscillator();
    const g = actx.createGain();
    osc.connect(g); g.connect(masterGain);
    const t0 = actx.currentTime + (delay||0);
    osc.type = type || 'sine';
    osc.frequency.setValueAtTime(freq, t0);
    if(glideTo) osc.frequency.linearRampToValueAtTime(glideTo, t0 + dur);
    g.gain.setValueAtTime(gain || 0.2, t0);
    g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
    osc.start(t0); osc.stop(t0 + dur);
  }catch(e){}
}

function noiseBurst(dur, gain, filterFreq, delay){
  if(!actx) return;
  try{
    const len = Math.max(1, Math.floor(actx.sampleRate * dur));
    const buf = actx.createBuffer(1, len, actx.sampleRate);
    const data = buf.getChannelData(0);
    for(let i=0;i<len;i++) data[i] = (Math.random()*2-1) * (1 - i/len);
    const src = actx.createBufferSource();
    src.buffer = buf;
    const filt = actx.createBiquadFilter();
    filt.type = 'bandpass';
    filt.frequency.value = filterFreq || 3000;
    const g = actx.createGain();
    g.gain.value = gain || 0.2;
    src.connect(filt); filt.connect(g); g.connect(masterGain);
    const t0 = actx.currentTime + (delay||0);
    src.start(t0);
  }catch(e){}
}

const _SE_RAW = {
  shoot(){ tone(820,0.07,'sine',0.13,1200); noiseBurst(0.05,0.06,6000); },
  freezeHit(){ tone(1050,0.12,'triangle',0.2,1600); tone(1550,0.1,'sine',0.1,2100,0.03); },
  fullFreeze(){ tone(1400,0.22,'triangle',0.22,2200); tone(2100,0.3,'sine',0.12,2800,0.05); noiseBurst(0.2,0.1,8000); },
  board(){ tone(520,0.09,'sine',0.16,760); noiseBurst(0.08,0.12,2200); },
  hop(){ tone(340,0.12,'square',0.16,620); tone(500,0.1,'sine',0.1,760,0.03); noiseBurst(0.1,0.08,3000,0.02); },
  bump(){ tone(240,0.1,'square',0.22,130); tone(160,0.14,'sawtooth',0.16,70,0.03); noiseBurst(0.14,0.16,1600); },
  kill(){ tone(200,0.16,'sawtooth',0.3,50); tone(400,0.1,'square',0.14,90,0.02); noiseBurst(0.22,0.32,1400); },
  crack(){ tone(700,0.07,'square',0.12,330); noiseBurst(0.06,0.14,5000); },
  shatter(){ tone(900,0.2,'square',0.16,120); tone(500,0.24,'sawtooth',0.14,60,0.04); noiseBurst(0.3,0.34,4500); },
  damage(){ tone(150,0.22,'sawtooth',0.3,55); noiseBurst(0.15,0.2,900); },
  splash(){ noiseBurst(0.3,0.24,1200); tone(300,0.2,'sine',0.08,120); },
  thaw(){ tone(430,0.15,'sine',0.12,220); },
  freezeSurface(){ tone(1200,0.1,'triangle',0.16,1900); tone(1800,0.08,'sine',0.1,2400,0.03); noiseBurst(0.12,0.1,7000); },
  dobon(){ tone(90,0.4,'sine',0.4,45); tone(60,0.5,'sine',0.3,30,0.05); noiseBurst(0.45,0.34,700); noiseBurst(0.3,0.2,300,0.08); },
  boost(){ tone(500,0.25,'sawtooth',0.12,1100); tone(260,0.3,'square',0.1,900,0.04); noiseBurst(0.28,0.14,2600); },
  nice(){ tone(660,0.09,'triangle',0.18,880); tone(880,0.12,'triangle',0.16,1100,0.08); },
  recover(){ [523,659,784,1046,1319].forEach((f,i)=>tone(f,0.2,'triangle',0.2,f*1.02,i*0.055)); noiseBurst(0.2,0.1,7000); },
  stageClear(){ [523,659,784].forEach((f,i)=>tone(f,0.22,'triangle',0.22,f*1.02,i*0.09)); noiseBurst(0.3,0.14,6000,0.2); },
  clear(){ [523,659,784,1046].forEach((f,i)=>tone(f,0.35,'triangle',0.2,f*1.01,i*0.12)); noiseBurst(0.5,0.15,5000,0.4); },
  gameover(){ tone(300,0.4,'sawtooth',0.22,90); tone(220,0.6,'sawtooth',0.18,60,0.2); },
};
// stale cache 等で未定義の効果音が呼ばれても落ちないよう安全にラップする
const SE = new Proxy(_SE_RAW, {
  get(target, prop){
    if(typeof target[prop] === 'function') return target[prop];
    return function(){ /* no-op: unknown SFX */ };
  }
});

/* ---- BGM: calypso風の軽快な2小節ループ ---- */
const BGM_BASS  = [131, 0, 196, 0, 165, 0, 196, 0, 147, 0, 220, 0, 165, 0, 196, 0];
const BGM_LEAD  = [523, 659, 784, 659, 880, 784, 659, 523, 587, 698, 880, 698, 784, 659, 587, 659];
const BGM_CHORD = [[262,330,392],[294,370,440]];

function startBGM(){
  if(!actx || bgmTimer) return;
  bgmStep = 0;
  bgmTimer = setInterval(()=>{
    const s = bgmStep % 16;
    if(BGM_BASS[s]) tone(BGM_BASS[s], 0.16, 'triangle', 0.09);
    if(s % 2 === 0) tone(BGM_LEAD[s], 0.11, 'square', 0.028, BGM_LEAD[s]*1.002);
    if(s % 4 === 2) noiseBurst(0.03, 0.05, 8000);           // シェイカー
    if(s === 0 || s === 8){
      const ch = BGM_CHORD[(Math.floor(bgmStep/16))%2];
      ch.forEach(f=>tone(f, 0.3, 'sine', 0.02));
    }
    bgmStep++;
  }, 140);
}
function stopBGM(){
  if(bgmTimer){ clearInterval(bgmTimer); bgmTimer = null; }
}



Characters.js

/* ============================================================
   Characters.js — キャラクターモデル / アニメーション / 敵バリエーション / 流氷
   ============================================================ */
const PASTELS = [
  { name:'mint',       base:0xb5ead7, dark:0x7fcdb0 },
  { name:'strawberry', base:0xffc2d6, dark:0xff8fae },
  { name:'lemon',      base:0xfff6b7, dark:0xffd75e },
  { name:'blueberry',  base:0xb8d8ff, dark:0x7fb0ff },
  { name:'grape',      base:0xdcc6ff, dark:0xb98aff },
  { name:'mango',      base:0xffe0b5, dark:0xffb45e },
  { name:'soda',       base:0xc2f5ff, dark:0x76d8ef },
  { name:'melon',      base:0xd6f5a8, dark:0x9fd45e },
];
function pickPal(){ return PASTELS[Math.floor(Math.random()*PASTELS.length)]; }

/* モデルの「顔」はローカル -Z 面に焼き込んである。
   dz が負(画面奥へ進む)なら yaw≈0 となり、カメラからは後頭部が見える。 */
function headingY(dx, dz){
  if(Math.abs(dx) < 1e-4 && Math.abs(dz) < 1e-4) return 0;
  return Math.atan2(-dx, -dz);
}

/* ============================================================
   人型ビルダー
   ・腕は「上腕 + 前腕」の2関節。人間並みの長さ(胴体より長い)
   ・頭は顔/後頭部/横をそれぞれ描き分ける
   ============================================================ */
function buildHumanoid(opts){
  const o = Object.assign({
    skin: 0xf2c49b, shirt: 0xff8a3d, shorts: 0x2ec8ff, hair: 0x4a2c1a,
    scale: 1, emissive: 0x000000, emissiveIntensity: 0.0,
  }, opts||{});
  const g = new THREE.Group();
  const mat = (c)=> new THREE.MeshLambertMaterial({ color: c, emissive: o.emissive, emissiveIntensity: o.emissiveIntensity });
  const parts = {};

  // ---- 腰 ----
  const hips = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.44, 0.5), mat(o.shorts));
  hips.position.y = 1.0;
  hips.castShadow = true;
  g.add(hips); parts.hips = hips;

  // ---- 胴 (少し先細り) ----
  const torso = new THREE.Mesh(new THREE.CylinderGeometry(0.46, 0.52, 1.15, 8), mat(o.shirt));
  torso.scale.z = 0.72;
  torso.position.y = 1.78;
  torso.castShadow = true;
  g.add(torso); parts.torso = torso;

  const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.2, 0.22, 6), mat(o.skin));
  neck.position.y = 2.44;
  g.add(neck);

  // ---- 頭 ----
  const head = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.82, 0.78), makeHeadMaterials(o));
  head.position.y = 2.9;
  head.castShadow = true;
  g.add(head); parts.head = head;

  // 髪(後頭部のボリューム) — カメラから見えるのはこちら側
  const hairBack = new THREE.Mesh(new THREE.BoxGeometry(0.84, 0.6, 0.3), mat(o.hair));
  hairBack.position.set(0, 2.98, 0.3);
  head.parent.add(hairBack);
  parts.hairBack = hairBack;
  const hairTop = new THREE.Mesh(new THREE.BoxGeometry(0.86, 0.2, 0.84), mat(o.hair));
  hairTop.position.set(0, 3.24, 0.0);
  g.add(hairTop);

  /* ---- 2関節の腕 ----
     shoulder(Group) -> upperArm mesh + elbow(Group) -> foreArm + hand   */
  function buildArm(side){
    const shoulder = new THREE.Group();
    shoulder.position.set(side*0.6, 2.2, 0);

    const upper = new THREE.Mesh(new THREE.CylinderGeometry(0.145, 0.13, 0.6, 6), mat(o.skin));
    upper.position.y = -0.3;
    upper.castShadow = true;
    shoulder.add(upper);

    const elbow = new THREE.Group();
    elbow.position.y = -0.6;
    shoulder.add(elbow);

    const fore = new THREE.Mesh(new THREE.CylinderGeometry(0.125, 0.11, 0.54, 6), mat(o.skin));
    fore.position.y = -0.27;
    fore.castShadow = true;
    elbow.add(fore);

    const hand = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.22, 0.15), mat(o.skin));
    hand.position.y = -0.62;
    elbow.add(hand);

    g.add(shoulder);
    return { shoulder, elbow };
  }
  const armL = buildArm(-1), armR = buildArm(1);
  parts.armL = armL.shoulder; parts.elbowL = armL.elbow;
  parts.armR = armR.shoulder; parts.elbowR = armR.elbow;

  /* ---- 2関節の脚 ---- */
  function buildLeg(side){
    const hip = new THREE.Group();
    hip.position.set(side*0.24, 0.98, 0);
    const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.17, 0.62, 6), mat(o.shorts));
    thigh.position.y = -0.31;
    thigh.castShadow = true;
    hip.add(thigh);
    const knee = new THREE.Group();
    knee.position.y = -0.62;
    hip.add(knee);
    const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.13, 0.56, 6), mat(o.skin));
    shin.position.y = -0.28;
    knee.add(shin);
    const foot = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.14, 0.46), mat(o.hair));
    foot.position.set(0, -0.6, 0.1);
    knee.add(foot);
    g.add(hip);
    return { hip, knee };
  }
  const legL = buildLeg(-1), legR = buildLeg(1);
  parts.legL = legL.hip; parts.kneeL = legL.knee;
  parts.legR = legR.hip; parts.kneeR = legR.knee;

  g.scale.setScalar(o.scale);
  return { group: g, parts };
}

function makeHeadMaterials(o){
  const skinHex = '#' + o.skin.toString(16).padStart(6,'0');
  const hairHex = '#' + o.hair.toString(16).padStart(6,'0');

  // 顔(-Z面): 通常プレイ中はカメラから見えない
  const faceCv = document.createElement('canvas'); faceCv.width = faceCv.height = 64;
  const fc = faceCv.getContext('2d');
  fc.fillStyle = skinHex; fc.fillRect(0,0,64,64);
  fc.fillStyle = hairHex; fc.fillRect(0,0,64,16);
  fc.fillStyle = '#22303a';
  fc.fillRect(14,28,9,9); fc.fillRect(41,28,9,9);
  fc.fillStyle = '#fff';
  fc.fillRect(16,30,3,3); fc.fillRect(43,30,3,3);
  fc.strokeStyle = '#a45a3a'; fc.lineWidth = 2.6;
  fc.beginPath(); fc.arc(32, 42, 8, 0.25*Math.PI, 0.75*Math.PI); fc.stroke();

  // 後頭部(+Z面): プレイ中に見えるのはこちら
  const backCv = document.createElement('canvas'); backCv.width = backCv.height = 64;
  const bc = backCv.getContext('2d');
  bc.fillStyle = hairHex; bc.fillRect(0,0,64,64);
  bc.fillStyle = 'rgba(0,0,0,0.16)';
  for(let i=0;i<7;i++) bc.fillRect(6+i*8, 6, 3, 52);   // 毛束のライン
  bc.fillStyle = skinHex; bc.fillRect(0,56,64,8);      // うなじ

  // 側面: 上が髪、下が肌
  const sideCv = document.createElement('canvas'); sideCv.width = sideCv.height = 64;
  const sc = sideCv.getContext('2d');
  sc.fillStyle = skinHex; sc.fillRect(0,0,64,64);
  sc.fillStyle = hairHex; sc.fillRect(0,0,64,26);
  sc.fillStyle = 'rgba(0,0,0,0.12)';
  for(let i=0;i<5;i++) sc.fillRect(4+i*13, 2, 3, 22);

  const side = new THREE.MeshLambertMaterial({ map: new THREE.CanvasTexture(sideCv) });
  const top  = new THREE.MeshLambertMaterial({ color: o.hair });
  const bot  = new THREE.MeshLambertMaterial({ color: o.skin });
  const back = new THREE.MeshLambertMaterial({ map: new THREE.CanvasTexture(backCv) });
  const face = new THREE.MeshLambertMaterial({ map: new THREE.CanvasTexture(faceCv) });
  // box面順: +x, -x, +y, -y, +z, -z
  return [side, side, top, bot, back, face];
}

/* ============================================================
   アニメーション
   ============================================================ */

/** サーフィン: ペンギンのように腕を横に広げてパタパタさせ、バランスを取る
    ※ 肩のZ回転は、左腕は負・右腕は正で「外側」へ開く(逆にすると体の前で交差してしまう) */
function animSurf(parts, t, lean, boostLean, intensity){
  const amp = 0.34 + (intensity||0)*0.42;
  const flap = Math.sin(t*9.5);
  const flap2 = Math.sin(t*9.5 + 0.5);

  // 肩: 左右へ大きく開いたまま、上下にパタパタ羽ばたく
  parts.armL.rotation.z = -(1.18 + flap*amp)  + lean*0.32;
  parts.armR.rotation.z =  (1.18 + flap2*amp) + lean*0.32;
  // 前後のあおりは控えめ(交差しないよう X回転は小さく)
  parts.armL.rotation.x = -0.12 + flap2*amp*0.35 - boostLean*0.3;
  parts.armR.rotation.x = -0.12 + flap*amp*0.35 - boostLean*0.3;
  parts.armL.rotation.y = 0;
  parts.armR.rotation.y = 0;

  // 肘はほぼ伸ばしたまま(ペンギンのヒレのように)
  parts.elbowL.rotation.x = -0.14;
  parts.elbowR.rotation.x = -0.14;
  parts.elbowL.rotation.z = -0.12 - flap*amp*0.2;
  parts.elbowR.rotation.z =  0.12 + flap2*amp*0.2;

  // 下半身: サーフスタンス
  parts.legL.rotation.x = -0.4 - boostLean*0.28;
  parts.legR.rotation.x =  0.28 - boostLean*0.28;
  parts.legL.rotation.z =  0.12 + lean*0.14;
  parts.legR.rotation.z = -0.12 + lean*0.14;
  parts.kneeL.rotation.x = 0.7 + boostLean*0.26;
  parts.kneeR.rotation.x = 0.6 + boostLean*0.26;

  parts.hips.rotation.y = lean*0.2;
  parts.torso.rotation.z = lean*0.2;
  parts.torso.rotation.x = -0.14 - boostLean*0.28 + Math.sin(t*3.4)*0.035;
  parts.head.rotation.z = -lean*0.24;
  parts.head.rotation.x = 0.06 + boostLean*0.1;
  if(parts.hairBack){
    parts.hairBack.rotation.z = -lean*0.24;
    parts.hairBack.rotation.x = 0.06;
  }
}

/** 遊泳: 必死のクロール */
function animSwim(parts, t){
  parts.armL.rotation.x = Math.sin(t*9)*2.0 - 0.4;
  parts.armR.rotation.x = Math.sin(t*9+Math.PI)*2.0 - 0.4;
  parts.armL.rotation.z = -0.3; parts.armR.rotation.z = 0.3;
  parts.armL.rotation.y = 0;    parts.armR.rotation.y = 0;
  parts.elbowL.rotation.x = -0.3; parts.elbowR.rotation.x = -0.3;
  parts.elbowL.rotation.z = 0;    parts.elbowR.rotation.z = 0;
  parts.legL.rotation.x = Math.sin(t*11)*0.7;
  parts.legR.rotation.x = Math.sin(t*11+Math.PI)*0.7;
  parts.legL.rotation.z = 0; parts.legR.rotation.z = 0;
  parts.kneeL.rotation.x = 0.3; parts.kneeR.rotation.x = 0.3;
  parts.torso.rotation.z = Math.sin(t*4.5)*0.12;
  parts.torso.rotation.x = 0.32;
  parts.hips.rotation.y = 0;
  parts.head.rotation.x = -0.3;
}

/* ============================================================
   流氷モデル
   ============================================================ */
let _crackTex = null;
function getCrackTexture(){
  if(_crackTex) return _crackTex;
  const cv = document.createElement('canvas'); cv.width = cv.height = 256;
  const g = cv.getContext('2d');
  g.clearRect(0,0,256,256);
  g.strokeStyle = 'rgba(255,255,255,0.92)';
  g.lineWidth = 2.6;
  const cx=128, cy=128;
  for(let i=0;i<11;i++){
    let x=cx, y=cy, ang = Math.random()*Math.PI*2;
    g.beginPath(); g.moveTo(x,y);
    const segs = 4 + Math.floor(Math.random()*3);
    for(let s=0;s<segs;s++){
      ang += (Math.random()-0.5)*1.1;
      x += Math.cos(ang) * (14+Math.random()*20);
      y += Math.sin(ang) * (14+Math.random()*20);
      g.lineTo(x,y);
    }
    g.stroke();
  }
  _crackTex = new THREE.CanvasTexture(cv);
  return _crackTex;
}

function makeFloeMesh(radius){
  const g = new THREE.Group();
  const geo = new THREE.CylinderGeometry(radius*0.92, radius*1.04, 0.78, 9, 1);
  const pos = geo.attributes.position;
  for(let i=0;i<pos.count;i++){
    const x = pos.getX(i), z = pos.getZ(i);
    const d = Math.sqrt(x*x + z*z);
    if(d > radius*0.5){
      const j = 0.84 + Math.random()*0.28;
      pos.setX(i, x*j); pos.setZ(i, z*j);
      pos.setY(i, pos.getY(i) + (Math.random()-0.5)*0.16);
    }
  }
  geo.computeVertexNormals();
  const body = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({
    color: 0xbfe4f7, emissive: 0x6fb8e0, emissiveIntensity: 0.16,
    shininess: 110, specular: 0xcdf0ff, flatShading: true,
  }));
  body.position.y = 0.34;
  body.castShadow = true;
  body.receiveShadow = true;
  g.add(body);

  // 積もった雪の起伏
  const snow = new THREE.Mesh(
    new THREE.SphereGeometry(radius*0.62, 10, 7, 0, Math.PI*2, 0, Math.PI*0.5),
    new THREE.MeshLambertMaterial({ color: 0xffffff })
  );
  snow.scale.y = 0.3;
  snow.position.y = 0.7;
  g.add(snow);

  // 水面下の氷塊
  const under = new THREE.Mesh(
    new THREE.CylinderGeometry(radius*1.0, radius*0.62, 1.1, 9),
    new THREE.MeshLambertMaterial({ color: 0x6fc8ea, transparent:true, opacity: 0.5 })
  );
  under.position.y = -0.5;
  g.add(under);

  const crack = new THREE.Mesh(
    new THREE.CircleGeometry(radius*1.0, 9),
    new THREE.MeshBasicMaterial({ map: getCrackTexture(), transparent:true, opacity:0, depthWrite:false })
  );
  crack.rotation.x = -Math.PI/2;
  crack.rotation.z = Math.random()*Math.PI*2;
  crack.position.y = 0.75;
  g.add(crack);
  g.userData.crackMesh = crack;
  return g;
}

function updateFloeCracks(floeGroup, damageRatio){
  const crack = floeGroup.userData.crackMesh;
  if(crack) crack.material.opacity = Math.min(0.9, damageRatio*1.2);
}

/* ---------- 氷の結晶(取ると流氷が全回復) ---------- */
function makeIceCrystal(){
  const g = new THREE.Group();
  const mat = new THREE.MeshPhongMaterial({
    color: 0xa0e0ff, emissive: 0x3fb8ff, emissiveIntensity: 0.85,
    shininess: 150, specular: 0xffffff, transparent: true, opacity: 0.92, flatShading: true,
  });
  const core = new THREE.Mesh(new THREE.OctahedronGeometry(0.95, 0), mat);
  core.scale.y = 1.7;
  g.add(core);
  for(let i=0;i<6;i++){
    const a = i/6*Math.PI*2;
    const shard = new THREE.Mesh(new THREE.OctahedronGeometry(0.34, 0), mat);
    shard.scale.y = 1.5;
    shard.position.set(Math.cos(a)*0.85, Math.sin(i*1.7)*0.4, Math.sin(a)*0.85);
    shard.rotation.z = Math.cos(a)*0.5;
    g.add(shard);
  }
  const glow = new THREE.Sprite(new THREE.SpriteMaterial({
    map: makeRadialTexture('rgba(255,255,255,0.95)','rgba(120,220,255,0.5)','rgba(120,220,255,0)'),
    transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, opacity: 0.85,
  }));
  glow.scale.set(6.5, 6.5, 1);
  g.add(glow);
  g.userData.glow = glow;
  return g;
}

/* ============================================================
   敵: ステージごとのバリエーション
   ============================================================ */
const ENEMY_VARIANTS = {
  1: ['snowGoon'],
  2: ['snowGoon', 'tikiGhoul'],
  3: ['tikiGhoul', 'crabBoss'],
  4: ['crystalWraith', 'crabBoss'],
  5: ['crystalWraith', 'magmaImp', 'crabBoss'],
};
function pickVariant(stage){
  const list = ENEMY_VARIANTS[Math.min(5, Math.max(1, stage))] || ENEMY_VARIANTS[1];
  return list[Math.floor(Math.random()*list.length)];
}

const VARIANT_CONFIG = {
  snowGoon:      { scale: 1.05, speed: 1.0,  hair: 0x9fd8ff },
  tikiGhoul:     { scale: 1.12, speed: 1.05, hair: 0x8a5a34 },
  crabBoss:      { scale: 1.25, speed: 0.85, hair: 0xff7a5c },
  crystalWraith: { scale: 1.0,  speed: 1.25, hair: 0xa8e8ff },
  magmaImp:      { scale: 0.95, speed: 1.4,  hair: 0xff6a3a },
};

/** バリエーションごとの飾り(角・冠・ハサミ など)を頭・肩まわりに付ける */
function decorateVariant(group, rig, variant, pal){
  const deco = new THREE.Group();
  const M = (c, emis)=> new THREE.MeshLambertMaterial({ color: c, emissive: emis||0x000000, emissiveIntensity: emis?0.5:0 });

  if(variant === 'snowGoon'){
    // 雪だるま風のバケツ帽 + にんじん鼻
    const bucket = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.34, 0.55, 8), M(0x9aa8b4));
    bucket.position.y = 3.42;
    deco.add(bucket);
    const nose = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.5, 6), M(0xff8a3d));
    nose.rotation.x = -Math.PI/2;
    nose.position.set(0, 2.9, -0.55);
    deco.add(nose);
  } else if(variant === 'tikiGhoul'){
    // ティキマスク + 草のスカート
    const mask = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.0, 0.2), M(0x7a4a24));
    mask.position.set(0, 2.9, -0.46);
    deco.add(mask);
    for(const s of [-1,1]){
      const eye = new THREE.Mesh(new THREE.BoxGeometry(0.2,0.2,0.1), M(0xffd35c, 0xffd35c));
      eye.position.set(s*0.2, 3.05, -0.58);
      deco.add(eye);
    }
    const tusk = new THREE.Mesh(new THREE.ConeGeometry(0.1,0.34,5), M(0xfff3e0));
    tusk.rotation.x = Math.PI;
    tusk.position.set(0, 2.55, -0.5);
    deco.add(tusk);
    for(let i=0;i<10;i++){
      const straw = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.62, 0.06), M(0xd6c060));
      const a = i/10*Math.PI*2;
      straw.position.set(Math.cos(a)*0.46, 0.86, Math.sin(a)*0.34);
      deco.add(straw);
    }
  } else if(variant === 'crabBoss'){
    // 巨大ハサミ + 目玉トゲ
    for(const s of [-1,1]){
      const claw = new THREE.Mesh(new THREE.ConeGeometry(0.42, 1.0, 5), M(0xff7a5c));
      claw.rotation.z = s*Math.PI/2;
      claw.position.set(s*1.35, 1.15, 0);
      deco.add(claw);
    }
    for(const s of [-1,1]){
      const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.06,0.5,5), M(0xff9a7c));
      stalk.position.set(s*0.24, 3.4, 0);
      deco.add(stalk);
      const eye = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 8), M(0xffffff));
      eye.position.set(s*0.24, 3.66, 0);
      deco.add(eye);
    }
  } else if(variant === 'crystalWraith'){
    // 背中から生えた結晶の翼
    for(let i=0;i<6;i++){
      const shard = new THREE.Mesh(new THREE.ConeGeometry(0.16, 1.3, 4),
        new THREE.MeshPhongMaterial({ color: 0xd8f4ff, emissive: 0x6fc8ea, emissiveIntensity: 0.5, shininess: 130, transparent:true, opacity: 0.9 }));
      const s = i<3 ? -1 : 1;
      shard.position.set(s*0.5, 1.9 + (i%3)*0.42, 0.42);
      shard.rotation.z = s*(0.5 + (i%3)*0.22);
      shard.rotation.x = 0.4;
      deco.add(shard);
    }
    const crown = new THREE.Mesh(new THREE.ConeGeometry(0.34, 0.7, 6),
      new THREE.MeshPhongMaterial({ color: 0xa8e8ff, emissive: 0x4fb0e0, emissiveIntensity: 0.6 }));
    crown.position.y = 3.5;
    deco.add(crown);
  } else if(variant === 'magmaImp'){
    // 角 + 燃える尻尾
    for(const s of [-1,1]){
      const horn = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.62, 5), M(0x5a2a1a));
      horn.position.set(s*0.28, 3.42, 0);
      horn.rotation.z = s*0.4;
      deco.add(horn);
    }
    const tail = new THREE.Mesh(new THREE.ConeGeometry(0.18, 1.5, 5), M(0xff6a3a, 0xff4a1a));
    tail.position.set(0, 1.1, 0.66);
    tail.rotation.x = 1.1;
    deco.add(tail);
    const aura = new THREE.Mesh(new THREE.SphereGeometry(1.15, 10, 8),
      new THREE.MeshBasicMaterial({ color: 0xff7a3a, transparent:true, opacity:0.18 }));
    aura.position.y = 1.9;
    deco.add(aura);
  }
  group.add(deco);
  return deco;
}


class EnemyBot {
  constructor(x, z, stage){
    this.pal = pickPal();
    this.variant = pickVariant(stage || 1);
    const cfg = VARIANT_CONFIG[this.variant];
    this.freezeLevel = 0;
    this.noHitTimer = 0;
    this.dead = false;
    this.animT = Math.random()*10;
    this.wobble = Math.random()*Math.PI*2;
    this.driftZ = (3 + Math.random()*4) * cfg.speed;
    this.stunTimer = 0;
    this.homeDX = 0;
    this.speedMult = cfg.speed;

    const rig = buildHumanoid({
      skin: this.pal.dark, shirt: this.pal.base, shorts: this.pal.dark,
      hair: cfg.hair, scale: cfg.scale, emissive: this.pal.dark, emissiveIntensity: 0.22,
    });
    this.group = new THREE.Group();
    this.rig = rig;
    this.group.add(rig.group);

    decorateVariant(rig.group, rig, this.variant, this.pal);

    // 敵も自分の氷パックでサーフィンしてくる
    this.puck = makeFloeMesh(1.7);
    this.group.add(this.puck);
    rig.group.position.y = 0.6;

    this.casing = new THREE.Mesh(
      new THREE.BoxGeometry(2.1, 3.6, 1.7),
      new THREE.MeshPhongMaterial({
        color: 0x9fd4f0, transparent: true, opacity: 0,
        shininess: 130, specular: 0xffffff, depthWrite: false,
      })
    );
    this.casing.position.y = 2.0;
    this.group.add(this.casing);

    // 被弾で増えていく氷のカタマリの入れ物
    this.iceChunks = new THREE.Group();
    this.group.add(this.iceChunks);

    this.group.position.set(x, 0, z);
    this.group.rotation.y = Math.PI; // プレイヤー側(+Z)を向く = 顔が見える
    scene.add(this.group);
  }

  /** 被弾ごとに body の周囲へ氷のカタマリを増設していく */
  addIceChunks(level){
    const count = 3 + level*2;
    const spread = 0.7 + level*0.34;
    for(let i=0;i<count;i++){
      const s = (0.34 + Math.random()*0.5) * (1 + level*0.4);
      const chunk = new THREE.Mesh(
        new THREE.IcosahedronGeometry(s, 0),
        new THREE.MeshPhongMaterial({
          color: 0xa8d8f0, emissive: 0x5fabd8, emissiveIntensity: 0.35,
          shininess: 120, specular: 0xffffff, transparent: true, opacity: 0.88, flatShading: true,
        })
      );
      const a = Math.random()*Math.PI*2;
      const rr = spread*(0.5 + Math.random()*0.8);
      chunk.position.set(Math.cos(a)*rr, 0.7 + Math.random()*2.4, Math.sin(a)*rr*0.7);
      chunk.rotation.set(Math.random()*3, Math.random()*3, Math.random()*3);
      this.iceChunks.add(chunk);
    }
  }

  applyFreezeVisual(){
    const lvl = this.freezeLevel;
    this.casing.material.opacity = lvl * 0.2;
    const s = 1 + lvl*0.22;
    this.casing.scale.set(s, s, s);
    const puckS = 1 + lvl*0.16;
    this.puck.scale.set(puckS, 1, puckS);
    if(lvl > 0){
      this.rig.group.traverse(m=>{
        if(m.isMesh && m.material && m.material.emissive){
          m.material.emissiveIntensity = 0.2 + lvl*0.12;
          m.material.emissive.setHex(0x9fd8ff);
        }
      });
    }
  }

  update(dt, worldSpeed, playerX, elapsed, waveY){
    if(this.dead) return;
    const slow = 1 - this.freezeLevel/4;   // 凍るほど鈍くなるが、3発目で転覆するので止まりはしない

    if(this.stunTimer > 0){
      this.stunTimer -= dt;
      this.group.position.z += worldSpeed * dt;
      this.group.rotation.z = Math.sin(elapsed*24)*0.2;
      this.group.position.y = (waveY||0);
      return;
    }

    const beforeX = this.group.position.x;
    this.group.position.z += (worldSpeed + this.driftZ*slow) * dt;

    this.animT += dt * (4.0*slow + 1.4);
    this.wobble += dt*1.8*slow;
    const dx = playerX - this.group.position.x;
    const moveX = Math.sign(dx) * Math.min(Math.abs(dx), 3.4*slow*this.speedMult*dt)
                + Math.sin(this.wobble)*0.55*slow*dt;
    this.group.position.x += moveX;
    this.homeDX = moveX/dt;

    const dX = this.group.position.x - beforeX;
    this.group.rotation.y = headingY(dX, 6);
    const bank = Math.max(-1, Math.min(1, this.homeDX/3.0));
    animSurf(this.rig.parts, this.animT, bank, 0, 0.5);
    this.puck.rotation.z = -bank*0.3;

    // 氷のカタマリはゆっくり回りながらまとわりつく
    this.iceChunks.rotation.y += dt*0.6;

    this.noHitTimer += dt;
    if(this.freezeLevel > 0 && this.noHitTimer > 3.6){
      this.freezeLevel--;
      this.noHitTimer = 0;
      // 溶けた分のカタマリを落とす
      const drop = this.iceChunks.children.slice(-3);
      drop.forEach(c=>this.iceChunks.remove(c));
      this.applyFreezeVisual();
      SE.thaw();
    }

    this.group.position.y = (waveY||0);
  }

  bump(pushX, pushZ){
    const mag = Math.hypot(pushX, pushZ) || 1;
    this.group.position.x += (pushX/mag) * 2.8;
    this.group.position.z += (pushZ/mag) * 1.5;
    this.stunTimer = 0.5;
    const p = this.group.position;
    spawnSpray(p.x, p.y+1.6, p.z, 22, 0xffffff, 6.5);
    spawnSpray(p.x, p.y+0.3, p.z, 18, 0xdff6ff, 5, 1.6);
    spawnRing(p.x, p.y + 0.4, p.z, this.pal.base, 10);
    SE.bump();
  }

  onShotHit(){
    if(this.dead) return false;
    this.freezeLevel++;
    this.noHitTimer = 0;
    this.addIceChunks(this.freezeLevel);
    this.applyFreezeVisual();
    const p = this.group.position;
    spawnSpray(p.x, p.y+2.2, p.z, 18, 0xd8f4ff, 5.5);
    spawnSpray(p.x, p.y+0.4, p.z, 14, 0xffffff, 4.5, 1.4);
    spawnRing(p.x, p.y + 0.4, p.z, 0xbfeaff, 9);
    if(this.freezeLevel >= 3){
      SE.fullFreeze();
      if(typeof capsizeEnemy === 'function') capsizeEnemy(this);
      return true;
    }
    SE.freezeHit();
    return false;
  }

  explode(){
    this.dead = true;
    const p = this.group.position;
    spawnCubeBurst(p.x, p.y+1.8, p.z, this.pal.base, 14, 8);
    spawnCubeBurst(p.x, p.y+1.2, p.z, this.pal.dark, 10, 6);
    spawnSpray(p.x, p.y+1, p.z, 20, 0xffffff, 7);
    spawnRing(p.x, p.y+0.4, p.z, this.pal.base, 15);
    scene.remove(this.group);
    if(typeof disposeObject3D === 'function') disposeObject3D(this.group);
  }

  remove(){
    this.dead = true;
    scene.remove(this.group);
    if(typeof disposeObject3D === 'function') disposeObject3D(this.group);
  }
}



Game.js

/* ============================================================
   Game.js — コアループ / レースバランス / ライド&戦闘 / HUD / ステージ進行
   ============================================================ */
const STAGE_COUNT = 5;
const STAGE_LEN = 720; // 1ステージあたりの距離

const BAL = {
  GOAL: STAGE_LEN * STAGE_COUNT,
  BASE_SPEED: 30,           // 基本ワールド速度
  RAMP_SPEED: 26,           // 進行で加算される速度
  BOOST_KEY: 15,            // ↑キーの加速
  BRAKE_KEY: 14,            // ↓キーの減速
  KILL_BOOST: 6,            // 撃破時のブーストゲージ加算
  BOOST_DECAY: 2.6,
  BOOST_MAX: 22,
  LAT_ACCEL: 130,           // 横方向の加速(もたつかないよう大幅強化)
  LAT_MAX: 30,              // 横方向の最高速
  LAT_FRICTION: 34,         // キーを離した時の減速(すぐ止まる)
  SHOT_SPEED: 120,          // 氷弾の飛翔速度(2倍化)
  SHOT_SCALE: 3,            // 氷弾の大きさ倍率
  SHOT_GRAVITY: 13,         // 放物線を描くための重力
  SHOT_CD: 0.26,
  FLOE_MIN_R: 1.2,          // これ未満で砕ける
  FLOE_START_R: 4.2,        // スタート時の流氷
  RAM_WEAR: 0.62,           // (未使用: 氷像は即転覆するようになったため)
  BUMP_WEAR: 0.8,           // 敵に体当たりして弾いた時の摩耗(ベース値)
  BOARD_CD: 0.5,
  LOCK_RANGE: 400,          // 画面内ならどんなに遠くてもロックオンできる
  WATER_GRACE: 1.5,
  ICE_KILL_SCORE: 150,
  CAPSIZE_SCORE_MULT: 2,
  COMBO_WINDOW: 2.6,
  FLOE_HP_PIPS: 5,
  HOP_MIN_DUR: 0.2, HOP_MAX_DUR: 0.62,
  EMERGENCY_FLOE_R: 1.7,    // 画面に氷が1つも無い時だけ作る最小の氷
  EMERGENCY_CD: 0.6,
  CRYSTAL_INTERVAL: 11,     // 氷の結晶が流れてくる間隔(秒)
};

/* ステージが進むほど: 敵と氷の出現が増える(氷の壊れやすさはステージに関係なく一定) */
function stageEnemyMult(stage){ return 1 + (stage-1)*0.24; }
function stageFloeSizeMult(stage){ return Math.max(0.62, 1 - (stage-1)*0.09); }

let G = null; // ゲーム状態

function newGameState(){
  return {
    state: 'title',
    distance: 0, score: 0, kills: 0,
    combo: 0, comboTimer: 0, maxCombo: 0,
    boostGauge: 0,
    shootCd: 0,
    boardCd: 0,
    emergencyCd: 0,
    elapsed: 0,
    totalScroll: 0,
    worldSpeed: BAL.BASE_SPEED,
    latVel: 0,
    facingY: Math.PI,
    swim: false, waterTimer: 0,
    enemySpawnT: 1.2, floeSpawnT: 0.2, propSpawnT: 0.2, crystalT: 6,
    lockTarget: null,
    stage: 1,
    hop: null, // { t, dur, fromX, fromZ, toFloe, peak }
  };
}

/* ---------- プレイヤーリグ ---------- */
let playerRig = null;
let playerFloe = null;   // {group, r, maxR}
let floes = [];          // 全流氷 {group, r, maxR, vx, drift}
let enemies = [];
let shots = [];
let crystals = [];
let lockRing = null;

function initPlayer(){
  playerRig = buildHumanoid({ shirt: 0xff8a3d, shorts: 0x2ec8ff, scale: 1 });
  scene.add(playerRig.group);

  const ringGeo = new THREE.TorusGeometry(1, 0.12, 8, 40);
  lockRing = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: 0xffb020, transparent:true, opacity: 0.9 }));
  lockRing.rotation.x = -Math.PI/2;
  lockRing.visible = false;
  scene.add(lockRing);
}

function makeFloeEntity(x, z, r){
  const group = makeFloeMesh(r);
  group.position.set(x, 0, z);
  scene.add(group);
  const f = { group, r, maxR: r, vx: 0, drift: (Math.random()*2-1)*0.6, bob: Math.random()*Math.PI*2, squash: 0, flipT: 0 };
  floes.push(f);
  return f;
}

function removeFloe(f){
  scene.remove(f.group);
  disposeObject3D(f.group);
  const i = floes.indexOf(f);
  if(i>=0) floes.splice(i,1);
}

/* ---------- リセット / 開始 ---------- */
function resetGame(){
  floes.forEach(f=>{ scene.remove(f.group); disposeObject3D(f.group); });
  enemies.forEach(e=>e.remove());
  shots.forEach(s=>{ scene.remove(s.mesh); disposeObject3D(s.mesh); });
  crystals.forEach(c=>{ scene.remove(c.group); disposeObject3D(c.group); });
  floes = []; enemies = []; shots = []; crystals = [];
  clearBgProps();

  G = newGameState();
  G.state = 'playing';
  if(lockRing) lockRing.visible = false;
  applyStageTheme(1);

  playerFloe = makeFloeEntity(0, 0, BAL.FLOE_START_R);
  playerFloe.maxR = BAL.FLOE_START_R;
  playerRig.group.visible = true;

  for(let i=0;i<8;i++){
    makeFloeEntity((Math.random()*2-1)*WORLD.laneHalf, -22 - i*20 - Math.random()*10, 3.4+Math.random()*2);
  }
  for(let i=0;i<8;i++) spawnBgProp();

  updateHUD();
}

/* ---------- 入力アクション (Input.jsから呼ばれる) ---------- */
function actionShoot(){
  if(!G || G.state !== 'playing' || G.shootCd > 0 || G.swim || G.hop) return;
  ensureAudio();
  G.shootCd = BAL.SHOT_CD;
  const px = playerRig.group.position.x;
  const pz = playerRig.group.position.z;
  const pal = PASTELS[Math.floor(Math.random()*PASTELS.length)];
  const mesh = new THREE.Mesh(
    new THREE.BoxGeometry(0.5, 0.5, 0.8),
    new THREE.MeshLambertMaterial({ color: pal.base, emissive: pal.dark, emissiveIntensity: 0.8 })
  );
  mesh.scale.setScalar(BAL.SHOT_SCALE);
  mesh.position.set(px, 1.6, pz - 1.2);
  scene.add(mesh);
  shots.push({ mesh, life: 1.6, pal, vy: 6.5 });
  spawnSpray(px, 1.6, pz-1, 4, pal.base, 2.5, 0.6);
  SE.shoot();
  playerRig.parts.armR.rotation.x = -2.2;
}

/* X: ①近く/遠くを問わずロックオン中の流氷へジャンプ
       ②画面に流氷が1つも無い時だけ、海面に氷を撃ち込んで最小の足場を作って飛び乗る */
function actionHop(){
  if(!G || G.state !== 'playing' || G.hop) return;
  ensureAudio();
  if(G.lockTarget){
    startHop(G.lockTarget);
    return;
  }
  if(G.emergencyCd > 0) return;
  const x = playerRig.group.position.x;
  const z = G.swim ? playerRig.group.position.z : 0;
  const f = makeFloeEntity(x, z - 8, BAL.EMERGENCY_FLOE_R);
  spawnRing(x, 0.3, z-8, 0xbfeaff, 13);
  spawnSpray(x, 0.5, z-8, 26, 0xeaf7ff, 6.5, 1.5);
  SE.freezeSurface();
  G.emergencyCd = BAL.EMERGENCY_CD;
  startHop(f);
}

/* ジャンプで別の氷へ飛び移る演出。auto-board と Xロックオンの両方から呼ばれる */
function startHop(target){
  const fromX = playerRig.group.position.x;
  const fromZ = G.swim ? playerRig.group.position.z : 0;
  const fromY = playerFloe ? playerFloe.group.position.y : 0;
  const d = Math.hypot(target.group.position.x - fromX, target.group.position.z - fromZ);
  G.hop = {
    t: 0,
    dur: Math.max(BAL.HOP_MIN_DUR, Math.min(BAL.HOP_MAX_DUR, d/26)),
    fromX, fromZ, fromY,
    toFloe: target,
    targetIsEnemy: enemies.indexOf(target) !== -1,
    peak: Math.min(4.2, 1.1 + d*0.14),
    wasSwim: G.swim,
  };
  if(playerFloe && !G.swim) spawnSpray(playerFloe.group.position.x, 0.5, playerFloe.group.position.z, 8, 0xffffff, 3.5);
  G.swim = false;
  kickFov(4);
  SE.hop();
}

function finishHop(){
  const h = G.hop;
  let target = h.toFloe;

  if(h.targetIsEnemy){
    if(!target.dead){
      // ジャンプが直撃して敵を撃破。敵の氷が乗れる流氷として残る
      const newFloe = capsizeEnemy(target);
      if(newFloe) target = newFloe;
    } else {
      // 着地までの間に既に転覆済み(他の氷弾等で撃破された)場合、
      // 残された流氷があればそれを、無ければ足場を新設して着地する
      const already = floes.find(f => f.group === target.puck);
      target = already || makeFloeEntity(target.group.position.x, 0, BAL.EMERGENCY_FLOE_R);
    }
  }

  playerFloe = target;
  target.group.position.z = 0;
  G.boardCd = BAL.BOARD_CD;
  spawnSpray(target.group.position.x, 0.6, 0, 16, 0xeaf7ff, 5.5);
  spawnRing(target.group.position.x, 0.4, 0, 0xffb020, 13);
  triggerShake(0.2, 0.16);
  SE.board();
  G.hop = null;
}

/* ---------- スポーン ---------- */
function spawnEnemyWave(){
  const x = (Math.random()*2-1)*WORLD.laneHalf*0.9;
  enemies.push(new EnemyBot(x, WORLD.spawnZ + Math.random()*20, G.stage));
}

function spawnFloeAhead(){
  const x = (Math.random()*2-1)*WORLD.laneHalf;
  const sizeMult = stageFloeSizeMult(G.stage);
  makeFloeEntity(x, WORLD.spawnZ - Math.random()*30, (3.4 + Math.random()*2.2) * sizeMult);
}

/* ---------- 氷へのダメージ演出(共通ヘルパー) ---------- */
function damageFloe(f, amount, hitX, hitY, hitZ){
  f.r -= amount;
  f.squash = 1;
  const ratio = Math.max(0, 1 - (f.r - BAL.FLOE_MIN_R) / Math.max(0.001, f.maxR - BAL.FLOE_MIN_R));
  updateFloeCracks(f.group, ratio);
  updateFloeScale(f);
  spawnCubeBurst(hitX, hitY, hitZ, 0xeaf7ff, 8 + Math.floor(amount*8), 6);
  spawnSpray(hitX, hitY, hitZ, 12, 0xffffff, 4.5);
  SE.crack();
  if(f.r <= BAL.FLOE_MIN_R && f === playerFloe) shatterRide();
}

function updateFloeScale(f){
  // 残りHPを 0.34〜1.0 の広いレンジにマップして、削れ具合をはっきり見せる
  const hpRatio = Math.max(0, Math.min(1, (f.r - BAL.FLOE_MIN_R) / Math.max(0.001, f.maxR - BAL.FLOE_MIN_R)));
  const base = (0.34 + hpRatio*0.66) * (f.baseScale || 1);
  const sq = f.squash > 0 ? 1 + f.squash*0.2 : 1;
  const sqY = f.squash > 0 ? 1 - f.squash*0.16 : 1;
  f.group.scale.set(base*sq, sqY, base*sq);
}

/* ---------- 体当たり(弾き飛ばし) ---------- */
function bumpEnemy(e){
  const px = playerFloe ? playerFloe.group.position.x : playerRig.group.position.x;
  const pz = playerFloe ? playerFloe.group.position.z : playerRig.group.position.z;
  const p = e.group.position;
  e.bump(p.x - px, p.z - pz);
  // 衝突点の派手な水しぶき
  const mx = (p.x + px)/2, mz = (p.z + pz)/2;
  spawnSpray(mx, (p.y||0)+0.4, mz, 34, 0xffffff, 8, 2.0);
  spawnSpray(mx, (p.y||0)+1.4, mz, 20, 0xdff6ff, 6, 1.5);
  spawnRing(mx, (p.y||0)+0.3, mz, 0xffffff, 13);
  triggerShake(0.26, 0.16);
  kickFov(2.5);
  if(playerFloe && !G.swim){
    damageFloe(playerFloe, BAL.BUMP_WEAR, p.x, (p.y||0)+1.2, p.z);
  }
}

/* 3発目の氷を食らった敵は、乗っている流氷ごと転覆(ドボン)。
   敵が乗っていた流氷は、プレイヤーが乗れる普通の流氷として残る */
function capsizeEnemy(e){
  if(e.dead) return null;
  const pos = e.group.position.clone();
  scene.remove(e.group);
  const idx = enemies.indexOf(e);
  if(idx>=0) enemies.splice(idx,1);
  e.dead = true;

  // 敵本体は氷の破片と共に海へ吹き飛ぶ
  spawnCubeBurst(pos.x, pos.y+1.8, pos.z, e.pal.base, 16, 8);
  spawnCubeBurst(pos.x, pos.y+1.2, pos.z, e.pal.dark, 12, 6.5);
  spawnCubeBurst(pos.x, pos.y+1.0, pos.z, 0xeaf7ff, 14, 7);
  // 派手な水柱
  spawnSpray(pos.x, pos.y+0.2, pos.z, 46, 0xffffff, 9.5, 2.2);
  spawnSpray(pos.x, pos.y+1.5, pos.z, 30, 0xdff6ff, 7, 1.6);
  spawnRing(pos.x, pos.y+0.3, pos.z, 0xbfeaff, 18);
  spawnRing(pos.x, pos.y+0.3, pos.z, 0xffffff, 11);

  // 敵の流氷を「乗れる流氷」として残す(ひっくり返るアニメ付き)
  const newR = 2.1 + Math.random()*1.1;
  const scaleFactor = newR/1.7;
  e.puck.position.copy(pos);
  e.puck.rotation.set(Math.PI, 0, 0);
  scene.add(e.puck); // puck を e.group から取り出して直接シーンへ再アタッチ
  if(typeof disposeObject3D === 'function') disposeObject3D(e.group); // puck を除いた残り(体/氷ケース/氷塊)を破棄
  const f = {
    group: e.puck, r: newR, maxR: newR, vx: 0,
    drift: (Math.random()*2-1)*0.5, bob: Math.random()*Math.PI*2,
    squash: 0, flipT: 0.6, baseScale: scaleFactor,
  };
  floes.push(f);

  G.combo++;
  G.comboTimer = BAL.COMBO_WINDOW;
  G.maxCombo = Math.max(G.maxCombo, G.combo);
  const mult = Math.min(G.combo, 8);
  const gained = BAL.ICE_KILL_SCORE * BAL.CAPSIZE_SCORE_MULT * mult;
  G.score += gained;
  G.kills++;
  G.boostGauge = Math.min(BAL.BOOST_MAX, G.boostGauge + BAL.KILL_BOOST);

  spawnPopup(pos.x, pos.y+3.4, pos.z, '+' + gained, 'score');
  spawnPopup(pos.x, pos.y+4.8, pos.z, 'DOBON!!', 'nice');
  if(mult >= 2) spawnPopup(pos.x, pos.y+2.2, pos.z, 'COMBO x' + mult, 'combo');
  if(mult === 4 || mult === 8) SE.nice();

  triggerShake(0.5, 0.34);
  kickFov(6);
  SE.dobon();
  return f;
}

/* ---------- 氷の結晶(取ると流氷が全回復) ---------- */
function spawnCrystal(){
  const g = makeIceCrystal();
  const x = (Math.random()*2-1)*WORLD.laneHalf*0.85;
  g.position.set(x, 2.2, WORLD.spawnZ - Math.random()*20);
  scene.add(g);
  crystals.push({ group: g, spin: 1.6 + Math.random()*0.8 });
}

function updateCrystals(dt){
  for(let i=crystals.length-1;i>=0;i--){
    const c = crystals[i];
    const g = c.group;
    g.position.z += G.worldSpeed * dt;
    g.rotation.y += c.spin * dt;
    const wy = oceanWaveHeight(g.position.x, g.position.z, G.elapsed, G.totalScroll) * 0.8;
    g.position.y = wy + 2.4 + Math.sin(G.elapsed*2.4)*0.35;
    if(g.userData.glow){
      const s = 6.5 + Math.sin(G.elapsed*4)*0.9;
      g.userData.glow.scale.set(s, s, 1);
    }
    if(Math.random() < 0.25) spawnSpray(g.position.x, g.position.y, g.position.z, 1, 0x9fe8ff, 1.2, 0.4);

    // 取得判定(playerRig の位置は乗車中/遊泳中/ジャンプ中いずれも常に正しく更新されている)
    const originX = playerRig.group.position.x;
    const originZ = playerRig.group.position.z;
    const pr = (!G.swim && !G.hop && playerFloe) ? playerFloe.r*playerFloe.group.scale.x : 1.4;
    const dx = g.position.x - originX, dz = g.position.z - originZ;
    if(dx*dx + dz*dz < (pr + 2.2)*(pr + 2.2)){
      collectCrystal(c, i);
      continue;
    }
    if(g.position.z > WORLD.killZ){
      scene.remove(g);
      disposeObject3D(g);
      crystals.splice(i,1);
    }
  }
}

function collectCrystal(c, index){
  const p = c.group.position.clone();
  scene.remove(c.group);
  disposeObject3D(c.group);
  crystals.splice(index, 1);

  if(playerFloe && !G.swim){
    playerFloe.r = playerFloe.maxR;
    updateFloeCracks(playerFloe.group, 0);
    updateFloeScale(playerFloe);
  }
  G.score += 200;
  spawnPopup(p.x, p.y+1.6, p.z, 'ICE FULL RECOVER!', 'nice');
  spawnPopup(p.x, p.y+0.4, p.z, '+200', 'score');
  spawnCubeBurst(p.x, p.y, p.z, 0xd8f8ff, 20, 8);
  spawnSpray(p.x, p.y, p.z, 34, 0xffffff, 7, 1.8);
  spawnRing(p.x, p.y, p.z, 0x9fe8ff, 16);
  kickFov(4);
  triggerShake(0.28, 0.2);
  SE.recover();
}

function shatterRide(){
  const p = playerFloe.group.position;
  spawnCubeBurst(p.x, 0.5, p.z, 0xeaf7ff, 16, 6.5);
  spawnSpray(p.x, 0.5, p.z, 28, 0xffffff, 7.5);
  SE.shatter();
  SE.splash();
  removeFloe(playerFloe);
  playerFloe = null;
  G.swim = true;
  G.waterTimer = 0;
  triggerShake(0.5, 0.35);
}

/* ---------- メイン更新 ---------- */
function update(dt){
  G.elapsed += dt;
  if(G.shootCd > 0) G.shootCd -= dt;
  if(G.boardCd > 0) G.boardCd -= dt;
  if(G.emergencyCd > 0) G.emergencyCd -= dt;
  if(G.comboTimer > 0){
    G.comboTimer -= dt;
    if(G.comboTimer <= 0) G.combo = 0;
  }
  if(G.boostGauge > 0) G.boostGauge = Math.max(0, G.boostGauge - BAL.BOOST_DECAY*dt);

  /* --- ステージ判定 --- */
  const newStage = Math.min(STAGE_COUNT, Math.floor(G.distance/STAGE_LEN) + 1);
  if(newStage !== G.stage){
    G.stage = newStage;
    const themeName = applyStageTheme(G.stage);
    showStageBanner(G.stage, themeName);
    // 背景の顔ぶれを新しいテーマのものへ入れ替える
    clearBgProps();
    for(let i=0;i<8;i++) spawnBgProp();
  }
  const prog = Math.min(1, G.distance / BAL.GOAL);

  /* --- 速度計算 --- */
  let target = BAL.BASE_SPEED + BAL.RAMP_SPEED*prog + G.boostGauge;
  if(!G.swim && !G.hop){
    if(Input.up)   target += BAL.BOOST_KEY;
    if(Input.down) target -= BAL.BRAKE_KEY;
  } else if(G.swim){
    target *= 0.45;
  }
  G.worldSpeed += (target - G.worldSpeed) * Math.min(1, dt*3);
  G.distance += G.worldSpeed * dt;
  G.totalScroll += G.worldSpeed * dt;

  /* --- 横移動(慣性ステア) --- */
  let steer = 0;
  if(Input.left) steer -= 1;
  if(Input.right) steer += 1;
  if(steer !== 0){
    G.latVel += steer * BAL.LAT_ACCEL * dt;
  } else {
    const dec = Math.min(Math.abs(G.latVel), BAL.LAT_FRICTION*dt*3);
    G.latVel -= Math.sign(G.latVel) * dec;
  }
  G.latVel = Math.max(-BAL.LAT_MAX, Math.min(BAL.LAT_MAX, G.latVel));
  const steerRatio = G.latVel/BAL.LAT_MAX;

  /* --- プレイヤー本体の状態遷移: ジャンプ中 / 遊泳中 / 通常ライド中 --- */
  if(G.hop){
    updateHopMotion(dt, steerRatio);
  } else if(!G.swim && playerFloe){
    const f = playerFloe;
    f.group.position.x += G.latVel * dt;
    f.group.position.x = Math.max(-WORLD.laneHalf, Math.min(WORLD.laneHalf, f.group.position.x));
    f.group.position.z = 0;
    const waveHere = oceanWaveHeight(f.group.position.x, f.group.position.z, G.elapsed, G.totalScroll);
    const waveX2 = oceanWaveHeight(f.group.position.x+2, f.group.position.z, G.elapsed, G.totalScroll);
    const waveZ2 = oceanWaveHeight(f.group.position.x, f.group.position.z+2, G.elapsed, G.totalScroll);
    const slopeX = (waveX2-waveHere)/2, slopeZ = (waveZ2-waveHere)/2;
    f.group.position.y = waveHere * 0.8;

    if(f.squash > 0) f.squash = Math.max(0, f.squash - dt*5);
    updateFloeScale(f);
    f.group.rotation.z = -steerRatio * 0.28 - slopeX*0.55;
    f.group.rotation.x = (Input.up ? -0.06 : 0) + slopeZ*0.55;
    if(f.flipT > 0){ f.flipT = Math.max(0, f.flipT - dt); f.group.rotation.x += Math.PI*(f.flipT/0.55); }

    playerRig.group.position.set(f.group.position.x, f.group.position.y + 0.72, 0);
    // 進行方向は -Z(画面奥)。dz を負で渡すことで後頭部がカメラを向く
    G.facingY = headingY(G.latVel, -46);
    playerRig.group.rotation.y = G.facingY;
    playerRig.group.rotation.z = -steerRatio * 0.32;
    playerRig.group.rotation.x = Input.up ? -0.08 : 0;
    const boostLean = Input.up ? 1 : 0;
    const flail = Math.min(1, G.worldSpeed/80) * 0.6 + Math.abs(steerRatio)*0.5;
    animSurf(playerRig.parts, G.elapsed, steerRatio, boostLean, flail);

    /* --- 派手な航跡の水しぶき(速度連動でロースターテール) --- */
    const speedT = Math.min(1, G.worldSpeed/85);
    const sprayChance = 0.35 + speedT*0.55 + (Input.up?0.25:0);
    if(Math.random() < sprayChance){
      const side = Math.random()<0.5?-1:1;
      const backX = f.group.position.x - Math.sin(G.facingY)*f.r*0.5;
      const backZ = f.r*0.65;
      spawnSpray(
        backX + side*f.r*0.7, 0.2, backZ,
        3 + Math.floor(speedT*4), 0xffffff, 3 + G.worldSpeed*0.09, 1.3
      );
      if(Math.random()<0.4) spawnSpray(backX, 0.15, backZ*1.1, 2, 0xdff6ff, 2.2+G.worldSpeed*0.05, 0.9);
    }
  } else if(G.swim){
    G.waterTimer += dt;
    playerRig.group.position.x += G.latVel * 0.3 * dt;
    playerRig.group.position.z += 6 * dt;
    playerRig.group.position.y = -0.35 + Math.sin(G.elapsed*5)*0.12;
    G.facingY = headingY(G.latVel*0.4, -8);
    playerRig.group.rotation.y = G.facingY;
    playerRig.group.rotation.z = Math.sin(G.elapsed*4)*0.08;
    animSwim(playerRig.parts, G.elapsed);
    if(Math.random()<0.6) spawnSpray(playerRig.group.position.x, 0.2, playerRig.group.position.z, 3, 0xd8f4ff, 2.4, 1);
    if(G.waterTimer > BAL.WATER_GRACE){
      endGame(false, '流氷を逃して南極の海に流されてしまった…');
      return;
    }
    if(playerRig.group.position.z > 14){
      endGame(false, '画面のかなたへ流されてしまった…');
      return;
    }
  }

  /* --- 流氷(非搭乗): 波のうねりに追従。背景の氷山/島はこれに連動させない --- */
  for(let i=floes.length-1;i>=0;i--){
    const f = floes[i];
    if(f === playerFloe) continue;
    f.group.position.z += (G.worldSpeed) * dt;
    f.group.position.x += f.drift * dt;
    const waveHere = oceanWaveHeight(f.group.position.x, f.group.position.z, G.elapsed, G.totalScroll);
    const waveX2 = oceanWaveHeight(f.group.position.x+2, f.group.position.z, G.elapsed, G.totalScroll);
    const waveZ2 = oceanWaveHeight(f.group.position.x, f.group.position.z+2, G.elapsed, G.totalScroll);
    f.group.position.y = waveHere * 0.8;
    f.group.rotation.z = -(waveX2-waveHere)/2 * 0.55;
    let tiltX = (waveZ2-waveHere)/2 * 0.55 + Math.sin(G.elapsed*1.6 + f.bob)*0.03;
    if(f.flipT > 0){ f.flipT = Math.max(0, f.flipT - dt); tiltX += Math.PI*(f.flipT/0.55); }
    f.group.rotation.x = tiltX;
    f.group.rotation.y += 0.1*dt;
    if(f.squash > 0){ f.squash = Math.max(0, f.squash - dt*5); updateFloeScale(f); }
    if(f.group.position.z > WORLD.killZ){
      removeFloe(f);
    }
  }

  /* --- 流氷どうしの衝突(非搭乗どうし): ぶつかって少しずつ欠ける --- */
  const brokenFloes = [];
  for(let i=0;i<floes.length;i++){
    for(let j=i+1;j<floes.length;j++){
      const a = floes[i], b = floes[j];
      if(a === playerFloe || b === playerFloe) continue; // 搭乗中の氷は自動乗り換え側で処理
      const dx = a.group.position.x - b.group.position.x;
      const dz = a.group.position.z - b.group.position.z;
      const rr = a.r*a.group.scale.x + b.r*b.group.scale.x;
      if(dx*dx + dz*dz < rr*rr*0.7){
        const nx = dx || 0.01, nz = dz || 0.01;
        const nlen = Math.hypot(nx,nz);
        a.group.position.x += (nx/nlen)*0.4; a.group.position.z += (nz/nlen)*0.4;
        b.group.position.x -= (nx/nlen)*0.4; b.group.position.z -= (nz/nlen)*0.4;
        damageFloe(a, 0.12, a.group.position.x, 0.5, a.group.position.z);
        damageFloe(b, 0.12, b.group.position.x, 0.5, b.group.position.z);
        if(a.r <= BAL.FLOE_MIN_R && !brokenFloes.includes(a)) brokenFloes.push(a);
        if(b.r <= BAL.FLOE_MIN_R && !brokenFloes.includes(b)) brokenFloes.push(b);
      }
    }
  }
  // ループ完了後にまとめて処理(ループ中に配列を splice するとインデックスが崩れるため)
  for(const f of brokenFloes){
    const p = f.group.position;
    spawnCubeBurst(p.x, p.y+0.3, p.z, 0xbfe4f7, 10, 5.5);
    spawnSpray(p.x, p.y+0.2, p.z, 16, 0xffffff, 5, 1.3);
    removeFloe(f);
  }

  /* --- 自動乗り換え(接触したらジャンプで飛び移る) --- */
  if(!G.swim && !G.hop && playerFloe && G.boardCd <= 0){
    for(const f of floes){
      if(f === playerFloe) continue;
      const dx = f.group.position.x - playerFloe.group.position.x;
      const dz = f.group.position.z - playerFloe.group.position.z;
      const rr = f.r*f.group.scale.x + playerFloe.r*playerFloe.group.scale.x;
      if(dx*dx + dz*dz < rr*rr*0.55){
        startHop(f);
        break;
      }
    }
  }

  /* --- 敵 --- */
  const px = playerRig.group.position.x;
  for(let i=enemies.length-1;i>=0;i--){
    const e = enemies[i];
    const eWaveY = oceanWaveHeight(e.group.position.x, e.group.position.z, G.elapsed, G.totalScroll) * 0.8;
    e.update(dt, G.worldSpeed, px, G.elapsed, eWaveY);
    if(e.dead) continue; // 更新中に自然ひっくり返り(capsize)した場合はここで打ち切る
    const p = e.group.position;

    if(!G.swim && !G.hop && playerFloe){
      const dx = p.x - playerFloe.group.position.x;
      const dz = p.z - playerFloe.group.position.z;
      const rr = playerFloe.r*playerFloe.group.scale.x + 1.3;
      if(dx*dx + dz*dz < rr*rr){
        bumpEnemy(e);
        continue;
      }
    }
    if(G.swim){
      const dx = p.x - playerRig.group.position.x;
      const dz = p.z - playerRig.group.position.z;
      if(dx*dx + dz*dz < 2.6){
        flashHit();
        endGame(false, '海で怪物に捕まってしまった…');
        return;
      }
    }
    if(p.z > WORLD.killZ){
      e.remove();
      enemies.splice(i,1);
    }
  }

  /* --- ショット(放物線) --- */
  for(let i=shots.length-1;i>=0;i--){
    const s = shots[i];
    s.life -= dt;
    s.vy -= BAL.SHOT_GRAVITY * dt;
    s.mesh.position.y += s.vy * dt;
    s.mesh.position.z -= BAL.SHOT_SPEED * dt;
    s.mesh.rotation.x += 8*dt;
    s.mesh.rotation.y += 6*dt;
    let hit = false;
    for(const e of enemies){
      if(e.dead) continue;
      const dx = e.group.position.x - s.mesh.position.x;
      const dz = e.group.position.z - s.mesh.position.z;
      if(dx*dx + dz*dz < 8){
        e.onShotHit();
        G.score += 15;
        hit = true;
        break;
      }
    }
    if(hit || s.life <= 0 || s.mesh.position.y < 0.15 || s.mesh.position.z < WORLD.spawnZ){
      if(!hit && s.mesh.position.y < 0.4){
        // 海面に落ちた氷弾の着水しぶき
        spawnSpray(s.mesh.position.x, 0.3, s.mesh.position.z, 12, 0xffffff, 4.5, 1.6);
        SE.splash();
      }
      scene.remove(s.mesh);
      disposeObject3D(s.mesh);
      shots.splice(i,1);
    }
  }

  /* --- スポーン(ステージが進むほど増える) --- */
  const enemyMult = stageEnemyMult(G.stage);
  G.enemySpawnT -= dt;
  if(G.enemySpawnT <= 0){
    spawnEnemyWave();
    if(G.stage >= 3 && Math.random() < 0.3) spawnEnemyWave(); // 後半は稀に同時湧き
    G.enemySpawnT = Math.max(0.4, (1.8 - prog*1.1)/enemyMult) * (0.7 + Math.random()*0.6);
  }
  G.floeSpawnT -= dt;
  if(G.floeSpawnT <= 0){
    spawnFloeAhead();
    G.floeSpawnT = Math.max(0.3, 0.9 - prog*0.3);
  }
  if(floes.length < 7 + Math.floor((G.stage-1)*0.8)) spawnFloeAhead();
  G.propSpawnT -= dt;
  if(G.propSpawnT <= 0){
    spawnBgProp();
    G.propSpawnT = 1.0 + Math.random()*1.4;
  }
  G.crystalT -= dt;
  if(G.crystalT <= 0){
    spawnCrystal();
    G.crystalT = BAL.CRYSTAL_INTERVAL * (0.75 + Math.random()*0.5);
  }

  /* --- ロックオン(流氷 + 氷弾を当てた敵のどちらか近い方) --- */
  G.lockTarget = null;
  if(!G.hop){
    let bestD = BAL.LOCK_RANGE*BAL.LOCK_RANGE;
    const originX = playerRig.group.position.x;
    const originZ = playerRig.group.position.z;
    for(const f of floes){
      if(f === playerFloe) continue;
      const dx = f.group.position.x - originX;
      const dz = f.group.position.z - originZ;
      const d2 = dx*dx + dz*dz;
      if(d2 < bestD && f.group.position.z < 8){
        bestD = d2; G.lockTarget = f;
      }
    }
    for(const e of enemies){
      if(e.dead || e.freezeLevel <= 0) continue;
      const dx = e.group.position.x - originX;
      const dz = e.group.position.z - originZ;
      const d2 = dx*dx + dz*dz;
      if(d2 < bestD && e.group.position.z < 8){
        bestD = d2; G.lockTarget = e;
      }
    }
  }
  if(G.lockTarget){
    lockRing.visible = true;
    const t = G.lockTarget;
    const rr = t.r*t.group.scale.x + 0.7 + Math.sin(G.elapsed*6)*0.15;
    lockRing.scale.set(rr, rr, rr);
    lockRing.position.set(t.group.position.x, t.group.position.y + 0.9, t.group.position.z);
    lockRing.rotation.z = G.elapsed*2;
  } else {
    lockRing.visible = false;
  }

  /* --- ワールド & VFX --- */
  updateWorld(dt, G.worldSpeed, G.totalScroll, G.elapsed);
  updateCrystals(dt);
  updateVFX(dt, G.worldSpeed);

  /* --- カメラワーク(ロールは目標値へレープ。加算し続けるバグを修正) --- */
  const camAnchorX = playerRig.group.position.x;
  const camTargetX = camAnchorX * 0.6;
  camera.position.x += (camTargetX - camera.position.x) * Math.min(1, dt*6);
  const speedNorm = (G.worldSpeed - BAL.BASE_SPEED) / 50;

  // 波のうねりにカメラも追従。さらに前方の斜面の勾配でピッチを変え、
  // 「ゲレンデを延々と滑り落ちている」ように、下りをより強く強調する
  const anchorY = G.swim ? 0 : (playerFloe ? playerFloe.group.position.y : 0);
  const waveHere  = oceanWaveHeight(camAnchorX, 0,   G.elapsed, G.totalScroll) * 0.8;
  const waveAhead = oceanWaveHeight(camAnchorX, -42, G.elapsed, G.totalScroll) * 0.8;
  const climb = waveAhead - waveHere;   // + なら登り、- なら下り
  // 下り坂の勾配は強く反映し、登り坂は控えめにする(常に滑り降りている感覚を作る)
  const climbWeighted = climb >= 0 ? climb*0.4 : climb*1.0;

  const camBaseY = 7.0 - speedNorm*1.3 - (G.hop ? Math.sin(Math.min(1,G.hop.t/G.hop.dur)*Math.PI)*0.6 : 0);
  const camWantY = camBaseY + anchorY*0.88 + Math.max(0, climbWeighted)*0.18;
  camera.position.y += (camWantY - camera.position.y) * Math.min(1, dt*5);
  camera.position.z = 11.5 - speedNorm*1.2;
  camera.fov = 66 + speedNorm*14 + VFX.fovKick;
  camera.updateProjectionMatrix();

  // 常に少し前下がりのベース視点(スピードが乗るほど前のめりに)+ 坂の勾配で仰角を変える
  const speedLean = Math.min(1, G.worldSpeed/95) * 1.6;
  const lookY = anchorY + 1.4 - speedLean + climbWeighted*0.9;
  camera.lookAt(camTargetX*0.5, lookY, -14);

  const targetRoll = -steerRatio * 0.11;
  camera.rotation.z += (targetRoll - camera.rotation.z) * Math.min(1, dt*6);
  applyCameraShake();

  document.getElementById('speedTint').style.opacity = Math.max(0, Math.min(1, speedNorm*1.2));

  updateHUD();

  if(G.distance >= BAL.GOAL){
    endGame(true, '');
  }
}

/* ジャンプ中の放物線モーション */
function updateHopMotion(dt, steerRatio){
  const h = G.hop;
  h.t += dt;
  const u = Math.min(1, h.t/h.dur);
  const toPos = h.toFloe.group.position;
  const x = h.fromX + (toPos.x - h.fromX)*u;
  const z = h.fromZ + (toPos.z*0 - h.fromZ)*u; // 着地点は常にz=0(自機の定位置)
  const arc = Math.sin(u*Math.PI) * h.peak;
  const targetY = h.toFloe.group.position.y || 0;
  const baseY = h.fromY + (targetY - h.fromY)*u;

  playerRig.group.position.set(x, baseY + 0.72 + arc, z);
  G.facingY = headingY((toPos.x - h.fromX)*0.6, -14);
  playerRig.group.rotation.y = G.facingY;
  playerRig.group.rotation.x = -0.3 - Math.sin(u*Math.PI)*0.25;
  playerRig.group.rotation.z = -steerRatio*0.2;
  animSurf(playerRig.parts, G.elapsed, steerRatio, 0.6, 1.0);

  if(u >= 1) finishHop();
}

/* ---------- ステージバナー ---------- */
function showStageBanner(stage, themeName){
  const el = document.getElementById('stageBanner');
  el.innerHTML = 'STAGE ' + stage +
    (themeName ? '<div style="font-size:0.32em;letter-spacing:6px;margin-top:6px;color:#ffd35c;">' + themeName + '</div>' : '');
  el.classList.remove('show');
  void el.offsetWidth;
  el.classList.add('show');
  SE.stageClear();
}

/* ---------- HUD ---------- */
function updateHUD(){
  const hpBox = document.getElementById('hpBox');
  if(hpBox.childElementCount !== BAL.FLOE_HP_PIPS){
    hpBox.innerHTML = '';
    for(let i=0;i<BAL.FLOE_HP_PIPS;i++){
      const d = document.createElement('div');
      d.className = 'floeChip';
      hpBox.appendChild(d);
    }
  }
  const ratio = (!G.swim && playerFloe) ? Math.max(0, playerFloe.r/playerFloe.maxR) : 0;
  const filled = Math.ceil(ratio * BAL.FLOE_HP_PIPS);
  [...hpBox.children].forEach((el,i)=>{
    el.classList.toggle('empty', i >= filled);
  });
  hpBox.classList.toggle('swimming', G.swim);

  document.getElementById('scoreVal').textContent = G.score.toLocaleString();
  document.getElementById('comboTag').textContent = G.combo >= 2 ? 'COMBO x' + Math.min(G.combo,8) : '';

  const pct = Math.min(100, Math.floor(G.distance/BAL.GOAL*100));
  document.getElementById('progressFill').style.width = pct + '%';
  document.getElementById('progressSurfer').style.left = pct + '%';
  document.getElementById('progressPct').textContent = pct;
  document.getElementById('stageIndicator').textContent = G.stage;

  document.getElementById('speedVal').textContent = Math.round(G.worldSpeed*2.4);
  document.getElementById('boostBarFill').style.width = (G.boostGauge/BAL.BOOST_MAX*100) + '%';

  document.getElementById('lockHint').style.display = G.lockTarget ? 'block' : 'none';
  document.getElementById('dangerHint').style.display = G.swim ? 'block' : 'none';
}

/* ---------- 終了処理 ---------- */
function endGame(cleared, reason){
  if(G.state !== 'playing') return;
  G.state = cleared ? 'clear' : 'gameover';
  stopBGM();
  if(cleared){
    SE.clear();
    for(let i=0;i<5;i++){
      setTimeout(()=>{
        spawnCubeBurst((Math.random()*2-1)*8, 3+Math.random()*3, -6-Math.random()*8,
          PASTELS[i%PASTELS.length].base, 12, 8);
        SE.nice();
      }, i*220);
    }
  } else {
    SE.gameover();
  }

  setTimeout(()=>{
    const overlay = document.getElementById('overlay');
    document.getElementById('titleArt').style.display = 'none';
    document.getElementById('titleSub').style.display = 'none';
    const rt = document.getElementById('resultTitle');
    rt.style.display = 'block';
    rt.textContent = cleared ? 'GRAND PRIX CLEAR!' : 'WIPE OUT...';
    rt.className = cleared ? 'clear' : 'over';
    document.getElementById('resultStats').style.display = 'flex';
    document.getElementById('statScore').textContent = G.score.toLocaleString();
    document.getElementById('statKills').textContent = G.kills;
    document.getElementById('statCombo').textContent = 'x' + Math.min(G.maxCombo,8);
    document.getElementById('rulesBox').innerHTML = cleared
      ? '<b>真夏の南極グランプリ、全5ステージ完全制覇!</b><br>コンボを絶やさず走れば、さらなるハイスコアが狙えるはず。'
      : (reason || '') + ' (STAGE ' + G.stage + '/' + STAGE_COUNT + ')<br><b>コツ:</b> 氷が欠けてきたら早めに乗り換え。近くに氷が無くても<span class="key">X</span>でその場を凍らせて緊急脱出できる。凍った敵以外には体当たりしても倒せないので注意。';
    document.getElementById('startBtn').textContent = cleared ? 'もう一周!' : 'RETRY';
    overlay.style.display = 'flex';
  }, cleared ? 1400 : 900);
}

/* ---------- ブートストラップ ---------- */
let _lastTs = null;
function mainLoop(ts){
  if(_lastTs === null) _lastTs = ts;
  let dt = (ts - _lastTs)/1000;
  _lastTs = ts;
  if(dt > 0.05) dt = 0.05;

  if(G && G.state === 'playing') update(dt);

  renderer.render(scene, camera);

  requestAnimationFrame(mainLoop);
}

window.addEventListener('DOMContentLoaded', ()=>{
  initWorld();
  initVFX();
  initPlayer();
  G = newGameState();

  document.getElementById('startBtn').addEventListener('click', ()=>{
    ensureAudio();
    startBGM();
    resetGame();
    document.getElementById('overlay').style.display = 'none';
  });

  requestAnimationFrame(mainLoop);
});




Input.js

/* ============================================================
   Input.js — キー入力
   ============================================================ */
const Input = { up:false, down:false, left:false, right:false };
let _zHeld = false, _xHeld = false;

window.addEventListener('keydown', (e)=>{
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyZ','KeyX','Space'].includes(e.code)) e.preventDefault();
  switch(e.code){
    case 'ArrowUp': Input.up = true; break;
    case 'ArrowDown': Input.down = true; break;
    case 'ArrowLeft': Input.left = true; break;
    case 'ArrowRight': Input.right = true; break;
    case 'KeyZ':
      if(!_zHeld){ _zHeld = true; if(typeof actionShoot === 'function') actionShoot(); }
      break;
    case 'KeyX':
      if(!_xHeld){ _xHeld = true; if(typeof actionHop === 'function') actionHop(); }
      break;
  }
});

window.addEventListener('keyup', (e)=>{
  switch(e.code){
    case 'ArrowUp': Input.up = false; break;
    case 'ArrowDown': Input.down = false; break;
    case 'ArrowLeft': Input.left = false; break;
    case 'ArrowRight': Input.right = false; break;
    case 'KeyZ': _zHeld = false; break;
    case 'KeyX': _xHeld = false; break;
  }
});


PostFX.js

/* ============================================================
   PostFX.js — 魚眼レンズ風ポストエフェクト(主観視点の演出)
   three.js の examples/postprocessing を使わず、
   レンダーターゲット + フルスクリーンクアッドで自前実装。
   ============================================================ */
let _pfxTarget = null;
let _pfxQuadScene = null;
let _pfxQuadCamera = null;
let _pfxQuadMat = null;

function initPostFX(){
  const w = window.innerWidth, h = window.innerHeight;
  _pfxTarget = new THREE.WebGLRenderTarget(w, h, {
    minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBAFormat,
  });

  _pfxQuadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  _pfxQuadScene = new THREE.Scene();

  _pfxQuadMat = new THREE.ShaderMaterial({
    uniforms: {
      tDiffuse: { value: _pfxTarget.texture },
      amount:   { value: 0.18 },
      aspect:   { value: w/h },
    },
    vertexShader: `
      varying vec2 vUv;
      void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
    `,
    fragmentShader: `
      uniform sampler2D tDiffuse;
      uniform float amount;
      uniform float aspect;
      varying vec2 vUv;
      void main(){
        vec2 p = vUv*2.0 - 1.0;
        p.x *= aspect;
        float r2 = dot(p,p);
        vec2 pd = p * (1.0 + amount*r2);
        pd.x /= aspect;
        vec2 uv = pd*0.5 + 0.5;
        // 画面外を黒枠にせず、端のピクセルを引き伸ばして埋める(TVフレーム状の縁を出さない)
        uv = clamp(uv, vec2(0.0), vec2(1.0));
        gl_FragColor = vec4(texture2D(tDiffuse, uv).rgb, 1.0);
      }
    `,
    depthTest: false, depthWrite: false,
  });

  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2,2), _pfxQuadMat);
  _pfxQuadScene.add(quad);

  window.addEventListener('resize', ()=>{
    const nw = window.innerWidth, nh = window.innerHeight;
    _pfxTarget.setSize(nw, nh);
    _pfxQuadMat.uniforms.aspect.value = nw/nh;
  });
}

/* amount: 0で歪みなし。速度感演出用に0.14〜0.26程度で可変にする */
function renderWithFisheye(amount){
  if(!_pfxTarget){ renderer.render(scene, camera); return; }
  _pfxQuadMat.uniforms.amount.value = amount;
  renderer.setRenderTarget(_pfxTarget);
  renderer.clear();
  renderer.render(scene, camera);
  renderer.setRenderTarget(null);
  renderer.render(_pfxQuadScene, _pfxQuadCamera);
}


VFX.js

/* ============================================================
   VFX.js — パーティクル / スピード演出 / カメラシェイク / ポップアップ
   ============================================================ */
/* オブジェクトを完全に破棄する時に呼ぶ。ジオメトリ/マテリアルを解放してGPUメモリのリークを防ぐ。
   ※ テクスチャ(map)は複数オブジェクトで共有されていることがあるため、ここでは触らない
      (共有中のテクスチャを誤って破棄すると他のオブジェクトの描画が壊れるため)。 */
function disposeObject3D(obj){
  if(!obj) return;
  obj.traverse(node=>{
    if(node.geometry) node.geometry.dispose();
    if(node.material){
      const mats = Array.isArray(node.material) ? node.material : [node.material];
      mats.forEach(m=>{ if(m && m.dispose) m.dispose(); });
    }
  });
}

const VFX = {
  sprayPts: null, sprayData: [],
  cubes: [], cubePoolSize: 90,
  rings: [],
  speedLines: [],
  shakeTime: 0, shakeMag: 0,
  fovKick: 0,
};

function makeSoftDotTexture(){
  const cv = document.createElement('canvas'); cv.width = cv.height = 64;
  const g = cv.getContext('2d');
  const grad = g.createRadialGradient(32,32,2,32,32,30);
  grad.addColorStop(0, 'rgba(255,255,255,1)');
  grad.addColorStop(0.5, 'rgba(255,255,255,0.55)');
  grad.addColorStop(1, 'rgba(255,255,255,0)');
  g.fillStyle = grad; g.fillRect(0,0,64,64);
  return new THREE.CanvasTexture(cv);
}

function initVFX(){
  /* --- 水しぶき: Points プール --- */
  const MAX = 600;
  const geo = new THREE.BufferGeometry();
  const pos = new Float32Array(MAX*3);
  const col = new Float32Array(MAX*3);
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
  for(let i=0;i<MAX;i++) pos[i*3+1] = -999; // 未使用パーティクルは画面外へ
  const mat = new THREE.PointsMaterial({
    size: 0.55, map: makeSoftDotTexture(), transparent: true, depthWrite: false,
    vertexColors: true, blending: THREE.NormalBlending, opacity: 0.95, sizeAttenuation: true,
  });
  VFX.sprayPts = new THREE.Points(geo, mat);
  VFX.sprayPts.frustumCulled = false;
  scene.add(VFX.sprayPts);
  for(let i=0;i<MAX;i++) VFX.sprayData.push({ life: 0, vx:0, vy:0, vz:0 });

  /* --- 破片キューブプール --- */
  const cubeGeo = new THREE.BoxGeometry(0.34, 0.34, 0.34);
  for(let i=0;i<VFX.cubePoolSize;i++){
    const m = new THREE.Mesh(cubeGeo, new THREE.MeshLambertMaterial({ color: 0xffffff }));
    m.visible = false;
    scene.add(m);
    VFX.cubes.push({ mesh: m, life: 0, vx:0, vy:0, vz:0, rx:0, ry:0 });
  }

  /* --- 衝撃リング --- */
  const ringGeo = new THREE.TorusGeometry(1, 0.09, 8, 32);
  for(let i=0;i<8;i++){
    const m = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: 0xffffff, transparent:true, opacity: 0.9 }));
    m.rotation.x = -Math.PI/2;
    m.visible = false;
    scene.add(m);
    VFX.rings.push({ mesh: m, life: 0, grow: 8 });
  }

  /* --- スピードライン: 左右の流れる白線 --- */
  const lineMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent:true, opacity: 0.35, blending: THREE.AdditiveBlending, depthWrite:false });
  const lineGeo = new THREE.BoxGeometry(0.06, 0.06, 5.5);
  for(let i=0;i<26;i++){
    const m = new THREE.Mesh(lineGeo, lineMat);
    resetSpeedLine(m, true);
    scene.add(m);
    VFX.speedLines.push(m);
  }
}

function resetSpeedLine(m, randomZ){
  const side = Math.random()<0.5?-1:1;
  m.position.set(
    side*(9 + Math.random()*16),
    0.5 + Math.random()*7,
    randomZ ? -120 + Math.random()*140 : -140 - Math.random()*30
  );
}

function spawnSpray(x,y,z, count, colorHex, power, upBias){
  const color = new THREE.Color(colorHex);
  const posAttr = VFX.sprayPts.geometry.attributes.position;
  const colAttr = VFX.sprayPts.geometry.attributes.color;
  let spawned = 0;
  for(let i=0;i<VFX.sprayData.length && spawned<count;i++){
    const p = VFX.sprayData[i];
    if(p.life > 0) continue;
    p.life = 0.5 + Math.random()*0.45;
    const a = Math.random()*Math.PI*2;
    const s = power*(0.3 + Math.random()*0.8);
    p.vx = Math.cos(a)*s;
    p.vz = Math.sin(a)*s;
    p.vy = (upBias||1.6)*power*(0.4+Math.random()*0.7);
    posAttr.setXYZ(i, x, y, z);
    colAttr.setXYZ(i, color.r, color.g, color.b);
    spawned++;
  }
  posAttr.needsUpdate = true;
  colAttr.needsUpdate = true;
}

function spawnCubeBurst(x,y,z, colorHex, count, power){
  let spawned = 0;
  for(const c of VFX.cubes){
    if(spawned >= count) break;
    if(c.life > 0) continue;
    c.life = 0.8 + Math.random()*0.5;
    c.mesh.visible = true;
    c.mesh.position.set(x, y, z);
    c.mesh.material.color.setHex(colorHex);
    const a = Math.random()*Math.PI*2;
    const s = power*(0.4+Math.random()*0.8);
    c.vx = Math.cos(a)*s; c.vz = Math.sin(a)*s;
    c.vy = power*(0.8+Math.random()*1.1);
    c.rx = (Math.random()*2-1)*9; c.ry = (Math.random()*2-1)*9;
    const sc = 0.6 + Math.random()*0.9;
    c.mesh.scale.set(sc,sc,sc);
    spawned++;
  }
}

function spawnRing(x,y,z, colorHex, grow){
  for(const r of VFX.rings){
    if(r.life > 0) continue;
    r.life = 0.45;
    r.grow = grow || 9;
    r.mesh.visible = true;
    r.mesh.position.set(x, y, z);
    r.mesh.scale.set(0.3,0.3,0.3);
    r.mesh.material.color.setHex(colorHex);
    return;
  }
}

function triggerShake(mag, time){ VFX.shakeMag = Math.max(VFX.shakeMag, mag); VFX.shakeTime = Math.max(VFX.shakeTime, time); }
function kickFov(amount){ VFX.fovKick = Math.min(VFX.fovKick + amount, 10); }

function updateVFX(dt, worldSpeed){
  /* spray */
  const posAttr = VFX.sprayPts.geometry.attributes.position;
  for(let i=0;i<VFX.sprayData.length;i++){
    const p = VFX.sprayData[i];
    if(p.life <= 0) continue;
    p.life -= dt;
    if(p.life <= 0){ posAttr.setXYZ(i, 0, -999, 0); continue; }
    p.vy -= 22*dt;
    posAttr.setX(i, posAttr.getX(i) + p.vx*dt);
    posAttr.setY(i, Math.max(0.02, posAttr.getY(i) + p.vy*dt));
    posAttr.setZ(i, posAttr.getZ(i) + (p.vz + worldSpeed*0.5)*dt);
  }
  posAttr.needsUpdate = true;

  /* cubes */
  for(const c of VFX.cubes){
    if(c.life <= 0) continue;
    c.life -= dt;
    if(c.life <= 0){ c.mesh.visible = false; continue; }
    c.vy -= 26*dt;
    c.mesh.position.x += c.vx*dt;
    c.mesh.position.y += c.vy*dt;
    c.mesh.position.z += (c.vz + worldSpeed*0.6)*dt;
    if(c.mesh.position.y < 0.15 && c.vy < 0){ c.vy *= -0.35; c.mesh.position.y = 0.15; }
    c.mesh.rotation.x += c.rx*dt;
    c.mesh.rotation.y += c.ry*dt;
    const s = Math.min(1, c.life*2);
    c.mesh.scale.setScalar(Math.max(0.05, s));
  }

  /* rings */
  for(const r of VFX.rings){
    if(r.life <= 0) continue;
    r.life -= dt;
    if(r.life <= 0){ r.mesh.visible = false; continue; }
    const s = r.mesh.scale.x + r.grow*dt;
    r.mesh.scale.set(s, s, s);
    r.mesh.material.opacity = Math.max(0, r.life/0.45)*0.9;
  }

  /* speed lines */
  const lineAlpha = Math.min(0.5, Math.max(0, (worldSpeed-26)/60));
  for(const m of VFX.speedLines){
    m.position.z += worldSpeed*2.1*dt;
    m.material.opacity = lineAlpha;
    if(m.position.z > 20) resetSpeedLine(m, false);
  }

  /* camera shake decay */
  if(VFX.shakeTime > 0){
    VFX.shakeTime -= dt;
    if(VFX.shakeTime <= 0) VFX.shakeMag = 0;
  }
  if(VFX.fovKick > 0) VFX.fovKick = Math.max(0, VFX.fovKick - 18*dt);
}

function applyCameraShake(){
  if(VFX.shakeTime > 0){
    camera.position.x += (Math.random()-0.5)*VFX.shakeMag;
    camera.position.y += (Math.random()-0.5)*VFX.shakeMag;
  }
}

/* ---------- DOM ポップアップ (3D→スクリーン投影) ---------- */
const _projVec = new THREE.Vector3();
function spawnPopup(x, y, z, text, cls){
  _projVec.set(x, y, z).project(camera);
  if(_projVec.z > 1) return;
  const sx = ( _projVec.x*0.5 + 0.5) * window.innerWidth;
  const sy = (-_projVec.y*0.5 + 0.5) * window.innerHeight;
  const el = document.createElement('div');
  el.className = 'popup ' + (cls||'score');
  el.textContent = text;
  el.style.left = sx + 'px';
  el.style.top = sy + 'px';
  document.getElementById('popupLayer').appendChild(el);
  setTimeout(()=>el.remove(), 1050);
}

function flashHit(){
  const el = document.getElementById('hitFlash');
  el.style.opacity = '1';
  setTimeout(()=>{ el.style.opacity = '0'; }, 90);
}



World.js

/* ============================================================
   World.js — シーン / カメラ / 空 / ビッグウェーブの海 / ステージ別背景
   座標系: プレイヤー前方 = -Z。ワールドは +Z 方向に流れる。
   ============================================================ */
let scene, camera, renderer;
let sunLight, hemiLight, rimLight;
let oceanMesh, oceanUniforms;
let skyMesh, skyUniforms;
let sunSprite, sunGlow;
let cloudSprites = [];
let bgPropsPool = [];
let birdFlock = [];

const WORLD = {
  laneHalf: 15,
  spawnZ: -180,
  killZ: 28,
  fogColor: 0xbfe9ff,
  waveScale: 1.0,   // ステージごとの波の激しさ倍率
};

/* ============================================================
   ステージテーマ: 空・海・光・背景プロップの構成をまるごと切り替える
   ============================================================ */
const STAGE_THEMES = [
  { // 1: 真昼の南極ビーチ
    name: 'POLAR BEACH',
    skyTop: 0x2f9fe0, skyMid: 0x9fdcf5, skyBottom: 0xffe8b8,
    deep: 0x0e6fa8, mid: 0x35b6e8, shallow: 0x9fe8ff,
    fog: 0xbfe9ff, sun: 0xfff2cc, sunInt: 1.15, hemi: 0.85,
    waveScale: 0.8,
    props: ['iceberg','penguinFloe','palmIsland','beachUmbrella'],
  },
  { // 2: トロピカルラグーン
    name: 'TROPIC LAGOON',
    skyTop: 0x1fb6d8, skyMid: 0x8ef0e6, skyBottom: 0xfff0c0,
    deep: 0x0d8fa0, mid: 0x2fd4d0, shallow: 0xb8fff2,
    fog: 0xcaf6ef, sun: 0xfff6d8, sunInt: 1.25, hemi: 0.9,
    waveScale: 0.95,
    props: ['palmIsland','beachUmbrella','tikiHut','floatRing','penguinFloe'],
  },
  { // 3: サンセット・ビッグウェーブ
    name: 'SUNSET SWELL',
    skyTop: 0x3b4b9c, skyMid: 0xf58f6a, skyBottom: 0xffd08a,
    deep: 0x1a3f7a, mid: 0x9a5fb0, shallow: 0xffb6a0,
    fog: 0xf0a888, sun: 0xffc078, sunInt: 1.05, hemi: 0.7,
    waveScale: 1.15,
    props: ['tikiHut','palmIsland','sailboat','beachUmbrella'],
  },
  { // 4: クリスタル氷海 / オーロラ
    name: 'CRYSTAL SEA',
    skyTop: 0x152a5e, skyMid: 0x3f7fc0, skyBottom: 0x9fe0d8,
    deep: 0x0a2f68, mid: 0x2a7fc8, shallow: 0x9fe8f8,
    fog: 0xa8d8ee, sun: 0xd8f0ff, sunInt: 0.95, hemi: 0.75,
    waveScale: 1.3,
    props: ['crystalSpire','iceberg','penguinFloe','sailboat'],
  },
  { // 5: 灼熱の火山島グランドフィナーレ
    name: 'VOLCANO FINALE',
    skyTop: 0x7a2b6a, skyMid: 0xff7a4a, skyBottom: 0xffd76a,
    deep: 0x2a1a5a, mid: 0xc85a7a, shallow: 0xffc27a,
    fog: 0xffa878, sun: 0xffdca0, sunInt: 1.3, hemi: 0.8,
    waveScale: 1.5,
    props: ['volcanoIsland','crystalSpire','tikiHut','sailboat','palmIsland'],
  },
];
let currentTheme = STAGE_THEMES[0];

function initWorld(){
  scene = new THREE.Scene();
  scene.fog = new THREE.Fog(WORLD.fogColor, 70, 230);

  camera = new THREE.PerspectiveCamera(64, window.innerWidth/window.innerHeight, 0.1, 500);
  camera.position.set(0, 6.4, 11);
  camera.lookAt(0, 1.2, -8);

  renderer = new THREE.WebGLRenderer({ antialias:true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.outputEncoding = THREE.sRGBEncoding;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.1;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  document.getElementById('app').appendChild(renderer.domElement);

  hemiLight = new THREE.HemisphereLight(0xcfefff, 0x2a7fae, 0.85);
  scene.add(hemiLight);

  sunLight = new THREE.DirectionalLight(0xfff2cc, 1.15);
  sunLight.position.set(24, 46, -30);
  sunLight.castShadow = true;
  sunLight.shadow.mapSize.set(1024, 1024);
  sunLight.shadow.camera.left = -34; sunLight.shadow.camera.right = 34;
  sunLight.shadow.camera.top = 34; sunLight.shadow.camera.bottom = -34;
  sunLight.shadow.camera.far = 140;
  scene.add(sunLight);

  // 逆光気味のリムライト(輪郭を出して立体感を強める)
  rimLight = new THREE.DirectionalLight(0xa8e8ff, 0.45);
  rimLight.position.set(-30, 18, 40);
  scene.add(rimLight);

  buildSky();
  buildOcean();
  buildClouds();
  buildBirds();

  window.addEventListener('resize', ()=>{
    camera.aspect = window.innerWidth/window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });
}

/* ============================================================
   ビッグウェーブの波形。シェーダーとJSで完全に同じ式を使う。
   低周波の大うねりを主体にし、pow で山を尖らせて谷を平坦に
   (トロコイド波に近い、切り立った10m級のうねりになる)
   ============================================================ */
const WAVE_NORM = 12.8;

/* のこぎり波に近い非対称形(3項フーリエ近似)。
   長く急な下り坂→短く鋭い上りの繰り返しになり、
   「ゲレンデを延々と滑り落ちる」ような一方向感の強いうねりになる。 */
function _sawAsym(x){
  return Math.sin(x) - 0.5*Math.sin(2*x) + 0.3333*Math.sin(3*x);
}
function _waveRaw(px, py, t){
  let w = 0;
  // 主うねり: 非対称形で「下りが長く、上りが短い」形状にする
  w += _sawAsym(px*0.018 + py*0.015 + t*0.5) * 6.0;
  w += _sawAsym(px*0.011 - py*0.022 + t*0.36) * 4.2;
  // 細かいディテールは通常のsinのまま(質感用)
  w += Math.sin(px*0.065 + py*0.052 + t*1.0) * 1.0;
  w += Math.sin(px*0.170 - py*0.130 + t*1.7) * 0.38;
  w += Math.sin(py*0.320 + t*2.3) * 0.16;
  return w;
}
/** ワールド座標での海面の高さ(流氷はこれに追従する / 背景プロップは追従させない) */
function oceanWaveHeight(worldX, worldZ, elapsed, totalScroll){
  const px = worldX;
  const py = -worldZ - 120 + totalScroll;
  return _waveRaw(px, py, elapsed) * WORLD.waveScale;
}

/* ---------- 空 ---------- */
function buildSky(){
  const geo = new THREE.SphereGeometry(360, 32, 20);
  skyUniforms = {
    topColor:    { value: new THREE.Color(currentTheme.skyTop) },
    midColor:    { value: new THREE.Color(currentTheme.skyMid) },
    bottomColor: { value: new THREE.Color(currentTheme.skyBottom) },
  };
  const mat = new THREE.ShaderMaterial({
    side: THREE.BackSide, depthWrite: false, uniforms: skyUniforms,
    vertexShader: `
      varying vec3 vPos;
      void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
    fragmentShader: `
      uniform vec3 topColor; uniform vec3 midColor; uniform vec3 bottomColor;
      varying vec3 vPos;
      void main(){
        float h = normalize(vPos).y;
        vec3 col = h > 0.12
          ? mix(midColor, topColor, smoothstep(0.12, 0.65, h))
          : mix(bottomColor, midColor, smoothstep(-0.10, 0.12, h));
        gl_FragColor = vec4(col, 1.0);
      }`,
  });
  skyMesh = new THREE.Mesh(geo, mat);
  scene.add(skyMesh);

  const sunTex = makeRadialTexture('#fff8dc', '#ffd35c', 'rgba(255,180,60,0)');
  sunSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: sunTex, transparent:true, depthWrite:false }));
  sunSprite.scale.set(52, 52, 1);
  sunSprite.position.set(70, 74, -250);
  scene.add(sunSprite);

  sunGlow = new THREE.Sprite(new THREE.SpriteMaterial({
    map: sunTex, transparent:true, depthWrite:false, opacity:0.5, blending: THREE.AdditiveBlending,
  }));
  sunGlow.scale.set(150, 150, 1);
  sunGlow.position.copy(sunSprite.position);
  scene.add(sunGlow);
}

function makeRadialTexture(c0, c1, c2){
  const cv = document.createElement('canvas'); cv.width = cv.height = 128;
  const g = cv.getContext('2d');
  const grad = g.createRadialGradient(64,64,4,64,64,64);
  grad.addColorStop(0, c0); grad.addColorStop(0.45, c1); grad.addColorStop(1, c2);
  g.fillStyle = grad; g.fillRect(0,0,128,128);
  return new THREE.CanvasTexture(cv);
}

function makeCloudTexture(){
  const cv = document.createElement('canvas'); cv.width = 256; cv.height = 128;
  const g = cv.getContext('2d');
  const grad = g.createRadialGradient(128,74,10,128,74,80);
  grad.addColorStop(0,'rgba(255,255,255,0.98)');
  grad.addColorStop(0.6,'rgba(255,255,255,0.75)');
  grad.addColorStop(1,'rgba(255,255,255,0)');
  g.fillStyle = grad;
  const blobs = [[70,80,44],[120,64,54],[176,82,42],[100,90,38],[152,92,36]];
  blobs.forEach(b=>{ g.beginPath(); g.arc(b[0],b[1],b[2],0,Math.PI*2); g.fill(); });
  return new THREE.CanvasTexture(cv);
}

function buildClouds(){
  const tex = makeCloudTexture();
  for(let i=0;i<14;i++){
    const sp = new THREE.Sprite(new THREE.SpriteMaterial({
      map: tex, transparent:true, opacity: 0.45 + Math.random()*0.4, depthWrite:false,
    }));
    const s = 30 + Math.random()*45;
    sp.scale.set(s, s*0.42, 1);
    sp.position.set((Math.random()*2-1)*250, 42 + Math.random()*55, -240 + Math.random()*80);
    sp.userData.drift = 1.0 + Math.random()*1.8;
    scene.add(sp);
    cloudSprites.push(sp);
  }
}

/* ---------- 海鳥(夏の空の生活感) ---------- */
function buildBirds(){
  const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
  for(let i=0;i<7;i++){
    const g = new THREE.Group();
    const shape = new THREE.Shape();
    shape.moveTo(0,0); shape.lineTo(1.6,0.5); shape.lineTo(0,0.16); shape.lineTo(-1.6,0.5); shape.lineTo(0,0);
    const wing = new THREE.Mesh(new THREE.ShapeGeometry(shape), mat);
    g.add(wing);
    g.position.set((Math.random()*2-1)*90, 30 + Math.random()*26, -150 - Math.random()*90);
    g.userData = { phase: Math.random()*Math.PI*2, speed: 5 + Math.random()*5, wing };
    scene.add(g);
    birdFlock.push(g);
  }
}

/* ---------- 海: ビッグウェーブ・シェーダー ---------- */
function buildOcean(){
  oceanUniforms = {
    uTime:   { value: 0 },
    uScroll: { value: 0 },
    uWaveScale:{ value: 1.0 },
    uDeep:   { value: new THREE.Color(currentTheme.deep) },
    uMid:    { value: new THREE.Color(currentTheme.mid) },
    uShallow:{ value: new THREE.Color(currentTheme.shallow) },
    uFoam:   { value: new THREE.Color(0xffffff) },
    uSunDir: { value: new THREE.Vector3(0.35, 0.6, -0.72).normalize() },
    uFogColor: { value: new THREE.Color(WORLD.fogColor) },
  };
  const geo = new THREE.PlaneGeometry(400, 460, 150, 170);
  const mat = new THREE.ShaderMaterial({
    uniforms: oceanUniforms,
    vertexShader: `
      uniform float uTime; uniform float uScroll; uniform float uWaveScale;
      varying float vWave; varying vec3 vWorld; varying vec3 vNrm;
      float sawAsym(float x){
        return sin(x) - 0.5*sin(2.0*x) + 0.3333*sin(3.0*x);
      }
      float waveRaw(vec2 p, float t){
        float w = 0.0;
        w += sawAsym(p.x*0.018 + p.y*0.015 + t*0.5) * 6.0;
        w += sawAsym(p.x*0.011 - p.y*0.022 + t*0.36) * 4.2;
        w += sin(p.x*0.065 + p.y*0.052 + t*1.0) * 1.0;
        w += sin(p.x*0.170 - p.y*0.130 + t*1.7) * 0.38;
        w += sin(p.y*0.320 + t*2.3) * 0.16;
        return w;
      }
      float waveH(vec2 p, float t){
        return waveRaw(p, t) * uWaveScale;
      }
      void main(){
        vec3 pos = position;
        vec2 wp = vec2(position.x, position.y + uScroll);
        float h = waveH(wp, uTime);
        pos.z += h;
        vWave = h;
        // 近傍差分で法線を出す(切り立った面の陰影が出る)
        float hx = waveH(wp + vec2(3.0, 0.0), uTime);
        float hy = waveH(wp + vec2(0.0, 3.0), uTime);
        vec3 tx = normalize(vec3(3.0, 0.0, hx - h));
        vec3 ty = normalize(vec3(0.0, 3.0, hy - h));
        vNrm = normalize(cross(tx, ty));
        vec4 world = modelMatrix * vec4(pos, 1.0);
        vWorld = world.xyz;
        gl_Position = projectionMatrix * viewMatrix * world;
      }`,
    fragmentShader: `
      uniform vec3 uDeep; uniform vec3 uMid; uniform vec3 uShallow; uniform vec3 uFoam;
      uniform vec3 uSunDir; uniform vec3 uFogColor;
      uniform float uTime; uniform float uScroll; uniform float uWaveScale;
      varying float vWave; varying vec3 vWorld; varying vec3 vNrm;
      void main(){
        float h = vWave / max(uWaveScale, 0.001);
        vec3 col = mix(uDeep, uMid, smoothstep(-10.5, 3.2, h));
        col = mix(col, uShallow, smoothstep(3.2, 12.5, h));

        vec3 n = normalize(vec3(vNrm.x, vNrm.z, vNrm.y));
        float ndl = max(dot(n, uSunDir), 0.0);
        col *= 0.72 + ndl*0.5;

        // 切り立った斜面の白波
        float steep = 1.0 - abs(n.y);
        float crest = smoothstep(6.5, 14.0, h) * smoothstep(0.12, 0.42, steep);
        col = mix(col, uFoam, clamp(crest, 0.0, 0.85));

        // 進行方向へ流れるフォームストライプ
        float stripe = sin((vWorld.z + uScroll)*0.55 + vWorld.x*0.22 + uTime*1.6);
        float foamBand = smoothstep(0.90, 1.0, stripe) * smoothstep(0.0, 6.5, h);
        col = mix(col, uFoam, foamBand*0.4);

        // きらめき
        float sparkle = sin(vWorld.x*3.1 + uTime*5.5) * sin(vWorld.z*3.7 - uTime*4.5);
        sparkle = pow(max(sparkle, 0.0), 20.0);
        col += uFoam * sparkle * 0.65;

        // 太陽のスペキュラ
        vec3 viewDir = normalize(cameraPosition - vWorld);
        vec3 halfV = normalize(uSunDir + viewDir);
        float spec = pow(max(dot(n, halfV), 0.0), 48.0);
        col += vec3(1.0, 0.95, 0.8) * spec * 0.55;

        float dist = length(vWorld.xz - vec2(0.0, 8.0));
        col = mix(col, uFogColor, smoothstep(70.0, 230.0, dist));
        gl_FragColor = vec4(col, 1.0);
      }`,
  });
  oceanMesh = new THREE.Mesh(geo, mat);
  oceanMesh.rotation.x = -Math.PI/2;
  oceanMesh.position.set(0, 0, -120);
  scene.add(oceanMesh);
}

/* ============================================================
   背景プロップ(ステージごとに顔ぶれが変わる)
   ※ これらは波のうねりに連動させない
   ============================================================ */
function makeIceberg(){
  const g = new THREE.Group();
  const mat = new THREE.MeshPhongMaterial({ color: 0xb8dcf0, emissive: 0x6fa8d0, emissiveIntensity: 0.16, shininess: 95, flatShading: true });
  const n = 3 + Math.floor(Math.random()*3);
  for(let i=0;i<n;i++){
    const h = 6 + Math.random()*13;
    const m = new THREE.Mesh(new THREE.ConeGeometry(2.4 + Math.random()*3.6, h, 5 + Math.floor(Math.random()*3)), mat);
    m.position.set((Math.random()*2-1)*5, h/2 - 0.6, (Math.random()*2-1)*4);
    m.rotation.y = Math.random()*Math.PI;
    m.castShadow = true;
    g.add(m);
  }
  const skirt = new THREE.Mesh(new THREE.CylinderGeometry(7, 9, 1.2, 9), mat);
  skirt.position.y = -0.2;
  g.add(skirt);
  return g;
}

function makeCrystalSpire(){
  const g = new THREE.Group();
  const mat = new THREE.MeshPhongMaterial({
    color: 0xa8e8ff, emissive: 0x4fb0e0, emissiveIntensity: 0.35,
    shininess: 140, specular: 0xffffff, transparent: true, opacity: 0.85, flatShading: true,
  });
  for(let i=0;i<4;i++){
    const h = 9 + Math.random()*16;
    const m = new THREE.Mesh(new THREE.ConeGeometry(1.1 + Math.random()*1.9, h, 6), mat);
    m.position.set((Math.random()*2-1)*4, h/2, (Math.random()*2-1)*4);
    m.rotation.z = (Math.random()*2-1)*0.16;
    g.add(m);
  }
  const base = new THREE.Mesh(new THREE.CylinderGeometry(5.5, 7, 1.4, 8), mat);
  base.position.y = 0.2;
  g.add(base);
  return g;
}

function makePalmIsland(){
  const g = new THREE.Group();
  const sand = new THREE.Mesh(
    new THREE.SphereGeometry(7, 14, 10, 0, Math.PI*2, 0, Math.PI*0.5),
    new THREE.MeshLambertMaterial({ color: 0xf6e2ad })
  );
  sand.scale.y = 0.35;
  sand.receiveShadow = true;
  g.add(sand);
  const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8a5a34 });
  const leafMat = new THREE.MeshLambertMaterial({ color: 0x3fae6a });
  const palms = 2 + Math.floor(Math.random()*2);
  for(let t=0;t<palms;t++){
    const tx = (Math.random()*2-1)*3.2, tz = (Math.random()*2-1)*3.2;
    const lean = (Math.random()*2-1)*0.2;
    const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.42, 6.2, 7), trunkMat);
    trunk.position.set(tx, 3.3, tz);
    trunk.rotation.z = lean;
    trunk.castShadow = true;
    g.add(trunk);
    const crownY = 6.4, crownX = tx - Math.sin(lean)*3;
    for(let l=0;l<6;l++){
      const leaf = new THREE.Mesh(new THREE.ConeGeometry(0.58, 3.4, 4), leafMat);
      const a = l/6*Math.PI*2;
      leaf.position.set(crownX + Math.cos(a)*1.5, crownY, tz + Math.sin(a)*1.5);
      leaf.rotation.set(Math.sin(a)*1.3, 0, -Math.cos(a)*1.3);
      g.add(leaf);
    }
    const coco = new THREE.Mesh(new THREE.SphereGeometry(0.3, 8, 8), new THREE.MeshLambertMaterial({color:0x6b4423}));
    coco.position.set(crownX+0.3, crownY-0.4, tz);
    g.add(coco);
  }
  return g;
}

function makeBeachUmbrella(){
  const g = new THREE.Group();
  const sand = new THREE.Mesh(new THREE.CylinderGeometry(4.4, 5.6, 1.1, 10), new THREE.MeshLambertMaterial({color:0xf6e2ad}));
  sand.position.y = 0.2; g.add(sand);
  const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.11,0.11,4.2,6), new THREE.MeshLambertMaterial({color:0xc8a06a}));
  pole.position.y = 2.6; pole.rotation.z = 0.12; g.add(pole);
  const stripeA = new THREE.MeshLambertMaterial({ color: 0xff6f6f });
  const stripeB = new THREE.MeshLambertMaterial({ color: 0xffffff });
  for(let i=0;i<8;i++){
    const seg = new THREE.Mesh(new THREE.CylinderGeometry(0, 2.7, 1.3, 3, 1, true), i%2 ? stripeA : stripeB);
    seg.position.set(-0.5, 4.6, 0);
    seg.rotation.y = i/8*Math.PI*2;
    seg.rotation.x = Math.PI;
    g.add(seg);
  }
  // ビーチボール
  const ball = new THREE.Group();
  const colors = [0xff5c7a, 0xffd35c, 0x5cc8ff, 0x8ef0a0];
  for(let i=0;i<4;i++){
    const wedge = new THREE.Mesh(
      new THREE.SphereGeometry(0.85, 10, 8, i*Math.PI/2, Math.PI/2),
      new THREE.MeshLambertMaterial({ color: colors[i] })
    );
    ball.add(wedge);
  }
  ball.position.set(2.6, 1.4, 1.4);
  g.add(ball);
  g.userData.spinner = ball;
  return g;
}

function makeTikiHut(){
  const g = new THREE.Group();
  const sand = new THREE.Mesh(new THREE.CylinderGeometry(5.4, 6.6, 1.2, 10), new THREE.MeshLambertMaterial({color:0xf0d89a}));
  sand.position.y = 0.2; g.add(sand);
  const postMat = new THREE.MeshLambertMaterial({ color: 0x9a6b3f });
  for(const p of [[-2,-2],[2,-2],[-2,2],[2,2]]){
    const post = new THREE.Mesh(new THREE.CylinderGeometry(0.22,0.26,3.6,6), postMat);
    post.position.set(p[0], 2.4, p[1]);
    g.add(post);
  }
  const roof = new THREE.Mesh(new THREE.ConeGeometry(4.4, 2.6, 6), new THREE.MeshLambertMaterial({ color: 0xc99b53 }));
  roof.position.y = 5.3;
  roof.castShadow = true;
  g.add(roof);
  // トーチの炎
  const flame = new THREE.Mesh(new THREE.ConeGeometry(0.42, 1.3, 6),
    new THREE.MeshBasicMaterial({ color: 0xffa53c, transparent:true, opacity:0.9 }));
  flame.position.set(3.4, 3.2, 0);
  g.add(flame);
  g.userData.flame = flame;
  return g;
}

function makeVolcanoIsland(){
  const g = new THREE.Group();
  const rock = new THREE.MeshLambertMaterial({ color: 0x4a3b3f });
  const cone = new THREE.Mesh(new THREE.CylinderGeometry(2.6, 11, 15, 9), rock);
  cone.position.y = 7;
  cone.castShadow = true;
  g.add(cone);
  const lava = new THREE.Mesh(new THREE.CylinderGeometry(2.4, 2.4, 0.6, 9),
    new THREE.MeshBasicMaterial({ color: 0xff6a2a }));
  lava.position.y = 14.4;
  g.add(lava);
  // 流れ落ちる溶岩
  for(let i=0;i<3;i++){
    const a = Math.random()*Math.PI*2;
    const flow = new THREE.Mesh(new THREE.ConeGeometry(0.5, 9, 4),
      new THREE.MeshBasicMaterial({ color: 0xff8a3a }));
    flow.position.set(Math.cos(a)*4.2, 7, Math.sin(a)*4.2);
    flow.rotation.z = Math.cos(a)*0.28;
    flow.rotation.x = -Math.sin(a)*0.28;
    g.add(flow);
  }
  const smoke = new THREE.Sprite(new THREE.SpriteMaterial({
    map: makeRadialTexture('rgba(90,80,80,0.9)','rgba(120,110,110,0.45)','rgba(120,110,110,0)'),
    transparent:true, depthWrite:false, opacity:0.7,
  }));
  smoke.scale.set(16,16,1);
  smoke.position.y = 22;
  g.add(smoke);
  g.userData.smoke = smoke;
  return g;
}

function makeSailboat(){
  const g = new THREE.Group();
  const hull = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 1.7, 6.5, 7, 1, false, 0, Math.PI),
    new THREE.MeshLambertMaterial({ color: 0xf2f2f2 }));
  hull.rotation.z = Math.PI/2;
  hull.rotation.y = Math.PI/2;
  hull.position.y = 0.5;
  g.add(hull);
  const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.1,7,6), new THREE.MeshLambertMaterial({color:0xd8c8a8}));
  mast.position.y = 4;
  g.add(mast);
  const sail = new THREE.Mesh(new THREE.PlaneGeometry(3.4, 5.4),
    new THREE.MeshLambertMaterial({ color: 0xffe08a, side: THREE.DoubleSide }));
  sail.position.set(1.3, 4.1, 0);
  sail.rotation.y = Math.PI/2;
  g.add(sail);
  g.userData.bobber = true;
  return g;
}

function makeFloatRing(){
  const g = new THREE.Group();
  const colors = [0xff7a8a, 0xffd35c, 0x5cc8ff];
  for(let i=0;i<2;i++){
    const ring = new THREE.Mesh(new THREE.TorusGeometry(2.1, 0.7, 10, 22),
      new THREE.MeshLambertMaterial({ color: colors[Math.floor(Math.random()*colors.length)] }));
    ring.rotation.x = -Math.PI/2;
    ring.position.set((Math.random()*2-1)*3, 0.35, (Math.random()*2-1)*3);
    g.add(ring);
  }
  g.userData.bobber = true;
  return g;
}

function makePenguinFloe(){
  const g = new THREE.Group();
  const floe = new THREE.Mesh(
    new THREE.CylinderGeometry(3.4, 4.0, 0.9, 8),
    new THREE.MeshPhongMaterial({ color: 0xb8dcf0, shininess: 85, flatShading:true })
  );
  floe.position.y = 0.25;
  floe.receiveShadow = true;
  g.add(floe);
  const bodyMat = new THREE.MeshLambertMaterial({ color: 0x2b2b33 });
  const bellyMat = new THREE.MeshLambertMaterial({ color: 0xf5f5f5 });
  const beakMat = new THREE.MeshLambertMaterial({ color: 0xffb347 });
  const count = 2 + Math.floor(Math.random()*3);
  const penguins = [];
  for(let i=0;i<count;i++){
    const p = new THREE.Group();
    const body = new THREE.Mesh(new THREE.SphereGeometry(0.62, 12, 12), bodyMat);
    body.scale.set(0.82, 1.2, 0.82);
    body.position.y = 0.72;
    body.castShadow = true;
    p.add(body);
    const belly = new THREE.Mesh(new THREE.SphereGeometry(0.46, 12, 12), bellyMat);
    belly.scale.set(0.74, 1.0, 0.55);
    belly.position.set(0, 0.68, 0.24);
    p.add(belly);
    const beak = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.34, 6), beakMat);
    beak.rotation.x = Math.PI/2;
    beak.position.set(0, 1.1, 0.56);
    p.add(beak);
    for(const s of [-1,1]){
      const flip = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.6, 0.3), bodyMat);
      flip.position.set(s*0.55, 0.72, 0);
      p.add(flip);
    }
    // サングラス(夏!)
    if(Math.random()<0.5){
      const shades = new THREE.Mesh(new THREE.BoxGeometry(0.62,0.16,0.1), new THREE.MeshLambertMaterial({color:0x222233}));
      shades.position.set(0, 1.16, 0.42);
      p.add(shades);
    }
    p.position.set((Math.random()*2-1)*2, 0.5, (Math.random()*2-1)*2);
    p.rotation.y = Math.random()*Math.PI*2;
    p.userData.bobPhase = Math.random()*Math.PI*2;
    g.add(p);
    penguins.push(p);
  }
  g.userData.penguins = penguins;
  return g;
}

const PROP_BUILDERS = {
  iceberg: makeIceberg,
  crystalSpire: makeCrystalSpire,
  palmIsland: makePalmIsland,
  beachUmbrella: makeBeachUmbrella,
  tikiHut: makeTikiHut,
  volcanoIsland: makeVolcanoIsland,
  sailboat: makeSailboat,
  floatRing: makeFloatRing,
  penguinFloe: makePenguinFloe,
};

function spawnBgProp(){
  const list = currentTheme.props;
  const key = list[Math.floor(Math.random()*list.length)];
  const obj = (PROP_BUILDERS[key] || makeIceberg)();
  const side = Math.random() < 0.5 ? -1 : 1;
  obj.position.set(side * (24 + Math.random()*42), 0, WORLD.spawnZ - Math.random()*70);
  obj.rotation.y = Math.random()*Math.PI*2;
  obj.userData.bobPhase = Math.random()*Math.PI*2;
  scene.add(obj);
  bgPropsPool.push(obj);
}

function clearBgProps(){
  bgPropsPool.forEach(p=>{ scene.remove(p); if(typeof disposeObject3D === 'function') disposeObject3D(p); });
  bgPropsPool = [];
}

/* ---------- ステージテーマ適用 ---------- */
function applyStageTheme(stage){
  const th = STAGE_THEMES[Math.min(STAGE_THEMES.length, Math.max(1, stage)) - 1];
  currentTheme = th;
  WORLD.waveScale = th.waveScale;
  WORLD.fogColor = th.fog;

  skyUniforms.topColor.value.setHex(th.skyTop);
  skyUniforms.midColor.value.setHex(th.skyMid);
  skyUniforms.bottomColor.value.setHex(th.skyBottom);

  oceanUniforms.uDeep.value.setHex(th.deep);
  oceanUniforms.uMid.value.setHex(th.mid);
  oceanUniforms.uShallow.value.setHex(th.shallow);
  oceanUniforms.uFogColor.value.setHex(th.fog);
  oceanUniforms.uWaveScale.value = th.waveScale;

  scene.fog.color.setHex(th.fog);
  sunLight.color.setHex(th.sun);
  sunLight.intensity = th.sunInt;
  hemiLight.intensity = th.hemi;
  return th.name;
}

/* ---------- 毎フレーム更新 ---------- */
function updateWorld(dt, worldSpeed, totalScroll, elapsed){
  oceanUniforms.uTime.value = elapsed;
  oceanUniforms.uScroll.value = totalScroll;

  for(const c of cloudSprites){
    c.position.x += c.userData.drift * dt;
    if(c.position.x > 270) c.position.x = -270;
  }

  for(const b of birdFlock){
    b.position.z += (worldSpeed*0.35) * dt;
    b.position.x += Math.sin(elapsed*0.4 + b.userData.phase) * 4 * dt;
    b.userData.wing.rotation.x = Math.sin(elapsed*b.userData.speed + b.userData.phase)*0.7;
    if(b.position.z > 40){
      b.position.z = -200 - Math.random()*60;
      b.position.x = (Math.random()*2-1)*90;
    }
  }

  for(let i=bgPropsPool.length-1;i>=0;i--){
    const p = bgPropsPool[i];
    p.position.z += worldSpeed * dt * 0.92;
    // 背景は波に連動させず、ゆったり独自に揺れるだけ
    p.position.y = Math.sin(elapsed*0.8 + p.userData.bobPhase) * (p.userData.bobber ? 0.55 : 0.1);
    if(p.userData.bobber) p.rotation.z = Math.sin(elapsed*1.1 + p.userData.bobPhase)*0.09;
    if(p.userData.penguins){
      for(const pg of p.userData.penguins) pg.rotation.z = Math.sin(elapsed*3 + pg.userData.bobPhase)*0.13;
    }
    if(p.userData.spinner) p.userData.spinner.rotation.y += dt*1.6;
    if(p.userData.flame){
      const s = 0.85 + Math.sin(elapsed*11 + p.userData.bobPhase)*0.2;
      p.userData.flame.scale.set(s, 1/s, s);
    }
    if(p.userData.smoke){
      p.userData.smoke.material.opacity = 0.55 + Math.sin(elapsed*1.3)*0.18;
      p.userData.smoke.position.y = 22 + Math.sin(elapsed*0.7)*1.2;
    }
    if(p.position.z > WORLD.killZ + 26){
      scene.remove(p);
      if(typeof disposeObject3D === 'function') disposeObject3D(p);
      bgPropsPool.splice(i,1);
    }
  }
}

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望は、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
流氷でサーフィングする「IceSurfSafari」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1