「クロエとテオの時間旅行」のミニゲーム


【更新履歴】

 ・2026/6/13 バージョン1.0公開。
       (中略)
 ・2026/6/13 バージョン1.3公開。
 ・2026/6/14 バージョン1.4公開。
 ・2026/6/14 バージョン1.5公開。

画像


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


【操作説明】

・方向キーで移動。(物にぶつかると突き飛ばし)
・Zキーでキック。(目の前の物を力強く突き飛ばす)
・Xキーで大キック。(2倍の威力。飛距離も伸びる。)

・お菓子をひろうと、HPの回復が始まる。

・本物のアーティファクトを2つ拾って
 テオドールに抱きつくとステージクリア。

・一定数の敵を倒してもステージクリア。

・テオドールは、後ろからついてきて
 たまに銃で攻撃してくれる。

・二人のうち、どちらかが死ぬとゲームオーバー。

・テオドールに向けて物を蹴り飛ばすと、
 近くの敵に蹴り飛ばしてくれることがあります。

【攻撃軌道の矢印】

・白いミシン線 … 移動でぶつかった場合の軌道です。
・白い実線 … 小キック時の軌道です。
・黄色の太線 … 大キック時の軌道です。

【マルチプレイモード】

・テオドールの操作は、
 テンキーの4、8、6、2で移動。
 Nキーでキック。Mキーで大キック。
 Hキーで銃撃。(エネルギーゲージが満タン時のみ)

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

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>クロエとテオの時間旅行</title>
<link rel="stylesheet" href="css/style.css">
</head>

<body>
<canvas id="scene"></canvas>

<div id="hud">
  <div class="panelL">
    <div class="card pcard">
      <img class="face" id="faceChloe" alt="">
      <div class="pcardBody">
        <div class="who"><div class="pip chloe">♥</div><div class="name">クロエ</div></div>
        <div class="hpRow">
          <div class="hpTop"><b id="hpText">20</b><small>/ 20 HP</small></div>
          <div class="hpTrack"><div class="hpFill" id="hpFill"></div></div>
          <div class="regen" id="regenTxt"></div>
        </div>
      </div>
    </div>
    <div class="card pcard" id="theoCard" style="display: flex; flex-direction: row; align-items: center; gap: 8px;">
      <img class="face" id="faceTheo" alt="" style="flex-shrink: 0;">
      <div class="pcardBody" style="flex-grow: 1;">
        <div class="who"><div class="pip theo">★</div><div class="name" id="theoName">テオドール</div></div>
        <div class="hpRow">
          <div class="hpTop"><b id="theoHpText">20</b><small>/ 20 HP</small></div>
          <div class="hpTrack"><div class="hpFill" id="theoHpFill"></div></div>
          <div class="chargeTrack"><div class="chargeFill" id="theoChargeFill"></div></div>
          <div class="regen" id="theoStatus" style="color:#bcd9ff">援護中…</div>
        </div>
      </div>
    </div>
  </div>

  <div class="objective">
    <div class="objCard" id="objCard">
      <div class="lbl">GENUINE RELICS</div>
      <div class="relics"><div class="relic" id="r0"></div><div class="relic" id="r1"></div></div>
      <div class="ko">撃破 <b id="koText">0 / 12</b></div>
      <div class="go" id="objGo">▶ 敵を倒すか、テオと合流!</div>
    </div>
  </div>

  <div class="panelR">
    <div class="card stageTag"><div class="era" id="hudEra">CHAPTER I</div><div class="nm" id="hudStage">ノミの市の銀貨</div></div>
    <div class="card score"><span id="scoreText">0</span><small>SCORE</small></div>
  </div>

  <div class="controls">
    <div class="card"><div class="row" id="controlsRow"></div></div>
  </div>
</div>
<div id="bubbles"></div>
<div id="flash"></div>
<div id="pauseOverlay"><div class="pauseBox"><h2>PAUSE</h2><p>Esc で再開</p></div></div>

<div id="screenTitle" class="screen vignette active">
  <canvas class="clockArt" id="clockArt"></canvas>
  <div class="title-wrap">
    <div class="kicker">A TIME-TRAVEL ADVENTURE</div>
    <h1 class="game-title">クロエと<span class="t">テオ</span>の<br><span class="b">時間旅行</span></h1>
    <div class="subtitle">CHLOE &amp; THEO'S TIME TRAVEL</div>
    <div id="menu">
      <div class="menu-item selected" id="menuSingle">SINGLE PLAY<span class="jp">ひとりで遊ぶ</span></div>
      <div class="menu-item multi" id="menuMulti">MULTI PLAY<span class="jp">ふたりで遊ぶ</span></div>
    </div>
    <div class="hint">↑ ↓ で選択 ・ クリック / Enter で決定</div>
  </div>
  <div class="press">CLICK or PRESS ANY KEY</div>
</div>

<div id="screenOpening" class="screen story vignette">
  <div class="panel"><h2 id="opTitle">プロローグ</h2><p id="openingText"></p></div>
  <div class="press">CLICK or PRESS ANY KEY</div>
</div>

<div id="screenEnding" class="screen story vignette">
  <div class="panel"><h2 id="edTitle">エピローグ</h2><p id="endingText"></p></div>
  <div class="press">CLICK or PRESS ANY KEY</div>
</div>

<div id="screenStageStart" class="screen big vignette">
  <div class="era" id="ssEra">CHAPTER I</div>
  <h1 id="stageStartText">STAGE 1 START!</h1>
  <div class="sub" id="ssSub">ノミの市の銀貨</div>
</div>

<div id="screenStageClear" class="screen big vignette">
  <div class="era">RELICS SECURED</div>
  <h1 id="scText">STAGE 1 CLEAR!</h1>
  <div class="sub" id="scSub">クロエとテオは、次の時代へ。</div>
  <div class="press">CLICK or PRESS ANY KEY</div>
</div>

<div id="screenGameOver" class="screen big vignette">
  <h1>GAME OVER</h1>
  <div class="sub">時の流れに呑まれてしまった……<br>このステージをもう一度。</div>
  <div class="press">CLICK or PRESS ANY KEY</div>
</div>

<div id="screenGameClear" class="screen big vignette">
  <div class="era">ALL CHAPTERS COMPLETE</div>
  <h1>GAME CLEAR!</h1>
  <div class="clearScore" id="gcScore">SCORE 0</div>
  <div class="press">CLICK or PRESS ANY KEY</div>
</div>

<div class="loader" id="loader"><div class="ring"></div><p id="loaderText">Loading…</p></div>

<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
<script src="js/config.js"></script>
<script src="js/audio.js"></script>
<script src="js/models.js"></script>
<script src="js/chars.js"></script>
<script src="js/props.js"></script>
<script src="js/animation.js"></script>
<script src="js/world.js"></script>
<script src="js/roll.js"></script>
<script src="js/entities.js"></script>
<script src="js/ui.js"></script>
<script src="js/game.js"></script>
</body>
</html>

