「クロエとテオの時間旅行」のミニゲーム
【更新履歴】
・2026/6/13 バージョン1.0公開。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>クロエとテオの時間旅行</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; overflow: hidden; background-color: #000; font-family: 'Courier New', monospace; user-select: none; }
canvas { display: block; position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; z-index: -1; }
.screen { display: none; position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0,0,0,0.8); flex-direction: column; justify-content: center; align-items: center; z-index: 100; color: #fff; text-align: center; background-size: cover; background-position: center; }
.screen.active { display: flex; }
.screen h1 { font-size: 64px; margin-bottom: 20px; letter-spacing: 5px; text-shadow: 2px 2px 4px #000; }
.screen p { font-size: 24px; max-width: 800px; line-height: 1.6; white-space: pre-wrap; text-shadow: 1px 1px 2px #000; }
.blink { animation: blinker 1.5s linear infinite; font-size: 16px; position: absolute; bottom: 30px; }
@keyframes blinker { 50% { opacity: 0; } }
#menu { margin-top: 30px; }
.menu-item { font-size: 24px; margin: 10px; cursor: pointer; color: #888; transition: color 0.2s; }
.menu-item.selected { color: #fff; text-shadow: 0 0 10px #fff; }
/* UI HUD */
#ui { position: absolute; top: 10px; left: 10px; color: #fff; z-index: 10; font-size: 18px; text-shadow: 1px 1px 2px #000; display: none; }
#targetHud { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 18px; z-index: 10; text-align: center; display: none; background: rgba(0,0,0,0.5); padding: 5px 15px; border-radius: 5px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
</head>
<body>
<div id="ui">
<div id="hpDisplay">HP: 20 / 20</div>
<div id="artifactDisplay">ARTIFACTS: 0 / 2</div>
<div id="scoreDisplay">SCORE: 0</div>
<div id="stageIndicator">STAGE 1</div>
</div>
<div id="targetHud">PRESS [Z] TO SELECT PATH, AGAIN TO EXECUTE</div>
<div id="screenTitle" class="screen active">
<h1>クロエとテオの時間旅行</h1>
<div id="menu">
<div class="menu-item selected" id="menuSingle">SINGLE PLAY</div>
<div class="menu-item" id="menuMulti">MULTI PLAY</div>
</div>
<div class="blink">CLICK or PRESS ANY KEY</div>
</div>
<div id="screenOpening" class="screen">
<p id="openingText">時空を超えたアンティークの修復劇が、今はじまる——。</p>
<div class="blink">CLICK or PRESS ANY KEY</div>
</div>
<div id="screenStageStart" class="screen">
<h1 id="stageStartText">STAGE 1 START!</h1>
</div>
<div id="screenStageClear" class="screen">
<h1 id="stageClearText">STAGE 1 CLEAR!</h1>
<div class="blink">CLICK or PRESS ANY KEY</div>
</div>
<div id="screenGameOver" class="screen">
<h1 style="color:#f44;">GAME OVER</h1>
<div class="blink">CLICK or PRESS ANY KEY</div>
</div>
<div id="screenGameClear" class="screen">
<h1 style="color:#ff0;">GAME CLEAR!</h1>
<div class="blink">CLICK or PRESS ANY KEY</div>
</div>
<div id="screenEnding" class="screen">
<p id="endingText">彼らの果てしない時間旅行は、まだ始まったばかりだ。</p>
<div class="blink">CLICK or PRESS ANY KEY</div>
</div>
<script>
// ==========================================
// 1. グローバル変数とシステム状態
// ==========================================
let gameState = 'TITLE';
let isMultiplayer = false;
let currentStage = 1;
const MAX_STAGES = 12;
let scene, camera, renderer;
let clock = new THREE.Clock();
const gameData = {
players: [],
enemies: [],
artifacts: [],
sweets: [],
rollables: [],
grid: [],
mapWidth: 15,
mapDepth: 25,
blockSize: 2,
score: 0,
collectedArtifacts: 0,
pathSelectionMode: false,
selectedPathIndex: 0,
availablePaths: []
};
// キー入力状態の保持
const keys = {};
// ==========================================
// 2. アセットマネージャー
// ==========================================
const AudioCtx = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioCtx();
function playBeep(type) {
if (audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
if (type === 'pageup') { osc.type = 'sine'; osc.frequency.setValueAtTime(800, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(1200, audioCtx.currentTime + 0.1); gain.gain.setValueAtTime(0.1, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1); osc.start(); osc.stop(audioCtx.currentTime + 0.1); }
else if (type === 'damage') { osc.type = 'sawtooth'; osc.frequency.setValueAtTime(150, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.2); gain.gain.setValueAtTime(0.3, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.2); osc.start(); osc.stop(audioCtx.currentTime + 0.2); }
else if (type === 'heal') { osc.type = 'sine'; osc.frequency.setValueAtTime(400, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(800, audioCtx.currentTime + 0.3); gain.gain.setValueAtTime(0.1, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3); osc.start(); osc.stop(audioCtx.currentTime + 0.3); }
else if (type === 'shock') { osc.type = 'square'; osc.frequency.setValueAtTime(100, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(20, audioCtx.currentTime + 0.15); gain.gain.setValueAtTime(0.4, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15); osc.start(); osc.stop(audioCtx.currentTime + 0.15); }
else if (type === 'barrel' || type === 'rock') { osc.type = 'triangle'; osc.frequency.setValueAtTime(60, audioCtx.currentTime); gain.gain.setValueAtTime(0.2, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5); osc.start(); osc.stop(audioCtx.currentTime + 0.5); }
else if (type === 'fall') { osc.type = 'sine'; osc.frequency.setValueAtTime(600, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(200, audioCtx.currentTime + 0.3); gain.gain.setValueAtTime(0.1, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3); osc.start(); osc.stop(audioCtx.currentTime + 0.3); }
}
function playSound(name) {
const audio = new Audio(`Sound/${name}.mp3`);
audio.play().catch(() => playBeep(name));
}
function loadTexture(path, fallbackColor) {
const loader = new THREE.TextureLoader();
const material = new THREE.MeshLambertMaterial({ color: fallbackColor });
loader.load(path,
(tex) => { material.map = tex; material.color.setHex(0xffffff); material.needsUpdate = true; },
undefined,
() => { /* fallback */ }
);
return material;
}
function fetchText(path, elementId) {
fetch(path).then(res => { if(res.ok) return res.text(); throw new Error(); })
.then(text => { document.getElementById(elementId).innerText = text; })
.catch(() => { /* Fallback */ });
}
function loadScreenBg(screenName, elementId) {
const el = document.getElementById(elementId);
const img = new Image();
img.onload = () => { el.style.backgroundImage = `url('ScreenBg/${screenName}.png')`; };
img.src = `ScreenBg/${screenName}.png`;
}
// ==========================================
// 3. Three.js 初期設定とモデル生成
// ==========================================
function init3D() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.Fog(0x87CEEB, 30, 80);
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
dirLight.position.set(20, 50, 20);
dirLight.castShadow = true;
scene.add(dirLight);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
}
function createCharacterModel(type) {
const group = new THREE.Group();
const bodyGeo = new THREE.BoxGeometry(0.8, 1.2, 0.4);
let bodyMat = new THREE.MeshLambertMaterial({ color: (type === 'chloe') ? 0xffffff : 0x222222 });
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.y = 1.0; body.castShadow = true; group.add(body);
const head = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.6, 0.6), new THREE.MeshLambertMaterial({ color: 0xffccaa }));
head.position.y = 1.9; group.add(head);
if (type === 'chloe') {
const hair = new THREE.Mesh(new THREE.BoxGeometry(0.65, 0.7, 0.65), new THREE.MeshLambertMaterial({ color: 0x5c4033 }));
hair.position.y = 2.0; group.add(hair);
} else if (type === 'theo') {
const hair = new THREE.Mesh(new THREE.BoxGeometry(0.65, 0.7, 0.65), new THREE.MeshLambertMaterial({ color: 0x111111 }));
hair.position.y = 2.0; group.add(hair);
} else if (type === 'agent') {
const glasses = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.15, 0.62), new THREE.MeshLambertMaterial({ color: 0x000000 }));
glasses.position.set(0, 1.9, 0.05); group.add(glasses);
}
let legMat = type === 'chloe' ? new THREE.MeshLambertMaterial({ color: 0x3b5998 }) : new THREE.MeshLambertMaterial({ color: 0x222222 });
const legL = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.0, 0.3), legMat); legL.position.set(-0.2, 0.5, 0);
const legR = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.0, 0.3), legMat); legR.position.set(0.2, 0.5, 0);
group.add(legL); group.add(legR);
group.userData = { body, head, legL, legR };
return group;
}
function createBarrel() {
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 0.6, 1.2, 12), loadTexture('barrel.png', 0x8b4513));
mesh.rotation.z = Math.PI / 2; mesh.castShadow = true;
return mesh;
}
function createRock() {
const mesh = new THREE.Mesh(new THREE.DodecahedronGeometry(0.8), new THREE.MeshLambertMaterial({ color: 0x777777 }));
mesh.castShadow = true;
return mesh;
}
// ==========================================
// 4. マップ生成
// ==========================================
function getGroundY(wx, wz) {
let gx = Math.round(wx / gameData.blockSize + (gameData.mapWidth - 1) / 2);
let gz = Math.round(wz / gameData.blockSize + (gameData.mapDepth - 1) / 2);
if(gx >= 0 && gx < gameData.mapWidth && gz >= 0 && gz < gameData.mapDepth && gameData.grid[gx][gz]) {
return gameData.grid[gx][gz].topY;
}
return -100; // 場外
}
function generateMap(stage) {
scene.children = scene.children.filter(c => c.type === 'AmbientLight' || c.type === 'DirectionalLight');
gameData.players = []; gameData.enemies = []; gameData.rollables = []; gameData.artifacts = [];
gameData.grid = Array(gameData.mapWidth).fill().map(() => Array(gameData.mapDepth).fill(null));
new THREE.TextureLoader().load(`StageBg/${stage}.png`, (tex) => { scene.background = tex; }, undefined, () => {});
let zHeights = [];
let currentH = 40;
for(let z = 0; z < gameData.mapDepth; z++) {
zHeights.push(currentH);
if(Math.random() < 0.20) currentH -= 4;
else currentH -= 1.5;
}
for(let x = 0; x < gameData.mapWidth; x++) {
for(let z = 0; z < gameData.mapDepth; z++) {
let topY = zHeights[z];
const baseDepth = 50;
const block = new THREE.Mesh(
new THREE.BoxGeometry(gameData.blockSize, baseDepth, gameData.blockSize),
new THREE.MeshLambertMaterial({ color: 0xcdeb8b })
);
const worldX = (x - (gameData.mapWidth-1)/2) * gameData.blockSize;
const worldZ = (z - (gameData.mapDepth-1)/2) * gameData.blockSize;
block.position.set(worldX, topY - baseDepth/2, worldZ);
block.receiveShadow = true; block.castShadow = true;
scene.add(block);
gameData.grid[x][z] = { topY: topY };
if(z < gameData.mapDepth - 1 && zHeights[z] - zHeights[z+1] <= 2.0) {
const slope = new THREE.Mesh(
new THREE.BoxGeometry(gameData.blockSize, 0.2, Math.sqrt(2) * gameData.blockSize * 1.5),
new THREE.MeshLambertMaterial({ color: 0xa9c878 })
);
slope.rotation.x = Math.PI / 6;
slope.position.set(worldX, topY - 0.75, worldZ + gameData.blockSize/2);
scene.add(slope);
}
if (z > 2 && Math.random() < 0.25) {
let isRock = Math.random() < 0.5;
let obj = isRock ? createRock() : createBarrel();
obj.position.set(worldX, topY + 1.0, worldZ);
scene.add(obj);
gameData.rollables.push({ mesh: obj, type: isRock ? 'rock' : 'barrel', rolling: false, speed: 0 });
}
}
}
const startZ = -(gameData.mapDepth-1)/2 * gameData.blockSize + gameData.blockSize;
const chloe = { model: createCharacterModel('chloe'), pos: new THREE.Vector3(0, 50, startZ), hp: 20, isPlayer: true, yVelocity: 0 };
scene.add(chloe.model); gameData.players.push(chloe);
if (isMultiplayer) {
const theo = { model: createCharacterModel('theo'), pos: new THREE.Vector3(3, 50, startZ), hp: 20, isPlayer: true, yVelocity: 0 };
scene.add(theo.model); gameData.players.push(theo);
}
const enemyCount = 3 + stage;
const endZ = (gameData.mapDepth-1)/2 * gameData.blockSize - gameData.blockSize;
for(let i=0; i<enemyCount; i++) {
const agent = { model: createCharacterModel('agent'), pos: new THREE.Vector3((Math.random()-0.5)*15, 0, endZ - Math.random()*10), hp: 1, isEnemy: true, yVelocity: 0 };
scene.add(agent.model); gameData.enemies.push(agent);
}
for(let i=0; i<5; i++) {
const mesh = new THREE.Mesh(new THREE.OctahedronGeometry(0.5), loadTexture('Artifact/art.png', i < 2 ? 0xffd700 : 0xaaaaaa));
mesh.position.set((Math.random()-0.5)*15, 30, (Math.random()-0.5)*20);
scene.add(mesh);
gameData.artifacts.push({ mesh, isReal: i < 2 });
}
}
// 攻撃パス計算(XZ平面での距離に変更し、高低差による判定漏れを防止)
function calculatePaths(playerPos) {
gameData.availablePaths = [];
gameData.rollables.forEach(obj => {
// 平面上の距離で索敵する
let distXZ = Math.hypot(obj.mesh.position.x - playerPos.x, obj.mesh.position.z - playerPos.z);
if(!obj.rolling && distXZ < 12) {
const start = obj.mesh.position.clone();
// 坂の下(+Z方向)へ向かってパスを伸ばす
const end = start.clone().add(new THREE.Vector3(0, -10, 25));
gameData.availablePaths.push({ obj, path: [start, end], color: obj.type === 'rock' ? 0x888888 : 0x8b4513 });
}
});
}
function drawPaths() {
scene.children = scene.children.filter(c => c.type !== 'Line');
if(!gameData.pathSelectionMode || gameData.availablePaths.length === 0) return;
gameData.availablePaths.forEach((p, index) => {
const material = new THREE.LineDashedMaterial({
color: index === gameData.selectedPathIndex ? 0x00ff00 : p.color,
dashSize: 1.0, gapSize: 0.5, linewidth: index === gameData.selectedPathIndex ? 3 : 1
});
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(p.path), material);
line.computeLineDistances(); // これがないと点線が表示されない
scene.add(line);
});
document.getElementById('targetHud').style.display = 'block';
}
// ==========================================
// 5. ゲームループと入力管理
// ==========================================
function switchState(newState) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
gameState = newState;
document.getElementById('ui').style.display = (newState === 'PLAYING') ? 'block' : 'none';
document.getElementById('targetHud').style.display = 'none';
if(newState === 'TITLE') {
document.getElementById('screenTitle').classList.add('active');
loadScreenBg('title', 'screenTitle');
isMultiplayer = false;
document.getElementById('menuSingle').classList.add('selected');
document.getElementById('menuMulti').classList.remove('selected');
}
else if(newState === 'OPENING') {
document.getElementById('screenOpening').classList.add('active');
fetchText('Text/opening.txt', 'openingText'); loadScreenBg('opening', 'screenOpening');
}
else if(newState === 'STAGE_START') {
document.getElementById('screenStageStart').classList.add('active');
document.getElementById('stageStartText').innerText = `STAGE ${currentStage} START!`;
setTimeout(() => { generateMap(currentStage); switchState('PLAYING'); }, 2000);
}
else if(newState === 'STAGE_CLEAR') {
document.getElementById('screenStageClear').classList.add('active');
document.getElementById('stageClearText').innerText = `STAGE ${currentStage} CLEAR!`;
}
else if(newState === 'GAME_OVER') { document.getElementById('screenGameOver').classList.add('active'); }
else if(newState === 'GAME_CLEAR') { document.getElementById('screenGameClear').classList.add('active'); }
else if(newState === 'ENDING') {
document.getElementById('screenEnding').classList.add('active');
fetchText('Text/ending.txt', 'endingText');
}
}
function handleInteraction(e) {
if(audioCtx.state === 'suspended') audioCtx.resume();
const isKey = e.type === 'keydown';
const key = isKey ? e.key.toLowerCase() : null;
if (gameState === 'TITLE') {
if(isKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
isMultiplayer = !isMultiplayer;
document.getElementById('menuSingle').classList.toggle('selected');
document.getElementById('menuMulti').classList.toggle('selected');
playSound('pageup');
} else if(!isKey || e.key === 'Enter' || key === 'z') {
playSound('pageup'); switchState('OPENING');
}
}
else if (gameState === 'OPENING') { playSound('pageup'); switchState('STAGE_START'); }
else if (gameState === 'STAGE_CLEAR') {
playSound('pageup'); currentStage++;
if(currentStage > MAX_STAGES) switchState('GAME_CLEAR');
else switchState('STAGE_START');
}
else if (['GAME_OVER', 'GAME_CLEAR', 'ENDING'].includes(gameState)) {
playSound('pageup'); currentStage = 1; gameData.score = 0; gameData.collectedArtifacts = 0;
switchState('TITLE');
}
else if (gameState === 'PLAYING') {
// Zキーでのパス選択・決定処理
if(isKey && key === 'z') {
if(!gameData.pathSelectionMode) {
calculatePaths(gameData.players[0].pos);
if(gameData.availablePaths.length > 0) {
gameData.pathSelectionMode = true;
gameData.selectedPathIndex = 0;
drawPaths();
}
} else {
const selected = gameData.availablePaths[gameData.selectedPathIndex];
selected.obj.rolling = true;
selected.obj.speed = 20; // Z+(画面上方向)に転がる
playSound(selected.obj.type);
gameData.players[0].model.userData.body.rotation.x = Math.PI / 4;
setTimeout(() => { gameData.players[0].model.userData.body.rotation.x = 0; }, 300);
gameData.pathSelectionMode = false;
scene.children = scene.children.filter(c => c.type !== 'Line');
document.getElementById('targetHud').style.display = 'none';
}
}
else if (gameData.pathSelectionMode && isKey) {
if(e.key === 'ArrowLeft') { gameData.selectedPathIndex = (gameData.selectedPathIndex - 1 + gameData.availablePaths.length) % gameData.availablePaths.length; drawPaths(); }
if(e.key === 'ArrowRight') { gameData.selectedPathIndex = (gameData.selectedPathIndex + 1) % gameData.availablePaths.length; drawPaths(); }
}
}
}
// リスナーの重複登録を解消し、長押し連打をブロック
window.addEventListener('keydown', (e) => {
if (e.repeat) return; // 長押しでの連続発火を防止
keys[e.key.toLowerCase()] = true;
handleInteraction(e);
});
window.addEventListener('keyup', (e) => {
keys[e.key.toLowerCase()] = false;
});
window.addEventListener('click', handleInteraction);
function applyPhysics(obj, dt) {
const groundY = getGroundY(obj.pos.x, obj.pos.z);
if (obj.pos.y > groundY + 0.1) {
obj.yVelocity -= 40 * dt;
if(obj.isPlayer && obj.yVelocity < -10 && obj.pos.y - groundY > 5 && !obj.fallingPlayed) {
playSound('fall'); obj.fallingPlayed = true;
}
} else {
if(obj.isPlayer && obj.fallingPlayed) { playSound('fallout'); obj.fallingPlayed = false; }
obj.yVelocity = 0;
obj.pos.y = groundY;
}
obj.pos.y += obj.yVelocity * dt;
if(obj.pos.y < groundY) { obj.pos.y = groundY; obj.yVelocity = 0; }
let yOffset = obj.isPlayer || obj.isEnemy ? 0 : 0.8;
if (obj.model) obj.model.position.copy(obj.pos).add(new THREE.Vector3(0, yOffset, 0));
if (obj.mesh) obj.mesh.position.copy(obj.pos).add(new THREE.Vector3(0, yOffset, 0));
}
function updatePlaying(dt) {
if(gameData.pathSelectionMode) return; // パス選択中は時間を止める
const player = gameData.players[0];
// --- プレイヤーの移動処理(方向の反転修正) ---
let moveX = 0;
let moveZ = 0;
// カメラがZ軸の+方向を向いているため、W(上)を押すとZ軸はプラス方向(奥)に進む
if (keys['arrowup'] || keys['w']) moveZ += 1;
if (keys['arrowdown'] || keys['s']) moveZ -= 1;
// 左右も反転
if (keys['arrowleft'] || keys['a']) moveX += 1;
if (keys['arrowright'] || keys['d']) moveX -= 1;
if (moveX !== 0 || moveZ !== 0) {
const speed = 12;
const dir = new THREE.Vector3(moveX, 0, moveZ).normalize();
player.pos.x += dir.x * speed * dt;
player.pos.z += dir.z * speed * dt;
// キャラクターを進行方向に向かせる
player.model.rotation.y = Math.atan2(dir.x, dir.z);
const maxX = ((gameData.mapWidth - 1) / 2) * gameData.blockSize;
player.pos.x = Math.max(-maxX, Math.min(maxX, player.pos.x));
}
// 物理と重力の適用
gameData.players.forEach(p => applyPhysics(p, dt));
gameData.enemies.forEach(e => {
applyPhysics(e, dt);
if(e.hp > 0) {
let dir = new THREE.Vector3(player.pos.x - e.pos.x, 0, player.pos.z - e.pos.z).normalize();
e.pos.x += dir.x * 3 * dt;
e.pos.z -= 4 * dt;
e.model.rotation.y = Math.atan2(dir.x, -1);
}
});
gameData.rollables.forEach(obj => {
if(!obj.pos) obj.pos = obj.mesh.position.clone();
applyPhysics(obj, dt);
if(obj.rolling) {
obj.pos.z += obj.speed * dt; // 画面上(奥)へ転がる
obj.mesh.rotation.x += obj.speed * dt * 0.5;
gameData.enemies.forEach(enemy => {
if(enemy.hp > 0 && obj.pos.distanceTo(enemy.pos) < 2.5) {
enemy.hp -= 1;
playSound('shock');
enemy.model.userData.body.rotation.x = -Math.PI/4;
}
});
}
});
gameData.enemies = gameData.enemies.filter(e => {
if(e.hp <= 0) { scene.remove(e.model); return false; }
return true;
});
gameData.artifacts.forEach((art) => {
art.mesh.rotation.y += dt;
if(art.mesh.visible && player.pos.distanceTo(art.mesh.position) < 3) {
playSound('artifact');
art.mesh.visible = false;
if(art.isReal) gameData.collectedArtifacts++;
else gameData.score += 100;
}
});
// 俯瞰(上から見下ろす)視点のカメラ
let camOffset = new THREE.Vector3(0, 30, -10);
let camTarget = player.pos.clone().add(new THREE.Vector3(0, -5, 5));
camera.position.lerp(player.pos.clone().add(camOffset), 0.1);
camera.lookAt(camTarget);
document.getElementById('hpDisplay').innerText = `HP: ${Math.floor(player.hp)} / 20`;
document.getElementById('artifactDisplay').innerText = `ARTIFACTS: ${gameData.collectedArtifacts} / 2`;
document.getElementById('scoreDisplay').innerText = `SCORE: ${gameData.score}`;
document.getElementById('stageIndicator').innerText = `STAGE ${currentStage}`;
if(player.hp <= 0 || player.pos.y < -50) {
switchState('GAME_OVER');
} else if(gameData.enemies.length === 0 || gameData.collectedArtifacts >= 2) {
switchState('STAGE_CLEAR');
}
}
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.1);
if(gameState === 'PLAYING') updatePlaying(dt);
renderer.render(scene, camera);
}
init3D();
switchState('TITLE');
animate();
</script>
</body>
</html>

コメント