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


【更新履歴】

 ・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>

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

コメント

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

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