style.css


  @import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&family=Zen+Maru+Gothic:wght@500;700;900&family=Cinzel:wght@600;800&display=swap');

  :root{
    --ink:#2b2440;
    --cream:#fbf3e3;
    --rose:#ff5e8a;        /* クロエ */
    --rose-deep:#e23a6e;
    --indigo:#3a3f7a;      /* テオ */
    --teal:#36c9c3;
    --gold:#ffce4d;
    --gold-deep:#e0a72e;
    --paper:#fff8ec;
  }
  *{ box-sizing:border-box; }
  html,body{ margin:0; height:100%; }
  body{
    overflow:hidden; background:#0c0a16; user-select:none;
    font-family:'Zen Maru Gothic','Baloo 2',sans-serif; color:var(--ink);
    -webkit-tap-highlight-color:transparent;
  }
  canvas#scene{ display:block; position:absolute; inset:0; width:100vw; height:100vh; z-index:0; }

  /* ---------- Screens ---------- */
  .screen{
    display:none; position:absolute; inset:0; z-index:30;
    flex-direction:column; justify-content:center; align-items:center; text-align:center;
    background-size:cover; background-position:center; overflow:hidden;
  }
  .screen.active{ display:flex; animation:fadein .5s ease; }
  @keyframes fadein{ from{opacity:0} to{opacity:1} }

  .vignette::after{
    content:""; position:absolute; inset:0; pointer-events:none;
    box-shadow:inset 0 0 220px 60px rgba(8,6,20,.85);
  }

  .press{
    position:absolute; bottom:34px; left:50%; transform:translateX(-50%);
    font-family:'Baloo 2',sans-serif; font-weight:700; letter-spacing:.18em;
    font-size:14px; color:#fff; opacity:.9; text-shadow:0 2px 8px rgba(0,0,0,.6);
    animation:blink 1.6s ease-in-out infinite;
  }
  @keyframes blink{ 0%,100%{opacity:.95} 50%{opacity:.25} }

  /* Title */
  #screenTitle{ background:
      radial-gradient(120% 90% at 50% 18%, #2a2350 0%, #161029 55%, #0b0817 100%); }
  .clockArt{ position:absolute; inset:0; pointer-events:none; opacity:.5; }
  .title-wrap{ position:relative; z-index:2; display:flex; flex-direction:column; align-items:center; }
  .kicker{ font-family:'Cinzel',serif; letter-spacing:.5em; color:var(--gold);
    font-size:14px; margin-bottom:10px; text-indent:.5em; opacity:.92; }
  .game-title{
    font-family:'Zen Maru Gothic',sans-serif; font-weight:900;
    font-size:clamp(40px,8vw,92px); line-height:1.04; color:#fff;
    text-shadow:0 0 26px rgba(255,94,138,.55), 0 8px 0 rgba(0,0,0,.18);
    margin:0;
  }
  .game-title .b{ color:var(--rose); } .game-title .t{ color:var(--teal); }
  .subtitle{ margin-top:14px; font-family:'Cinzel',serif; letter-spacing:.32em;
    font-size:13px; color:#d9d3f0; opacity:.8; }
  #menu{ margin-top:38px; display:flex; flex-direction:column; gap:14px; z-index:3; }
  .menu-item{
    font-weight:700; font-size:24px; color:#b9b3d6; cursor:pointer; position:relative;
    padding:8px 30px; border-radius:40px; transition:.18s; letter-spacing:.04em;
    border:2px solid transparent;
  }
  .menu-item .jp{ font-size:13px; display:block; letter-spacing:.2em; margin-top:2px; opacity:.8; }
  .menu-item.selected{ color:#fff; background:linear-gradient(90deg,var(--rose),var(--rose-deep));
    box-shadow:0 8px 24px rgba(255,94,138,.4); transform:scale(1.04); }
  .menu-item.selected.multi{ background:linear-gradient(90deg,var(--indigo),var(--teal));
    box-shadow:0 8px 24px rgba(54,201,195,.4); }
  .hint{ margin-top:26px; font-family:'Baloo 2'; font-size:12px; color:#8e89ad; letter-spacing:.06em; z-index:3; }

  /* Story (opening/ending) */
  .story{ background:linear-gradient(160deg,#1a1530,#0c0918); }
  .story .panel{
    position:relative; z-index:2; max-width:760px; margin:0 26px; padding:38px 40px;
    background:linear-gradient(180deg,var(--paper),#fdeecf);
    border-radius:22px; box-shadow:0 24px 60px rgba(0,0,0,.5);
    border:3px solid #efd9a6;
  }
  .story .panel::before{ content:""; position:absolute; inset:9px; border:1.5px dashed #d8b873; border-radius:14px; pointer-events:none; }
  .story h2{ font-weight:900; font-size:26px; color:var(--rose-deep); margin:0 0 14px; }
  .story p{ font-size:18px; line-height:1.85; color:#4a3f63; white-space:pre-wrap; margin:0; }

  /* Stage start / clear / over / gameclear : big centered */
  .big{ }
  .big .era{ font-family:'Cinzel',serif; letter-spacing:.4em; font-size:14px; color:var(--gold); margin-bottom:6px; z-index:2; }
  .big h1{ position:relative; z-index:2; font-weight:900; font-size:clamp(40px,9vw,86px); margin:6px 0; color:#fff;
    text-shadow:0 6px 0 rgba(0,0,0,.22), 0 0 28px rgba(255,255,255,.18); }
  .big .sub{ position:relative; z-index:2; font-size:18px; color:#e9e4ff; opacity:.9; }
  #screenStageStart{ background:radial-gradient(120% 100% at 50% 40%,#28204a,#0c0917); }
  #screenStageClear{ background:radial-gradient(120% 100% at 50% 40%,#1d3b2e,#0a1812); }
  #screenStageClear h1{ color:#bff7c9; text-shadow:0 0 30px rgba(120,255,160,.5); }
  #screenGameOver{ background:radial-gradient(120% 100% at 50% 40%,#3a1320,#120307); }
  #screenGameOver h1{ color:#ff8aa3; text-shadow:0 0 30px rgba(255,80,110,.5); }
  #screenGameClear{ background:radial-gradient(120% 100% at 50% 40%,#3a2f12,#120d03); }
  #screenGameClear h1{ color:var(--gold); text-shadow:0 0 36px rgba(255,206,77,.6); }
  .clearScore{ position:relative; z-index:2; margin-top:18px; font-family:'Baloo 2'; font-weight:700;
    font-size:20px; color:#fff; background:rgba(0,0,0,.3); padding:8px 22px; border-radius:30px; }

  .loader{ position:absolute; inset:0; display:flex; flex-direction:column; gap:18px; justify-content:center; align-items:center;
    background:#0b0817; z-index:60; color:#fff; }
  .loader .ring{ width:54px; height:54px; border-radius:50%; border:5px solid rgba(255,255,255,.15);
    border-top-color:var(--rose); animation:spin 1s linear infinite; }
  @keyframes spin{ to{ transform:rotate(360deg); } }
  .loader p{ font-family:'Baloo 2'; letter-spacing:.1em; font-size:14px; opacity:.8; }

  /* ---------- HUD ---------- */
  #hud{ position:absolute; inset:0; z-index:20; pointer-events:none; display:none; font-family:'Baloo 2',sans-serif; }
  #hud.show{ display:block; }

  .panelL{ position:absolute; top:16px; left:16px; display:flex; flex-direction:column; gap:10px; }
  .card{ background:linear-gradient(180deg, rgba(28,22,48,.82), rgba(20,16,36,.82));
    border:2px solid rgba(255,255,255,.16); border-radius:16px; padding:10px 14px;
    box-shadow:0 10px 30px rgba(0,0,0,.4); backdrop-filter:blur(4px); }

  .who{ display:flex; align-items:center; gap:10px; }
  .pip{ width:30px; height:30px; border-radius:50%; flex:none; display:grid; place-items:center; font-size:16px; }
  .pip.chloe{ background:linear-gradient(135deg,var(--rose),var(--rose-deep)); }
  .pip.theo{ background:linear-gradient(135deg,var(--indigo),var(--teal)); }
  .who .name{ font-weight:800; color:#fff; font-size:15px; letter-spacing:.04em; }
  .hpRow{ margin-top:8px; }
  .hpTop{ display:flex; justify-content:space-between; align-items:baseline; color:#fff; }
  .hpTop b{ font-size:18px; } .hpTop small{ opacity:.7; font-size:12px; }
  .hpTrack{ height:14px; border-radius:8px; background:rgba(255,255,255,.14); overflow:hidden; margin-top:4px; width:210px; border:1.5px solid rgba(255,255,255,.2); }
  .hpFill{ height:100%; width:100%; border-radius:8px; transition:width .25s ease;
    background:linear-gradient(90deg,#7CFFB0,#34d977); }
  .hpFill.low{ background:linear-gradient(90deg,#ffd24d,#ff7a59); }
  .hpFill.crit{ background:linear-gradient(90deg,#ff8a8a,#ff3b5c); }
  .regen{ font-size:11px; color:#bdf5c8; margin-top:3px; height:13px; }

  .panelR{ position:absolute; top:16px; right:16px; text-align:right; display:flex; flex-direction:column; gap:10px; align-items:flex-end; }
  .stageTag{ color:#fff; }
  .stageTag .era{ font-family:'Cinzel',serif; letter-spacing:.28em; color:var(--gold); font-size:11px; }
  .stageTag .nm{ font-weight:800; font-size:18px; letter-spacing:.02em; }
  .score{ color:#fff; font-weight:800; font-size:22px; }
  .score small{ font-size:11px; opacity:.7; display:block; letter-spacing:.18em; font-weight:700; }

  .objective{ position:absolute; top:16px; left:50%; transform:translateX(-50%); }
  .objCard{ background:linear-gradient(180deg, rgba(28,22,48,.86), rgba(20,16,36,.86));
    border:2px solid rgba(255,206,77,.5); border-radius:14px; padding:8px 16px; color:#fff;
    box-shadow:0 8px 26px rgba(0,0,0,.45); text-align:center; }
  .objCard .lbl{ font-family:'Cinzel',serif; letter-spacing:.28em; font-size:10px; color:var(--gold); }
  .relics{ display:flex; gap:7px; margin-top:6px; justify-content:center; align-items:center; }
  .relic{ width:22px; height:22px; border-radius:6px; transform:rotate(45deg);
    background:rgba(255,255,255,.12); border:2px solid rgba(255,255,255,.25); }
  .relic.on{ background:linear-gradient(135deg,#fff0b3,var(--gold)); border-color:#fff;
    box-shadow:0 0 14px rgba(255,206,77,.9); }
  .objCard .go{ margin-top:6px; font-weight:800; color:var(--teal); font-size:13px; display:none; }
  .objCard.ready .go{ display:block; animation:blink 1.2s infinite; }
  .objCard.ready{ border-color:var(--teal); }

  .controls{ position:absolute; bottom:14px; left:16px; }
  .controls .card{ padding:8px 12px; }
  .controls .row{ color:#cfc9e6; font-size:11px; letter-spacing:.02em; line-height:1.7; }
  .controls b{ color:#fff; font-family:'Baloo 2'; }
  .key{ display:inline-block; min-width:18px; text-align:center; padding:1px 6px; margin:0 1px;
    background:rgba(255,255,255,.14); border:1px solid rgba(255,255,255,.25); border-radius:6px;
    font-weight:700; color:#fff; }

  /* speech bubble */
  #bubbles{ position:absolute; inset:0; z-index:25; pointer-events:none; }
  .bubble{ position:absolute; transform:translate(-50%,-100%);
    background:#fff; color:#2b2440; font-weight:800; font-size:15px;
    padding:7px 14px; border-radius:14px; white-space:nowrap;
    box-shadow:0 8px 20px rgba(0,0,0,.4); border:2px solid var(--rose);
    animation:pop .25s ease; }
  .bubble.theo{ border-color:var(--teal); }
  .bubble::after{ content:""; position:absolute; left:50%; bottom:-9px; transform:translateX(-50%);
    border:8px solid transparent; border-top-color:#fff; }
  @keyframes pop{ from{ transform:translate(-50%,-100%) scale(.6); opacity:0 } to{ opacity:1 } }

  #flash{ position:absolute; inset:0; z-index:28; pointer-events:none; background:#fff; opacity:0; }

  /* ---- player card with face image (追加) ---- */
  .pcard{ display:flex; gap:12px; align-items:center; }
  .pcardBody{ flex:1; min-width:0; }
  .pcard .hpTop{ white-space:nowrap; }
  .pcard .hpTrack{ width:260px; }
  .face{ width:128px; height:128px; border-radius:18px; object-fit:cover; flex:none; display:none;
    border:3px solid rgba(255,255,255,.32); box-shadow:0 6px 16px rgba(0,0,0,.45); background:rgba(255,255,255,.06); }

  /* テオの銃チャージ(水色のゲージ:HPの下) */
  .chargeTrack{ height:8px; border-radius:6px; background:rgba(255,255,255,.14); overflow:hidden; margin-top:5px; width:260px; border:1.5px solid rgba(120,200,255,.35); }
  .chargeFill{ height:100%; width:0%; border-radius:6px; transition:width .12s linear;
    background:linear-gradient(90deg,#39d6ff,#7ce8ff); box-shadow:0 0 8px rgba(80,210,255,.6); }

  /* ---- KO progress on objective card (追加) ---- */
  .objCard .ko{ margin-top:6px; font-size:12px; color:#fff; letter-spacing:.04em; }
  .objCard .ko b{ color:var(--gold); font-size:14px; }

  /* ---- enemy speech bubble (味方と別色) (追加) ---- */
  .bubble.enemy{ border-color:#ff5a5a; color:#41101b; }
  .bubble.enemy::after{ border-top-color:#fff; }

  /* ---- pause overlay (追加) ---- */
  #pauseOverlay{ display:none; position:absolute; inset:0; z-index:40;
    background:rgba(8,6,20,.66); backdrop-filter:blur(3px); align-items:center; justify-content:center; }
  #pauseOverlay.show{ display:flex; animation:fadein .2s ease; }
  .pauseBox{ text-align:center; color:#fff; }
  .pauseBox h2{ font-family:'Cinzel',serif; letter-spacing:.34em; font-size:46px; margin:0; color:var(--gold);
    text-shadow:0 4px 20px rgba(0,0,0,.6); }
  .pauseBox p{ letter-spacing:.2em; opacity:.85; margin-top:10px; }

animation.js

/* =====================================================================
   animation.js — キャラクターのアニメーション
   状態: run / idle / attack / kick / shoot / fall / damage / hug
   lockAnim(c,name,dur) で一時的に上書き再生。
   chars.js のリグ(parts.*)を駆動します。欠けたパーツは安全に無視します。
   ===================================================================== */

function lockAnim(c, name, dur){ c.lock={name, t:0, dur}; }

function animateChar(c, dt, moving){
  const p=c.parts; c.animT += dt;
  let name = c.lock ? c.lock.name : (moving ? 'run' : 'idle');
  if(c.lock){ c.lock.t+=dt; if(c.lock.t>=c.lock.dur) c.lock=null; }

  // 触る変形は毎フレーム初期化
  p.upper.position.y = 0;  p.upper.rotation.set(0,0,0);
  if(p.torso) p.torso.rotation.set(0,0,0);
  p.head.rotation.set(0,0,0);
  p.armL.rotation.set(0,0,0); p.armR.rotation.set(0,0,0);
  p.foreL.rotation.set(0,0,0); p.foreR.rotation.set(0,0,0);
  p.legL.rotation.set(0,0,0); p.legR.rotation.set(0,0,0);
  p.shinL.rotation.set(0,0,0); p.shinR.rotation.set(0,0,0);

  if(name==='run'){
    const sp=13, ph=c.animT*sp, s=Math.sin(ph), co=Math.cos(ph);
    p.legL.rotation.x = s*0.95;       p.legR.rotation.x = -s*0.95;
    p.shinL.rotation.x = Math.max(0,Math.cos(ph))*1.1;
    p.shinR.rotation.x = Math.max(0,Math.cos(ph+Math.PI))*1.1;
    p.armL.rotation.x = -s*0.9;       p.armR.rotation.x = s*0.9;
    p.armL.rotation.z = 0.08;         p.armR.rotation.z = -0.08;
    p.foreL.rotation.x = -0.7;        p.foreR.rotation.x = -0.7;
    p.upper.rotation.x = 0.22;
    p.upper.rotation.y = -s*0.12;                 // 胴のひねり
    if(p.torso) p.torso.rotation.y = s*0.06;
    p.head.rotation.y = s*0.08;                   // 頭は進行方向を見る
    p.upper.position.y = Math.abs(s)*0.08;        // 上下の弾み
    if(p.hair) { p.hair.rotation.x = 0.1 + s*0.22; p.hair.rotation.z = co*0.1; }
    if(p.gun && c.type==='theo') p.gun.visible=false;
  } else if(name==='idle'){
    const ph=c.animT*1.8, s=Math.sin(ph), s2=Math.sin(c.animT*0.6);
    p.armL.rotation.x=0.05+s*0.03; p.armR.rotation.x=0.05-s*0.03;
    p.foreL.rotation.x=-0.18; p.foreR.rotation.x=-0.18;
    p.upper.position.y = s*0.025;                 // 呼吸
    p.upper.rotation.z = s2*0.02;                 // 重心の揺れ
    p.head.rotation.y = Math.sin(c.animT*0.7)*0.14;
    p.head.rotation.x = s*0.03;
    if(p.hair) p.hair.rotation.x = 0.06 + s*0.05;
    if(p.gun && c.type==='theo') p.gun.visible=false;
  } else if(name==='attack'){            // 素手の打撃(蹴る物が無い時)
    const k=Math.min(1, (c.lock?c.lock.t/Math.max(0.01,c.lock.dur):1));
    const sw=Math.sin(k*Math.PI);
    p.armR.rotation.x=-2.4+sw*1.8; p.foreR.rotation.x=-0.1;
    p.armL.rotation.x=-1.0; p.foreL.rotation.x=-0.5;
    p.legL.rotation.x=0.3; p.legR.rotation.x=-0.3;
    p.upper.rotation.x=-0.16; p.upper.rotation.y=-sw*0.35;
  } else if(name==='kick'){              // 蹴り:振り上げ→踏み込み
    const k=Math.min(1, (c.lock?c.lock.t/Math.max(0.01,c.lock.dur):1));
    const sw=Math.sin(k*Math.PI);
    p.legR.rotation.x = -1.6*sw - 0.15;           // 利き足を振り出す
    p.shinR.rotation.x = 0.7*sw;
    p.legL.rotation.x = 0.35;                     // 軸足を踏ん張る
    p.shinL.rotation.x = 0.25;
    p.armL.rotation.x = -0.5; p.armL.rotation.z = 0.3;   // バランスの腕
    p.armR.rotation.x = -0.6; p.armR.rotation.z = -0.2;
    p.upper.rotation.x = 0.18; p.upper.rotation.y = sw*0.25;
    if(p.hair) p.hair.rotation.x = 0.1 + sw*0.2;
  } else if(name==='shoot'){             // 射撃:反動つき
    if(p.gun) p.gun.visible=true;
    const rt=c.lock?c.lock.t:0;
    const recoil = rt<0.12 ? (0.12-rt)/0.12 : 0;
    p.armR.rotation.x=-1.55 - recoil*0.35; p.foreR.rotation.x=-0.05;
    p.armL.rotation.x=-0.6; p.foreL.rotation.x=-0.6;
    p.upper.rotation.y=-0.12; p.upper.rotation.x=-recoil*0.08;
    p.head.rotation.y=-0.08;
  } else if(name==='fall'){
    p.armL.rotation.x=-2.6; p.armR.rotation.x=-2.6; p.foreL.rotation.x=-0.4; p.foreR.rotation.x=-0.4;
    p.legL.rotation.x=0.6; p.legR.rotation.x=-0.5; p.shinL.rotation.x=0.8;
    p.upper.rotation.x=-0.15;
    if(p.hair) p.hair.rotation.x=-0.4;
  } else if(name==='damage'){
    const rt=c.lock?c.lock.t:0; const j=Math.max(0,1-rt*6);
    p.upper.rotation.x=-0.55*j-0.05; p.head.rotation.x=-0.4*j;
    p.armL.rotation.x=-1.2; p.armR.rotation.x=-1.2; p.foreL.rotation.x=-0.3; p.foreR.rotation.x=-0.3;
    p.legL.rotation.x=-0.2; p.legR.rotation.x=0.2;
  } else if(name==='hug'){
    const s=Math.sin(c.animT*3);
    p.armL.rotation.x=-1.7; p.armR.rotation.x=-1.7;
    p.armL.rotation.z=0.55; p.armR.rotation.z=-0.55;
    p.foreL.rotation.x=-1.1; p.foreR.rotation.x=-1.1;
    p.upper.rotation.x=0.12; p.head.rotation.x=-0.1;
    p.head.rotation.z=s*0.06;
    p.upper.position.y=Math.abs(s)*0.03;
  }
}

audio.js

/* =====================================================================
   audio.js — 効果音とBGM
   素材フォルダ Sound/ StageBgm/ ScreenBgm/ があれば読み込み、
   無ければ WebAudio で手続き的に生成(ビープ/アルペジオ)。
   ===================================================================== */

const AudioCtx = window.AudioContext || window.webkitAudioContext;
const actx = new AudioCtx();
const masterGain = actx.createGain();
masterGain.gain.value = 0.9;
masterGain.connect(actx.destination);

let muted = false;
const sfxBuffers = {};
const SFX_NAMES = ['pageup','barrel','rock','damage','fall','fallout','shock','heal','artifact','dash','shot','hug'];

function tryLoadSfx(){
  SFX_NAMES.forEach(n=>{
    fetch('Sound/'+n+'.mp3').then(r=>{ if(!r.ok) throw 0; return r.arrayBuffer(); })
      .then(buf=>actx.decodeAudioData(buf)).then(ab=>{ sfxBuffers[n]=ab; }).catch(()=>{});
  });
}

function beep(type){
  if(muted) return;
  if(actx.state==='suspended') actx.resume();
  const o=actx.createOscillator(), g=actx.createGain();
  o.connect(g); g.connect(masterGain);
  const t=actx.currentTime;
  const set=(wave,f0,f1,vol,dur)=>{ o.type=wave; o.frequency.setValueAtTime(f0,t);
    o.frequency.exponentialRampToValueAtTime(f1,t+dur); g.gain.setValueAtTime(vol,t);
    g.gain.exponentialRampToValueAtTime(0.001,t+dur); o.start(t); o.stop(t+dur); };
  switch(type){
    case 'pageup':  set('sine',700,1300,0.12,0.12); break;
    case 'barrel':  set('triangle',70,40,0.2,0.45); break;
    case 'rock':    set('sawtooth',55,28,0.18,0.5); break;
    case 'damage':  set('square',220,60,0.22,0.3); break;
    case 'fall':    set('sine',900,300,0.12,0.4); break;
    case 'fallout': set('triangle',120,50,0.25,0.18); break;
    case 'shock':   set('square',90,30,0.3,0.16); break;
    case 'heal':    set('sine',520,1040,0.12,0.32); break;
    case 'artifact':set('sine',1100,2100,0.14,0.3); break;
    case 'dash':    set('sine',300,360,0.04,0.06); break;
    case 'shot':    set('square',900,300,0.08,0.08); break;
    case 'kick':    set('square',180,60,0.2,0.18); break;
    case 'hug':     set('sine',660,990,0.16,0.5); break;
    default:        set('sine',440,440,0.1,0.15);
  }
}

function playSfx(name){
  if(muted) return;
  if(sfxBuffers[name]){
    if(actx.state==='suspended') actx.resume();
    const s=actx.createBufferSource(); s.buffer=sfxBuffers[name];
    const g=actx.createGain(); g.gain.value=0.8; s.connect(g); g.connect(masterGain); s.start();
  } else beep(name);
}

/* ---- 手続き的BGM(ステージ毎の柔らかいアルペジオ)。mp3があれば優先 ---- */
let bgmTimer=null, bgmEl=null, bgmStep=0, bgmScale=[];
const SCALES = {
  bright:[0,2,4,7,9,12], minor:[0,3,5,7,10,12], penta:[0,2,4,7,9], lydian:[0,2,4,6,7,11],
};
function stageScale(s){
  if([3,6,8].includes(s)) return SCALES.minor;
  if([5,7].includes(s)) return SCALES.penta;
  if(s>=10) return SCALES.lydian;
  return SCALES.bright;
}
function stopBgm(){ if(bgmTimer){clearInterval(bgmTimer); bgmTimer=null;} if(bgmEl){bgmEl.pause(); bgmEl=null;} }
function startBgm(kind, stage){
  stopBgm();
  const url = stage!=null ? ('StageBgm/'+stage+'.mp3') : ('ScreenBgm/'+kind+'.mp3');
  const a = new Audio(); a.src=url; a.loop=true; a.volume=0.5;
  a.play().then(()=>{ bgmEl=a; }).catch(()=>{ proceduralBgm(stage); });
}
function proceduralBgm(stage){
  if(muted) return;
  bgmScale = stageScale(stage||1); bgmStep=0;
  const root = 220 * Math.pow(2,(((stage||1)%4)-2)/12);
  bgmTimer = setInterval(()=>{
    if(muted) return;
    if(actx.state==='suspended') actx.resume();
    const sc=bgmScale; const deg = sc[bgmStep % sc.length] + (Math.floor(bgmStep/sc.length)%2)*12;
    const f = root * Math.pow(2, deg/12);
    const o=actx.createOscillator(), g=actx.createGain();
    o.type='triangle'; o.connect(g); g.connect(masterGain);
    const t=actx.currentTime; o.frequency.value=f;
    g.gain.setValueAtTime(0.0001,t); g.gain.exponentialRampToValueAtTime(0.035,t+0.04);
    g.gain.exponentialRampToValueAtTime(0.0001,t+0.5); o.start(t); o.stop(t+0.55);
    if(bgmStep%4===0){ const ob=actx.createOscillator(), gb=actx.createGain(); ob.type='sine';
      ob.frequency.value=root/2; ob.connect(gb); gb.connect(masterGain);
      gb.gain.setValueAtTime(0.0001,t); gb.gain.exponentialRampToValueAtTime(0.05,t+0.05);
      gb.gain.exponentialRampToValueAtTime(0.0001,t+0.9); ob.start(t); ob.stop(t+0.95); }
    bgmStep++;
  }, 290);
}

/* opening/ending テキストの差し替え(Text/ フォルダがあれば) */
function tryLoadText(){
  fetch('Text/opening.txt').then(r=>r.ok?r.text():Promise.reject()).then(t=>{ const el=document.getElementById('openingText'); if(el) el.dataset.override=t; }).catch(()=>{});
  fetch('Text/ending.txt').then(r=>r.ok?r.text():Promise.reject()).then(t=>{ const el=document.getElementById('endingText'); if(el) el.dataset.override=t; }).catch(()=>{});
}

chars.js

/* =====================================================================
   chars.js — キャラクターのモデリング(クロエ/テオ/エージェント)
   models.js の共有ヘルパ(GEO, limb, group, matLam)を使います。
   リグ(parts.*)は animation.js が参照します。
   parts.{root, upper, torso, chest, head, hair, armL,armR, foreL,foreR,
          handL,handR, legL,legR, shinL,shinR, gun}
   ===================================================================== */
function createCharacter(type){
  const SK = { chloe:0xffd9b8, theo:0xf0c39a, agent:0xe7cdb6 }[type];
  const root = new THREE.Group();
  const parts = { root };

  const upper = group(0,0,0); root.add(upper); parts.upper = upper;
  const torso = group(0,0.9,0); upper.add(torso); parts.torso = torso;

  let shirt, lower, hairCol;
  if(type==='chloe'){ shirt=0xffffff; lower=0x3f63a8; hairCol=0x7a4a24; }
  else if(type==='theo'){ shirt=0x20202c; lower=0x191922; hairCol=0x14141a; }
  else { shirt=0x15151d; lower=0x101017; hairCol=0x1c1c22; }

  /* ---- torso build ---- */
  const chest = limb(0.62,0.5,0.34, shirt); chest.position.y=0.55; torso.add(chest); parts.chest=chest;
  const belly = limb(0.5,0.34,0.3, type==='chloe'?shirt:lower); belly.position.y=0.2; torso.add(belly);
  const hips  = limb(0.56,0.22,0.32, lower); hips.position.y=0.02; torso.add(hips);
  // shoulders
  [-1,1].forEach(s=>{ const sh=limb(0.2,0.18,0.32, shirt); sh.position.set(0.3*s,0.74,0); torso.add(sh); });
  // belt
  const belt = limb(0.58,0.1,0.34, type==='chloe'?0x6a4a2a:0x0c0c12); belt.position.y=0.08; torso.add(belt);
  const buckle = limb(0.12,0.1,0.05, type==='chloe'?0xffce4d:0xcaccd6); buckle.position.set(0,0.08,0.18); torso.add(buckle);

  if(type==='chloe'){
    const collar=limb(0.3,0.08,0.32,0xeeeeee); collar.position.y=0.78; torso.add(collar);
    const heart=limb(0.12,0.12,0.02,0xff5e8a); heart.position.set(0,0.55,0.18); torso.add(heart);
    // small jacket seam
    const seam=limb(0.02,0.5,0.02,0xe6e6e6); seam.position.set(0,0.55,0.18); torso.add(seam);
  } else {
    // suit jacket: lapels, shirt V, tie, pocket square
    const lapL=limb(0.16,0.5,0.06,0x0e0e14); lapL.position.set(-0.12,0.55,0.18); lapL.rotation.z=0.18; torso.add(lapL);
    const lapR=limb(0.16,0.5,0.06,0x0e0e14); lapR.position.set(0.12,0.55,0.18); lapR.rotation.z=-0.18; torso.add(lapR);
    const shirtV=limb(0.16,0.42,0.04,0xf4f4f6); shirtV.position.set(0,0.5,0.19); torso.add(shirtV);
    const tie=limb(0.07,0.36,0.04, type==='theo'?0x6a3fb0:0x8b1d1d); tie.position.set(0,0.46,0.21); torso.add(tie);
    const knot=limb(0.1,0.08,0.05, type==='theo'?0x7a4fc0:0x9b2d2d); knot.position.set(0,0.66,0.21); torso.add(knot);
    const pocket=limb(0.12,0.04,0.02, type==='theo'?0xb39be0:0xcacaca); pocket.position.set(-0.18,0.42,0.19); torso.add(pocket);
  }

  /* ---- neck + head ---- */
  const neck = limb(0.16,0.16,0.16, SK); neck.position.y=0.86; torso.add(neck);
  const head = group(0,1.08,0); torso.add(head); parts.head=head;
  const face = limb(0.42,0.46,0.4, SK); head.add(face);
  const jaw = limb(0.34,0.12,0.36, SK); jaw.position.y=-0.22; head.add(jaw);
  // ears
  [-1,1].forEach(s=>{ const e=limb(0.06,0.12,0.08, SK); e.position.set(0.22*s,-0.02,0); head.add(e); });

  if(type==='agent'){
    const shades=limb(0.46,0.13,0.06,0x07070a); shades.position.set(0,0.03,0.2); head.add(shades);
    const bridge=limb(0.08,0.04,0.05,0x07070a); bridge.position.set(0,0.05,0.22); head.add(bridge);
    const glint=limb(0.1,0.04,0.01,0x4a5a6a); glint.position.set(-0.1,0.05,0.235); head.add(glint);
    const mouth=limb(0.16,0.03,0.02,0x6a4a4a); mouth.position.set(0,-0.16,0.21); head.add(mouth);
  } else {
    const white=0xffffff;
    [-1,1].forEach(s=>{
      const wEye=limb(0.1,0.12,0.04,white); wEye.position.set(0.1*s,0.02,0.205); head.add(wEye);
      const pup=limb(0.06,0.08,0.03, type==='chloe'?0x6a3b1f:0x222a3a); pup.position.set(0.1*s,0.02,0.225); head.add(pup);
      const brow=limb(0.12,0.03,0.03, hairCol); brow.position.set(0.1*s,0.13,0.21); head.add(brow);
      const lash=limb(0.11,0.02,0.02, 0x2a1a10); lash.position.set(0.1*s,0.08,0.225); head.add(lash);
    });
    const mouth=limb(0.12,0.04,0.02, type==='chloe'?0xd45a6a:0x8a5a52); mouth.position.set(0,-0.14,0.21); head.add(mouth);
    if(type==='chloe'){
      const bL=limb(0.09,0.06,0.02,0xff9bb0); bL.position.set(-0.16,-0.05,0.2); head.add(bL);
      const bR=limb(0.09,0.06,0.02,0xff9bb0); bR.position.set(0.16,-0.05,0.2); head.add(bR);
      const nose=limb(0.05,0.05,0.04,SK); nose.position.set(0,-0.03,0.22); head.add(nose);
    }
  }

  /* ---- hair ---- */
  if(type==='chloe'){
    const cap=limb(0.5,0.34,0.46,hairCol); cap.position.set(0,0.18,-0.01); head.add(cap);
    const bang=limb(0.46,0.16,0.16,hairCol); bang.position.set(0,0.12,0.2); head.add(bang);
    const bangHi=limb(0.3,0.06,0.04,0x9a6a3a); bangHi.position.set(-0.05,0.16,0.22); head.add(bangHi);
    const sideL=limb(0.1,0.42,0.34,hairCol); sideL.position.set(-0.23,-0.06,0); head.add(sideL);
    const sideR=limb(0.1,0.42,0.34,hairCol); sideR.position.set(0.23,-0.06,0); head.add(sideR);
    const tieB=limb(0.12,0.12,0.12,0xff5e8a); tieB.position.set(0,0.12,-0.27); head.add(tieB);
    const pony=group(0,-0.05,-0.3); head.add(pony); parts.hair=pony;
    const p1=limb(0.18,0.46,0.18,hairCol); p1.position.set(0,-0.18,0); p1.rotation.x=0.35; pony.add(p1);
    const p2=limb(0.12,0.3,0.12,0x8a5a2a); p2.position.set(0.04,-0.4,0.04); p2.rotation.x=0.5; pony.add(p2);
  } else if(type==='theo'){
    const cap=limb(0.48,0.3,0.46,hairCol); cap.position.set(0,0.2,-0.02); head.add(cap);
    const quiff=group(0,0.28,0.1); head.add(quiff); parts.hair=quiff;
    const qf=limb(0.34,0.18,0.2,hairCol); qf.rotation.x=-0.5; quiff.add(qf);
    const qhi=limb(0.2,0.05,0.06,0x33333f); qhi.position.set(0.04,0.06,0.1); qhi.rotation.x=-0.5; quiff.add(qhi);
    const sideL=limb(0.06,0.32,0.4,hairCol); sideL.position.set(-0.24,0.0,0); head.add(sideL);
    const sideR=limb(0.06,0.32,0.4,hairCol); sideR.position.set(0.24,0.0,0); head.add(sideR);
  } else {
    const cap=limb(0.48,0.26,0.46,hairCol); cap.position.set(0,0.22,-0.02); head.add(cap);
    const sideL=limb(0.06,0.26,0.42,hairCol); sideL.position.set(-0.24,0.05,0); head.add(sideL);
    const sideR=limb(0.06,0.26,0.42,hairCol); sideR.position.set(0.24,0.05,0); head.add(sideR);
    const earp=limb(0.05,0.08,0.05,0x222228); earp.position.set(0.24,-0.04,0.04); head.add(earp);
    const wire=limb(0.03,0.22,0.03,0x222228); wire.position.set(0.24,-0.18,0.04); head.add(wire);
  }

  /* ---- arms (with fingers) ---- */
  function arm(side){
    const pivot=group(0.34*side,1.45,0); upper.add(pivot);
    const up=limb(0.16,0.44,0.16, shirt); up.position.y=-0.22; pivot.add(up);
    if(type==='chloe'){ const sleeve=limb(0.17,0.16,0.17,0xffffff); sleeve.position.y=-0.16; pivot.add(sleeve);
                        const skin=limb(0.15,0.22,0.15,SK); skin.position.y=-0.36; pivot.add(skin); }
    const fore=group(0,-0.44,0); pivot.add(fore);
    const fm=limb(0.14,0.4,0.14, type==='chloe'?SK:shirt); fm.position.y=-0.2; fore.add(fm);
    const hand=limb(0.16,0.14,0.17,SK); hand.position.y=-0.42; fore.add(hand);
    // fingers + thumb
    for(let f=-1;f<=1;f++){ const fg=limb(0.04,0.1,0.05,SK); fg.position.set(0.05*f,-0.52,0.05); fore.add(fg); }
    const thumb=limb(0.05,0.1,0.06,SK); thumb.position.set(0.08*side,-0.46,0.04); fore.add(thumb);
    if(type!=='chloe'){ const cuff=limb(0.17,0.06,0.17,0xf0f0f2); cuff.position.y=-0.34; fore.add(cuff); }
    return {pivot,fore,hand};
  }
  const aL=arm(-1), aR=arm(1);
  parts.armL=aL.pivot; parts.foreL=aL.fore; parts.armR=aR.pivot; parts.foreR=aR.fore;
  parts.handL=aL.hand; parts.handR=aR.hand;

  if(type==='theo' || type==='agent'){
    const gun=group(0,-0.46,0.1); aR.fore.add(gun);
    const barrel=limb(0.08,0.08,0.36,0x111116); barrel.position.z=0.16; gun.add(barrel);
    const slide=limb(0.1,0.1,0.24,0x2a2a32); slide.position.z=0.06; gun.add(slide);
    const grip=limb(0.08,0.2,0.1,0x1a1a20); grip.position.set(0,-0.12,0); gun.add(grip);
    parts.gun=gun; gun.visible = (type==='agent');
  }

  /* ---- legs ---- */
  function leg(side){
    const pivot=group(0.15*side,0.9,0); root.add(pivot);
    const thigh=limb(0.22,0.5,0.24, lower); thigh.position.y=-0.25; pivot.add(thigh);
    const knee=limb(0.2,0.12,0.22, lower); knee.position.y=-0.5; pivot.add(knee);
    const shin=group(0,-0.5,0); pivot.add(shin);
    const calf=limb(0.2,0.5,0.22, type==='chloe'?0x35528c:lower); calf.position.y=-0.25; shin.add(calf);
    const shoeCol = type==='chloe'?0xffffff:0x0c0c10;
    const shoe=limb(0.24,0.16,0.4, shoeCol); shoe.position.set(0,-0.5,0.08); shin.add(shoe);
    const sole=limb(0.26,0.06,0.44, type==='chloe'?0xe0e0e0:0x050507); sole.position.set(0,-0.58,0.1); shin.add(sole);
    if(type==='chloe'){ const lace=limb(0.18,0.05,0.2,0xff5e8a); lace.position.set(0,-0.44,0.14); shin.add(lace); }
    return {pivot,shin};
  }
  const lL=leg(-1), lR=leg(1);
  parts.legL=lL.pivot; parts.legR=lR.pivot; parts.shinL=lL.shin; parts.shinR=lR.shin;

  root.traverse(o=>{ if(o.isMesh) o.castShadow=true; });
  return { type, parts, animName:'idle', animT:Math.random()*6, lock:null, baseY:0 };
}

config.js

/* =====================================================================
   config.js — グローバル定数・ステージ定義・物語テキスト
   原作小説『クロエとテオの時間旅行』の全12話に対応。
   各ステージを編集したいときはここを書き換えるだけでOK。
   ===================================================================== */

const ROMAN = ['I','II','III','IV','V','VI','VII','VIII','IX','X','XI','XII'];

/* ---- ゲーム全体のチューニング値(まとめてここに) ---- */
const CFG = {
  MAX_STAGES   : 12,
  TARGET_REAL  : 2,     // ハグでクリアするのに必要な「本物」アーティファクト数
  MAX_ARTIFACTS: 60,    // 1ステージに出るアーティファクトの上限(ステージは無限に続くので多め)
  REAL_RATE    : 0.1,   // アーティファクト取得時に本物が出る確率(=10回に1回)

  ENEMIES_TO_CLEAR: 12, // ステージクリアに必要な撃破数(基準値。ステージが進むと少し増える)
  SHIELD_RANGE : 4.0,   // クロエが被弾しそうな時にテオが身代わりできる距離
  AUTOAIM_RANGE: 18,    // この距離内に敵がいると、キックで自動的に敵へ照準
  THEO_CHARGE_TIME: 2.6,// テオの銃チャージ時間(秒)。満タンで1発撃てる(撃つと0に)

  FIELD_HALF   : 22,    // フィールド半幅(X方向に動ける範囲)
  PLAYER_SPEED : 13,
  PLAYER_HP    : 20,    // クロエのHP
  THEO_HP      : 20,    // テオドールのHP

  KICK_SPEED   : 30,    // Z/Nキック時の初速(遠くまで転がる)
  STRONG_MULT  : 2.0,   // X/M 強キックの倍率(2倍の威力・飛距離)
  BUMP_SPEED   : 14,    // 体当たり(ぶつかった)時の初速(軽く転がる)
  ROLL_FRICTION: 1.55,  // 転がり摩擦(小さいほど遠くへ)
  ROLL_SLOPE   : 15,    // 坂による加速の強さ
  ROLL_STOP    : 1.3,   // この速度以下で停止
  ROLL_TRANSFER: 0.92,  // 連鎖時に次の物へ渡す運動量の割合
};

/* ステージ毎の必要撃破数 */
function enemiesToClear(stage){ return CFG.ENEMIES_TO_CLEAR + Math.floor((stage-1)*1.5); }

/* ---- ステージ定義 ----
   sky/fog/g1/g2 : 空・霧・地面2色 accent : 章のテーマカラー
   rolls : そのステージに転がっている物の種類(重み付き・ランダム抽選)
   prop  : 背景装飾のテーマ
*/
const STAGES = [
  { n:1,  title:'ノミの市の銀貨',     era:'現代ロンドン・蚤の市',
    sky:0x9fb1c4, fog:0x8a9bb0, g1:0x9a8e7a, g2:0x877b69, accent:0xc9d4e0,
    rolls:['crate','barrel','vase','crate'], prop:'market' },

  { n:2,  title:'お茶の時間',         era:'19世紀・ヴィクトリア朝ロンドン',
    sky:0xd8c7a4, fog:0xc3b08a, g1:0x8d7d63, g2:0x7a6b52, accent:0xb5562e,
    rolls:['teapot','teacup','barrel','cheese'], prop:'victorian' },

  { n:3,  title:'不機嫌なワルツ',     era:'1920年代・ジャズエイジ',
    sky:0x2a2138, fog:0x1d1628, g1:0x47324f, g2:0x382742, accent:0xe0a93e,
    rolls:['cask','drum','bottle','cask'], prop:'jazz' },

  { n:4,  title:'ファラオの猫',       era:'紀元前・古代エジプト',
    sky:0xf3cd86, fog:0xe7b76f, g1:0xe4c382, g2:0xd3ad66, accent:0x2bb2c7,
    rolls:['urn','amphora','stone','vase'], prop:'egypt' },

  { n:5,  title:'星降る夜の秘密',     era:'シルクロードのオアシス都市',
    sky:0x1b2747, fog:0x141d36, g1:0xc9a25e, g2:0xb38c4c, accent:0x37c9c3,
    rolls:['sack','vase','urn','melon'], prop:'bazaar' },

  { n:6,  title:'壊れた秒針',         era:'1960年代・冷戦下のベルリン',
    sky:0x59616b, fog:0x474e57, g1:0x4a4f57, g2:0x3c4047, accent:0xc7402e,
    rolls:['oildrum','gear','crate','barrel'], prop:'factory' },

  { n:7,  title:'水の都、迷子のワルツ', era:'水の都ヴェネツィア',
    sky:0x86c2d6, fog:0x6fb0c7, g1:0xb89a6e, g2:0xa6885c, accent:0xe05a8a,
    rolls:['cask','vase','barrel','melon'], prop:'venice' },

  { n:8,  title:'嵐の海の海賊船',     era:'18世紀・カリブの海賊船',
    sky:0x36404e, fog:0x2a3340, g1:0x6a4a2c, g2:0x573c22, accent:0xe0a93e,
    rolls:['barrel','cannonball','cask','crate'], prop:'pirate' },

  { n:9,  title:'いつわりの夫婦',     era:'1889年・ベル・エポックのパリ',
    sky:0x2a2c52, fog:0x20223f, g1:0x6b5a8a, g2:0x564873, accent:0xffce4d,
    rolls:['cask','vase','teapot','urn'], prop:'paris' },

  { n:10, title:'交錯する世界線',     era:'時空が交差するロンドン',
    sky:0x241a3a, fog:0x1a1230, g1:0x3a3160, g2:0x2c244c, accent:0xb14dff,
    rolls:['gear','urn','cannonball','barrel','orb','crate'], prop:'rift' },

  { n:11, title:'反撃の鼓動',         era:'ビッグ・ベン時計塔の内部',
    sky:0x241c30, fog:0x191324, g1:0x5a4a2e, g2:0x473a24, accent:0xb14dff,
    rolls:['gear','clockweight','orb','cannonball'], prop:'clockwork' },

  { n:12, title:'秒針の帰還',         era:'特異点ジェネレーター',
    sky:0x2a1840, fog:0x1c0f2e, g1:0x4a3a7a, g2:0x382c5e, accent:0xffce4d,
    rolls:['orb','core','gear','clockweight'], prop:'singularity' },
];

/* ---- プロローグ/エンディング(小説から要約・脚色) ---- */
const OPENING_TEXT =
`ロンドン郊外の蚤の市で、
アンティーク修復師クロエは
不思議な銀貨を手に入れた。

どの王室にも該当しない肖像、
存在しないはずの年号——

その正体は、時空を旅する鍵《遺物》。
黒ずくめの男たちが銃を手に迫る中、
巻き込まれたのは謎めいた青年テオドール。

こうして、ふたりの時間旅行が幕を開ける。`;

const ENDING_TEXT =
`歪んだ時間の特異点を越え、
クロエとテオは秒針を取り戻した。

崩れかけた世界は静かに修復され、
歯車はふたたび正しく時を刻みはじめる。

ロンドンの空に、見慣れた現在が戻ってくる。
銀貨はもう、ただ少しだけ古いコイン。

——けれど、ふたりの旅の記憶は、
たしかにここに刻まれている。`;

/* ---- セリフ集 ----
   クロエ/テオ(味方)と 敵 のセリフは、はっきり別物にしてあります。
   ここを書き換えれば口調を調整できます。 */
const LINES = {
  chloeReal  : ['本物よ、これ!','やった、当たり!','これこそ探してた遺物!'],
  chloeFake  : ['ニセものだった…','はずれ、ただのガラクタね','うーん、これは偽物か'],
  chloeStolen: ['くそっ ニセものを掴まされた!','奪い返したのにニセものなんて!','ちぇっ、これもニセものだった'],
  chloeHeal  : ['回復するよ♪','んっ、おいしい!','ふぅ、生き返る〜'],
  chloeKick  : ['それっ!','どいてっ!','邪魔よ!'],
  chloeHug   : ['テオ、やったね!','帰ろう、私たちの時代へ!','二人なら、こわくない!'],
  theoShield : ['危ない、下がってろ!','俺が受ける!','クロエは守る!'],
  theoShoot  : ['任せろ!','後ろは取らせない','ナイスパスだ!'],
  theoHug    : ['……よくやった、クロエ','ああ、帰ろう','無茶ばかりしやがって'],
  enemyTaunt : ['遺物を渡せ!','逃がさんぞ!','そいつをよこせ!','時間遡行者め!'],
};
function pickLine(arr){ return arr[Math.floor(Math.random()*arr.length)]; }

entities.js

/* =====================================================================
   entities.js — 戦闘とAI + 最適化(オブジェクトプーリング、DOMキャッシュ)
   ・fireProjectile : 弾の発射(プーリング対応)
   ・killEnemy      : 敵撃破(HPが0になったら撃破)
   ・damagePlayer   : プレイヤーの被弾
   ・resolvePlayerHit: テオの身代わり
   ・updateEnemies  : 敵AI
   ・updateCompanion: テオAI
   ・updateProjectiles
   ===================================================================== */

/* --- 最適化:UI更新のキャッシュ(DOMの無駄な書き換えを防ぐ) --- */
let _theoStatusEl = null;
let _lastTheoStatus = '';
function setTheoStatus(text) {
  if(!_theoStatusEl) _theoStatusEl = document.getElementById('theoStatus');
  if(_theoStatusEl && _lastTheoStatus !== text) {
    _theoStatusEl.textContent = text;
    _lastTheoStatus = text;
  }
}

/* --- 最適化:オブジェクトプール(GCによるカクつきを防ぐ) --- */
const Pools = { projectiles: [], flashes: [] };

function getProjMesh(colorHex){
  let p = Pools.projectiles.find(x => !x.active && x.color === colorHex);
  if(!p){
    p = { active:true, color:colorHex, mesh: new THREE.Mesh(new THREE.SphereGeometry(0.16,8,6), new THREE.MeshBasicMaterial({color: colorHex})) };
    Pools.projectiles.push(p);
  }
  p.active = true; p.mesh.visible = true;
  // ステージ切り替えでsceneから消えている場合は再追加
  if(!p.mesh.parent) scene.add(p.mesh);
  return p;
}

function getFlashMesh(){
  let f = Pools.flashes.find(x => !x.active);
  if(!f){
    f = { active:true, mesh: new THREE.Mesh(new THREE.SphereGeometry(0.3,6,5), new THREE.MeshBasicMaterial({color:0xffe9a8, transparent:true, opacity:0.9})) };
    Pools.flashes.push(f);
  }
  f.active = true; f.mesh.visible = true;
  if(!f.mesh.parent) scene.add(f.mesh);
  return f;
}

/* --- 関数 --- */
function getTheo(){ return isMultiplayer ? (G.players[1]||null) : G.companion; }

function fireProjectile(from, to, fromWho){
  const dir=to.clone().sub(from).normalize();
  const colorHex = fromWho==='theo'?0x66e0ff:0xff5a5a;
  
  const p = getProjMesh(colorHex);
  p.mesh.position.copy(from);
  G.projectiles.push({ pRef: p, mesh: p.mesh, pos:from.clone(), vel:dir.multiplyScalar(46), life:1.5, from:fromWho });
  
  playSfx('shot');
  
  const f = getFlashMesh();
  f.mesh.position.copy(from);
  setTimeout(() => { f.active = false; f.mesh.visible = false; }, 70);
}

function killEnemy(e, impulse){
  if(e.isDead) return;
  if(e.hp === undefined) e.hp = 3;
  e.hp--;
  
  if(e.hp > 0) {
    playSfx('damage');
    lockAnim(e.char, 'damage', 0.15);
    return;
  }
  
  e.isDead=true;
  e.vel=new THREE.Vector3((impulse.x||0)*0.5+(Math.random()-0.5)*6, 0, (impulse.z||0)*0.4 - 6);
  e.yVelocity=16+Math.random()*8; e.rotSpeed=8+Math.random()*8;
  G.score+=120; G.enemiesDefeated=(G.enemiesDefeated||0)+1;
  playSfx('damage'); lockAnim(e.char,'damage',0.3);
  
  if(e.carrying){
    const a=e.carrying; a.by=null; a.taken=false; a.stolenFake=true; a.real=false;
    a.mesh.visible=true; makeArtifactFake(a.mesh);
    a.mesh.position.set(e.pos.x, getHeight(e.pos.x,e.pos.z)+1.4, e.pos.z);
    e.carrying=null;
  }
}

function damagePlayer(p, amt){
  if(!p || (p.hitCd||0)>0) return;
  p.hp-=amt; p.hitCd=0.9; playSfx('damage');
  lockAnim(p.char,'damage',0.35);
  flash();
}

function resolvePlayerHit(target, amt){
  if(target===G.players[0]){
    const theo=getTheo();
    if(theo && theo.hp>0 && (theo.hitCd||0)<=0){
      const nx=target.pos.x-theo.pos.x, nz=target.pos.z-theo.pos.z, dd=Math.hypot(nx,nz)||1;
      if(dd < CFG.SHIELD_RANGE){
        const step=Math.min(dd-0.6, 1.2);
        if(step>0){ theo.pos.x+=nx/dd*step; theo.pos.z+=nz/dd*step; }
        damagePlayer(theo, amt);
        if(Math.random()<0.6) say(theo.pos.clone(), pickLine(LINES.theoShield), 'theo');
        return;
      }
    }
  }
  damagePlayer(target, amt);
}

function updateEnemies(dt){
  const p1=G.players[0], p2=G.players[1]||null;
  G.enemies.forEach(e=>{
    if(e.isDead){
      e.pos.add(e.vel.clone().multiplyScalar(dt));
      e.yVelocity-=55*dt; e.pos.y+=e.yVelocity*dt;
      e.char.parts.root.rotation.x+=e.rotSpeed*dt; e.char.parts.root.rotation.z+=e.rotSpeed*0.6*dt;
      e.char.parts.root.position.copy(e.pos);
      return;
    }
    e.pos.y=getHeight(e.pos.x,e.pos.z);
    if(e.meleeCd>0) e.meleeCd-=dt;
    let target=p1; if(p2 && p2.pos.distanceTo(e.pos)<p1.pos.distanceTo(e.pos)) target=p2;
    let goal=target.pos;
    let art=null,ad=Infinity;
    for(const a of G.artifacts){ if(a.taken||a.by) continue; const dd=a.mesh.position.distanceTo(e.pos); if(dd<16 && dd<ad){ad=dd; art=a;} }
    if(art){ goal=art.mesh.position; e.mode='steal'; } else e.mode='chase';

    const dir=new THREE.Vector3(goal.x-e.pos.x,0,goal.z-e.pos.z); const gd=dir.length(); dir.normalize();
    const espeed=e.mode==='steal'?6:4.5;
    e.pos.x+=dir.x*espeed*dt; e.pos.z+=dir.z*espeed*dt;
    e.char.parts.root.rotation.y=Math.atan2(dir.x,dir.z);
    animateChar(e.char,dt,true);
    e.char.parts.root.position.copy(e.pos);

    if(e.mode==='steal' && art && gd<1.4){ art.by=e; art.real=false; art.wasStolen=true; art.mesh.visible=false; e.carrying=art; }

    e.shootCd-=dt;
    const toP=target.pos.distanceTo(e.pos);
    if(e.mode==='chase' && toP<22 && e.shootCd<=0){
      e.shootCd=1.6+Math.random();
      lockAnim(e.char,'shoot',0.4);
      fireProjectile(e.pos.clone().add(new THREE.Vector3(0,1.4,0)), target.pos.clone().add(new THREE.Vector3(0,1.2,0)),'enemy');
      if(Math.random()<0.16) say(e.pos.clone(), pickLine(LINES.enemyTaunt), 'enemy');
    }
    if(toP<1.4 && (e.meleeCd||0)<=0){ e.meleeCd=0.9; resolvePlayerHit(target,1); }
  });
}

function updateCompanion(dt){
  const t=G.companion; if(!t) return;
  if(t.hitCd>0) t.hitCd-=dt;
  if(t.regen>0){ t.regen-=dt; t._healAcc=(t._healAcc||0)+dt; if(t._healAcc>=1){ t._healAcc-=1; t.hp=Math.min(CFG.THEO_HP,t.hp+1);} }

  const lead=G.players[0];
  const behind=new THREE.Vector3(
    lead.pos.x - Math.sin(lead.char.parts.root.rotation.y)*3.0, 0,
    lead.pos.z - Math.cos(lead.char.parts.root.rotation.y)*3.0 + 2.0);
  const to=behind.clone().sub(t.pos); to.y=0; const d=to.length();
  let moving=false;
  if(d>1.2 && !G.hugging && !G.warping){ to.normalize(); const sp=Math.min(14,d*4);
    t.pos.x+=to.x*sp*dt; t.pos.z+=to.z*sp*dt; t.char.parts.root.rotation.y=Math.atan2(to.x,to.z); moving=true; }
  t.pos.y=getHeight(t.pos.x,t.pos.z);

  let tgt=null,td=Infinity;
  for(const e of G.enemies){ if(e.isDead) continue; const dd=e.pos.distanceTo(t.pos); if(dd<26 && dd<td){td=dd; tgt=e;} }
  
  if(tgt && (t.charge||0)>=1 && !G.hugging && !G.warping){
    t.charge=0;
    t.char.parts.root.rotation.y=Math.atan2(tgt.pos.x-t.pos.x, tgt.pos.z-t.pos.z);
    lockAnim(t.char,'shoot',0.45); moving=false;
    fireProjectile(t.pos.clone().add(new THREE.Vector3(0,1.4,0)), tgt.pos.clone().add(new THREE.Vector3(0,1.4,0)),'theo');
    setTheoStatus('射撃!');
  } else if(!G.hugging){ 
    setTheoStatus(moving?'追従中…':'援護中…'); 
  }
  if(G.reunionReady){ setTheoStatus('合流できる!'); }
  if(G.hugging) lockAnim(t.char,'hug',1.0);
  
  animateChar(t.char,dt,moving);
  t.char.parts.root.position.copy(t.pos);
}

function theoShootNearest(theo){
  if(!theo || (theo.charge||0) < 1) return false;
  let tgt=null,td=Infinity;
  for(const e of G.enemies){ if(e.isDead) continue; const dd=Math.hypot(e.pos.x-theo.pos.x,e.pos.z-theo.pos.z); if(dd<30 && dd<td){td=dd; tgt=e;} }
  if(!tgt) return false;
  theo.charge=0;
  theo.char.parts.root.rotation.y=Math.atan2(tgt.pos.x-theo.pos.x, tgt.pos.z-theo.pos.z);
  lockAnim(theo.char,'shoot',0.45);
  fireProjectile(theo.pos.clone().add(new THREE.Vector3(0,1.4,0)), tgt.pos.clone().add(new THREE.Vector3(0,1.4,0)),'theo');
  if(Math.random()<0.6) say(theo.pos.clone(), pickLine(LINES.theoShoot),'theo');
  return true;
}

function updateProjectiles(dt){
  G.projectiles.forEach(pr=>{
    pr.pos.add(pr.vel.clone().multiplyScalar(dt)); pr.mesh.position.copy(pr.pos); pr.life-=dt;
    if(pr.from==='theo'){
      G.enemies.forEach(e=>{ if(!e.isDead && e.pos.clone().add(new THREE.Vector3(0,1.4,0)).distanceTo(pr.pos)<1.3){ killEnemy(e,new THREE.Vector3(0,0,-6)); pr.life=0; } });
    } else {
      G.players.forEach(p=>{ if(p.pos.clone().add(new THREE.Vector3(0,1.2,0)).distanceTo(pr.pos)<1.2){ resolvePlayerHit(p,1); pr.life=0; } });
    }
    if(pr.life<=0){ 
      pr.pRef.active = false; 
      pr.mesh.visible = false; 
      pr._dead=true; 
    }
  });
  G.projectiles=G.projectiles.filter(pr=>!pr._dead);
}

game.js

/* =====================================================================
   game.js — 中核(状態・初期化・メインループ・起動)
   これが最後に読み込まれ、全モジュールをまとめて動かします。
   ===================================================================== */

let scene, camera, renderer, clock;
let gameState='TITLE';
let isMultiplayer=false;
let currentStage=1;
const keys={};

/* 共有ステート */
const G = {
  cfg:null, players:[], companion:null,
  enemies:[], rollables:[], artifacts:[], sweets:[], projectiles:[], decos:[], paths:[],
  tiles:{}, generatedChunks:new Set(),
  artifactBag:[], artifactsSpawned:0,
  realCollected:0, fakeCount:0,
  reunionReady:false, hugging:false, hugT:0,
  enemiesDefeated:0, warping:false, warpT:0, paused:false,
  score:0, lastSpawnZ:0, // オブジェクト自動生成のトラッキング用
};

/* ---- THREE 初期化 ---- */
function init3D(){
  scene=new THREE.Scene();
  clock=new THREE.Clock();
  camera=new THREE.PerspectiveCamera(52, innerWidth/innerHeight, 0.1, 600);
  renderer=new THREE.WebGLRenderer({ canvas:document.getElementById('scene'), antialias:true });
  renderer.setSize(innerWidth, innerHeight);
  renderer.setPixelRatio(Math.min(devicePixelRatio,2));
  renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;

  const hemi=new THREE.HemisphereLight(0xffffff,0x554466,0.85); scene.add(hemi);
  const dir=new THREE.DirectionalLight(0xffffff,0.95);
  dir.position.set(18,40,16); dir.castShadow=true; dir.shadow.mapSize.set(1024,1024);
  dir.shadow.camera.near=1; dir.shadow.camera.far=160;
  dir.shadow.camera.left=-50; dir.shadow.camera.right=50; dir.shadow.camera.top=50; dir.shadow.camera.bottom=-50;
  scene.add(dir); scene.userData.dir=dir; scene.userData.hemi=hemi;

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

/* ---- ステージ初期化 ---- */
function initStage(stage){
  G.cfg=STAGES[stage-1];
  for(let i=scene.children.length-1;i>=0;i--){ const c=scene.children[i]; if(c.isLight) continue; scene.remove(c); }
  G.players=[]; G.companion=null; G.enemies=[]; G.rollables=[]; G.artifacts=[];
  G.sweets=[]; G.projectiles=[]; G.decos=[]; G.paths=[];
  G.tiles={}; G.generatedChunks=new Set();
  G.realCollected=0; G.fakeCount=0; G.artifactsSpawned=0;
  G.reunionReady=false; G.hugging=false; G.hugT=0;
  G.enemiesDefeated=0; G.warping=false; G.warpT=0; G.paused=false;
  G.lastSpawnZ = 0; // スポーン位置リセット
  reunionArrow=null;

  applyStageVisuals(G.cfg);

  const chloe=createCharacter('chloe');
  const pC={ char:chloe, pos:new THREE.Vector3(-1.5, getHeight(-1.5,0), 0), hp:CFG.PLAYER_HP, isPlayer:true,
    yVelocity:0, regen:0, hitCd:0, radius:0.6, name:'chloe' };
  scene.add(chloe.parts.root); G.players.push(pC);

  if(isMultiplayer){
    const theo=createCharacter('theo');
    const pT={ char:theo, pos:new THREE.Vector3(1.5, getHeight(1.5,0), 0), hp:CFG.THEO_HP, isPlayer:true,
      yVelocity:0, regen:0, hitCd:0, radius:0.6, name:'theo', charge:1 };
    scene.add(theo.parts.root); G.players.push(pT);
  } else {
    const theo=createCharacter('theo');
    const comp={ char:theo, pos:new THREE.Vector3(2, getHeight(2,2), 2), hp:CFG.THEO_HP,
      yVelocity:0, hitCd:0, regen:0, shootCd:0, radius:0.6, name:'theo', charge:1 };
    scene.add(theo.parts.root); G.companion=comp;
  }

  updateChunks(pC.pos.z);
  setupFaces();
  setControlsHud();
}

/* ---- メイン更新 ---- */
function updatePlaying(dt){
  const p1=G.players[0], p2=G.players[1]||null;
  const maxX=CFG.FIELD_HALF;

  /* ワープ演出中は専用処理だけ */
  if(G.warping){ updateWarp(dt); return; }

  /* プレイヤー */
  G.players.forEach((p,idx)=>{
    let mx=0,mz=0;
    if(idx===0){
      if(keys['arrowup']||keys['w']) mz-=1;
      if(keys['arrowdown']||keys['s']) mz+=1;
      if(keys['arrowleft']||keys['a']) mx-=1;
      if(keys['arrowright']||keys['d']) mx+=1;
    } else {
      if(keys['numpad8']) mz-=1;
      if(keys['numpad2']) mz+=1;
      if(keys['numpad4']) mx-=1;
      if(keys['numpad6']) mx+=1;
    }
    const moving=(mx!==0||mz!==0) && !G.hugging;
    if(moving){
      const il=Math.hypot(mx,mz); const dx=mx/il, dz=mz/il;
      p.pos.x+=dx*CFG.PLAYER_SPEED*dt; p.pos.z+=dz*CFG.PLAYER_SPEED*dt;
      p.char.parts.root.rotation.y=Math.atan2(dx,dz);
      p.pos.x=Math.max(-maxX,Math.min(maxX,p.pos.x));
      tryBump(p, dx, dz);
      if(Math.random()<0.05) playSfx('dash');
    }
    p.pos.y=getHeight(p.pos.x,p.pos.z);
    if(p.hitCd>0) p.hitCd-=dt;
    if(p.regen>0){ p.regen-=dt; p._healAcc=(p._healAcc||0)+dt; if(p._healAcc>=1){ p._healAcc-=1; p.hp=Math.min(CFG.PLAYER_HP,p.hp+1);} }
    animateChar(p.char, dt, moving);
    p.char.parts.root.position.copy(p.pos);
  });

  if(G.companion) updateCompanion(dt);
  const theoCharge=getTheo();
  if(theoCharge && theoCharge.hp>0){ theoCharge.charge=Math.min(1,(theoCharge.charge||0)+dt/CFG.THEO_CHARGE_TIME); }
  updateEnemies(dt);
  updateRollables(dt);
  updateProjectiles(dt);

  /* アーティファクト取得 */
  G.artifacts.forEach(a=>{
    if(a.mesh.userData.ring) a.mesh.userData.ring.rotation.z+=dt*1.5;
    a.mesh.rotation.y+=dt*1.2;
    if(a.taken||a.by) return;
    if(p1.pos.distanceTo(a.mesh.position)<2.2){
      a.taken=true; a.mesh.visible=false; playSfx('artifact');
      if(a.stolenFake){ G.fakeCount++; G.score+=200; say(p1.pos.clone(), pickLine(LINES.chloeStolen),'chloe'); }
      else if(a.real){ G.realCollected++; G.score+=1500; say(p1.pos.clone(), pickLine(LINES.chloeReal),'chloe'); }
      else { G.fakeCount++; G.score+=300; say(p1.pos.clone(), pickLine(LINES.chloeFake),'chloe'); }
    }
  });

  /* お菓子(回復) */
  G.sweets.forEach(s=>{
    s.mesh.rotation.y+=dt*1.5;
    if(s.mesh.visible && p1.pos.distanceTo(s.mesh.position)<2.2){
      s.mesh.visible=false; playSfx('heal'); p1.regen=6; p1._healAcc=0;
      say(p1.pos.clone(), pickLine(LINES.chloeHeal),'chloe');
    }
  });

  /* 合流(ハグ)→ ワープ演出 → クリア */
  if(G.realCollected>=CFG.TARGET_REAL && !G.hugging){
    G.reunionReady=true;
    const theo=isMultiplayer?p2:G.companion;
    if(theo && p1.pos.distanceTo(theo.pos)<2.6){
      G.hugging=true; G.hugT=0; playSfx('hug');
      lockAnim(p1.char,'hug',2.0); if(theo.char) lockAnim(theo.char,'hug',2.0);
      say(p1.pos.clone(), pickLine(LINES.chloeHug),'chloe');
    }
  }
  if(G.hugging && !G.warping){ G.hugT+=dt; if(G.hugT>0.7) startWarp(); }

  if(!G.hugging && (G.enemiesDefeated||0) >= enemiesToClear(currentStage)){
    switchState('STAGE_CLEAR'); return;
  }

  updateChunks(p1.pos.z);

  /* 奥への延伸に伴うオブジェクトの自動生成・配置処理 (要望2) */
  if(G.lastSpawnZ === undefined) G.lastSpawnZ = 0;
  if(p1.pos.z < G.lastSpawnZ - 15) {
    G.lastSpawnZ = p1.pos.z;
    const spawnZ = p1.pos.z - 55; // プレイヤーの前方にポップ
    
    // 1) 転がる物の動的生成
    if(G.cfg && G.cfg.rolls && G.rollables.length < CFG.MAX_ARTIFACTS){
      const numRolls = 1 + Math.floor(Math.random() * 2);
      for(let i=0; i<numRolls; i++){
        const rx = (Math.random() - 0.5) * CFG.FIELD_HALF * 1.5;
        const rKind = G.cfg.rolls[Math.floor(Math.random() * G.cfg.rolls.length)];
        const rData = createRollable(rKind);
        rData.x = rx; rData.z = spawnZ + (Math.random() - 0.5) * 6;
        rData.y = getHeight(rData.x, rData.z) + rData.radius;
        rData.mesh.position.set(rData.x, rData.y, rData.z);
        rData.rolling = false; rData.vx = 0; rData.vz = 0;
        scene.add(rData.mesh); G.rollables.push(rData);
      }
    }
    // 2) アーティファクトの動的生成
    if(G.artifacts.length < 6 && Math.random() < 0.3 && G.artifactsSpawned < CFG.MAX_ARTIFACTS){
      G.artifactsSpawned++;
      const isReal = Math.random() < CFG.REAL_RATE;
      const artMesh = createArtifact(isReal);
      const ax = (Math.random() - 0.5) * CFG.FIELD_HALF * 1.3;
      const az = spawnZ + (Math.random() - 0.5) * 4;
      artMesh.position.set(ax, getHeight(ax, az) + 0.8, az);
      scene.add(artMesh); G.artifacts.push({ mesh: artMesh, taken: false, by: null, real: isReal, stolenFake: false });
    }
    // 3) お菓子(回復アイテム)の動的生成
    if(Math.random() < 0.12){
      const swMesh = createSweet();
      const sx = (Math.random() - 0.5) * CFG.FIELD_HALF * 1.3;
      const sz = spawnZ + (Math.random() - 0.5) * 4;
      swMesh.position.set(sx, getHeight(sx, sz) + 0.6, sz);
      scene.add(swMesh); G.sweets.push({ mesh: swMesh });
    }
    // 4) 敵の動的生成(耐久HP:3に初期化)
    if(G.enemies.filter(e => !e.isDead).length < 4 && Math.random() < 0.35){
      const eChar = createCharacter('agent');
      const ex = (Math.random() - 0.5) * CFG.FIELD_HALF * 1.5;
      const ez = spawnZ - 12;
      const ePos = new THREE.Vector3(ex, getHeight(ex, ez), ez);
      scene.add(eChar.parts.root);
      G.enemies.push({
        char: eChar, pos: ePos, vel: new THREE.Vector3(),
        yVelocity: 0, rotSpeed: 0, isDead: false, mode: 'chase',
        shootCd: 1.2 + Math.random() * 2, meleeCd: 0, carrying: null, hp: 3
      });
    }
  }

  // 古くなった背後(手前側)の古いオブジェクトを定期的にクリーンアップ
  G.artifacts.forEach(a => { if(!a.taken && a.mesh.position.z > p1.pos.z + 75) { scene.remove(a.mesh); a.taken = true; } });
  G.sweets.forEach(s => { if(s.mesh.visible && s.mesh.position.z > p1.pos.z + 75) { scene.remove(s.mesh); s.mesh.visible = false; } });
  G.enemies.forEach(e => { if(!e.isDead && e.pos.z > p1.pos.z + 75) { scene.remove(e.char.parts.root); e.isDead = true; } });

  drawPaths(p1);
  updateUI();
  updateReunionArrow();

  const camOff=new THREE.Vector3(0,19,12.5);
  camera.position.lerp(p1.pos.clone().add(camOff),0.12);
  camera.lookAt(p1.pos.clone().add(new THREE.Vector3(0,0.5,-5)));

  updateBubbles(dt);

  const theoEnt=getTheo();
  if(p1.hp<=0 || (theoEnt && theoEnt.hp<=0)){ switchState('GAME_OVER'); return; }
}

/* ---- ハグ後のワープ演出 ---- */
function startWarp(){
  G.warping=true; G.warpT=0; clearPaths();
  const theo=getTheo();
  playSfx('hug');
  if(theo) say(theo.pos.clone(), pickLine(LINES.theoHug),'theo');
}
function updateWarp(dt){
  G.warpT+=dt;
  const p1=G.players[0], theo=getTheo();
  const ents=[p1, theo].filter(Boolean);
  const center=new THREE.Vector3();
  ents.forEach(e=>{
    e.char.parts.root.rotation.y += dt*16;
    e.pos.y += (6 + G.warpT*12)*dt;
    const s=Math.max(0.05, 1 - G.warpT*0.45);
    e.char.parts.root.scale.setScalar(s);
    e.char.parts.root.position.copy(e.pos);
    center.add(e.pos);
  });
  center.multiplyScalar(1/Math.max(1,ents.length));
  camera.position.lerp(new THREE.Vector3(p1.pos.x, getHeight(p1.pos.x,p1.pos.z)+6, p1.pos.z+14), 0.1);
  camera.lookAt(center);
  updateUI(); updateBubbles(dt);
  if(G.warpT>1.5){ flash(); switchState('STAGE_CLEAR'); }
}

/* ---- ループ ---- */
function animate(){
  requestAnimationFrame(animate);
  const dt=Math.min(clock.getDelta(),0.05);
  if(gameState==='PLAYING' && !G.paused) updatePlaying(dt);
  renderer.render(scene,camera);
}

/* ---- 起動 ---- */
function boot(){
  init3D();
  tryLoadSfx(); tryLoadText();
  setupInput();
  titleArt();
  hideLoader();
  switchState('TITLE');
  animate();
}
boot();

models.js

/* =====================================================================
   models.js — 共有ジオメトリ+転がる物/お菓子/遺物のモデル
   ・buildSharedGeo : 使い回す基本ジオメトリ
   ・matLam/matStd/limb/group : 共有ヘルパ(chars.js, props.js でも使用)
   ・createRollable  : 転がる物(種類いろいろ)
   ・createSweet     : お菓子(種類いろいろ・回復)
   ・createArtifact  : アーティファクト(本物/偽物)
   ※ キャラのモデルは chars.js、背景・建物のモデルは props.js に分割しています。
   ===================================================================== */

let GEO = {};
function buildSharedGeo(){
  GEO.limb = new THREE.BoxGeometry(1,1,1);
  GEO.ground = new THREE.BoxGeometry(1,1,1);
  GEO.cyl  = new THREE.CylinderGeometry(1,1,1,16);
}
function matLam(c){ return new THREE.MeshLambertMaterial({color:c}); }
function matStd(c, em, ei){ const m=new THREE.MeshLambertMaterial({color:c}); if(em!=null){ m.emissive=new THREE.Color(em); m.emissiveIntensity=ei||0.4; } return m; }

/* box "limb" centered at origin, scaled */
function limb(w,h,d,color,shadow=true){
  const m = new THREE.Mesh(GEO.limb, matLam(color));
  m.scale.set(w,h,d); m.castShadow=shadow; return m;
}
function group(x=0,y=0,z=0){ const g=new THREE.Group(); g.position.set(x,y,z); return g; }


/* =====================================================================
   ROLLABLES  (many cute variants)
   ===================================================================== */
function createRollable(kind){
  const g=new THREE.Group();
  let radius=0.8, sfx='barrel';
  const wood=(c)=>matLam(c);
  switch(kind){
    case 'barrel': case 'cask': {
      const woodC = kind==='cask'?0x7a4a2a:0x9c6b3a;
      const body=new THREE.Mesh(new THREE.CylinderGeometry(0.62,0.62,1.2,16), wood(woodC));
      body.rotation.z=Math.PI/2; g.add(body);
      [-0.32,0.32].forEach(x=>{ const r=new THREE.Mesh(new THREE.TorusGeometry(0.64,0.07,8,16), matLam(0x4a3320)); r.position.x=x; g.add(r); });
      const stave=new THREE.Mesh(new THREE.BoxGeometry(1.2,0.04,0.04), matLam(0x6a4524)); stave.position.y=0.6; g.add(stave);
      radius=0.8; break;
    }
    case 'bottle': {
      const body=new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.4,1.0,14), matStd(0x2f5d3a)); body.rotation.z=Math.PI/2; g.add(body);
      const neck=new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.22,0.5,12), matStd(0x2f5d3a)); neck.rotation.z=Math.PI/2; neck.position.x=0.7; g.add(neck);
      const cork=new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.16,0.16,10), matLam(0xcaa46a)); cork.rotation.z=Math.PI/2; cork.position.x=0.96; g.add(cork);
      radius=0.55; break;
    }
    case 'urn': case 'amphora': case 'vase': {
      const clay = kind==='urn'?0xcaa46a : kind==='vase'?0x6a86b0 : 0xb5703f;
      const belly=new THREE.Mesh(new THREE.SphereGeometry(0.6,16,12), matLam(clay)); belly.scale.y=1.18; g.add(belly);
      const neck=new THREE.Mesh(new THREE.CylinderGeometry(0.22,0.34,0.5,12), matLam(clay)); neck.position.y=0.64; g.add(neck);
      const lip=new THREE.Mesh(new THREE.TorusGeometry(0.24,0.06,8,12), matLam(clay)); lip.position.y=0.88; lip.rotation.x=Math.PI/2; g.add(lip);
      // painted band
      const band=new THREE.Mesh(new THREE.CylinderGeometry(0.61,0.61,0.18,16), matLam(kind==='vase'?0xffce4d:0x8a4a2a)); g.add(band);
      if(kind==='amphora'){ [-1,1].forEach(s=>{ const h=new THREE.Mesh(new THREE.TorusGeometry(0.16,0.05,6,10), matLam(clay)); h.position.set(0.5*s,0.5,0); g.add(h); }); }
      radius=0.72; break;
    }
    case 'teapot': {
      const body=new THREE.Mesh(new THREE.SphereGeometry(0.55,16,12), matLam(0xf2efe6)); body.scale.y=0.85; g.add(body);
      const lid=new THREE.Mesh(new THREE.SphereGeometry(0.3,12,8,0,Math.PI*2,0,Math.PI/2), matLam(0xe0dccf)); lid.position.y=0.42; g.add(lid);
      const knob=new THREE.Mesh(new THREE.SphereGeometry(0.1,8,6), matLam(0xb5562e)); knob.position.y=0.6; g.add(knob);
      const spout=new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.13,0.6,10), matLam(0xf2efe6)); spout.position.set(0.55,0.1,0); spout.rotation.z=-1.0; g.add(spout);
      const handle=new THREE.Mesh(new THREE.TorusGeometry(0.22,0.05,8,14,Math.PI), matLam(0xe0dccf)); handle.position.set(-0.55,0.1,0); handle.rotation.z=Math.PI/2; g.add(handle);
      const flower=new THREE.Mesh(new THREE.SphereGeometry(0.08,8,6), matLam(0xff7eae)); flower.position.set(0,0.1,0.54); g.add(flower);
      radius=0.62; sfx='rock'; break;
    }
    case 'teacup': {
      const cup=new THREE.Mesh(new THREE.CylinderGeometry(0.5,0.34,0.5,16), matLam(0xf6f3ec)); g.add(cup);
      const inside=new THREE.Mesh(new THREE.CylinderGeometry(0.42,0.28,0.1,16), matLam(0x9c6a3a)); inside.position.y=0.2; g.add(inside);
      const saucer=new THREE.Mesh(new THREE.CylinderGeometry(0.7,0.7,0.08,18), matLam(0xeae6dc)); saucer.position.y=-0.28; g.add(saucer);
      const handle=new THREE.Mesh(new THREE.TorusGeometry(0.18,0.05,8,12), matLam(0xf6f3ec)); handle.position.set(0.55,0,0); g.add(handle);
      radius=0.6; sfx='rock'; break;
    }
    case 'cheese': {
      const wheel=new THREE.Mesh(new THREE.CylinderGeometry(0.7,0.7,0.55,20), matLam(0xf2c84b)); wheel.rotation.z=Math.PI/2; g.add(wheel);
      const rind=new THREE.Mesh(new THREE.TorusGeometry(0.7,0.06,8,20), matLam(0xd6a52e)); g.add(rind);
      for(let i=0;i<6;i++){ const hole=new THREE.Mesh(new THREE.SphereGeometry(0.07,6,5), matLam(0xe0b53e)); hole.position.set((Math.random()-0.5)*0.5,(Math.random()-0.5)*0.9,(Math.random()-0.5)*0.9); g.add(hole); }
      radius=0.72; break;
    }
    case 'drum': case 'oildrum': {
      const c = kind==='oildrum'?0x3f6b4a:0x4a5a6a;
      const body=new THREE.Mesh(new THREE.CylinderGeometry(0.6,0.6,1.3,18), matLam(c)); body.rotation.z=Math.PI/2; g.add(body);
      for(let i=-1;i<=1;i++){ const ring=new THREE.Mesh(new THREE.TorusGeometry(0.62,0.05,8,18), matLam(0x2a323c)); ring.position.x=i*0.45; g.add(ring); }
      if(kind==='oildrum'){ const mark=new THREE.Mesh(new THREE.BoxGeometry(0.4,0.4,0.02), matLam(0xe0b020)); mark.position.set(0,0,0.61); g.add(mark); }
      radius=0.8; break;
    }
    case 'sack': {
      const body=new THREE.Mesh(new THREE.SphereGeometry(0.62,12,10), matLam(0xc7a86a)); body.scale.set(1,1.15,1); g.add(body);
      const tie=new THREE.Mesh(new THREE.TorusGeometry(0.28,0.07,8,12), matLam(0x8a6a3a)); tie.position.y=0.45; tie.rotation.x=Math.PI/2; g.add(tie);
      const top=new THREE.Mesh(new THREE.ConeGeometry(0.28,0.3,8), matLam(0xb89a5a)); top.position.y=0.62; g.add(top);
      radius=0.7; break;
    }
    case 'melon': {
      const body=new THREE.Mesh(new THREE.SphereGeometry(0.68,16,14), matLam(0x4f9b3a)); g.add(body);
      for(let i=0;i<6;i++){ const st=new THREE.Mesh(new THREE.BoxGeometry(0.03,1.36,0.05), matLam(0x2f6b22)); st.rotation.y=i/6*Math.PI; g.add(st); }
      radius=0.68; sfx='rock'; break;
    }
    case 'stone': case 'rock': {
      const r=new THREE.Mesh(new THREE.DodecahedronGeometry(0.78), matLam(0x808890)); r.rotation.set(Math.random(),Math.random(),Math.random()); g.add(r);
      radius=0.78; sfx='rock'; break;
    }
    case 'cannonball': {
      const b=new THREE.Mesh(new THREE.SphereGeometry(0.55,16,14), matStd(0x20242c)); g.add(b);
      const hi=new THREE.Mesh(new THREE.SphereGeometry(0.2,8,6), matLam(0x3a3f48)); hi.position.set(-0.2,0.2,0.2); g.add(hi);
      radius=0.6; sfx='rock'; break;
    }
    case 'gear': {
      const teeth=12; const body=new THREE.Mesh(new THREE.CylinderGeometry(0.55,0.55,0.3,teeth*2), matLam(0xb98a3a)); body.rotation.x=Math.PI/2; g.add(body);
      for(let i=0;i<teeth;i++){ const t=new THREE.Mesh(new THREE.BoxGeometry(0.18,0.18,0.32), matLam(0xa87a2e)); const a=i/teeth*Math.PI*2; t.position.set(Math.cos(a)*0.62,Math.sin(a)*0.62,0); t.rotation.z=a; g.add(t); }
      const hub=new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.36,12), matLam(0x6a4a1e)); hub.rotation.x=Math.PI/2; g.add(hub);
      radius=0.72; sfx='rock'; break;
    }
    case 'clockweight': {
      const body=new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.4,1.1,16), matStd(0xcaa84a)); g.add(body);
      const cap=new THREE.Mesh(new THREE.SphereGeometry(0.4,14,8,0,Math.PI*2,0,Math.PI/2), matLam(0xe0c468)); cap.position.y=0.55; g.add(cap);
      const bot=cap.clone(); bot.position.y=-0.55; bot.rotation.x=Math.PI; g.add(bot);
      radius=0.62; sfx='rock'; break;
    }
    case 'crate': {
      const box=new THREE.Mesh(new THREE.BoxGeometry(1.1,1.1,1.1), matLam(0xb07a3e)); g.add(box);
      const ed=new THREE.Mesh(new THREE.BoxGeometry(1.14,1.14,1.14), new THREE.MeshBasicMaterial({color:0x6a4a24, wireframe:true})); g.add(ed);
      const plank=new THREE.Mesh(new THREE.BoxGeometry(1.12,0.16,1.12), matLam(0x97672e)); g.add(plank);
      radius=0.78; break;
    }
    case 'orb': {
      const orb=new THREE.Mesh(new THREE.SphereGeometry(0.6,18,14), matStd(0xffe14d,0xffd24d,0.55)); g.add(orb);
      const ring=new THREE.Mesh(new THREE.TorusGeometry(0.78,0.05,8,24), new THREE.MeshBasicMaterial({color:0xffffff})); ring.rotation.x=Math.PI/2.3; g.add(ring);
      g.userData.spin=ring; radius=0.78; sfx='rock'; break;
    }
    case 'core': {
      const core=new THREE.Mesh(new THREE.IcosahedronGeometry(0.58,0), matStd(0xff36d0,0xff36d0,0.6)); g.add(core);
      const cage=new THREE.Mesh(new THREE.IcosahedronGeometry(0.76,0), new THREE.MeshBasicMaterial({color:0x66ffff, wireframe:true})); g.add(cage);
      g.userData.spin=cage; radius=0.76; sfx='rock'; break;
    }
    default: {
      const r=new THREE.Mesh(new THREE.DodecahedronGeometry(0.78), matLam(0x808890)); g.add(r); radius=0.78; sfx='rock';
    }
  }
  g.traverse(o=>{ if(o.isMesh) o.castShadow=true; });
  return { g, radius, sfx, kind };
}

/* =====================================================================
   SWEETS  (many cute variants — all heal)
   ===================================================================== */
let SWEET_TEX = null, ARTI_TEX = null;
(function tryItemTex(){
  const L=new THREE.TextureLoader();
  L.load('Sweets/sweet.png',(t)=>{SWEET_TEX=t;},undefined,()=>{});
  L.load('Artifact/artifact.png',(t)=>{ARTI_TEX=t;},undefined,()=>{});
})();

const SWEET_KINDS=['cake','donut','macaron','cupcake','lollipop','candycane','cookie'];
function createSweet(kind){
  const g=new THREE.Group();
  if(SWEET_TEX){
    const s=new THREE.Mesh(new THREE.PlaneGeometry(1.4,1.4), new THREE.MeshBasicMaterial({map:SWEET_TEX, transparent:true}));
    g.add(s); g.userData.billboard=true; g.scale.set(1.4,1.4,1.4);
    g.traverse(o=>{ if(o.isMesh) o.castShadow=true; }); return g;
  }
  kind = kind || SWEET_KINDS[Math.floor(Math.random()*SWEET_KINDS.length)];
  switch(kind){
    case 'donut': {
      const ring=new THREE.Mesh(new THREE.TorusGeometry(0.4,0.2,12,20), matLam(0xc77a3a)); ring.rotation.x=Math.PI/2; g.add(ring);
      const ice=new THREE.Mesh(new THREE.TorusGeometry(0.4,0.21,12,20,Math.PI*2), matLam(0xff7eae)); ice.rotation.x=Math.PI/2; ice.position.y=0.06; ice.scale.set(1,1,0.7); g.add(ice);
      for(let i=0;i<8;i++){ const sp=new THREE.Mesh(new THREE.BoxGeometry(0.06,0.04,0.12), matLam([0xffffff,0x6ad0ff,0xffe14d][i%3])); const a=i/8*Math.PI*2; sp.position.set(Math.cos(a)*0.4,0.18,Math.sin(a)*0.4); sp.rotation.y=a; g.add(sp); }
      break;
    }
    case 'macaron': {
      const top=new THREE.Mesh(new THREE.SphereGeometry(0.45,16,8,0,Math.PI*2,0,Math.PI/2), matLam(0xa6e0a0)); top.position.y=0.12; g.add(top);
      const bot=top.clone(); bot.position.y=-0.12; bot.rotation.x=Math.PI; g.add(bot);
      const cream=new THREE.Mesh(new THREE.CylinderGeometry(0.42,0.42,0.16,16), matLam(0xfff0d0)); g.add(cream);
      break;
    }
    case 'cupcake': {
      const cup=new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.3,0.45,14), matLam(0xd98a4a)); cup.position.y=-0.1; g.add(cup);
      const cream=new THREE.Mesh(new THREE.ConeGeometry(0.45,0.6,14), matLam(0xff9ec4)); cream.position.y=0.4; g.add(cream);
      const cherry=new THREE.Mesh(new THREE.SphereGeometry(0.12,10,8), matLam(0xd0153b)); cherry.position.y=0.75; g.add(cherry);
      break;
    }
    case 'lollipop': {
      const disc=new THREE.Mesh(new THREE.CylinderGeometry(0.45,0.45,0.12,20), matLam(0xff5e8a)); disc.rotation.x=Math.PI/2; disc.position.y=0.5; g.add(disc);
      const swirl=new THREE.Mesh(new THREE.TorusGeometry(0.22,0.06,8,16), matLam(0xffffff)); swirl.position.y=0.5; g.add(swirl);
      const stick=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.05,0.7,8), matLam(0xffffff)); stick.position.y=0.05; g.add(stick);
      break;
    }
    case 'candycane': {
      const cane=new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.1,1.0,10), matLam(0xffffff)); cane.position.y=0; g.add(cane);
      const hook=new THREE.Mesh(new THREE.TorusGeometry(0.18,0.1,8,12,Math.PI), matLam(0xff3b5c)); hook.position.y=0.5; hook.rotation.z=Math.PI; g.add(hook);
      for(let i=0;i<5;i++){ const stripe=new THREE.Mesh(new THREE.CylinderGeometry(0.105,0.105,0.1,10), matLam(0xff3b5c)); stripe.position.y=-0.4+i*0.2; g.add(stripe); }
      break;
    }
    case 'cookie': {
      const base=new THREE.Mesh(new THREE.CylinderGeometry(0.55,0.55,0.18,18), matLam(0xc79a5a)); g.add(base);
      for(let i=0;i<6;i++){ const chip=new THREE.Mesh(new THREE.SphereGeometry(0.09,8,6), matLam(0x5a3a22)); chip.position.set((Math.random()-0.5)*0.7,0.1,(Math.random()-0.5)*0.7); g.add(chip); }
      break;
    }
    default: { // cake (scone+cream+berry)
      const sponge=new THREE.Mesh(new THREE.CylinderGeometry(0.55,0.55,0.5,18,1,false,0,Math.PI/2.2), matLam(0xfff0d8)); sponge.position.y=0.25; g.add(sponge);
      const cream=new THREE.Mesh(new THREE.CylinderGeometry(0.57,0.57,0.16,18,1,false,0,Math.PI/2.2), matLam(0xffffff)); cream.position.y=0.4; g.add(cream);
      const berry=new THREE.Mesh(new THREE.SphereGeometry(0.14,10,8), matLam(0xff3b5c)); berry.position.set(0.18,0.56,0.18); g.add(berry);
      const cherry=new THREE.Mesh(new THREE.SphereGeometry(0.1,8,6), matLam(0xc0153b)); cherry.position.set(-0.1,0.58,-0.05); g.add(cherry);
    }
  }
  g.scale.set(1.3,1.3,1.3);
  g.traverse(o=>{ if(o.isMesh) o.castShadow=true; });
  return g;
}

/* =====================================================================
   ARTIFACT  (real / fake)
   ===================================================================== */
function createArtifact(real){
  const g=new THREE.Group();
  if(ARTI_TEX){
    const s=new THREE.Mesh(new THREE.PlaneGeometry(1.4,1.4), new THREE.MeshBasicMaterial({map:ARTI_TEX, transparent:true}));
    g.add(s); g.userData.billboard=true;
  } else {
    const col = real?0xffce4d:0x9fb6d6;
    const gem=new THREE.Mesh(new THREE.OctahedronGeometry(0.5,0), matStd(col,col,real?0.55:0.22)); g.add(gem); g.userData.gem=gem;
    const base=new THREE.Mesh(new THREE.CylinderGeometry(0.34,0.42,0.18,12), matLam(real?0xe0a72e:0x6c7c92)); base.position.y=-0.55; g.add(base);
    const ring=new THREE.Mesh(new THREE.TorusGeometry(0.62,0.04,8,24), new THREE.MeshBasicMaterial({color: real?0xfff3c4:0xcfe0f5})); ring.rotation.x=Math.PI/2.2; g.add(ring); g.userData.ring=ring;
    // little silver-coin nod
    const coin=new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.05,16), matLam(real?0xfff0b0:0xb8c4d2)); coin.position.y=0.0; coin.rotation.x=Math.PI/2; g.add(coin);
  }
  const halo=new THREE.Mesh(new THREE.SphereGeometry(0.9,12,10), new THREE.MeshBasicMaterial({color: real?0xffe9a8:0xcfe0f5, transparent:true, opacity:0.16}));
  g.add(halo);
  g.scale.set(1.2,1.2,1.2);
  g.traverse(o=>{ if(o.isMesh && o.geometry.type!=='SphereGeometry') o.castShadow=true; });
  return g;
}

/* recolor an artifact mesh to the "fake" look (used when an enemy drops a stolen relic) */
function makeArtifactFake(mesh){
  mesh.traverse(o=>{ if(o.isMesh && o.material){
    if(o.material.color) o.material.color.setHex(0x9fb6d6);
    if(o.material.emissive){ o.material.emissive.setHex(0x9fb6d6); o.material.emissiveIntensity=0.22; }
  }});
}

props.js

/* =====================================================================
   props.js — ステージ毎の背景・建物モデリング
   models.js の matLam / matStd を使用。world.js から createDeco() を呼びます。
   ここは「侵入不能エリア(飾り)」専用。フィールド外側に置かれます。
   ===================================================================== */
function createDeco(kind, x, z, h, accent){
  const A = accent;
  const g=new THREE.Group(); g.position.set(x,h,z);
  const put=(mesh)=>{ mesh.traverse(o=>{if(o.isMesh)o.castShadow=true;}); g.add(mesh); };
  const R=()=>Math.random();
  // 建物の土台プレート(構造物が地面に“建っている”ように見せる)
  const base=(w,d,col)=>{ const p=new THREE.Mesh(new THREE.BoxGeometry(w,0.4,d),matLam(col)); p.position.y=0.2; put(p); };
  const winFrame=(px,py,pz,col)=>{ const f=new THREE.Mesh(new THREE.BoxGeometry(0.6,0.8,0.06),matLam(col||0x3a2a22)); f.position.set(px,py,pz); put(f);
    const gl=new THREE.Mesh(new THREE.BoxGeometry(0.46,0.64,0.04),new THREE.MeshBasicMaterial({color:0xffe7a8})); gl.position.set(px,py,pz+0.02); put(gl); };

  switch(kind){
    case 'market': { // 蚤の市の屋台+木箱+看板
      if(R()<0.5){
        base(2.8,1.8,0x5a3f24);
        const post1=new THREE.Mesh(new THREE.BoxGeometry(0.12,2.4,0.12),matLam(0x6a4a2a)); post1.position.set(-1,1.2,0); put(post1);
        const post2=post1.clone(); post2.position.x=1; put(post2);
        const canopy=new THREE.Mesh(new THREE.BoxGeometry(2.6,0.2,1.6),matLam(R()<0.5?0xc7402e:0x2e6ac7)); canopy.position.y=2.4; put(canopy);
        // ストライプ屋根
        for(let i=-2;i<=2;i++){ const st=new THREE.Mesh(new THREE.BoxGeometry(0.3,0.22,1.62),matLam(0xf2ead8)); st.position.set(i*0.5,2.41,0); if(i%2)put(st); }
        const table=new THREE.Mesh(new THREE.BoxGeometry(2.4,0.15,1.2),matLam(0x8a6a3a)); table.position.y=1; put(table);
        for(let i=0;i<3;i++){ const j=new THREE.Mesh(new THREE.BoxGeometry(0.3,0.3,0.3),matLam([0xb5562e,0xc9d4e0,0xffce4d][i])); j.position.set(-0.7+i*0.7,1.25,0); put(j);}
        const sign=new THREE.Mesh(new THREE.BoxGeometry(1.4,0.4,0.06),matLam(0x3a2a1a)); sign.position.set(0,2.0,0.82); put(sign);
      } else {
        base(1.6,1.6,0x5a3f24);
        const c=new THREE.Mesh(new THREE.BoxGeometry(1,1,1),matLam(0xb07a3e)); c.position.y=0.9; put(c);
        const c2=new THREE.Mesh(new THREE.BoxGeometry(0.8,0.8,0.8),matLam(0x97672e)); c2.position.set(0.3,1.8,0.2); put(c2);
      }
      break; }
    case 'victorian': { // レンガの家+窓枠+ドア+煙突
      const h2=4+R()*3; base(3.0,3.0,0x3a2a22);
      const b=new THREE.Mesh(new THREE.BoxGeometry(2.6,h2,2.6),matLam(0x7a4a3a)); b.position.y=h2/2+0.3; put(b);
      for(let i=0;i<3;i++)for(let j=0;j<2;j++){ winFrame(-0.7+j*1.4,1.2+i*1.2,1.32); }
      const door=new THREE.Mesh(new THREE.BoxGeometry(0.7,1.3,0.1),matLam(0x3a241a)); door.position.set(0,0.95,1.33); put(door);
      const knob=new THREE.Mesh(new THREE.SphereGeometry(0.06,8,6),matLam(0xffce4d)); knob.position.set(0.22,1.0,1.4); put(knob);
      const roof=new THREE.Mesh(new THREE.BoxGeometry(2.8,0.4,2.8),matLam(0x4a3a32)); roof.position.y=h2+0.3; put(roof);
      const chim=new THREE.Mesh(new THREE.BoxGeometry(0.4,1,0.4),matLam(0x5a3a32)); chim.position.set(0.7,h2+0.8,0); put(chim);
      break; }
    case 'jazz': { // 隠れ酒場:ステージ・ネオン・柱
      base(1.6,1.6,0x2a1f30);
      const pil=new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.4,4.5,12),matLam(0x47324f)); pil.position.y=2.55; put(pil);
      const neon=new THREE.Mesh(new THREE.TorusGeometry(0.7,0.08,8,20),new THREE.MeshBasicMaterial({color:A})); neon.position.y=3.9; neon.rotation.x=Math.PI/2; put(neon);
      const note=new THREE.Mesh(new THREE.SphereGeometry(0.2,8,6),new THREE.MeshBasicMaterial({color:A})); note.position.y=4.7; put(note);
      const bar=new THREE.Mesh(new THREE.BoxGeometry(1.2,0.1,0.5),matLam(0x6a4a2a)); bar.position.y=4.7; put(bar);
      break; }
    case 'egypt': { // ピラミッド・オベリスク・列柱(屋外=砂地のまま)
      const r=R();
      if(r<0.34){ const py=new THREE.Mesh(new THREE.ConeGeometry(4,5,4),matLam(0xd9b873)); py.rotation.y=Math.PI/4; py.position.y=2.5; put(py);
        for(let i=1;i<5;i++){ const step=new THREE.Mesh(new THREE.BoxGeometry(8-i*1.6,0.3,8-i*1.6),matLam(0xc9a85f)); step.rotation.y=Math.PI/4; step.position.y=i*1.0-0.4; put(step);} }
      else if(r<0.67){ const ob=new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.5,6,4),matLam(0xc8a85f)); ob.position.y=3; put(ob);
        const cap=new THREE.Mesh(new THREE.ConeGeometry(0.4,0.8,4),matLam(0xffce4d)); cap.position.y=6.2; put(cap); }
      else { const col=new THREE.Mesh(new THREE.CylinderGeometry(0.6,0.7,5,12),matLam(0xe0c88a)); col.position.y=2.5; put(col);
        const cap=new THREE.Mesh(new THREE.CylinderGeometry(0.9,0.6,0.6,12),matLam(0xd0b06a)); cap.position.y=5.2; put(cap); }
      break; }
    case 'bazaar': { // テント・絨毯・ランタン・椰子(夜)
      if(R()<0.5){ const tent=new THREE.Mesh(new THREE.ConeGeometry(1.8,2.2,8),matLam([0xc7402e,0x2e6ac7,0x37c9c3][Math.floor(R()*3)])); tent.position.y=1.6; put(tent);
        const pole=new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.06,3,6),matLam(0x6a4a2a)); pole.position.y=1.5; put(pole);
        const rug=new THREE.Mesh(new THREE.BoxGeometry(2.2,0.06,1.4),matLam([0x9b2d2d,0x2d6b9b,0x9b7d2d][Math.floor(R()*3)])); rug.position.y=0.05; put(rug); }
      else { const trunk=new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.22,3.2,8),matLam(0x6a4a2a)); trunk.position.y=1.6; put(trunk);
        for(let i=0;i<6;i++){ const leaf=new THREE.Mesh(new THREE.BoxGeometry(0.1,1.4,0.4),matLam(0x3a8a4a)); const a=i/6*Math.PI*2; leaf.position.set(Math.cos(a)*0.6,3.3,Math.sin(a)*0.6); leaf.rotation.z=Math.cos(a)*0.7; leaf.rotation.y=a; put(leaf);} }
      const lant=new THREE.Mesh(new THREE.SphereGeometry(0.2,8,6),new THREE.MeshBasicMaterial({color:0xffb14d})); lant.position.set(0.6,2.6,0); put(lant);
      break; }
    case 'factory': { // 冷戦期の工場:機械・配管・壁・金網フェンス
      base(2.4,2.0,0x33373d);
      const m=new THREE.Mesh(new THREE.BoxGeometry(2,3,1.6),matLam(0x4a4f57)); m.position.y=1.8; put(m);
      const pipe=new THREE.Mesh(new THREE.CylinderGeometry(0.2,0.2,4,10),matLam(0x6a6f77)); pipe.position.set(0.8,2.3,0.6); put(pipe);
      const valve=new THREE.Mesh(new THREE.TorusGeometry(0.3,0.08,8,12),matLam(A)); valve.position.set(0.8,3.9,0.6); valve.rotation.x=Math.PI/2; put(valve);
      const stack=new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.5,3,10),matLam(0x3a3f47)); stack.position.set(-0.7,3.3,0); put(stack);
      for(let i=-2;i<=2;i++){ const postf=new THREE.Mesh(new THREE.BoxGeometry(0.06,1.4,0.06),matLam(0x6a6f77)); postf.position.set(i*0.5,1.0,1.0); put(postf);} 
      break; }
    case 'venice': { // 運河沿いの建物・アーチ・ゴンドラ杭・水面
      const h2=3.5+R()*2; base(2.8,2.8,0x5a4a3a);
      const b=new THREE.Mesh(new THREE.BoxGeometry(2.4,h2,2.4),matLam([0xc98a6a,0xd6a86a,0xb0705a][Math.floor(R()*3)])); b.position.y=h2/2+0.3; put(b);
      for(let i=0;i<2;i++)for(let j=0;j<2;j++){ winFrame(-0.6+j*1.2,1.4+i*1.3,1.22,0xffffff); }
      const arch=new THREE.Mesh(new THREE.TorusGeometry(0.6,0.16,8,12,Math.PI),matLam(0xe0d0b0)); arch.position.set(0,1.2,1.22); put(arch);
      const post=new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.12,2,8),matLam(0x8a4a3a)); post.position.set(1.4,1,1.4); put(post);
      const stripe=new THREE.Mesh(new THREE.CylinderGeometry(0.11,0.11,0.3,8),matLam(0xffffff)); stripe.position.set(1.4,1.6,1.4); put(stripe);
      const water=new THREE.Mesh(new THREE.BoxGeometry(3.2,0.1,2.0),new THREE.MeshLambertMaterial({color:0x2f7d9b,transparent:true,opacity:0.7})); water.position.set(0,-0.1,2.2); put(water);
      break; }
    case 'pirate': { // 帆船:船体・マスト・帆・大砲・宝箱
      base(3.0,2.0,0x4a3320);
      const hull=new THREE.Mesh(new THREE.BoxGeometry(2.6,1.0,1.6),matLam(0x6a4326)); hull.position.y=0.9; put(hull);
      const mast=new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.22,7,8),matLam(0x6a4a2a)); mast.position.y=4.0; put(mast);
      const sail=new THREE.Mesh(new THREE.PlaneGeometry(3,3),new THREE.MeshLambertMaterial({color:0xeae0cf, side:THREE.DoubleSide})); sail.position.y=4.5; put(sail);
      const flag=new THREE.Mesh(new THREE.PlaneGeometry(0.9,0.6),new THREE.MeshBasicMaterial({color:0x101014, side:THREE.DoubleSide})); flag.position.set(0.5,7.1,0); put(flag);
      const skull=new THREE.Mesh(new THREE.SphereGeometry(0.12,8,6),new THREE.MeshBasicMaterial({color:0xffffff})); skull.position.set(0.5,7.1,0.02); put(skull);
      const chest=new THREE.Mesh(new THREE.BoxGeometry(0.7,0.4,0.45),matLam(0x7a4a22)); chest.position.set(-1.0,1.6,0.4); put(chest);
      break; }
    case 'paris': { // ベル・エポック:ガス灯・オペラ座の柱・大理石
      base(1.4,1.4,0x6a6270);
      const post=new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.18,4,8),matLam(0x2a2a30)); post.position.y=2.2; put(post);
      const lamp=new THREE.Mesh(new THREE.SphereGeometry(0.3,10,8),new THREE.MeshBasicMaterial({color:0xffe7a8})); lamp.position.y=4.3; put(lamp);
      const arm=new THREE.Mesh(new THREE.BoxGeometry(1,0.08,0.08),matLam(0x2a2a30)); arm.position.y=3.8; put(arm);
      if(R()<0.5){ base(1.2,1.2,0xcfc6b0); const col=new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.45,5,14),matLam(0xe6dcc4)); col.position.set(1.6,2.8,0); put(col);
        // 縦溝
        for(let i=0;i<8;i++){ const fl=new THREE.Mesh(new THREE.BoxGeometry(0.05,5,0.05),matLam(0xd6ccb4)); const a=i/8*Math.PI*2; fl.position.set(1.6+Math.cos(a)*0.42,2.8,Math.sin(a)*0.42); put(fl);} 
        const cap=new THREE.Mesh(new THREE.BoxGeometry(1,0.4,1),matLam(A)); cap.position.set(1.6,5.4,0); put(cap);}
      break; }
    case 'rift': { // 時代が交差:石柱+未来ネオン+浮遊歯車
      const col=new THREE.Mesh(new THREE.CylinderGeometry(0.45,0.5,4.5,12),matLam(0xc8b89a)); col.position.set(-0.8,2.25,0); col.material.transparent=true; col.material.opacity=0.7; put(col);
      const tower=new THREE.Mesh(new THREE.BoxGeometry(1.4,6,1.4),matLam(0x2a2050)); tower.position.set(0.9,3,0); tower.material.transparent=true; tower.material.opacity=0.7; put(tower);
      const neon=new THREE.Mesh(new THREE.BoxGeometry(1.45,6,0.08),new THREE.MeshBasicMaterial({color:A,transparent:true,opacity:0.8})); neon.position.set(0.9,3,0.75); put(neon);
      const gear=new THREE.Mesh(new THREE.TorusGeometry(1.1,0.3,6,10),matLam(0x6a5aa0)); gear.position.y=5+R()*3; put(gear); g.userData.float=true; g.userData.spin=gear;
      break; }
    case 'clockwork': { // 巨大歯車+振り子
      const gear=new THREE.Mesh(new THREE.TorusGeometry(1.6,0.4,6,14),matLam(0xb98a3a)); gear.position.y=3+R()*3; put(gear); g.userData.spin=gear;
      const spoke=new THREE.Mesh(new THREE.BoxGeometry(3,0.3,0.3),matLam(0xa87a2e)); spoke.position.copy(gear.position); put(spoke);
      const spoke2=new THREE.Mesh(new THREE.BoxGeometry(0.3,3,0.3),matLam(0xa87a2e)); spoke2.position.copy(gear.position); put(spoke2);
      const pend=new THREE.Mesh(new THREE.CylinderGeometry(0.08,0.08,3.5,8),matLam(0x8a6a2e)); pend.position.set(0,2.5,0); put(pend);
      const bob=new THREE.Mesh(new THREE.CylinderGeometry(0.5,0.5,0.2,16),matStd(0xe0c468,0xe0c468,0.2)); bob.position.set(0,0.8,0); bob.rotation.x=Math.PI/2; put(bob);
      break; }
    case 'singularity': { // 特異点ジェネレーター+エネルギー環
      const gen=new THREE.Mesh(new THREE.IcosahedronGeometry(1.2,0),matStd(0xb14dff,0xb14dff,0.6)); gen.position.y=3; put(gen); g.userData.spin=gen;
      const cage=new THREE.Mesh(new THREE.IcosahedronGeometry(1.6,0),new THREE.MeshBasicMaterial({color:0xffce4d,wireframe:true})); cage.position.y=3; put(cage);
      for(let i=0;i<3;i++){ const ring=new THREE.Mesh(new THREE.TorusGeometry(1.4+i*0.3,0.06,8,28),new THREE.MeshBasicMaterial({color:0xffce4d,transparent:true,opacity:0.5})); ring.position.y=3; ring.rotation.x=R()*Math.PI; ring.rotation.y=R()*Math.PI; put(ring); }
      g.userData.float=true;
      break; }
  }
  return g;
}

