「クロエとテオの時間旅行」のミニゲーム
【更新履歴】
・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 & 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; });
}



コメント