<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>3D Biollante Laboratory Sandbox</title>
<!-- Tailwind CSS (Aesthetics and layouts) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome for matching bio-hazard style icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #010301;
font-family: 'Courier New', Courier, monospace;
user-select: none;
/* モバイル端末でのバウンススクロールやリフレッシュ動作を完全に無効化 */
touch-action: none;
}
/* スキャンライン効果 */
.scanlines {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(
rgba(18, 16, 16, 0) 50%,
rgba(0, 0, 0, 0.3) 50%
), linear-gradient(
90deg,
rgba(255, 0, 0, 0.04),
rgba(0, 255, 0, 0.01),
rgba(0, 0, 255, 0.04)
);
background-size: 100% 4px, 6px 100%;
z-index: 10;
pointer-events: none;
}
/* サイバーホログラム風グリッド */
.glitch-text {
text-shadow: 0 0 8px rgba(74, 242, 74, 0.8);
animation: pulse 2s infinite alternate;
}
@keyframes pulse {
0% { opacity: 0.8; text-shadow: 0 0 4px rgba(74, 242, 74, 0.5); }
100% { opacity: 1; text-shadow: 0 0 15px rgba(74, 242, 74, 1); }
}
</style>
<!-- 安定して動作する同一バージョンの Three.js と OrbitControls を読み込み -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
</head>
<body class="relative w-full h-full">
<div class="scanlines"></div>
<!-- 万が一のエラーを画面に表示するためのデバッグ用コンソール (初期状態は隠し) -->
<div id="debug-log" class="absolute top-24 left-4 z-50 bg-red-950/90 border border-red-500 text-red-200 p-3 rounded font-mono text-xs max-w-sm hidden">
<div class="font-bold border-b border-red-500 pb-1 mb-1 text-red-400"><i class="fa-solid fa-triangle-exclamation"></i> SYSTEM ERROR LOG:</div>
<p id="debug-text" class="break-all"></p>
</div>
<!-- UIトグル用ボタン (右上・タッチしやすい適切なパディングの極小ボタン z-30) -->
<div class="absolute top-3 right-3 md:top-4 right-4 z-30 pointer-events-auto">
<button id="btn-toggle-hud" class="bg-black/90 hover:bg-green-950/80 border border-green-500/50 text-green-400 py-1.5 px-2.5 rounded text-[10px] font-bold flex items-center gap-1.5 shadow-[0_0_10px_rgba(74,242,74,0.2)] transition-all duration-200 active:scale-95">
<i class="fa-solid fa-eye-slash text-[11px]" id="hud-icon"></i>
<span id="hud-text">UI非表示</span>
</button>
</div>
<!-- UI Overlay (メインコントロール群 z-20) -->
<div class="absolute inset-0 z-20 pointer-events-none flex flex-col justify-between p-3 md:p-4">
<!-- TOP NAV: Status Monitor (高さを抑えてスリム化、右上ボタンとの干渉を避ける右パディング) -->
<div id="panel-top" class="w-full flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 bg-black/75 border border-green-900/50 p-2 md:p-2.5 rounded backdrop-blur-md pointer-events-auto transition-all duration-500 ease-in-out opacity-100 pr-24">
<div>
<div class="flex items-center gap-2">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-600"></span>
</span>
<h1 class="text-sm md:text-lg font-bold text-green-400 tracking-wider glitch-text">
G-CELL HYBRID: <span class="text-orange-500">BIOLLANTE</span>
</h1>
</div>
<p class="text-[8px] md:text-[9px] text-green-600/70">
SUBJECT ID: #1989-AR2 | CODENAME: 植獣形態 (PLANT BEAST)
</p>
</div>
<div class="flex gap-2.5 text-[9px] md:text-xs text-green-500 font-mono">
<div class="border-l border-green-800/80 pl-2">
<span class="text-green-700 block text-[7px] md:text-[8px]">CORE TEMPERATURE</span>
<span id="core-status" class="font-bold text-orange-400">1200°C CRITICAL</span>
</div>
<div class="border-l border-green-800/80 pl-2">
<span class="text-green-700 block text-[7px] md:text-[8px]">TENTACLE FORESTS</span>
<span id="tentacle-status" class="font-bold text-red-500">8 VINES ACTIVE</span>
</div>
<div class="border-l border-green-800/80 pl-2 hidden lg:block">
<span class="text-green-700 block text-[8px]">GENOME ANALYSIS</span>
<span class="text-cyan-400">G-Cells 50% / Rose 30%</span>
</div>
</div>
</div>
<!-- CENTER: Custom notifications -->
<div class="self-center flex flex-col items-center justify-center">
<div id="action-alert" class="hidden bg-red-950/90 border border-red-500 text-red-400 px-4 py-1.5 rounded font-bold text-xs md:text-sm tracking-widest uppercase animate-bounce shadow-[0_0_10px_rgba(239,68,68,0.4)]">
WARNING: ACID SPITTING ACTIVE!
</div>
</div>
<!-- BOTTOM CONTROLS & MONITOR (スマホではタブ切り替え、PCでは3カラム並列) -->
<div id="panel-bottom" class="w-full flex flex-col gap-2 transition-all duration-500 ease-in-out opacity-100 pointer-events-auto">
<!-- Mobile-only Tabs Navigation (スマホの時だけ出現するスリムなタブボタン群) -->
<div class="flex md:hidden w-full bg-black/90 border border-green-950 p-0.5 rounded gap-0.5">
<button id="tab-btn-bio" class="flex-1 text-center py-1.5 rounded text-[10px] font-bold transition-all bg-green-950/60 text-green-400 border border-green-500/30">
<i class="fa-solid fa-flask mr-1"></i>制御 (CTRL)
</button>
<button id="tab-btn-light" class="flex-1 text-center py-1.5 rounded text-[10px] font-bold transition-all bg-black/40 text-gray-500">
<i class="fa-solid fa-sliders mr-1"></i>環境 (LIGHT)
</button>
<button id="tab-btn-telemetry" class="flex-1 text-center py-1.5 rounded text-[10px] font-bold transition-all bg-black/40 text-gray-500">
<i class="fa-solid fa-chart-line mr-1"></i>生体 (DATA)
</button>
</div>
<!-- Panels Wrapper (スマホでは表示/非表示を動的制御、PC(md:)では横並び) -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
<!-- 1. LEFT PANEL: Bio-commands -->
<div id="panel-bio" class="bg-black/80 border border-green-950 p-2 md:p-2.5 rounded flex flex-col gap-2 shadow-[0_0_15px_rgba(0,0,0,0.5)]">
<span class="text-green-400 font-bold text-[9px] md:text-[10px] border-b border-green-900/40 pb-1 flex items-center gap-1">
<i class="fa-solid fa-flask"></i> BIO-CHEMICAL CONTROL
</span>
<div class="grid grid-cols-2 gap-2">
<button id="btn-spit" class="bg-emerald-950/80 border border-emerald-500/60 text-emerald-400 hover:bg-emerald-900/80 active:scale-95 transition-all py-2 px-2 rounded text-[10px] md:text-[11px] font-bold flex items-center justify-center gap-1.5">
<i class="fa-solid fa-skull-crossbones text-xs"></i>
<span>酸性樹液 (Acid Spit)</span>
</button>
<button id="btn-roar" class="bg-red-950/80 border border-red-500/60 text-red-400 hover:bg-red-900/80 active:scale-95 transition-all py-2 px-2 rounded text-[10px] md:text-[11px] font-bold flex items-center justify-center gap-1.5">
<i class="fa-solid fa-volume-high text-xs"></i>
<span>大咆哮 (Roar)</span>
</button>
</div>
<button id="btn-combat" class="bg-orange-950/80 border border-orange-500/60 text-orange-400 hover:bg-orange-900/80 active:scale-95 transition-all py-1.5 px-2 rounded text-[9px] md:text-[10px] font-bold flex items-center justify-center gap-1.5">
<i class="fa-solid fa-bolt"></i>
<span>戦闘モード切替 (Combat Mode)</span>
</button>
</div>
<!-- 2. MIDDLE PANEL: Environmental Chamber Config -->
<div id="panel-light" class="hidden md:flex bg-black/80 border border-green-950 p-2 md:p-2.5 rounded flex-col gap-2 shadow-[0_0_15px_rgba(0,0,0,0.5)]">
<span class="text-green-400 font-bold text-[9px] md:text-[10px] border-b border-green-900/40 pb-1 flex items-center gap-1">
<i class="fa-solid fa-sliders"></i> CHAMBER LIGHTING
</span>
<div class="flex flex-col gap-1">
<span class="text-[8px] md:text-[9px] text-green-600/80 font-mono">CHAMBER ENVIRONMENT PRESET</span>
<div class="grid grid-cols-3 gap-1">
<button id="env-swamp" class="bg-green-900/40 text-green-300 border border-green-500/50 text-[9px] md:text-[10px] py-1 px-1 rounded hover:bg-green-900/80 transition-all font-mono active">
SWAMP
</button>
<button id="env-alert" class="bg-black/50 text-red-500/60 border border-red-950 text-[9px] md:text-[10px] py-1 px-1 rounded hover:bg-red-950/50 transition-all font-mono">
ALERT
</button>
<button id="env-glow" class="bg-black/50 text-amber-500/60 border border-amber-950 text-[9px] md:text-[10px] py-1 px-1 rounded hover:bg-amber-950/50 transition-all font-mono">
GLOW
</button>
</div>
</div>
<div class="flex justify-between text-[8px] md:text-[9px] text-green-600/70 font-mono">
<span>Cam Dist: <span id="val-dist">0.00</span>m</span>
<span>Fog: Adaptive auto</span>
</div>
</div>
<!-- 3. RIGHT PANEL: Biotech Telemetry readout -->
<div id="panel-telemetry" class="hidden md:flex bg-black/80 border border-green-950 p-2 md:p-2.5 rounded flex-col justify-between shadow-[0_0_15px_rgba(0,0,0,0.5)] text-green-500 font-mono">
<div>
<span class="text-green-400 font-bold text-[9px] md:text-[10px] border-b border-green-900/40 pb-1 block mb-1">
<i class="fa-solid fa-chart-line"></i> BIOMETRIC READOUT
</span>
<div class="grid grid-cols-2 gap-y-0.5 gap-x-2 text-[8px] md:text-[9px]">
<span class="text-green-700">TEMPERATURE:</span><span id="tel-temp">36.8 °C</span>
<span class="text-green-700">BIO-EMISSION:</span><span id="tel-em">284.1 mSv/h</span>
<span class="text-green-700">REGENERATION:</span><span class="text-green-400 animate-pulse">MAXIMUM</span>
<span class="text-green-700">THREAT LEVEL:</span><span class="text-red-500 font-bold">CRITICAL</span>
</div>
</div>
<div class="mt-1 border-t border-green-950/60 pt-1 text-[8px] text-green-600/80 flex justify-between">
<span>ROTATION: <span id="tel-rot" class="text-green-400">AUTO</span></span>
<span>DRAG / SCROLL TO ZOOM</span>
</div>
</div>
</div>
</div>
</div>
<!-- MAIN THREE.JS CANVAS CONTAINER (背面に絶対配置 z-0) -->
<div id="canvas-container" class="absolute inset-0 w-full h-full z-0"></div>
<script>
// エラーハンドラー
window.onerror = function(message, source, lineno, colno, error) {
const debugPanel = document.getElementById('debug-log');
const debugText = document.getElementById('debug-text');
debugPanel.classList.remove('hidden');
debugText.innerText = `${message}\nLine: ${lineno}:${colno}`;
console.error(error);
return false;
};
// Global variables & state
let scene, camera, renderer, controls;
let biollanteGroup;
let coreMesh, coreLight, ambientLight, spotLight, floor;
let tentacles = [];
let acidParticles = [];
let slimeParticles = []; // ★ 滴り落ちる生々しい粘液パーティクル用
let sporeParticles;
let mouthUpper, mouthLower;
let sideMouths = [];
let webL, webR; // ★ 伸縮追従する口角肉膜用
let chestWallL, chestWallR, centerMound; // 体のアニメーション連動用
let isCombatMode = false;
let isSpitting = false;
let spitTimer = 0; // ★ 口の開閉時間を確実に制御する自立タイマー
let isRoaring = false;
let roarTimer = 0;
let isHUDVisible = true; // HUD表示フラグ
// 劇中スチルの空気感を完全に再現するライティング&フォグ環境設定
const environments = {
swamp: {
bg: 0x000602, // 真夜中の静まり返った闇
ambient: 0x0a140a, // 極限まで抑えた環境光で、明暗のコントラストを引き立てる
spot: 0x66ff66,
spotIntensity: 2.8, // 上部からのサーチライト風月光
coreColor: 0xff4500, // 黄金色〜オレンジに燃え盛る溶岩コア
coreLightColor: 0xffaa00,
fogNear: 10,
fogFar: 36
},
alert: {
bg: 0x0a0101,
ambient: 0x240a0a,
spot: 0xff0000,
spotIntensity: 3.5,
coreColor: 0xff2200,
coreLightColor: 0xff5500,
fogNear: 10,
fogFar: 36
},
glow: {
bg: 0x01080d,
ambient: 0x0a2024,
spot: 0x00ffcc,
spotIntensity: 2.5,
coreColor: 0xff8800,
coreLightColor: 0xff3300,
fogNear: 10,
fogFar: 36
}
};
let currentEnv = 'swamp';
// システム通知表示
function showAlert(msg, duration = 2000) {
const el = document.getElementById('action-alert');
el.innerText = msg;
el.classList.remove('hidden');
setTimeout(() => {
el.classList.add('hidden');
}, duration);
}
// アプリケーション初期化
function init() {
const container = document.getElementById('canvas-container');
const width = container.clientWidth || window.innerWidth;
const height = container.clientHeight || window.innerHeight;
// 1. Scene setup
scene = new THREE.Scene();
const config = environments[currentEnv];
scene.background = new THREE.Color(config.bg);
scene.fog = new THREE.Fog(config.bg, config.fogNear, config.fogFar);
// 2. Camera setup (劇中スチルのド迫力のアングルを意識)
camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 1000);
camera.position.set(-6.5, 3.8, 11.5);
// 3. Renderer setup (モバイルデバイス向けにPixelRatioの天井値を1.5に抑えて動作を劇的に最適化)
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(renderer.domElement);
// 4. OrbitControls setup
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.maxPolarAngle = Math.PI / 2 - 0.05;
controls.minDistance = 3.5;
controls.maxDistance = 20;
controls.target.set(0, 1.3, 0.5); // ビオランテの咆哮する顔を常に見つめる
// 自動回転を設定
controls.autoRotate = true;
controls.autoRotateSpeed = 0.35; // 劇中のスケール感を出すため非常に重厚にゆっくり回転
// 5. Lighting Setup (劇中の妖艶なエッジライティング&ナイトシーンを形成)
ambientLight = new THREE.AmbientLight(config.ambient, 1.2);
scene.add(ambientLight);
// メインスポットライト(上部から照らし、濡れた皮膚に美しいハイライトを走らせる)
spotLight = new THREE.SpotLight(config.spot, config.spotIntensity);
spotLight.position.set(4, 18, 4);
spotLight.angle = Math.PI / 4.2;
spotLight.penumbra = 0.7;
spotLight.castShadow = true;
spotLight.shadow.mapSize.width = 1024;
spotLight.shadow.mapSize.height = 1024;
spotLight.shadow.bias = -0.001;
scene.add(spotLight);
// 劇中特有の超強力な「グリーン/シアンの逆光エッジライト」
const rimLight = new THREE.DirectionalLight(0x22ee44, 3.2); // インテンシティを極限まで強化
rimLight.position.set(8, 6, -10);
scene.add(rimLight);
// 前面補助の寒色系ローライト(ディテールを完全に潰さないための暗視風ライト)
const fillLight = new THREE.DirectionalLight(0x1a2e3a, 1.1);
fillLight.position.set(-8, 3, 8);
scene.add(fillLight);
// 6. ビオランテモデル構築
buildBiollante();
// 7. 胞子エフェクト
buildSpores();
// 8. 湿地の地面
buildFloor();
// コントロールリスナー登録
setupControls();
// 画面リサイズ対応
window.addEventListener('resize', onWindowResize);
// 初回強制サイズチェック
onWindowResize();
// アニメーションループ開始 (自律高精度タイマーを使用するため、requestAnimationFrameを呼びます)
requestAnimationFrame(animate);
}
// ゴツゴツした不規則な形状を高精度で生成する関数
function createOrganicGeometry(radius, detail, noiseScale = 0.16) {
const geo = new THREE.DodecahedronGeometry(radius, detail);
const pos = geo.attributes.position;
// 頂点ノイズを何重にも適用して、劇中の生々しい樹皮と肉腫の起伏を作る
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
const z = pos.getZ(i);
const noise = (Math.sin(x*3) * Math.cos(y*3) * Math.sin(z*3) +
Math.sin(x*8) * 0.4 +
Math.cos(y*12) * 0.15) * noiseScale;
pos.setXYZ(i, x + noise, y + noise, z + noise);
}
geo.computeVertexNormals();
return geo;
}
// ★ 高密度Sphereベース:下部ほどラッパ状に広がる、ずっしりと山型(円錐台)ボディを作る特殊幾何学生成
function createMountainGeometry(radius, widthSeg, heightSeg, noiseScale = 0.16) {
const geo = new THREE.SphereGeometry(radius, widthSeg, heightSeg);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
let x = pos.getX(i);
let y = pos.getY(i);
let z = pos.getZ(i);
// 地面に近づく(Yが低くなる)ほど、外周への広がりを大きくして「丸み」を「末広がりの山」に変える
const heightFactor = (radius - y) / (radius * 2); // 0 から 1.0
const spread = 1.0 + heightFactor * 1.6; // 最下部を約2.6倍に広げる
x *= spread;
z *= spread;
// 樹木のような縦のうねり(幹テクスチャ)をサイン波でシミュレート
const noise = (Math.sin(x * 1.5) * Math.cos(y * 1.5) * Math.sin(z * 1.5) +
Math.sin(y * 4.0) * 0.35 +
Math.cos(x * 6.0) * 0.1) * noiseScale;
pos.setXYZ(i, x + noise, y + noise, z + noise);
}
geo.computeVertexNormals();
return geo;
}
// ビオランテモデルの構築 (劇中スチルの完全再現)
function buildBiollante() {
// 地面に埋没しすぎず、湖面から美しく生えるように、グループ自体を少しY方向に引き上げ
biollanteGroup = new THREE.Group();
biollanteGroup.position.set(0, 0.22, 0);
scene.add(biollanteGroup);
// ★ 劇中スチルの「濡れた漆黒の肉体」を完璧にシミュレートするマテリアル設定(荒さをさらに下げてテカリを強調)
const wetSkinMat = new THREE.MeshStandardMaterial({
color: 0x080e07, // 極めて深い漆黒オリーブグリーン
roughness: 0.25, // 粘液をまとい、濡れ光る質感を高めるために粗さをさらに低減
metalness: 0.35, // サーチライトをヌラヌラと反射させる光沢
flatShading: true // 面の角を残してゴツゴツ感を強調
});
const vineMat = new THREE.MeshStandardMaterial({
color: 0x0a160b,
roughness: 0.30, // 触手も粘液に濡れた質感に変更
metalness: 0.30,
flatShading: true
});
// 喉奥の共通赤肉マテリアル (より湿った濡れ質感に)
const throatMat = new THREE.MeshStandardMaterial({
color: 0x6e0606,
roughness: 0.2,
flatShading: true
});
// ★ 顎開閉時の隙間を埋める「口角の肉膜ウェブ」用マテリアル
const webMat = new THREE.MeshStandardMaterial({
color: 0x3d0303, // 深い血の色
roughness: 0.3,
flatShading: true
});
// ★ 歯・牙用マテリアル (ReferenceErrorを防ぐため、一番初めに定義を統一します)
const toothMat = new THREE.MeshStandardMaterial({
color: 0xe5dfd0,
roughness: 0.3,
flatShading: true
});
// A. 本体(createMountainGeometryによって破綻のない美しい火山型シルエットを構築)
const bodyRoot = new THREE.Group();
biollanteGroup.add(bodyRoot);
// 細分化された高密度Sphereを用いて、美しくゴツゴツした「植物の山」を形成。
const centerMoundGeo = createMountainGeometry(2.3, 32, 24, 0.25);
centerMound = new THREE.Mesh(centerMoundGeo, wetSkinMat);
centerMound.scale.set(1.4, 0.65, 1.4);
centerMound.position.set(0, 0.45, -0.4);
centerMound.castShadow = true;
centerMound.receiveShadow = true;
bodyRoot.add(centerMound);
// ★ 劇中スチルのような「心臓を抱く胸 of 割れ目」を演出する肉厚な胸壁セグメント(左右)
chestWallL = new THREE.Mesh(createOrganicGeometry(0.9, 1, 0.1), wetSkinMat);
chestWallL.scale.set(1.0, 1.4, 1.8);
chestWallL.position.set(0.9, 0.5, 0.9);
chestWallL.rotation.y = -Math.PI / 4;
bodyRoot.add(chestWallL);
chestWallR = chestWallL.clone();
chestWallR.position.x = -0.9;
chestWallR.rotation.y = Math.PI / 4;
bodyRoot.add(chestWallR);
// 地面に頑強に広がり這う巨大な根コブ
const tuberCount = 8;
for(let i=0; i < tuberCount; i++) {
const tuber = new THREE.Mesh(createOrganicGeometry(1.2, 1, 0.14), wetSkinMat);
const angle = (i / tuberCount) * Math.PI * 2;
const dist = 2.3 + Math.random() * 0.4;
tuber.position.set(
Math.cos(angle) * dist,
-0.5 + Math.random() * 0.08,
Math.sin(angle) * dist
);
tuber.scale.set(1.6 + Math.random()*0.4, 0.42, 1.3 + Math.random()*0.3);
tuber.rotation.set(Math.random() * 0.3, Math.random() * 6.2, Math.random() * 0.3);
tuber.castShadow = true;
tuber.receiveShadow = true;
bodyRoot.add(tuber);
// 土台コブの棘
if (Math.random() > 0.3) {
const spike = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.45, 4), wetSkinMat);
spike.position.set(tuber.position.x * 1.1, 0.1, tuber.position.z * 1.1);
spike.rotation.set(Math.random()*0.5, Math.random()*6, Math.random()*0.5);
bodyRoot.add(spike);
}
}
// A-1. 首
const neckGroup = new THREE.Group();
biollanteGroup.add(neckGroup);
const neckSegmentCount = 7;
for (let i = 0; i < neckSegmentCount; i++) {
const t = i / (neckSegmentCount - 1);
const neckSeg = new THREE.Mesh(createOrganicGeometry(1.35 - t * 0.45, 1, 0.08), wetSkinMat);
neckSeg.position.set(0, 0.55 + t * 1.45, t * 1.3);
neckSeg.scale.set(1.25, 0.95, 1.25);
neckSeg.castShadow = true;
neckSeg.receiveShadow = true;
neckGroup.add(neckSeg);
// 首の棘
if (i > 1 && i < 6) {
const spikeL = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.5, 4), wetSkinMat);
spikeL.position.set(0.8 - t * 0.2, 0.55 + t * 1.45, t * 1.3 + 0.2);
spikeL.rotation.set(0, -Math.PI/6, Math.PI/3);
neckGroup.add(spikeL);
const spikeR = spikeL.clone();
spikeR.position.x = -spikeL.position.x;
spikeR.rotation.z = -Math.PI/3;
neckGroup.add(spikeR);
}
}
// A-2. 首回りの「襟巻き状の外殻装甲」
const collarGroup = new THREE.Group();
collarGroup.position.set(0, 1.25, 0.5);
bodyRoot.add(collarGroup);
const collarParts = 8;
for(let i = 0; i < collarParts; i++) {
const scale = 0.85 + Math.random() * 0.45;
const part = new THREE.Mesh(createOrganicGeometry(0.8, 1, 0.12), wetSkinMat);
const angle = (i / (collarParts - 1)) * Math.PI - Math.PI / 2;
part.position.set(
Math.sin(angle) * 1.45,
0.25 + (Math.random() * 0.25),
-Math.cos(angle) * 1.25 - 0.25
);
part.scale.set(scale * 1.3, scale * 0.75, scale * 1.0);
part.rotation.set(Math.random()*0.4, Math.random()*3.1, Math.random()*0.4);
collarGroup.add(part);
}
// A-3. 背骨の多重装甲びれ
const spineGroup = new THREE.Group();
spineGroup.position.set(0, 1.6, -0.6);
bodyRoot.add(spineGroup);
const spineCount = 9;
for(let i = 0; i < spineCount; i++) {
const hFactor = 1.0 - (i / spineCount) * 0.5;
const spinePart = new THREE.Mesh(createOrganicGeometry(0.4, 1, 0.15), wetSkinMat);
spinePart.scale.set(1.5 * hFactor, 1.2 * hFactor, 0.3 * hFactor);
spinePart.position.set(
(Math.random() - 0.5) * 0.25,
(i * 0.42),
-i * 0.26 + 0.25
);
spinePart.rotation.set(
-Math.PI / 4 - (Math.random() * 0.1),
(Math.random() - 0.5) * 0.3,
(Math.random() - 0.5) * 0.25
);
spinePart.castShadow = true;
spineGroup.add(spinePart);
}
// B. 胸の奥深くに脈動する「心臓コア」
const coreGroup = new THREE.Group();
coreGroup.position.set(0, 0.48, 1.45);
bodyRoot.add(coreGroup);
const coreGeo = new THREE.SphereGeometry(0.85, 20, 20);
const coreConfig = environments[currentEnv];
const coreMat = new THREE.MeshStandardMaterial({
color: coreConfig.coreColor,
emissive: coreConfig.coreColor,
emissiveIntensity: 3.8,
roughness: 0.1,
transparent: true,
opacity: 0.98
});
coreMesh = new THREE.Mesh(coreGeo, coreMat);
coreMesh.scale.set(1.1, 0.82, 1.25);
coreGroup.add(coreMesh);
coreLight = new THREE.PointLight(coreConfig.coreLightColor, 9, 16);
coreGroup.add(coreLight);
// B-1. コアを縛る極細根(48本)
const rootCage = new THREE.Group();
coreGroup.add(rootCage);
const rootCount = 48;
const rootMat = new THREE.MeshStandardMaterial({ color: 0x050a05, roughness: 1.0, flatShading: true });
for(let i = 0; i < rootCount; i++) {
const rootGeo = new THREE.CylinderGeometry(0.016, 0.035, 2.1, 4);
const root = new THREE.Mesh(rootGeo, rootMat);
const angle = (i / rootCount) * Math.PI * 2;
root.position.set(
Math.cos(angle) * 0.88,
(Math.random() - 0.5) * 0.4,
Math.sin(angle) * 0.45 + 0.12
);
root.rotation.set(
Math.PI / 4.5 + (Math.random() - 0.5) * 1.1,
angle + Math.PI / 2,
(Math.random() - 0.5) * 0.7
);
rootCage.add(root);
}
// C. ★ 鰐口(わにぐち)状の巨大なメイン頭部
const headRoot = new THREE.Group();
headRoot.position.set(0, 1.95, 1.45);
biollanteGroup.add(headRoot);
// ★ 伸縮する口角肉膜ウェブ(顎関節ピボットに完全にアライメント調整)
webL = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.65, 0.95), webMat);
webL.position.set(0.48, 0.0, 0.75);
webL.rotation.y = Math.PI / 10;
headRoot.add(webL);
webR = webL.clone();
webR.position.x = -0.48;
webR.rotation.y = -Math.PI / 10;
headRoot.add(webR);
// 1. ★ 上顎 (Upper Jaw)
// 回転ピボットを顎の根本(関節後端 Z:0)に設定
mouthUpper = new THREE.Group();
mouthUpper.position.set(0, 0.2, 0.4); // 後部関節位置アライメント
headRoot.add(mouthUpper);
// 長く裂けた獰猛な頭蓋
const upperSkull = new THREE.Mesh(createOrganicGeometry(0.76, 1, 0.1), wetSkinMat);
upperSkull.scale.set(1.15, 0.65, 2.75);
upperSkull.position.set(0, 0.25, 1.85); // 根本ピボット位置にスライド
mouthUpper.add(upperSkull);
// 鼻筋のデコボコ突起 (ピボットシフトに合わせてZ位置を調整)
const snoutBump = new THREE.Mesh(createOrganicGeometry(0.34, 0, 0.08), wetSkinMat);
snoutBump.scale.set(1.0, 0.5, 1.55);
snoutBump.position.set(0, 0.57, 2.8);
mouthUpper.add(snoutBump);
// 瞳 (ピボットシフトに連動)
const eyeGeo = new THREE.SphereGeometry(0.09, 8, 8);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xffdd00 });
const eyeL = new THREE.Mesh(eyeGeo, eyeMat);
eyeL.position.set(0.62, 0.32, 1.35);
const eyeR = eyeL.clone();
eyeR.position.x = -0.62;
mouthUpper.add(eyeL);
mouthUpper.add(eyeR);
// 上顎の牙:多重配列構造 (Zアライメント調整)
const upperToothCount = 24;
for(let i=0; i < upperToothCount; i++) {
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.32, 4), toothMat);
tooth.rotation.x = Math.PI;
const t = (i / (upperToothCount - 1));
const angle = t * Math.PI - Math.PI/2;
const doubleLayer = (i % 2 === 0) ? 1.0 : 0.92;
const zPos = 0.85 + Math.cos(angle * 0.75) * 2.05 * doubleLayer + 1.0;
const xPos = Math.sin(angle * 1.1) * 0.46 * doubleLayer;
tooth.position.set(xPos, -0.02, zPos);
tooth.rotation.set(
-Math.PI + (Math.random() - 0.5) * 0.3,
(Math.random() - 0.5) * 0.2,
-xPos * 0.7 + (Math.random() - 0.5) * 0.2
);
mouthUpper.add(tooth);
}
// 2. ★ 下顎 (Lower Jaw)
// 開閉時のめり込みを防ぐため、ピボット位置を十分に下方へ移動
mouthLower = new THREE.Group();
mouthLower.position.set(0, -0.2, 0.35); // 顎関節位置を低く最適配置
headRoot.add(mouthLower);
// ピボット関節(Z:0)に基づき、メッシュが不自然にせり上がるのを完全に解消
const lowerSkull = new THREE.Mesh(createOrganicGeometry(0.66, 1, 0.1), wetSkinMat);
lowerSkull.scale.set(0.98, 0.42, 2.5);
lowerSkull.position.set(0, -0.15, 1.7); // ピボットアライメント
lowerSkull.castShadow = true;
lowerSkull.receiveShadow = true;
mouthLower.add(lowerSkull);
// 下顎の多重牙 (アライメント修正)
const lowerToothCount = 24;
for(let i=0; i < lowerToothCount; i++) {
const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.28, 4), toothMat);
const t = (i / (lowerToothCount - 1));
const angle = t * Math.PI - Math.PI/2;
const doubleLayer = (i % 2 === 0) ? 1.0 : 0.94;
const zPos = 0.85 + Math.cos(angle * 0.75) * 2.0 * doubleLayer + 1.05;
const xPos = Math.sin(angle * 1.1) * 0.42 * doubleLayer;
tooth.position.set(xPos, 0.04, zPos);
tooth.rotation.set(
(Math.random() - 0.5) * 0.3,
(Math.random() - 0.5) * 0.2,
xPos * 0.7 + (Math.random() - 0.5) * 0.2
);
mouthLower.add(tooth);
}
// ★ 左右の巨大犬歯(大犬歯)の咬合めり込み防止アライメント
const bigToothL = new THREE.Mesh(new THREE.ConeGeometry(0.11, 0.65, 5), toothMat);
bigToothL.position.set(0.62, 0.05, 1.4);
bigToothL.rotation.set(-Math.PI / 5.5, 0.15, Math.PI / 3.6);
mouthLower.add(bigToothL);
const bigToothR = bigToothL.clone();
bigToothR.position.x = -0.62;
bigToothR.rotation.y = -0.15;
bigToothR.rotation.z = -Math.PI / 3.6;
mouthLower.add(bigToothR);
// 喉の奥
const throatGeo = new THREE.CylinderGeometry(0.32, 0.42, 1.8, 6);
throatGeo.rotateX(Math.PI / 2);
const throat = new THREE.Mesh(throatGeo, throatMat);
throat.position.set(0, 0.05, 0.25);
headRoot.add(throat);
// D. ★ うねり蠢く極太の蔦触手(8本)
const numTentacles = 8;
for (let i = 0; i < numTentacles; i++) {
const tGroup = new THREE.Group();
const angle = (i / numTentacles) * Math.PI * 2 + Math.PI / 8;
const isOuter = (i % 2 === 1);
const rad = isOuter ? 2.95 : 2.15;
const posY = isOuter ? -0.4 : -0.1;
tGroup.position.set(Math.cos(angle) * rad, posY, Math.sin(angle) * rad);
tGroup.rotation.y = -angle + Math.PI / 2;
let parentPart = tGroup;
const segments = [];
const segCount = 9;
for (let j = 0; j < segCount; j++) {
const thick = 0.23 * (1 - (j / (segCount + 1)));
const length = 0.8;
const segGeo = new THREE.CylinderGeometry(thick * 0.75, thick, length, 6);
segGeo.rotateX(Math.PI / 2);
segGeo.translate(0, 0, length / 2);
const seg = new THREE.Mesh(segGeo, vineMat);
seg.castShadow = true;
seg.receiveShadow = true;
parentPart.add(seg);
segments.push(seg);
const nextAnchor = new THREE.Group();
nextAnchor.position.set(0, 0, length);
seg.add(nextAnchor);
parentPart = nextAnchor;
}
// 小頭部
const headScale = 0.55;
const smGroup = new THREE.Group();
smGroup.scale.set(headScale, headScale, headScale);
parentPart.add(smGroup);
const smUpper = new THREE.Mesh(createOrganicGeometry(0.55, 0, 0.08), wetSkinMat);
smUpper.scale.set(1, 0.55, 2.2);
smUpper.position.set(0, 0.15, 0.5);
smGroup.add(smUpper);
const smLower = new THREE.Mesh(createOrganicGeometry(0.53, 0, 0.08), wetSkinMat);
smLower.scale.set(0.95, 0.5, 2.1);
smLower.position.set(0, -0.15, 0.5);
smGroup.add(smLower);
const smThroat = new THREE.Mesh(new THREE.SphereGeometry(0.25, 4, 4), throatMat);
smThroat.position.set(0, 0, 0.2);
smGroup.add(smThroat);
const smToothGeo = new THREE.ConeGeometry(0.04, 0.22, 4);
for(let t=0; t<8; t++) {
const tooth = new THREE.Mesh(smToothGeo, toothMat);
const tAngle = (t / 7) * Math.PI - Math.PI/2;
tooth.position.set(Math.sin(tAngle)*0.25, 0.08, 0.4 + Math.cos(tAngle)*0.8);
tooth.rotation.x = Math.PI;
smUpper.add(tooth);
const toothLow = tooth.clone();
toothLow.rotation.x = 0;
toothLow.position.y = 0.08;
smLower.add(toothLow);
}
sideMouths.push({
upper: smUpper,
lower: smLower,
index: i
});
biollanteGroup.add(tGroup);
tentacles.push({
group: tGroup,
segments: segments,
baseAngle: angle,
speedFactor: 0.8 + Math.random() * 0.5,
isOuter: isOuter,
huntMode: false,
huntTimer: Math.random() * 4,
targetYaw: 0,
targetPitch: 0
});
}
}
// 湖上に漂うバイオ胞子パーティクル
function buildSpores() {
const count = 300;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count * 3);
const sizes = new Float32Array(count);
for(let i=0; i<count*3; i+=3) {
pos[i] = (Math.random() - 0.5) * 35;
pos[i+1] = Math.random() * 12 - 1.5;
pos[i+2] = (Math.random() - 0.5) * 35;
sizes[i/3] = Math.random() * 0.15 + 0.05;
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const pMat = new THREE.PointsMaterial({
color: 0x4df313,
size: 0.2,
transparent: true,
opacity: 0.75,
blending: THREE.AdditiveBlending,
depthWrite: false
});
sporeParticles = new THREE.Points(geo, pMat);
scene.add(sporeParticles);
}
// 蘆ノ湖の水面
function buildFloor() {
const size = 150;
const floorGeo = new THREE.PlaneGeometry(size, size, 40, 40);
const pos = floorGeo.attributes.position;
for(let i=0; i<pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
const wave = Math.sin(x*0.12) * Math.cos(y*0.12) * 0.22;
pos.setZ(i, wave);
}
floorGeo.computeVertexNormals();
const floorMat = new THREE.MeshStandardMaterial({
color: 0x011404,
roughness: 0.2,
metalness: 0.85,
flatShading: true
});
floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.4;
floor.receiveShadow = true;
scene.add(floor);
}
// アシッド毒樹液放射
function triggerAcidSpit() {
if (isSpitting) return;
isSpitting = true;
spitTimer = 1.8; // ★1.8秒間大口を開けて放射し、タイマー終了後に確実に自動で閉じます
showAlert("WARNING: BIO-ACID TOXIN VENTED!", 2500);
const particleCount = 120;
const acidGeo = new THREE.SphereGeometry(0.12, 5, 5);
const acidMat = new THREE.MeshBasicMaterial({
color: 0x39ff14,
transparent: true,
opacity: 0.95
});
const baseDir = new THREE.Vector3(0, 0.15, 3.2);
baseDir.applyQuaternion(mouthUpper.parent.quaternion);
baseDir.add(biollanteGroup.position);
for (let i = 0; i < particleCount; i++) {
const mesh = new THREE.Mesh(acidGeo, acidMat);
mesh.position.set(
baseDir.x + (Math.random() - 0.5) * 0.3,
baseDir.y + (Math.random() - 0.5) * 0.2,
baseDir.z + (Math.random() - 0.5) * 0.3
);
const velocity = new THREE.Vector3(
(Math.random() - 0.5) * 4,
(Math.random() - 0.2) * 4 + 3.5,
9 + Math.random() * 8
);
scene.add(mesh);
acidParticles.push({
mesh: mesh,
vel: velocity,
life: 1.0
});
}
// 唾液スライム
triggerSlimeDrop(30, new THREE.Vector3(0, 2.0, 2.85));
}
// 粘液滴(スライムドロップ)生成関数
function triggerSlimeDrop(count, basePos) {
const slimeGeo = new THREE.SphereGeometry(0.06, 4, 4);
const slimeMat = new THREE.MeshStandardMaterial({
color: 0x33aa11,
roughness: 0.1,
transparent: true,
opacity: 0.8
});
for(let i=0; i<count; i++) {
const mesh = new THREE.Mesh(slimeGeo, slimeMat);
mesh.scale.set(0.6 + Math.random()*0.5, 1.5 + Math.random()*1.0, 0.6 + Math.random()*0.5);
mesh.position.copy(basePos).add(new THREE.Vector3(
(Math.random() - 0.5) * 0.8,
(Math.random() - 0.5) * 0.2,
(Math.random() - 0.5) * 0.8
));
scene.add(mesh);
slimeParticles.push({
mesh: mesh,
velY: -1.0 - Math.random()*2.0,
life: 1.0 + Math.random()*1.0
});
}
}
// 大咆哮
function triggerRoar() {
if (isRoaring) return;
isRoaring = true;
roarTimer = 0;
showAlert("SEISMIC ROAR: DETECTING ULTRASONIC PULSES", 3000);
coreLight.intensity = 35;
coreMesh.scale.set(1.4, 1.15, 1.6);
// 口元から粘液落下
triggerSlimeDrop(40, new THREE.Vector3(0, 2.0, 2.85));
}
// コントロール・HUDリスナー登録 (レスポンシブ&タブ切替ロジック追加)
function setupControls() {
const btnToggleHUD = document.getElementById('btn-toggle-hud');
const panelTop = document.getElementById('panel-top');
const panelBottom = document.getElementById('panel-bottom');
const hudIcon = document.getElementById('hud-icon');
const hudText = document.getElementById('hud-text');
// --- モバイル専用タブ切り替えロジック ---
const tabBtnBio = document.getElementById('tab-btn-bio');
const tabBtnLight = document.getElementById('tab-btn-light');
const tabBtnTelemetry = document.getElementById('tab-btn-telemetry');
const panelBio = document.getElementById('panel-bio');
const panelLight = document.getElementById('panel-light');
const panelTelemetry = document.getElementById('panel-telemetry');
function switchMobileTab(activeTab) {
// すべてのタブのボタンデザインをリセット
[tabBtnBio, tabBtnLight, tabBtnTelemetry].forEach(btn => {
btn.classList.remove('bg-green-950/60', 'text-green-400', 'border-green-500/30');
btn.classList.add('bg-black/40', 'text-gray-500', 'border-transparent');
});
// すべてのパネルを隠す (PC表示のときはTailwindの md:flex 等で強制横並びになります)
[panelBio, panelLight, panelTelemetry].forEach(p => {
p.classList.add('hidden');
});
if (activeTab === 'bio') {
tabBtnBio.classList.remove('bg-black/40', 'text-gray-500', 'border-transparent');
tabBtnBio.classList.add('bg-green-950/60', 'text-green-400', 'border', 'border-green-500/30');
panelBio.classList.remove('hidden');
} else if (activeTab === 'light') {
tabBtnLight.classList.remove('bg-black/40', 'text-gray-500', 'border-transparent');
tabBtnLight.classList.add('bg-green-950/60', 'text-green-400', 'border', 'border-green-500/30');
panelLight.classList.remove('hidden', 'md:hidden');
panelLight.classList.add('flex');
} else if (activeTab === 'telemetry') {
tabBtnTelemetry.classList.remove('bg-black/40', 'text-gray-500', 'border-transparent');
tabBtnTelemetry.classList.add('bg-green-950/60', 'text-green-400', 'border', 'border-green-500/30');
panelTelemetry.classList.remove('hidden', 'md:hidden');
panelTelemetry.classList.add('flex');
}
}
// モバイル用タブイベントハンドラー (clickとtouchstartの両方で軽快に応答)
const handleTabClick = (tabKey) => (e) => {
e.preventDefault();
switchMobileTab(tabKey);
};
tabBtnBio.addEventListener('click', handleTabClick('bio'));
tabBtnLight.addEventListener('click', handleTabClick('light'));
tabBtnTelemetry.addEventListener('click', handleTabClick('telemetry'));
// 最初はBIOパネルを表示
switchMobileTab('bio');
// --- HUD表示/非表示トグルの制御 ---
btnToggleHUD.addEventListener('click', (e) => {
e.preventDefault();
isHUDVisible = !isHUDVisible;
if (isHUDVisible) {
panelTop.classList.remove('opacity-0', 'pointer-events-none');
panelTop.classList.add('opacity-100', 'pointer-events-auto');
panelBottom.classList.remove('opacity-0', 'pointer-events-none');
panelBottom.classList.add('opacity-100', 'pointer-events-auto');
hudIcon.className = "fa-solid fa-eye-slash text-sm";
hudText.innerText = "UI非表示";
showAlert("SYSTEM HUD ACTIVATED", 1500);
} else {
panelTop.classList.remove('opacity-100', 'pointer-events-auto');
panelTop.classList.add('opacity-0', 'pointer-events-none');
panelBottom.classList.remove('opacity-100', 'pointer-events-auto');
panelBottom.classList.add('opacity-0', 'pointer-events-none');
hudIcon.className = "fa-solid fa-eye text-sm";
hudText.innerText = "UI表示";
showAlert("CINEMATIC OBSERVER MODE ACTIVE", 2000);
}
});
document.getElementById('btn-spit').addEventListener('click', (e) => {
e.preventDefault();
triggerAcidSpit();
});
document.getElementById('btn-roar').addEventListener('click', (e) => {
e.preventDefault();
triggerRoar();
});
document.getElementById('btn-combat').addEventListener('click', (e) => {
e.preventDefault();
isCombatMode = !isCombatMode;
const modeStr = isCombatMode ? "THRASHING" : "SWAYING";
document.getElementById('tentacle-status').innerText = isCombatMode ? "COMBAT / ACTIVE" : "8 VINES ACTIVE";
document.getElementById('tentacle-status').className = isCombatMode ? "font-bold text-red-500 animate-pulse" : "font-bold text-green-500";
const btn = document.getElementById('btn-combat');
if (isCombatMode) {
btn.classList.replace('bg-orange-950/80', 'bg-red-800/90');
btn.classList.replace('border-orange-500/60', 'border-red-500/60');
showAlert("COMBAT MODE ACTIVE: TENSION AMPLIFIED", 2000);
} else {
btn.classList.replace('bg-red-800/90', 'bg-orange-950/80');
btn.classList.replace('border-red-500/60', 'border-orange-500/60');
showAlert("STANDBY: DE-ESCALATING BIOMETRICS", 2000);
}
});
const swapEv = (presetKey) => {
currentEnv = presetKey;
const cfg = environments[presetKey];
scene.background.setHex(cfg.bg);
scene.fog.color.setHex(cfg.bg);
scene.fog.near = cfg.fogNear;
scene.fog.far = cfg.fogFar;
ambientLight.color.setHex(cfg.ambient);
spotLight.color.setHex(cfg.spot);
spotLight.intensity = cfg.spotIntensity;
coreMesh.material.color.setHex(cfg.coreColor);
coreMesh.material.emissive.setHex(cfg.coreColor);
coreLight.color.setHex(cfg.coreLightColor);
['env-swamp', 'env-alert', 'env-glow'].forEach(id => {
const el = document.getElementById(id);
el.classList.remove('bg-green-900/40', 'bg-red-950/80', 'bg-amber-950/80', 'text-green-300', 'text-red-500', 'text-amber-500', 'border-green-500/50', 'border-red-500', 'border-amber-500');
el.classList.add('bg-black/50', 'text-gray-500', 'border-transparent');
});
const activeBtn = document.getElementById(`env-${presetKey}`);
if (presetKey === 'swamp') {
activeBtn.className = "bg-green-900/40 text-green-300 border border-green-500/50 text-[10px] py-1 px-1.5 rounded hover:bg-green-900/80 transition-all font-mono";
} else if (presetKey === 'alert') {
activeBtn.className = "bg-red-950/80 text-red-500 border border-red-500 text-[10px] py-1 px-1.5 rounded hover:bg-red-900/80 transition-all font-mono";
} else if (presetKey === 'glow') {
activeBtn.className = "bg-amber-950/80 text-amber-500 border border-amber-500 text-[10px] py-1 px-1.5 rounded hover:bg-amber-950/50 transition-all font-mono";
}
showAlert("ENVIRONMENT RE-CONFIGURED", 1500);
};
document.getElementById('env-swamp').addEventListener('click', () => swapEv('swamp'));
document.getElementById('env-alert').addEventListener('click', () => swapEv('alert'));
document.getElementById('env-glow').addEventListener('click', () => swapEv('glow'));
}
// アニメーション&物理更新ループ (THREE.Clockに頼らない自律物理演算タイマーへ移行)
let lastTime = 0;
let totalTime = 0;
function animate(timestamp) {
requestAnimationFrame(animate);
// タイムスタンプが未定義(初回の直接呼び出し等)の場合のフォールバック
if (!timestamp) timestamp = performance.now();
if (!lastTime) lastTime = timestamp;
// タブが非アクティブから復帰した時の、過剰なdelta時間の蓄積(パーティクルの大暴走)を避けるために0.1秒でクランプします
const delta = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;
totalTime += delta;
const time = totalTime; // 既存の time 変数に代入して互換性を維持
// 1. 重量感ある呼吸(胸腔全体が吸気で膨らみ、呼気で縮み、巨体が前後に小さく揺らぐ)
const breathingCycle = Math.sin(time * 0.8);
biollanteGroup.rotation.y = breathingCycle * 0.015;
biollanteGroup.position.y = 0.22 + breathingCycle * 0.035;
// 首と胸の連動:呼吸に合わせて胸壁が動的な厚み変化をする
const breathingScale = 1.0 + breathingCycle * 0.02;
if (chestWallL && chestWallR) {
chestWallL.scale.set(breathingScale, 1.4, 1.8 * breathingScale);
chestWallR.scale.set(breathingScale, 1.4, 1.8 * breathingScale);
}
// 2. 心臓コアの生命心電拍動(QRS鼓動)
const ecgSpeed = isCombatMode ? 6.5 : 2.8;
const rawECG = Math.sin(time * ecgSpeed);
const heartBeat = Math.pow(Math.max(0, rawECG), 8.0) * 0.12 + Math.pow(Math.max(0, Math.sin(time * ecgSpeed - 0.45)), 8.0) * 0.06;
const pulseScale = 1.0 + heartBeat;
if (!isRoaring) {
coreMesh.scale.set(pulseScale * 1.12, pulseScale * 0.82, pulseScale * 1.25);
coreLight.intensity = (isCombatMode ? 10.0 : 6.0) + heartBeat * 35.0;
const warmGold = new THREE.Color(0xffaa00);
const deepRed = new THREE.Color(0x8a0303);
coreMesh.material.emissive.copy(deepRed).lerp(warmGold, heartBeat * 6.0);
}
// リアルタイムセンサー情報ゆらぎ
if (Math.random() < 0.08) {
document.getElementById('core-status').innerText = isCombatMode ? "TACHYCARDIA" : `HR: ${(60 + heartBeat*40).toFixed(0)} BPM`;
document.getElementById('tel-temp').innerText = `${(36.6 + Math.random()*1.2).toFixed(1)} °C`;
document.getElementById('tel-em').innerText = `${(270.0 + Math.random()*60.0).toFixed(1)} mSv/h`;
}
// ★ 酸性樹液の自律タイマー更新制御 (パーティクル監視から時間経過方式に完全修正しました)
if (isSpitting) {
spitTimer -= delta;
if (spitTimer <= 0) {
isSpitting = false; // 指定時間経過で確実に口がスムーズに閉じるようになります
}
}
// 3. 蔦触手 (S字湾曲をデフォルトポーズに設定。地面への不自然なめり込みを解消)
const speedScale = isCombatMode ? 4.0 : 1.35;
const flexScale = isCombatMode ? 0.4 : 0.13;
tentacles.forEach((t, tIdx) => {
t.huntTimer -= delta;
if (t.huntTimer <= 0) {
t.huntMode = Math.random() > (isCombatMode ? 0.2 : 0.6);
t.huntTimer = 4.0 + Math.random() * 5.0;
// ハントモード移行時、狙うターゲット角度を設定
t.targetYaw = (Math.random() - 0.5) * 1.2;
t.targetPitch = (Math.random() - 0.5) * 0.8;
}
t.segments.forEach((seg, sIdx) => {
let defaultRotX = 0;
if (sIdx < 3) {
defaultRotX = t.isOuter ? -Math.PI / 11 : -Math.PI / 18;
} else if (sIdx < 7) {
defaultRotX = t.isOuter ? Math.PI / 11 : Math.PI / 7.5;
} else {
defaultRotX = t.isOuter ? -Math.PI / 14 : -Math.PI / 9;
}
const noiseWave = Math.sin(time * 0.35 + tIdx * 2.3) * 0.06 + Math.cos(time * 2.2 + sIdx * 1.4) * 0.03;
let waveX = Math.sin(time * speedScale * t.speedFactor + (sIdx * 0.45) + tIdx) * flexScale + noiseWave;
let waveY = Math.cos(time * (speedScale * 0.95) * t.speedFactor + (sIdx * 0.4) + tIdx) * flexScale;
if (t.huntMode && sIdx >= 5) {
const huntWeight = (sIdx - 4) / 5;
waveX = THREE.MathUtils.lerp(waveX, t.targetPitch, huntWeight * 0.45);
waveY = THREE.MathUtils.lerp(waveY, t.targetYaw, huntWeight * 0.45);
}
seg.rotation.x = defaultRotX + waveX;
seg.rotation.y = waveY;
});
});
// 4. 触手の先端(すべての小顎)の自律的・非同期スナップ噛み付き
sideMouths.forEach((sm, index) => {
const isHunting = tentacles[index]?.huntMode;
const snapThresh = isHunting ? 0.0 : 0.3;
const snapSpeed = isHunting ? 6.0 : 3.5;
const triggerSnapping = Math.sin(time * snapSpeed + index * 1.8) > snapThresh;
const targetAngle = triggerSnapping ? (Math.PI / 6.5) : 0.03;
sm.upper.rotation.x = THREE.MathUtils.lerp(sm.upper.rotation.x, targetAngle, 0.15);
sm.lower.rotation.x = THREE.MathUtils.lerp(sm.lower.rotation.x, -targetAngle, 0.15);
if (Math.random() < 0.005 && isHUDVisible) {
const worldPos = new THREE.Vector3();
sm.upper.getWorldPosition(worldPos);
if(worldPos.y > -1.0) triggerSlimeDrop(2, worldPos);
}
});
// 5. ★ メイン大顎の生物的ダイナミック開閉
let targetUpperAngle = 0.0;
let targetLowerAngle = 0.0;
if (isRoaring) {
roarTimer += delta;
// 上顎はマイナス方向(反り上がる)、下顎はプラス方向(深く大きく落ちる)
targetUpperAngle = -Math.PI / 16.0;
targetLowerAngle = Math.PI / 4.0;
camera.position.x += (Math.random() - 0.5) * 0.15;
camera.position.y += (Math.random() - 0.5) * 0.15;
if (roarTimer > 2.2) {
isRoaring = false;
roarTimer = 0;
}
} else if (isSpitting) {
targetUpperAngle = -Math.PI / 24.0;
targetLowerAngle = Math.PI / 5.5;
} else {
// 普段の呼吸(下顎メインの微小なうごめき:上顎僅かに上、下顎僅かに下)
targetUpperAngle = -0.005 - Math.sin(time * 0.7) * 0.005;
targetLowerAngle = 0.015 + Math.sin(time * 0.7) * 0.015;
}
mouthUpper.rotation.x = THREE.MathUtils.lerp(mouthUpper.rotation.x, targetUpperAngle, 0.12);
mouthLower.rotation.x = THREE.MathUtils.lerp(mouthLower.rotation.x, targetLowerAngle, 0.12);
// ★ 顎を開閉させた時の「口角肉膜ウェブ」の伸縮シミュレーション
if (webL && webR) {
// 下顎(プラス)から上顎(マイナス)を引くことで、開くほど大きくなる正の「openAngle(開口角度)」を算出
const openAngle = mouthLower.rotation.x - mouthUpper.rotation.x;
// 開いたとき、ウェブを縦方向(Y軸)に美しく引き伸ばし、Z方向には少し引き締める
webL.scale.y = 1.0 + openAngle * 2.2;
webR.scale.y = 1.0 + openAngle * 2.2;
webL.scale.z = 1.0 - openAngle * 0.2;
webR.scale.z = 1.0 - openAngle * 0.2;
// 開きにあわせたリアルタイムY・Z位置補正(ピボット円弧移動の追従)
const midAngle = (mouthUpper.rotation.x + mouthLower.rotation.x) * 0.5;
webL.position.y = 0.0 + midAngle * 0.4;
webR.position.y = 0.0 + midAngle * 0.4;
webL.position.z = 0.75 - openAngle * 0.15;
webR.position.z = 0.75 - openAngle * 0.15;
// ウェブ自体の角度も、上顎と下顎の「完全に中間の傾き」へ自動同期
webL.rotation.x = midAngle;
webR.rotation.x = midAngle;
}
// 6. 浮遊胞子の巡回
if (sporeParticles) {
const posArr = sporeParticles.geometry.attributes.position.array;
for (let i = 1; i < posArr.length; i += 3) {
posArr[i] += 0.015;
if (posArr[i] > 10.0) {
posArr[i] = -1.5;
}
}
sporeParticles.geometry.attributes.position.needsUpdate = true;
}
// 7. アシッド強酸液パーティクル
for (let i = acidParticles.length - 1; i >= 0; i--) {
const drop = acidParticles[i];
drop.mesh.position.addScaledVector(drop.vel, delta);
drop.vel.y -= 13.0 * delta;
drop.life -= 0.75 * delta;
drop.mesh.scale.setScalar(drop.life);
if (drop.life <= 0 || drop.mesh.position.y < -1.1) {
scene.remove(drop.mesh);
drop.mesh.geometry.dispose();
drop.mesh.material.dispose();
acidParticles.splice(i, 1);
}
}
// 滴り落ちる生々しい唾液(粘液スライム)の物理演算更新
for (let i = slimeParticles.length - 1; i >= 0; i--) {
const sp = slimeParticles[i];
sp.mesh.position.y += sp.velY * delta;
sp.velY -= 9.8 * delta;
sp.life -= 0.5 * delta;
if (sp.mesh.position.y < -1.35) {
sp.mesh.position.y = -1.35;
sp.mesh.scale.x += 2.0 * delta;
sp.mesh.scale.z += 2.0 * delta;
sp.mesh.scale.y = 0.01;
}
if (sp.life <= 0) {
scene.remove(sp.mesh);
sp.mesh.geometry.dispose();
sp.mesh.material.dispose();
slimeParticles.splice(i, 1);
}
}
// 8. インタラクティブ・ウォーター
if (floor) {
const pos = floor.geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
const distToCenter = Math.sqrt(x*x + y*y);
let ripple = Math.sin(distToCenter * 0.45 - time * 2.8) * 0.09 / (1.0 + distToCenter * 0.08);
let baseWave = Math.sin(x*0.12 + time*0.5) * Math.cos(y*0.12 + time*0.4) * 0.12;
pos.setZ(i, baseWave + ripple);
}
floor.geometry.attributes.position.needsUpdate = true;
floor.geometry.computeVertexNormals();
}
// カメラ・コントロールの更新
controls.update();
const distance = camera.position.distanceTo(biollanteGroup.position);
document.getElementById('val-dist').innerText = distance.toFixed(2);
renderer.render(scene, camera);
}
// 画面リサイズ対応
function onWindowResize() {
const container = document.getElementById('canvas-container');
if (!container) return;
const w = container.clientWidth || window.innerWidth;
const h = container.clientHeight || window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
window.onload = function() {
init();
};
</script>
</body>
</html>
ツルの数を工夫しました。