roll.js

/* =====================================================================
   roll.js — 転がり物理と連鎖パス予測(重要)+ 空間分割最適化
   ===================================================================== */

function rollSpeed(b){ return Math.hypot(b.vx,b.vz); }

function simStep(bodies, enemies, dt){
  const ev={started:[], kills:[]};
  
  // 1) 積分(坂の加速+摩擦)
  for(const b of bodies){
    if(!b.rolling) continue;
    const g=groundGradient(b.x,b.z);
    b.vx += -g.gx*CFG.ROLL_SLOPE*dt;
    b.vz += -g.gz*CFG.ROLL_SLOPE*dt;
    const damp=Math.max(0,1-CFG.ROLL_FRICTION*dt);
    b.vx*=damp; b.vz*=damp;
    b.x += b.vx*dt; b.z += b.vz*dt;
    if(rollSpeed(b) < CFG.ROLL_STOP){ b.rolling=false; b.vx=0; b.vz=0; }
  }

  // ★ 空間ハッシュ(グリッド)の構築(O(N^2)の総当たり負荷を軽減)
  const cellSize = 4.0;
  const grid = new Map();
  for(const b of bodies){
    const hash = Math.floor(b.x/cellSize) + ',' + Math.floor(b.z/cellSize);
    if(!grid.has(hash)) grid.set(hash, []);
    grid.get(hash).push(b);
  }

  // 2) 物どうしの衝突(空間分割を利用して近傍のセルのみチェック)
  for(const b of bodies){
    if(!b.rolling) continue;
    const bx = Math.floor(b.x/cellSize);
    const bz = Math.floor(b.z/cellSize);

    // 自分の周囲3x3のセルだけを走査
    for(let cx = bx-1; cx <= bx+1; cx++){
      for(let cz = bz-1; cz <= bz+1; cz++){
        const cell = grid.get(cx + ',' + cz);
        if(!cell) continue;
        
        for(const o of cell){
          if(o===b) continue;
          const dx=o.x-b.x, dz=o.z-b.z, dd=Math.hypot(dx,dz), rr=b.radius+o.radius;
          if(dd<rr && dd>1e-4){
            const nx=dx/dd, nz=dz/dd;
            const sp=rollSpeed(b);
            const oProj=o.vx*nx+o.vz*nz;
            if(oProj < sp*0.6){
              const give=sp*CFG.ROLL_TRANSFER;
              o.vx=nx*give; o.vz=nz*give;
              if(!o.rolling){ o.rolling=true; ev.started.push(o); } else o.rolling=true;
            }
            b.vx*=0.16; b.vz*=0.16;
            const push=(rr-dd);
            b.x-=nx*push; b.z-=nz*push;
            if(rollSpeed(b)<CFG.ROLL_STOP){ b.rolling=false; b.vx=0; b.vz=0; }
          }
        }
      }
    }
  }

  // 3) 敵に当たる(敵は数が少ないため通常ループで十分高速)
  for(const b of bodies){
    if(!b.rolling) continue;
    for(const e of enemies){
      if(e.dead) continue;
      const dd=Math.hypot(e.x-b.x, e.z-b.z);
      if(dd < b.radius + 0.75){
        if(e.hp === undefined) e.hp = 3;
        e.hp--;
        if(e.hp <= 0) e.dead=true;
        ev.kills.push({enemy:e, vx:b.vx, vz:b.vz});
        b.vx*=0.82; b.vz*=0.82;
      }
    }
  }
  return ev;
}

function updateRollables(dt){
  const eview=G.enemies.map(e=>({x:e.pos.x, z:e.pos.z, dead:e.isDead, hp:e.hp!==undefined?e.hp:3, ref:e}));
  const ev=simStep(G.rollables, eview, dt);
  ev.started.forEach(b=> playSfx(b.type||'barrel'));
  ev.kills.forEach(k=>{ if(k.enemy.ref && !k.enemy.ref.isDead) killEnemy(k.enemy.ref, new THREE.Vector3(k.vx,0,k.vz)); });
  const player=G.players[0];
  G.rollables.forEach(o=>{
    if(o.spin) o.spin.rotation.y+=dt*2;
    o.y=getHeight(o.x,o.z)+o.radius;
    o.mesh.position.set(o.x,o.y,o.z);
    if(o.rolling){
      o.mesh.rotation.x -= o.vz*dt*0.7;
      o.mesh.rotation.z += o.vx*dt*0.7;
    }
    if(player && (o.z > player.pos.z+70)) { scene.remove(o.mesh); o._dead=true; }
  });
  G.rollables=G.rollables.filter(o=>!o._dead);
}

function simulateRoll(leadIndex, vx0, vz0){
  const bodies=G.rollables.map((r,i)=>({x:r.x,z:r.z,vx:0,vz:0,rolling:false,radius:r.radius,_i:i}));
  if(!bodies[leadIndex]) return {trails:[],kills:[]};
  bodies[leadIndex].vx=vx0; bodies[leadIndex].vz=vz0; bodies[leadIndex].rolling=true;
  const enemies=G.enemies.filter(e=>!e.isDead).map(e=>({x:e.pos.x,z:e.pos.z,dead:false,hp:e.hp!==undefined?e.hp:3}));
  const trails={}; const kills=[];
  trails[leadIndex]=[{x:bodies[leadIndex].x,z:bodies[leadIndex].z}];
  const dt=1/60; let steps=0;
  while(steps<260){
    const ev=simStep(bodies, enemies, dt);
    ev.kills.forEach(k=>kills.push({x:k.enemy.x,z:k.enemy.z}));
    for(const b of bodies){
      if(b.rolling){ if(!trails[b._i]) trails[b._i]=[{x:b.x,z:b.z}]; (steps%3===0)&&trails[b._i].push({x:b.x,z:b.z}); }
    }
    steps++;
    if(!bodies.some(b=>b.rolling)) break;
  }
  for(const b of bodies){ if(trails[b._i]){ const t=trails[b._i]; const last=t[t.length-1]; if(last.x!==b.x||last.z!==b.z) t.push({x:b.x,z:b.z}); } }
  const out=[];
  for(const i in trails){ if(trails[i].length>=2) out.push({lead: (+i===leadIndex), pts:trails[i]}); }
  return {trails:out, kills};
}

function dashArrow(pts, color, isThick, isDotted, rollableKind){
  const grp=new THREE.Group();
  const mat=new THREE.MeshBasicMaterial({color, transparent:true, opacity:0.95});
  const yOf=(x,z)=>getHeight(x,z)+0.7;
  
  const dash = isDotted ? 0.2 : 0.6;
  const gap = isDotted ? 0.5 : 0.45;
  const thickness = isThick ? 0.85 : 0.42; 
  
  for(let i=0;i<pts.length-1;i++){
    const a=new THREE.Vector3(pts[i].x, yOf(pts[i].x,pts[i].z), pts[i].z);
    const b=new THREE.Vector3(pts[i+1].x, yOf(pts[i+1].x,pts[i+1].z), pts[i+1].z);
    const seg=b.clone().sub(a); const len=seg.length(); if(len<1e-3) continue;
    const dirn=seg.clone().normalize();
    const step=dash+gap; const count=Math.max(1,Math.floor(len/step));
    for(let d=0; d<count; d++){
      const t=d*step+dash/2; if(t>len) break;
      const p=a.clone().add(dirn.clone().multiplyScalar(t));
      const m=new THREE.Mesh(GEO.ground, mat); m.scale.set(thickness,0.1,dash);
      m.position.copy(p); m.lookAt(p.clone().add(dirn));
      grp.add(m);
    }
  }
  if(pts.length>=2){
    const n=pts.length;
    const last=new THREE.Vector3(pts[n-1].x, yOf(pts[n-1].x,pts[n-1].z), pts[n-1].z);
    const prev=new THREE.Vector3(pts[n-2].x, yOf(pts[n-2].x,pts[n-2].z), pts[n-2].z);
    const dirn=last.clone().sub(prev).normalize();
    const headSize = isThick ? 0.8 : 0.5;
    const headLen = isThick ? 1.4 : 1.0;
    const head=new THREE.Mesh(new THREE.ConeGeometry(headSize,headLen,4), mat.clone());
    head.position.copy(last.clone().add(dirn.clone().multiplyScalar(headLen*0.3)));
    head.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dirn);
    grp.add(head);
  }

  // 幽霊残像
  if(rollableKind && pts.length >= 2){
    const ghostCount = Math.min(3, Math.floor(pts.length / 6) + 1);
    for(let i=0; i<ghostCount; i++){
      let pIdx = Math.floor((pts.length - 1) * ((i + 1) / (ghostCount + 1)));
      if(i === ghostCount - 1) pIdx = pts.length - 1; 
      if(pIdx < 0 || pIdx >= pts.length) continue;
      
      const pt = pts[pIdx];
      const rollData = createRollable(rollableKind);
      const ghost = rollData.g;
      
      ghost.traverse(o => {
        if(o.isMesh && o.material){
          o.material = o.material.clone();
          o.material.transparent = true;
          o.material.opacity = 0.15; 
          o.castShadow = false;
          o.receiveShadow = false;
        }
      });
      ghost.position.set(pt.x, getHeight(pt.x, pt.z) + rollData.radius, pt.z);
      if(pIdx > 0){
        ghost.lookAt(new THREE.Vector3(pts[pIdx-1].x, ghost.position.y, pts[pIdx-1].z));
      }
      grp.add(ghost);
    }
  }
  return grp;
}

function killMarker(x,z){
  const grp=new THREE.Group();
  const mat=new THREE.MeshBasicMaterial({color:0xff4b6b});
  for(const r of [0.4,-0.4]){
    const bar=new THREE.Mesh(GEO.ground, mat); bar.scale.set(0.12,0.1,1.0);
    bar.position.set(x, getHeight(x,z)+0.75, z); bar.rotation.y=r;
    grp.add(bar);
  }
  return grp;
}

function clearPaths(){ G.paths.forEach(p=>scene.remove(p)); G.paths.length=0; }

function shootArrow(from, to, color){
  const grp=new THREE.Group();
  const mat=new THREE.MeshBasicMaterial({color, transparent:true, opacity:0.95});
  const a=from.clone(), b=to.clone();
  const seg=b.clone().sub(a); const len=seg.length(); if(len<1e-3) return grp;
  const dirn=seg.clone().normalize();
  const dash=0.55, gap=0.4, step=dash+gap; const count=Math.max(1,Math.floor(len/step));
  for(let d=0; d<count; d++){
    const t=d*step+dash/2; if(t>len) break;
    const p=a.clone().add(dirn.clone().multiplyScalar(t));
    const m=new THREE.Mesh(GEO.ground, mat); m.scale.set(0.16,0.16,dash);
    m.position.copy(p); m.lookAt(p.clone().add(dirn));
    grp.add(m);
  }
  const head=new THREE.Mesh(new THREE.ConeGeometry(0.34,0.8,4), mat.clone());
  head.position.copy(b.clone().add(dirn.clone().multiplyScalar(0.2)));
  head.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), dirn);
  grp.add(head);
  return grp;
}

function theoAimEnemy(player, facing){
  const theo=getTheo();
  if(!theo || theo.hp<=0) return null;
  const dx=theo.pos.x-player.pos.x, dz=theo.pos.z-player.pos.z, d=Math.hypot(dx,dz)||1;
  if(d>16) return null;
  if((dx*facing.x+dz*facing.z)/d < 0.5) return null;
  let best=null,bd=1e9;
  for(const e of G.enemies){ if(e.isDead) continue; const ed=Math.hypot(e.pos.x-theo.pos.x,e.pos.z-theo.pos.z); if(ed<28 && ed<bd){bd=ed; best=e;} }
  if(!best) return null;
  return {theo, enemy:best};
}

function theoFireAt(theo, enemy){
  if(!theo || (theo.charge||0) < 1) return false;
  theo.charge = 0;
  theo.char.parts.root.rotation.y=Math.atan2(enemy.pos.x-theo.pos.x, enemy.pos.z-theo.pos.z);
  lockAnim(theo.char,'shoot',0.45);
  const from=theo.pos.clone().add(new THREE.Vector3(0,1.4,0));
  const to=enemy.pos.clone().add(new THREE.Vector3(0,1.4,0));
  fireProjectile(from, to, 'theo');
  if(Math.random()<0.7) say(theo.pos.clone(), pickLine(LINES.theoShoot), 'theo');
  const arrow=shootArrow(from, to, 0x66e0ff); scene.add(arrow);
  setTimeout(()=>scene.remove(arrow), 480);
  return true;
}

function findLead(player, reachBonus, includeRolling){
  const facing=new THREE.Vector3(Math.sin(player.char.parts.root.rotation.y),0,Math.cos(player.char.parts.root.rotation.y));
  let lead=-1, bd=Infinity;
  G.rollables.forEach((r,i)=>{
    if(r.rolling && !includeRolling) return;
    const dx=r.x-player.pos.x, dz=r.z-player.pos.z, d=Math.hypot(dx,dz);
    const reach = (player.radius||0.6)+r.radius+ (reachBonus||0.5);
    if(d<reach+1.2){
      const dot=(dx*facing.x+dz*facing.z)/(d||1);
      if(dot>0.2 && d<bd){ bd=d; lead=i; }
    }
  });
  return {lead, facing};
}

function nearestEnemyTo(pos, maxD){
  let best=null,bd=maxD;
  for(const e of G.enemies){ if(e.isDead) continue; const d=Math.hypot(e.pos.x-pos.x,e.pos.z-pos.z); if(d<bd){bd=d; best=e;} }
  return best;
}
function nearestRollableIndex(pos, maxD){
  let bi=-1,bd=maxD;
  G.rollables.forEach((r,i)=>{ const d=Math.hypot(r.x-pos.x,r.z-pos.z); if(d<bd){bd=d; bi=i;} });
  return bi;
}

function showKickPreview(lead, vx, vz){
  const {trails, kills}=simulateRoll(lead, vx, vz);
  const grp=new THREE.Group();
  trails.forEach((tr)=>{ grp.add(dashArrow(tr.pts, 0xffffff, false, false, G.rollables[lead].kind)); });
  kills.forEach(k=>{ grp.add(killMarker(k.x,k.z)); });
  scene.add(grp); setTimeout(()=>scene.remove(grp), 650);
}

let _lastPreviewKey='';
function drawPaths(player){
  if(G.hugging){ clearPaths(); return; }
  const {lead, facing}=findLead(player, 0.6);
  if(lead<0){ if(G.paths.length){ clearPaths(); } _lastPreviewKey=''; return; }
  
  const key=lead+'|'+facing.x.toFixed(2)+'|'+facing.z.toFixed(2)+'|'+G.rollables.length;
  if(key===_lastPreviewKey) return;
  _lastPreviewKey=key;
  clearPaths();

  const kind = G.rollables[lead].kind;

  // 1) 大キック
  const simStrong = simulateRoll(lead, facing.x * (CFG.KICK_SPEED * CFG.STRONG_MULT), facing.z * (CFG.KICK_SPEED * CFG.STRONG_MULT));
  simStrong.trails.forEach((tr)=>{
    const arrow = dashArrow(tr.pts, 0xffce4d, true, false, tr.lead ? kind : null);
    scene.add(arrow); G.paths.push(arrow);
  });
  simStrong.kills.forEach(k=>{ const m=killMarker(k.x,k.z); scene.add(m); G.paths.push(m); });

  // 2) 小キック
  const simSmall = simulateRoll(lead, facing.x * CFG.KICK_SPEED, facing.z * CFG.KICK_SPEED);
  simSmall.trails.forEach((tr)=>{
    const arrow = dashArrow(tr.pts, 0xffffff, false, false, tr.lead ? kind : null);
    scene.add(arrow); G.paths.push(arrow);
  });
  simSmall.kills.forEach(k=>{ const m=killMarker(k.x,k.z); scene.add(m); G.paths.push(m); });

  // 3) 体当たり
  const simBump = simulateRoll(lead, facing.x * CFG.BUMP_SPEED, facing.z * CFG.BUMP_SPEED);
  simBump.trails.forEach((tr)=>{
    const arrow = dashArrow(tr.pts, 0xffffff, false, true, tr.lead ? kind : null);
    scene.add(arrow); G.paths.push(arrow);
  });
  simBump.kills.forEach(k=>{ const m=killMarker(k.x,k.z); scene.add(m); G.paths.push(m); });

  const aim=theoAimEnemy(player, facing);
  if(aim && (aim.theo.charge||0)>=1){
    const from=aim.theo.pos.clone().add(new THREE.Vector3(0,1.4,0));
    const to=aim.enemy.pos.clone().add(new THREE.Vector3(0,1.4,0));
    const a=shootArrow(from, to, 0x66e0ff); scene.add(a); G.paths.push(a);
  }
}

function beginKick(player, strong){
  const sp = CFG.KICK_SPEED * (strong?CFG.STRONG_MULT:1);
  const enemy = nearestEnemyTo(player.pos, CFG.AUTOAIM_RANGE);
  if(enemy){
    let li = findLead(player, 0.8, true).lead;
    if(li<0) li = nearestRollableIndex(player.pos, 7);
    if(li>=0){
      const r=G.rollables[li];
      let dx=enemy.pos.x-r.x, dz=enemy.pos.z-r.z; const d=Math.hypot(dx,dz)||1; dx/=d; dz/=d;
      player.pos.x = r.x - dx*((player.radius||0.6)+r.radius+0.4);
      player.pos.z = r.z - dz*((player.radius||0.6)+r.radius+0.4);
      player.pos.y = getHeight(player.pos.x, player.pos.z);
      player.char.parts.root.rotation.y = Math.atan2(dx,dz);
      player.char.parts.root.position.copy(player.pos);
      showKickPreview(li, dx*sp, dz*sp);
      r.vx=dx*sp; r.vz=dz*sp; r.rolling=true;
      playSfx('kick'); playSfx(r.type); if(strong) playSfx('kick');
      lockAnim(player.char,'kick',0.34);
      if(player===G.players[0] && Math.random()<0.5) say(player.pos.clone(), pickLine(LINES.chloeKick), 'chloe');
      const aim=theoAimEnemy(player, new THREE.Vector3(dx,0,dz)); if(aim) theoFireAt(aim.theo, aim.enemy);
      _lastPreviewKey='';
      return true;
    }
  }

  const {lead, facing}=findLead(player, 0.7, true);
  if(lead<0){ lockAnim(player.char,'attack',0.3); return false; }
  const r=G.rollables[lead];
  r.vx=facing.x*sp; r.vz=facing.z*sp; r.rolling=true;
  playSfx('kick'); playSfx(r.type);
  if(strong) playSfx('kick');
  lockAnim(player.char,'kick',0.34);
  if(player===G.players[0] && Math.random()<0.5) say(player.pos.clone(), pickLine(LINES.chloeKick), 'chloe');
  const aim=theoAimEnemy(player, facing);
  if(aim) theoFireAt(aim.theo, aim.enemy);
  _lastPreviewKey='';
  return true;
}

function tryBump(player, moveX, moveZ){
  if(moveX===0 && moveZ===0) return;
  const pr=(player.radius||0.6);
  for(const r of G.rollables){
    if(r.rolling) continue;
    const dx=r.x-player.pos.x, dz=r.z-player.pos.z, d=Math.hypot(dx,dz);
    const rr=pr+r.radius;
    if(d<rr+0.15 && d>1e-3){
      const nx=dx/d, nz=dz/d;
      const ml=Math.hypot(moveX,moveZ); const mvx=moveX/ml, mvz=moveZ/ml;
      if(nx*mvx+nz*mvz > 0.1){
        r.vx=nx*CFG.BUMP_SPEED; r.vz=nz*CFG.BUMP_SPEED; r.rolling=true;
        playSfx(r.type);
        player.pos.x -= nx*(rr-d); player.pos.z -= nz*(rr-d);
      }
    }
  }
}

ui.js

/* =====================================================================
   ui.js — 画面・HUD・吹き出し・入力・タイトル演出
   ===================================================================== */

function projectToScreen(v3){
  const v=v3.clone().project(camera);
  return { x:(v.x*0.5+0.5)*innerWidth, y:(-v.y*0.5+0.5)*innerHeight, visible:v.z<1 };
}

/* ---- 吹き出し ---- */
let bubbleList=[];
function say(worldPos, text, who){
  const cls = who==='theo' ? ' theo' : (who==='enemy' ? ' enemy' : '');
  const el=document.createElement('div'); el.className='bubble'+cls; el.textContent=text;
  
  // 最適化:初期位置を左上に固定し、以後の移動は transform(GPU合成)で行う
  el.style.left = '0px';
  el.style.top = '0px';
  document.getElementById('bubbles').appendChild(el);
  bubbleList.push({el, pos:worldPos.clone(), t:0, dur:1.6});
}
function updateBubbles(dt){
  bubbleList=bubbleList.filter(b=>{
    b.t+=dt;
    if(b.t>b.dur){ b.el.remove(); return false; }
    const s=projectToScreen(b.pos.clone().add(new THREE.Vector3(0,2.6,0)));
    // 最適化:left/topの書き換えから transform へ変更し、リフロー描画負荷を削減
    b.el.style.transform = `translate(${s.x}px, ${s.y}px)`;
    b.el.style.opacity = s.visible ? (b.t>b.dur-0.4 ? (b.dur-b.t)/0.4 : 1) : 0;
    return true;
  });
}

/* ---- 合流アロー(テオの上) ---- */
let reunionArrow=null;
function updateReunionArrow(){
  const theo = isMultiplayer ? G.players[1] : G.companion;
  if(G.reunionReady && !G.hugging && theo){
    if(!reunionArrow){
      reunionArrow=new THREE.Group();
      const cone=new THREE.Mesh(new THREE.ConeGeometry(0.4,0.8,4), new THREE.MeshBasicMaterial({color:0x36c9c3}));
      cone.rotation.x=Math.PI; reunionArrow.add(cone);
      const heart=new THREE.Mesh(new THREE.SphereGeometry(0.25,8,6), new THREE.MeshBasicMaterial({color:0xff5e8a}));
      heart.position.y=0.7; reunionArrow.add(heart);
      scene.add(reunionArrow);
    }
    reunionArrow.position.set(theo.pos.x, theo.pos.y+3.2+Math.sin(performance.now()*0.005)*0.2, theo.pos.z);
    reunionArrow.rotation.y+=0.05; reunionArrow.visible=true;
  } else if(reunionArrow){ reunionArrow.visible=false; }
}

/* ---- HUD ---- */
function setControlsHud(){
  const row=document.getElementById('controlsRow');
  if(isMultiplayer){
    row.innerHTML = '<b>P1 クロエ</b> <span class="key">↑</span><span class="key">↓</span><span class="key">←</span><span class="key">→</span> 移動 ・ <span class="key">Z</span> キック ・ <span class="key">X</span> 強キック<br>'+
                    '<b>P2 テオドール</b> <span class="key">8</span><span class="key">2</span><span class="key">4</span><span class="key">6</span> 移動 ・ <span class="key">N</span>/<span class="key">M</span> キック ・ <span class="key">H</span> 射撃 ・ <span class="key">Esc</span> 停止 ・ <span class="key">B</span> 音';
  } else {
    row.innerHTML = '<span class="key">↑</span><span class="key">↓</span><span class="key">←</span><span class="key">→</span> / <span class="key">WASD</span> 移動 ・ <span class="key">Z</span> キック ・ <span class="key">X</span> 強キック(2倍)<br>敵が近いとキックで自動照準 ・ テオへ蹴ると援護射撃 ・ <span class="key">Esc</span> 停止 ・ <span class="key">B</span> 音';
  }
}

/* ★ 最適化:UI更新のキャッシュ化(DOMの無駄な再描画を防ぐ) ★ */
const _uiCache = {};

function updateDOMText(id, val) {
  if (_uiCache[id] !== val) {
    const el = document.getElementById(id);
    if (el) el.textContent = val;
    _uiCache[id] = val;
  }
}
function updateDOMWidth(id, val) {
  const key = id + '_w';
  if (_uiCache[key] !== val) {
    const el = document.getElementById(id);
    if (el) el.style.width = val;
    _uiCache[key] = val;
  }
}
function updateDOMClass(id, val) {
  const key = id + '_c';
  if (_uiCache[key] !== val) {
    const el = document.getElementById(id);
    if (el) el.className = val;
    _uiCache[key] = val;
  }
}
function updateDOMDisplay(id, val) {
  const key = id + '_d';
  if (_uiCache[key] !== val) {
    const el = document.getElementById(id);
    if (el) el.style.display = val;
    _uiCache[key] = val;
  }
}
function toggleDOMClassState(id, cls, state) {
  const key = id + '_t_' + cls;
  if (_uiCache[key] !== state) {
    const el = document.getElementById(id);
    if (el) {
      if (state) el.classList.add(cls);
      else el.classList.remove(cls);
    }
    _uiCache[key] = state;
  }
}

/* 毎フレーム呼ばれるUI更新処理をキャッシュ経由に変更 */
function updateUI(){
  const p1=G.players[0];
  
  updateDOMText('hpText', Math.max(0,Math.floor(p1.hp)));
  
  const pct=Math.max(0,(p1.hp/CFG.PLAYER_HP)*100);
  updateDOMWidth('hpFill', pct+'%');
  updateDOMClass('hpFill', 'hpFill'+(pct<=25?' crit':pct<=55?' low':''));
  
  updateDOMText('regenTxt', p1.regen>0 ? '♻ 回復中 '+Math.ceil(p1.regen)+'s' : '');
  updateDOMText('scoreText', G.score.toLocaleString());
  updateDOMText('hudEra', 'CHAPTER '+ROMAN[currentStage-1]);
  updateDOMText('hudStage', G.cfg.title);
  
  updateDOMClass('r0', 'relic'+(G.realCollected>=1?' on':''));
  updateDOMClass('r1', 'relic'+(G.realCollected>=2?' on':''));
  
  updateDOMText('koText', (G.enemiesDefeated||0)+' / '+enemiesToClear(currentStage));
  toggleDOMClassState('objCard', 'ready', G.reunionReady);

  // テオドールのHP
  const theo=getTheo();
  if(theo){
    updateDOMDisplay('theoCard', 'flex'); // ※html側のflex設定に合わせる
    updateDOMText('theoHpText', Math.max(0,Math.floor(theo.hp)));
    
    const tpct=Math.max(0,(theo.hp/CFG.THEO_HP)*100);
    updateDOMWidth('theoHpFill', tpct+'%');
    updateDOMClass('theoHpFill', 'hpFill'+(tpct<=25?' crit':tpct<=55?' low':''));
    
    if(document.getElementById('theoChargeFill')){
      updateDOMWidth('theoChargeFill', Math.max(0,Math.min(1,(theo.charge||0))*100)+'%');
    }
    if(isMultiplayer){ updateDOMText('theoStatus', 'プレイヤー2'); }
  } else { 
    updateDOMDisplay('theoCard', 'none'); 
  }
}

/* 顔画像(Face/player1.png, player2.png があれば表示) */
function setupFaces(){
  const set=(id,src)=>{ const img=document.getElementById(id); if(!img) return;
    img.style.display='none'; img.onload=()=>{ img.style.display='block'; }; img.onerror=()=>{ img.style.display='none'; }; img.src=src; };
  set('faceChloe','Face/player1.png');
  set('faceTheo','Face/player2.png');
}

/* ---- 画面フロー ---- */
const screenIds={ TITLE:'screenTitle', OPENING:'screenOpening', ENDING:'screenEnding',
  STAGE_START:'screenStageStart', STAGE_CLEAR:'screenStageClear', GAME_OVER:'screenGameOver', GAME_CLEAR:'screenGameClear' };

function showLoader(txt){ const l=document.getElementById('loader'); document.getElementById('loaderText').textContent=txt; l.style.display='flex'; }
function hideLoader(){ document.getElementById('loader').style.display='none'; }

function switchState(s){
  document.querySelectorAll('.screen').forEach(e=>e.classList.remove('active'));
  document.getElementById('hud').classList.toggle('show', s==='PLAYING');
  gameState=s;
  if(s!=='PLAYING') clearPaths();

  if(s==='TITLE'){
    stopBgm(); startBgm('title',null);
    document.getElementById('screenTitle').classList.add('active');
    isMultiplayer=false;
    document.getElementById('menuSingle').classList.add('selected');
    document.getElementById('menuMulti').classList.remove('selected');
  }
  else if(s==='OPENING'){
    stopBgm(); startBgm('opening',null);
    const o=document.getElementById('openingText');
    o.textContent=o.dataset.override||OPENING_TEXT;
    document.getElementById('opTitle').textContent='プロローグ';
    document.getElementById('screenOpening').classList.add('active');
  }
  else if(s==='STAGE_START'){
    document.getElementById('ssEra').textContent='CHAPTER '+ROMAN[currentStage-1]+' ・ '+STAGES[currentStage-1].era;
    document.getElementById('stageStartText').textContent='STAGE '+currentStage+' START!';
    document.getElementById('ssSub').textContent='第'+toKanji(currentStage)+'話 '+STAGES[currentStage-1].title;
    document.getElementById('screenStageStart').classList.add('active');
    showLoader('時代を構築中…');
    stopBgm();
    setTimeout(()=>{ initStage(currentStage); startBgm(null,currentStage); hideLoader(); switchState('PLAYING'); }, 1400);
  }
  else if(s==='PLAYING'){ /* HUD表示のみ */ }
  else if(s==='STAGE_CLEAR'){
    stopBgm(); startBgm('stage_clear',null);
    document.getElementById('scText').textContent='STAGE '+currentStage+' CLEAR!';
    document.getElementById('scSub').textContent = currentStage<CFG.MAX_STAGES ? 'クロエとテオは、次の時代へ。' : '最後のアーティファクトを手に入れた!';
    document.getElementById('screenStageClear').classList.add('active');
  }
  else if(s==='GAME_OVER'){ stopBgm(); startBgm('game_over',null); document.getElementById('screenGameOver').classList.add('active'); }
  else if(s==='GAME_CLEAR'){ stopBgm(); startBgm('game_clear',null);
    document.getElementById('gcScore').textContent='SCORE '+G.score.toLocaleString();
    document.getElementById('screenGameClear').classList.add('active'); }
  else if(s==='ENDING'){ stopBgm(); startBgm('ending',null);
    const e=document.getElementById('endingText'); e.textContent=e.dataset.override||ENDING_TEXT;
    document.getElementById('screenEnding').classList.add('active'); }
}
function toKanji(n){ return ['零','一','二','三','四','五','六','七','八','九','十','十一','十二'][n]||(''+n); }

/* ---- 入力→ステートマシン ---- */
function advance(e){
  if(actx.state==='suspended') actx.resume();
  const isKey = e && e.type==='keydown';
  if(gameState==='TITLE'){
    if(isKey && (e.key==='ArrowUp'||e.key==='ArrowDown')){
      isMultiplayer=!isMultiplayer;
      document.getElementById('menuSingle').classList.toggle('selected', !isMultiplayer);
      document.getElementById('menuMulti').classList.toggle('selected', isMultiplayer);
      playSfx('pageup'); return;
    }
    playSfx('pageup'); switchState('OPENING'); return;
  }
  if(gameState==='OPENING'){ playSfx('pageup'); switchState('STAGE_START'); return; }
  if(gameState==='STAGE_CLEAR'){
    playSfx('pageup');
    if(currentStage>=CFG.MAX_STAGES){ switchState('GAME_CLEAR'); }
    else { currentStage++; switchState('STAGE_START'); }
    return;
  }
  if(gameState==='GAME_CLEAR'){ playSfx('pageup'); switchState('ENDING'); return; }
  if(gameState==='GAME_OVER'){ playSfx('pageup'); switchState('STAGE_START'); return; } // 同じステージをやり直し
  if(gameState==='ENDING'){ playSfx('pageup'); currentStage=1; G.score=0; switchState('TITLE'); return; }
}

/* ---- ポーズ(Escでトグル) ---- */
function togglePause(){
  if(gameState!=='PLAYING') return;
  G.paused=!G.paused;
  const ov=document.getElementById('pauseOverlay');
  if(ov) ov.classList.toggle('show', G.paused);
  if(G.paused){ stopBgm(); }
  else if(!muted){ startBgm(null,currentStage); }
}

function flash(){ const f=document.getElementById('flash'); f.style.transition='none'; f.style.opacity='0.5';
  requestAnimationFrame(()=>{ f.style.transition='opacity .3s'; f.style.opacity='0'; }); }

/* ---- 入力リスナー設定 ---- */
function setupInput(){
  document.getElementById('menuSingle').addEventListener('click',(e)=>{ e.stopPropagation(); isMultiplayer=false;
    document.getElementById('menuSingle').classList.add('selected'); document.getElementById('menuMulti').classList.remove('selected');
    playSfx('pageup'); switchState('OPENING'); });
  document.getElementById('menuMulti').addEventListener('click',(e)=>{ e.stopPropagation(); isMultiplayer=true;
    document.getElementById('menuMulti').classList.add('selected'); document.getElementById('menuSingle').classList.remove('selected');
    playSfx('pageup'); switchState('OPENING'); });

  addEventListener('keydown',(e)=>{
    if(e.repeat) return;
    keys[e.key.toLowerCase()]=true; keys[e.code.toLowerCase()]=true;
    if(['arrowup','arrowdown','arrowleft','arrowright'].includes(e.key.toLowerCase())) e.preventDefault();
    if(e.key.toLowerCase()==='escape'){ togglePause(); return; }
    if(e.key.toLowerCase()==='b'){ muted=!muted; if(muted) stopBgm(); else if(gameState==='PLAYING' && !G.paused) startBgm(null,currentStage); }
    if(gameState==='PLAYING'){
      if(!G.paused){
        const k=e.key.toLowerCase();
        if(k==='z' && G.players[0]) beginKick(G.players[0], false);   // クロエ通常キック
        if(k==='x' && G.players[0]) beginKick(G.players[0], true);    // クロエ強キック(2倍)
        if(k==='n' && G.players[1]) beginKick(G.players[1], false);   // テオ通常キック(2人プレイ)
        if(k==='m' && G.players[1]) beginKick(G.players[1], true);    // テオ強キック(2人プレイ)
        if(k==='h' && G.players[1]) theoShootNearest(G.players[1]);   // テオ射撃(2人プレイ・Hキー)
      }
      return;
    }
    advance(e);
  });
  addEventListener('keyup',(e)=>{ keys[e.key.toLowerCase()]=false; keys[e.code.toLowerCase()]=false; });
  addEventListener('click',(e)=>{ if(gameState!=='PLAYING') advance(e); });
}

/* ---- タイトル背景:漂う歯車(2D canvas) ---- */
function titleArt(){
  const cv=document.getElementById('clockArt'); const cx=cv.getContext('2d');
  function rs(){ cv.width=innerWidth; cv.height=innerHeight; } rs(); addEventListener('resize',rs);
  const gears=[]; for(let i=0;i<7;i++) gears.push({x:Math.random()*innerWidth,y:Math.random()*innerHeight,
    r:30+Math.random()*70, sp:(Math.random()-0.5)*0.01, a:Math.random()*6, teeth:8+Math.floor(Math.random()*8)});
  function gear(g){ cx.save(); cx.translate(g.x,g.y); cx.rotate(g.a);
    cx.strokeStyle='rgba(255,206,77,0.18)'; cx.lineWidth=3; cx.beginPath();
    for(let t=0;t<g.teeth;t++){ const ang=t/g.teeth*Math.PI*2; const r2=g.r*(t%2?1:1.18);
      cx.lineTo(Math.cos(ang)*r2,Math.sin(ang)*r2);} cx.closePath(); cx.stroke();
    cx.beginPath(); cx.arc(0,0,g.r*0.4,0,Math.PI*2); cx.stroke(); cx.restore(); }
  (function loop(){ if(gameState==='TITLE'){ cx.clearRect(0,0,cv.width,cv.height); gears.forEach(g=>{g.a+=g.sp; gear(g);}); }
    requestAnimationFrame(loop); })();
}

world.js

/* =====================================================================
   world.js — マップ(地形)と出現物
   ・getHeight     : なだらかな起伏(必ず歩ける・退屈しない程度の高低)
   ・groundGradient: 坂の傾き(転がりの加速計算に使用)
   ・updateChunks  : 地形タイルとエンティティのストリーミング生成
   ・spawnBand     : 物・敵・アーティファクト・お菓子・装飾の配置
   ===================================================================== */

/* なだらかな丘。振幅は小さめ=どこでも歩ける。前方(-Z)へごく緩い下り。 */
function getHeight(x, z){
  return Math.sin(x*0.16)*Math.cos(z*0.12)*0.85
       + Math.sin((x*0.6 + z)*0.05)*0.7
       + Math.cos(z*0.045 + 1.7)*0.55
       - z*0.035;
}
function groundGradient(x, z){
  const e=0.6;
  return {
    gx:(getHeight(x+e,z)-getHeight(x-e,z))/(2*e),
    gz:(getHeight(x,z+e)-getHeight(x,z-e))/(2*e)
  };
}
/* どこでも地面あり(穴なし)→ 必ず移動可能。プレイヤーは横幅だけ制限。 */
function getGroundY(x,z){ return getHeight(x,z); }

/* ---- ステージ見た目 ---- */
function applyStageVisuals(cfg){
  scene.background=new THREE.Color(cfg.sky);
  scene.fog=new THREE.Fog(cfg.fog, 30, 110);
  new THREE.TextureLoader().load('StageBg/'+cfg.n+'.png',(t)=>{ scene.background=t; },undefined,()=>{});
  scene.userData.hemi.groundColor.setHex(cfg.fog);
  // night-ish stages: dim the directional a touch
  const dark=[3,5,10,11,12].includes(cfg.n);
  scene.userData.dir.intensity = dark?0.6:0.95;
  scene.userData.hemi.intensity = dark?0.6:0.85;
}

/* ---- 床の見た目(屋内ステージは砂地ではなく、それっぽい床に) ---- */
function floorSpec(prop){
  switch(prop){
    case 'victorian':  return {a:0x6b4a2e,b:0x5a3f27,cell:1.4,plank:true};   // 寄木張り
    case 'jazz':       return {a:0x3a2a44,b:0x2f2238,cell:1.6,plank:true};   // 板張りの舞台
    case 'venice':     return {a:0x9a8f7e,b:0x877c6b,cell:2.0,plank:false};  // 石畳
    case 'pirate':     return {a:0x6a4a2a,b:0x5a3f22,cell:1.2,plank:true};   // 甲板
    case 'factory':    return {a:0x4a4f57,b:0x3f444b,cell:3.0,plank:false};  // コンクリート
    case 'clockwork':  return {a:0x6a5226,b:0x5a4520,cell:2.4,plank:false};  // 真鍮の床
    case 'singularity':return {a:0x2c2350,b:0x241c42,cell:2.6,plank:false};  // 金属パネル
    case 'paris':      return {a:0xd8cdb4,b:0xc6bba2,cell:1.8,plank:false};  // 大理石
    case 'rift':       return {a:0x352b58,b:0x2a2148,cell:2.2,plank:false};  // 異質な床
    default:           return null;                                          // 屋外(砂・土)は従来通り
  }
}

/* ---- 地形タイル(細分割した平面を getHeight で変位=なめらかな丘) ---- */
const TILE_D = 16;                 // 1タイルの奥行き
function makeTerrainTile(zc){
  const W = CFG.FIELD_HALF*2 + 30;             // 横幅(フィールド+装飾域)
  const segX = Math.floor(W/2), segZ = Math.floor(TILE_D/2);
  const geo = new THREE.PlaneGeometry(W, TILE_D, segX, segZ);
  geo.rotateX(-Math.PI/2);
  const pos = geo.attributes.position;
  const colors=[];
  const spec = floorSpec(G.cfg.prop);
  const c1=new THREE.Color(spec?spec.a:G.cfg.g1), c2=new THREE.Color(spec?spec.b:G.cfg.g2);
  const cell = spec?spec.cell:3;
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i), lz=pos.getZ(i), wz=lz+zc;
    pos.setY(i, getHeight(x, wz));
    let checker;
    if(spec && spec.plank) checker = (Math.floor((x+1000)/(cell*0.5))&1);                      // 板=X方向で交互
    else                   checker = ((Math.floor((x+1000)/cell)+Math.floor((wz+1000)/cell))&1); // 市松
    const c=checker?c1:c2; colors.push(c.r,c.g,c.b);
  }
  geo.setAttribute('color', new THREE.Float32BufferAttribute(colors,3));
  geo.computeVertexNormals();
  const mesh=new THREE.Mesh(geo, new THREE.MeshLambertMaterial({vertexColors:true}));
  mesh.position.z=zc; mesh.receiveShadow=true;
  return mesh;
}

function pickRoll(){
  const list=G.cfg.rolls;
  // 1/6でユニバーサルな石も混ぜて多様性UP
  if(Math.random()<0.16) return 'stone';
  return list[Math.floor(Math.random()*list.length)];
}

/* ---- 1帯(z band)ぶんのエンティティ配置 ---- */
function spawnBand(k){
  const z0 = k*TILE_D - TILE_D/2;
  if(Math.abs(k) <= 0) return;                 // 開始地点付近は空けておく
  const HALF = CFG.FIELD_HALF - 4;             // 物が出る横幅
  const randX = ()=> (Math.random()*2-1)*HALF;
  const randZ = ()=> z0 + Math.random()*TILE_D;
  const randZfar = ()=> z0 + Math.random()*TILE_D*0.5;   // 帯の「奥(-Z)寄り」
  const pc = G.players[0] ? Math.round(G.players[0].pos.z/TILE_D) : 0; // プレイヤーのチャンク

  // 物:かなり多め(毎帯 6〜11個)。いろんな種類。
  const nRoll = 6 + Math.floor(Math.random()*6);
  for(let i=0;i<nRoll;i++){
    const kind=pickRoll();
    const r=createRollable(kind);
    const x=randX(), z=randZ();
    r.g.position.set(x, getHeight(x,z)+r.radius, z); scene.add(r.g);
    G.rollables.push({ mesh:r.g, radius:r.radius, type:r.sfx, kind:r.kind, chunk:k,
      rolling:false, vx:0, vz:0, x, z, y:r.g.position.y, yVelocity:0, spin:r.g.userData.spin||null });
  }

  // 敵は「奥(-Z)」からのみ出現(プレイヤーより前方のチャンクだけ)。手前には出さない。
  if(k < pc && Math.random()<0.8){
    const n=1+(Math.random()<0.45?1:0)+(Math.random()<0.15?1:0);
    for(let i=0;i<n;i++){ const x=randX(), z=randZfar(); spawnEnemy(x,z,k); }
  }

  // アーティファクト(取得時 10回に1回が本物=1個ごとに REAL_RATE で本物判定)
  if(Math.random()<0.34){
    const real = (Math.random() < CFG.REAL_RATE);
    const x=randX(), z=randZ();
    const m=createArtifact(real); m.position.set(x, getHeight(x,z)+1.4, z); scene.add(m);
    G.artifacts.push({ mesh:m, real, taken:false, stolenFake:false, by:null, chunk:k });
    G.artifactsSpawned++;
  }

  // お菓子(種類ランダム・回復)
  if(Math.random()<0.5){
    const x=randX(), z=randZ();
    const m=createSweet(); m.position.set(x, getHeight(x,z)+0.7, z); scene.add(m);
    G.sweets.push({mesh:m, chunk:k});
  }

  // 背景装飾(屋台などのセットを増量:両サイドに複数+クラスター)
  const nDeco=2+Math.floor(Math.random()*3);   // 2〜4個
  for(let i=0;i<nDeco;i++){
    const side=Math.random()<0.5?-1:1;
    const dx=side*(CFG.FIELD_HALF + 1 + Math.random()*10);
    const z=randZ();
    const d=createDeco(G.cfg.prop, dx, z, getHeight(dx,z), G.cfg.accent);
    d.userData.chunk=k; scene.add(d); G.decos.push(d);
  }
  // たまに「セット」を密集配置(屋台・建物の並び)
  if(Math.random()<0.45){
    const side=Math.random()<0.5?-1:1;
    const baseX=side*(CFG.FIELD_HALF + 3 + Math.random()*5);
    const baseZ=randZ();
    const cnt=2+Math.floor(Math.random()*2);
    for(let i=0;i<cnt;i++){
      const dx=baseX + (Math.random()-0.5)*3;
      const dz=baseZ + i*3.2;
      const d=createDeco(G.cfg.prop, dx, dz, getHeight(dx,dz), G.cfg.accent);
      d.userData.chunk=k; scene.add(d); G.decos.push(d);
    }
  }
}

function spawnEnemy(x,z,k){
  const c=createCharacter('agent'); c.baseY=0; scene.add(c.parts.root);
  G.enemies.push({ char:c, pos:new THREE.Vector3(x, getHeight(x,z)+2, z), hp:1, isDead:false, chunk:k,
    yVelocity:0, vel:new THREE.Vector3(), shootCd:1+Math.random()*1.5, meleeCd:0, mode:'chase', carrying:null });
}

/* ---- ストリーミング ----
   プレイヤーを中心にZ方向の「窓」を保ち、窓に入ったタイルを生成する時に
   そのチャンクの物・敵・アイテム・装飾も生成。窓から出たタイルは、その上の
   生成物ごとまとめて削除する(進行方向に関係なく前後どちらでも機能)。 */
function updateChunks(centerZ){
  const ck=Math.round(centerZ/TILE_D);
  const FRONT=6, BACK=3;             // -Z(奥/前方)を多め、+Z(手前)も保持
  const keep=new Set();
  for(let k=ck-FRONT;k<=ck+BACK;k++){
    keep.add(k);
    const key='t'+k;
    if(!G.tiles[key]){
      const m=makeTerrainTile(k*TILE_D); scene.add(m); G.tiles[key]=m;
      spawnBand(k);                  // タイル生成と同時に中身も生成
    }
  }
  // 窓の外のタイル+その上の生成物を削除
  for(const key in G.tiles){
    const k=parseInt(key.slice(1),10);
    if(!keep.has(k)){
      scene.remove(G.tiles[key]); G.tiles[key].geometry.dispose(); delete G.tiles[key];
      cullChunk(k);
    }
  }
  // 撃破されて落下した敵だけ別途除去
  G.enemies=G.enemies.filter(e=>{
    if(e.isDead && e.pos.y<-60){ scene.remove(e.char.parts.root); return false; }
    return true;
  });
}

/* 指定チャンクで生成された物・敵・アイテム・装飾をまとめて削除 */
function cullChunk(k){
  const rm=(it)=>{ if(it.mesh) scene.remove(it.mesh); if(it.char) scene.remove(it.char.parts.root); };
  G.rollables = G.rollables.filter(it=>{ if(it.chunk===k){ rm(it); return false; } return true; });
  G.sweets    = G.sweets.filter(it=>{    if(it.chunk===k){ rm(it); return false; } return true; });
  G.artifacts = G.artifacts.filter(it=>{ if(it.chunk===k){ if(it.mesh) scene.remove(it.mesh); return false; } return true; });
  G.enemies   = G.enemies.filter(it=>{   if(it.chunk===k){ rm(it); return false; } return true; });
  G.decos     = G.decos.filter(d=>{      if(d.userData.chunk===k){ scene.remove(d); return false; } return true; });
}

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

ピックアップされています

気楽に遊べるゲーム

  • 26本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
「クロエとテオの時間旅行」のミニゲーム|古井和雄
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