横スクロールアクションゲーム?「Street Brawler」

【更新履歴】
・2026/1/8 バージョン1.0公開
・2026/1/8 バージョン1.1公開

画像
殴っている様子
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Street Brawler 3D - Jump & UI Fix</title>
<style>
    body { margin: 0; overflow: hidden; background: #000; font-family: 'Arial Black', sans-serif; user-select: none; color: white; }
    canvas { display: block; width: 100vw; height: 100vh; }

    /* UI Layer */
    #ui { 
        position: absolute; top: 0; left: 0; width: 100%; height: 100%; 
        pointer-events: none; z-index: 10; 
        display: flex; flex-direction: column; justify-content: space-between;
    }
    
    /* HUD Top Container */
    #hud-top {
        width: 100%; padding: 20px;
        display: flex; justify-content: flex-start; align-items: flex-start;
        background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent);
        box-sizing: border-box;
    }

    /* 1. Stats (Left) - Fixed Width to prevent overlap */
    .hud-section-left {
        display: flex; flex-direction: column;
        width: 320px; /* HPゲージと同程度の幅を確保 */
        margin-right: 20px;
        text-shadow: 2px 2px 0 #000;
        flex-shrink: 0;
    }
    .stage-text { font-size: 24px; color: #ffcc00; line-height: 1.2; }
    .score-text { font-size: 24px; color: #00ffff; line-height: 1.2; }

    /* 2. Player HP (Center-Left) */
    .player-hp-box {
        display: flex; flex-direction: column;
        width: 300px;
        flex-shrink: 0;
    }
    .hp-label { font-size: 20px; color: #ffcc00; text-shadow: 2px 2px 0 #000; margin-bottom: 2px; }
    .hp-frame {
        width: 100%; height: 24px; background: #220000; border: 2px solid #fff;
        transform: skewX(-20deg); overflow: hidden; box-shadow: 2px 2px 5px rgba(0,0,0,0.8);
    }
    .hp-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #ff0000, #ffff00, #00ff00); transition: width 0.1s; }

    /* 3. Enemy HP (Right Edge) */
    .enemy-hp-box {
        display: flex; flex-direction: column; align-items: flex-end;
        width: 300px;
        margin-left: auto; /* Push to right edge */
        opacity: 0; transition: opacity 0.2s;
    }
    .enemy-hp-frame {
        width: 100%; height: 24px; background: #220000; border: 2px solid #ffaaaa;
        transform: skewX(-20deg); overflow: hidden; box-shadow: 2px 2px 5px rgba(0,0,0,0.8);
    }
    .enemy-hp-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #550000, #ff0000); transition: width 0.1s; }

    /* Controls Hint */
    #controls {
        padding: 20px;
        background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
        font-size: 14px; font-family: sans-serif; text-shadow: 1px 1px 0 #000;
    }

    /* Overlay */
    #overlay {
        position: absolute; top: 0; left: 0; width: 100%; height: 100%;
        background: rgba(0,0,0,0.85); display: flex; flex-direction: column;
        justify-content: center; align-items: center; z-index: 100;
    }
    #overlay.hidden { display: none; }
    h1 { font-size: 80px; color: #ffcc00; text-shadow: 5px 5px 0 #ff0000; margin: 0; font-style: italic; }
    .blink { animation: blink 0.8s infinite; font-size: 30px; margin-top: 20px; }
    @keyframes blink { 50% { opacity: 0; } }
</style>
</head>
<body>

<canvas id="gameCanvas"></canvas>

<div id="ui">
    <div id="hud-top">
        <div class="hud-section-left">
            <div class="stage-text">STAGE <span id="stage">1</span></div>
            <div class="score-text">SCORE <span id="score">0</span></div>
        </div>

        <div class="player-hp-box">
            <div class="hp-label">PLAYER</div>
            <div class="hp-frame"><div id="hp-bar" class="hp-fill"></div></div>
        </div>

        <div class="enemy-hp-box" id="enemy-hud">
            <div class="hp-label" style="color:#ff5555;">ENEMY</div>
            <div class="enemy-hp-frame"><div id="enemy-hp-bar" class="enemy-hp-fill"></div></div>
        </div>
    </div>

    <div id="controls">
        <strong>[F2] Side View</strong> | [F1] FPS | [F3] TPS<br>
        Move: Arrows | Punch: A | Kick: Z | Jump: X | Throw: Move+A | Mega: Z+X
    </div>
</div>

<div id="overlay">
    <h1 id="msg-main">STREET BRAWLER</h1>
    <div id="msg-sub" class="blink">PRESS 'A' KEY TO START</div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>

<script>
window.onload = function() {
    // --- Audio System ---
    const AudioSys = {
        ctx: null,
        init() { const AC = window.AudioContext||window.webkitAudioContext; if(AC) this.ctx = new AC(); },
        play(freq, type, dur, vol, slide=0) {
            if(!this.ctx) return; if(this.ctx.state==='suspended') this.ctx.resume();
            const o=this.ctx.createOscillator(), g=this.ctx.createGain();
            o.type=type; o.frequency.setValueAtTime(freq, this.ctx.currentTime);
            if(slide!==0) o.frequency.linearRampToValueAtTime(freq+slide, this.ctx.currentTime+dur);
            g.gain.setValueAtTime(vol, this.ctx.currentTime);
            g.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime+dur);
            o.connect(g); g.connect(this.ctx.destination); o.start(); o.stop(this.ctx.currentTime+dur);
        },
        noise(dur, vol) {
            if(!this.ctx) return;
            const b=this.ctx.createBuffer(1,44100*dur,44100), d=b.getChannelData(0);
            for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
            const s=this.ctx.createBufferSource(); s.buffer=b;
            const g=this.ctx.createGain(); g.gain.setValueAtTime(vol, this.ctx.currentTime);
            g.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime+dur);
            s.connect(g); g.connect(this.ctx.destination); s.start();
        },
        punch() { this.noise(0.1, 0.5); this.play(150,'square',0.1,0.2,-50); },
        kick() { this.noise(0.15, 0.5); this.play(100,'square',0.15,0.2,-30); },
        hit() { this.noise(0.2, 0.8); this.play(80,'sawtooth',0.2,0.4); },
        heavyHit() { this.noise(0.3, 1.0); this.play(60,'sawtooth',0.3,0.6); },
        jump() { this.play(300,'sine',0.3,0.2,200); },
        explosion() { this.noise(0.8, 1.0); this.play(40,'sawtooth',1.0,0.8,-20); },
        pickup() { this.play(600,'sine',0.1,0.3,200); },
        heal() { this.play(400,'sine',0.2,0.3,100); this.play(600,'sine',0.4,0.3,100); },
        stageStart() { this.play(400,'triangle',1.0,0.5); },
        clear() { this.play(800,'sine',0.2,0.4,100); setTimeout(()=>this.play(1200,'sine',0.4,0.4),200); }
    };

    // --- Config ---
    const CFG = { GRAVITY: 0.7, GROUND: 0, Z_LIMIT: 45 };
    const WAVE_SIZE = 5;
    const TOTAL_ENEMIES = 25; 

    const State = {
        mode: 'START', view: 'SIDE',
        score: 0, stage: 1, kills: 0, 
        spawnedCount: 0, bossSpawned: false,
        player: null, enemies: [], items: [], particles: [],
        colors: { bg: 0x000000, road: 0x333333 },
        activeEnemy: null, activeEnemyTimer: 0
    };

    // --- Three.js ---
    const canvas = document.getElementById('gameCanvas');
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.shadowMap.enabled = true;

    // Lights
    const amb = new THREE.AmbientLight(0xffffff, 0.6);
    scene.add(amb);
    const sun = new THREE.DirectionalLight(0xffffff, 0.8);
    sun.position.set(50, 100, 50);
    sun.castShadow = true;
    sun.shadow.mapSize.set(2048, 2048);
    scene.add(sun);

    // Lock-on Cursor
    const cursorGeo = new THREE.ConeGeometry(2, 4, 4);
    const cursorMat = new THREE.MeshBasicMaterial({color: 0xffff00, wireframe: false});
    const lockOnCursor = new THREE.Mesh(cursorGeo, cursorMat);
    lockOnCursor.rotation.x = Math.PI; 
    lockOnCursor.visible = false;
    scene.add(lockOnCursor);

    // Stage
    const stageObj = new THREE.Group();
    scene.add(stageObj);

    function resetStageEnv() {
        const h = Math.random();
        State.colors.bg = new THREE.Color().setHSL(h, 0.3, 0.1);
        State.colors.road = new THREE.Color().setHSL((h+0.5)%1, 0.1, 0.2);
        scene.background = State.colors.bg;
        scene.fog = new THREE.Fog(State.colors.bg, 100, 600);
        while(stageObj.children.length) stageObj.remove(stageObj.children[0]);
        const road = new THREE.Mesh(new THREE.BoxGeometry(10000, 50, 100), new THREE.MeshLambertMaterial({color: State.colors.road}));
        road.position.y = -25; road.receiveShadow = true;
        stageObj.add(road);
        const water = new THREE.Mesh(new THREE.PlaneGeometry(10000, 1000), new THREE.MeshBasicMaterial({color: 0x001133}));
        water.rotation.x = -Math.PI/2; water.position.y = -30;
        stageObj.add(water);
    }

    // --- Helpers ---
    function createLimb(w, h, d, col, parent, x, y, z) {
        const grp = new THREE.Group(); grp.position.set(x, y, z);
        const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), new THREE.MeshLambertMaterial({color: col}));
        m.position.y = -h/2; m.castShadow = true; grp.add(m);
        if(parent) parent.add(grp);
        return grp;
    }
    function randBrightColor() { return new THREE.Color().setHSL(Math.random(), 0.8, 0.5); }

    // --- Physics & Collision ---
    function resolveCollision(char, dx, dz) {
        const nextX = char.x + dx;
        const nextZ = char.z + dz;
        
        let moveX = dx;
        let moveZ = dz;
        let onGroundY = CFG.GROUND; // Default ground

        // Check against Containers
        for (let item of State.items) {
            if (item.type !== 'crate') continue;
            
            const size = item.size / 2;
            const cx = item.mesh.position.x;
            const cz = item.mesh.position.z;
            const topY = item.mesh.position.y + size;
            const margin = 4;
            
            const overlapX = (nextX > cx - size - margin && nextX < cx + size + margin);
            const overlapZ = (nextZ > cz - size - margin && nextZ < cz + size + margin);
            
            // Check if player is currently above this crate (allowing landing)
            // Use current Y, not predicted Y, to determine "above"
            const currentlyAbove = (char.y >= topY - 2.0); // 2.0 tolerance

            if (overlapX && overlapZ) {
                if (currentlyAbove && char.vy <= 0) { 
                    // Landing on top (only if falling or level)
                    onGroundY = Math.max(onGroundY, topY);
                } else if (!currentlyAbove) {
                    // Collision from side - Stop horizontal movement towards center
                    // Identify penetration axis
                    const xOverlapOnly = (char.x > cx - size - margin && char.x < cx + size + margin);
                    const zOverlapOnly = (char.z > cz - size - margin && char.z < cz + size + margin);
                    
                    if (zOverlapOnly) { moveX = 0; } // Block X
                    else if (xOverlapOnly) { moveZ = 0; } // Block Z
                    else { moveX = 0; moveZ = 0; } // Corner/Inside
                }
            }
        }

        // Bound limits
        if (char.z + moveZ < -CFG.Z_LIMIT || char.z + moveZ > CFG.Z_LIMIT) moveZ = 0;

        return { x: moveX, z: moveZ, ground: onGroundY };
    }

    function isPositionClear(x, z, radius) {
        for(let it of State.items) {
            if(it.type==='crate') {
                const s = it.size/2 + radius;
                if(Math.abs(it.mesh.position.x - x) < s && Math.abs(it.mesh.position.z - z) < s) return false;
            }
        }
        return true;
    }

    // --- Character ---
    class Character {
        constructor(x, z, isPlayer, isBoss = false) {
            this.x=x; this.y=isPlayer?0:50; this.z=z;
            this.vx=0; this.vy=0; this.vz=0;
            this.isPlayer=isPlayer; this.isBoss=isBoss;
            this.maxHp = isPlayer ? 100 : (isBoss ? 90 : 30);
            this.hp = this.maxHp;
            this.facing=1; 
            this.state='idle'; this.stateTimer=0;
            this.dashTarget=null; this.dashAction=null;
            this.collidedItem = null;
            this.deadTimer=0;
            this.grounded=false; this.invincible=0;
            this.heldItem=null;

            this.mesh = new THREE.Group();
            scene.add(this.mesh);
            this.buildBody(isPlayer ? 0x3366cc : (isBoss ? 0x660000 : randBrightColor()));
            
            if(isBoss) {
                const scale = 1.5 * (1 + (State.stage-1)*0.1);
                this.mesh.scale.set(scale, scale, scale);
                this.y=100;
            }
        }

        buildBody(shirtCol) {
            const skin = 0xffccaa;
            this.root = new THREE.Group(); this.root.position.y=10; this.mesh.add(this.root);
            
            const pantsCol = this.isPlayer ? 0x222222 : randBrightColor();
            this.root.add(new THREE.Mesh(new THREE.BoxGeometry(3.5,3,2.5), new THREE.MeshLambertMaterial({color: pantsCol})));

            this.spine = new THREE.Group(); this.spine.position.y=1.5; this.root.add(this.spine);
            const chest = new THREE.Mesh(new THREE.BoxGeometry(4.5,6,3), new THREE.MeshLambertMaterial({color: shirtCol}));
            chest.position.y=3; chest.castShadow=true; this.spine.add(chest);

            this.headGroup = new THREE.Group(); this.headGroup.position.y=6.5; this.spine.add(this.headGroup);
            this.headMesh = new THREE.Mesh(new THREE.BoxGeometry(3,3.5,3), new THREE.MeshLambertMaterial({color: skin}));
            this.headMesh.position.y=1.75; this.headGroup.add(this.headMesh);
            
            const nose = new THREE.Mesh(new THREE.BoxGeometry(0.5,0.5,0.5), new THREE.MeshLambertMaterial({color: 0xffaaaa}));
            nose.position.set(0, 1.5, 1.6); this.headGroup.add(nose);

            const hairCol = randBrightColor();
            for(let i=0; i<3; i++) {
                const h = new THREE.Mesh(new THREE.BoxGeometry(3.2, 1.5, 3.2), new THREE.MeshLambertMaterial({color: hairCol}));
                h.position.set((Math.random()-0.5), 3 + i*0.8, (Math.random()-0.5));
                h.rotation.y = Math.random();
                this.headGroup.add(h);
            }

            this.armL = createLimb(1.3,4.5,1.3, shirtCol, this.spine, 2.8, 5.5, 0);
            this.foreL = createLimb(1.1,4.5,1.1, skin, this.armL, 0, -4.5, 0);
            this.armR = createLimb(1.3,4.5,1.3, shirtCol, this.spine, -2.8, 5.5, 0);
            this.foreR = createLimb(1.1,4.5,1.1, skin, this.armR, 0, -4.5, 0);
            this.handR = new THREE.Group(); this.handR.position.y=-4.5; this.foreR.add(this.handR);

            const bootsCol = this.isPlayer ? 0x111111 : randBrightColor();
            this.thighL = createLimb(1.6,5.5,1.6, pantsCol, this.root, 1.2, -1.5, 0);
            this.shinL = createLimb(1.4,5.5,1.4, bootsCol, this.thighL, 0, -5.5, 0);
            this.thighR = createLimb(1.6,5.5,1.6, pantsCol, this.root, -1.2, -1.5, 0);
            this.shinR = createLimb(1.4,5.5,1.4, bootsCol, this.thighR, 0, -5.5, 0);
            
            this.limbs = [this.armL, this.armR, this.foreL, this.foreR, this.thighL, this.thighR, this.shinL, this.shinR, this.spine, this.headGroup];
        }

        setVisibleParts(mode) {
            if(mode==='FPS') {
                this.headMesh.visible = false;
                this.headGroup.children.forEach(c => { if(c!==this.headMesh && c.geometry.type==='BoxGeometry') c.visible=false; }); 
            } else {
                this.headMesh.visible = true;
                this.headGroup.children.forEach(c => c.visible=true);
            }
        }

        update() {
            if(this.state==='dead') {
                this.deadTimer++;
                this.mesh.visible = Math.floor(this.deadTimer/4)%2===0;
                this.vy -= CFG.GRAVITY; this.x+=this.vx; this.y+=this.vy;
                if(this.y<=CFG.GROUND) this.y=CFG.GROUND;
                this.mesh.position.set(this.x,this.y,this.z);
                if(this.deadTimer > 40) this.remove();
                return;
            }

            // Physics
            this.vy -= CFG.GRAVITY;
            
            // Collision & Movement
            const move = resolveCollision(this, this.vx, this.vz);
            this.x += move.x;
            this.z += move.z;
            this.y += this.vy;
            this.collidedItem = move.hit;

            // Landing Logic
            if(this.y <= move.ground) {
                this.y = move.ground; 
                this.vy = 0; 
                this.grounded = true;
                this.vx *= 0.8; this.vz *= 0.8; 
            } else {
                this.grounded = false;
            }

            this.mesh.position.set(this.x, this.y, this.z);
            this.mesh.rotation.y = this.facing === 1 ? Math.PI/2 : -Math.PI/2;

            this.animate();
            if(this.stateTimer>0) { this.stateTimer--; if(this.stateTimer<=0) this.state='idle'; }
            if(this.invincible>0) { this.invincible--; this.mesh.visible = (Math.floor(this.invincible/4)%2===0); }
            else this.mesh.visible = true;
        }

        animate() {
            const t = Date.now()*0.01;
            this.limbs.forEach(o=>o.rotation.set(0,0,0));
            this.root.position.set(0,10,0); this.root.rotation.x=0;

            const s = this.state;
            if(s==='idle') {
                this.spine.rotation.x = Math.sin(t*0.2)*0.05;
                this.armL.rotation.z=0.1; this.armR.rotation.z=-0.1;
                this.armL.rotation.x=Math.sin(t*0.5)*0.1; this.armR.rotation.x=Math.sin(t*0.5+1)*0.1;
            } else if(s==='walk') {
                const w = Math.sin(t*1.5);
                this.thighL.rotation.x=w*0.8; this.shinL.rotation.x=w>0?0.8:0.1;
                this.thighR.rotation.x=-w*0.8; this.shinR.rotation.x=w<0?0.8:0.1;
                this.armL.rotation.x=-w*0.8; this.foreL.rotation.x=-0.5;
                this.armR.rotation.x=w*0.8; this.foreR.rotation.x=-0.5;
            } else if(s==='dash') { 
                this.root.rotation.x = 0.5; 
                const w = Math.sin(t*2.0);
                this.thighL.rotation.x=w*1.2; this.thighR.rotation.x=-w*1.2;
                this.armL.rotation.x=-1.5; this.armR.rotation.x=-1.5;
            } else if(s==='punch') {
                this.spine.rotation.y = -0.8;
                this.root.position.z = 2.0; 
                if(this.heldItem) {
                    this.armR.rotation.x = -2.5; this.foreR.rotation.x = 0;
                } else {
                    this.armR.rotation.x = -1.5; this.armR.rotation.z = -0.5;
                    this.foreR.rotation.x = 0;
                }
                this.armL.rotation.x=-1.0; this.foreL.rotation.x=-2.0;
            } else if(s==='kick') {
                this.root.rotation.x = -0.5;
                this.thighR.rotation.x = -1.8; this.shinR.rotation.x = 0.2;
                this.thighL.rotation.x = 0.5; this.shinL.rotation.x = 1.0;
                this.armL.rotation.x = 1.5;
            } else if(s==='jump') {
                this.root.position.y = 13;
                this.thighL.rotation.x=-0.5; this.shinL.rotation.x=1.5;
                this.thighR.rotation.x=0.5; this.shinR.rotation.x=1.0;
                this.armL.rotation.z=2.5; this.armR.rotation.z=-2.5;
            } else if(s==='hurt') {
                this.spine.rotation.x = -0.5;
                this.armL.rotation.x=-2.5; this.armR.rotation.x=-2.5;
                this.root.position.z = -1.5;
            }
        }

        takeDamage(dmg, knockback=3) {
            if(this.invincible>0 || this.state==='dead') return;
            this.hp-=dmg; this.state='hurt'; this.stateTimer=12; this.invincible=20;
            AudioSys.hit(); createBlood(this.mesh.position);
            this.vx = -this.facing * knockback;
            this.dashTarget = null; 

            if(!this.isPlayer) {
                State.activeEnemy = this; State.activeEnemyTimer = 60;
            }

            if(this.heldItem) this.dropWeapon(true);

            if(this.hp<=0) {
                this.state='dead';
                this.mesh.rotation.x = -Math.PI/2; this.mesh.position.y = 2;
                this.vx = -this.facing * 8; this.vy = 10; 
                if(!this.isPlayer) {
                    State.score+=this.isBoss?1000:10; 
                    State.kills++; 
                    if(this.isBoss) { finishStage(); }
                } else {
                    showMsg("GAME OVER", "PRESS 'A' TO RETRY");
                    State.mode='GAME_OVER';
                }
            }
        }

        dropWeapon(vanish=false) {
            if(!this.heldItem) return;
            const it = this.heldItem;
            it.holder = null; this.heldItem = null;
            if(vanish) it.remove();
            else {
                scene.add(it.mesh); it.mesh.position.set(this.x, 10, this.z);
                it.vx = this.facing * 10; it.vy = 2; it.isProjectile = true;
            }
        }

        remove() {
            scene.remove(this.mesh);
            if(this === State.player) return;
            State.enemies = State.enemies.filter(e => e!==this);
        }
    }

    // --- Items ---
    class GameItem {
        constructor(x, z, type) {
            this.type = type;
            this.hp = type==='crate' ? 5 : 999;
            this.holder = null;
            this.mesh = new THREE.Group();
            this.mesh.position.set(x, type==='crate'?5:2, z);
            scene.add(this.mesh);

            let geo, mat;
            if(type==='crate') {
                const s = 10 + Math.random()*20;
                geo = new THREE.BoxGeometry(s,s,s);
                mat = new THREE.MeshLambertMaterial({color: Math.random()*0xffffff});
                this.color = mat.color;
                this.size = s;
                this.topY = s; 
            } else if(type==='weapon') {
                geo = new THREE.CylinderGeometry(0.5,0.5,14,6);
                mat = new THREE.MeshLambertMaterial({color: 0x8b5a2b});
                this.mesh.rotation.z = Math.PI/2;
            } else if(type==='health') {
                geo = new THREE.BoxGeometry(3,3,3); mat = new THREE.MeshLambertMaterial({color: 0x00ff00});
            } else if(type==='score') {
                geo = new THREE.BoxGeometry(3,1,2); mat = new THREE.MeshLambertMaterial({color: 0xffd700});
            }
            this.body = new THREE.Mesh(geo, mat);
            this.body.castShadow = true; this.mesh.add(this.body);
            State.items.push(this);
        }

        update(player) {
            if(this.holder) return;

            if(this.isProjectile) {
                this.mesh.position.x += this.vx; this.mesh.position.y += this.vy; this.vy -= CFG.GRAVITY;
                this.mesh.rotation.z += 0.5;
                State.enemies.forEach(e=>{
                    if(e.state!=='dead' && this.mesh.position.distanceTo(e.mesh.position)<10) {
                        e.takeDamage(100, 5); AudioSys.hit(); this.remove();
                    }
                });
                if(this.mesh.position.y<0) this.remove();
                return;
            }

            if(player && this.type!=='crate' && !this.isProjectile) {
                if(player.mesh.position.distanceTo(this.mesh.position) < 10) {
                    if(this.type==='score') { State.score+=100; AudioSys.score(); this.remove(); }
                    else if(this.type==='health') { player.hp=Math.min(100, player.hp+100); AudioSys.heal(); this.remove(); }
                    else if(this.type==='weapon' && !player.heldItem) {
                        player.heldItem = this; this.holder = player;
                        scene.remove(this.mesh); player.handR.add(this.mesh);
                        this.mesh.position.set(0,0,0); this.mesh.rotation.set(Math.PI/2,0,0);
                        AudioSys.pickup();
                    }
                }
            }
        }

        hit() {
            if(this.type!=='crate') return;
            this.hp--; AudioSys.hit();
            createSpark(this.mesh.position);
            if(this.hp<=0) {
                AudioSys.explosion();
                for(let i=0; i<10; i++) createDebris(this.mesh.position, this.color);
                const r = Math.random();
                if(r<0.3) new GameItem(this.mesh.position.x, this.mesh.position.z, 'weapon');
                else if(r<0.6) new GameItem(this.mesh.position.x, this.mesh.position.z, 'health');
                this.remove();
            }
        }

        remove() {
            scene.remove(this.mesh);
            State.items = State.items.filter(i=>i!==this);
        }
    }

    // --- Particle Effects ---
    function createBlood(pos) {
        const m = new THREE.Mesh(new THREE.BoxGeometry(0.8,0.8,0.8), new THREE.MeshBasicMaterial({color: 0xff0000}));
        m.position.copy(pos); m.vx = (Math.random()-0.5)*3; m.vy = Math.random()*3+2; m.vz = (Math.random()-0.5)*3;
        scene.add(m); State.particles.push(m);
    }
    function createSpark(pos) { 
        const m = new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshBasicMaterial({color: 0xffff00}));
        m.position.copy(pos); m.vx = (Math.random()-0.5)*4; m.vy = Math.random()*4+2; m.vz = (Math.random()-0.5)*4;
        scene.add(m); State.particles.push(m);
    }
    function createDebris(pos, col) {
        const m = new THREE.Mesh(new THREE.BoxGeometry(2,2,2), new THREE.MeshBasicMaterial({color: col}));
        m.position.copy(pos); m.vx = (Math.random()-0.5)*4; m.vy = Math.random()*3+2; m.vz = (Math.random()-0.5)*4;
        scene.add(m); State.particles.push(m);
    }

    function showMsg(main, sub) {
        const el = document.getElementById('overlay');
        el.classList.remove('hidden');
        document.getElementById('msg-main').innerText = main;
        document.getElementById('msg-sub').innerText = sub;
    }

    function startStage() {
        resetStageEnv();
        State.mode = 'PLAY'; State.kills = 0; State.stageX = 0; 
        State.spawnedCount = 0; State.bossSpawned = false;
        State.player.x = 0; State.player.z = 0;
        State.player.hp = Math.min(100, State.player.hp+20);
        
        State.enemies.forEach(e => { scene.remove(e.mesh); });
        State.items.forEach(i => { scene.remove(i.mesh); });
        State.enemies = []; State.items = [];
        
        // Spawn Containers
        for(let i=0; i<8; i++) {
            let cx, cz, ok=false;
            for(let t=0; t<10; t++) {
                cx = 50+Math.random()*200; cz = (Math.random()*2-1)*(CFG.Z_LIMIT-10);
                if(isPositionClear(cx, cz, 10)) { ok=true; break; }
            }
            if(ok) new GameItem(cx, cz, 'crate');
        }

        showMsg(`STAGE ${State.stage}`, "START!");
        AudioSys.stageStart();
        setTimeout(() => { document.getElementById('overlay').classList.add('hidden'); spawnWave(); }, 2000);
    }

    function finishStage() {
        State.mode = 'CLEAR'; AudioSys.clear();
        showMsg("STAGE CLEAR!", "FULL RECOVERY!");
        State.player.hp = State.player.maxHp;
        setTimeout(() => {
            State.stage++; document.getElementById('stage').innerText = State.stage;
            startStage();
        }, 4000);
    }

    function spawnWave() {
        for(let i=0; i<5; i++) {
            let ex, ez, ok=false;
            for(let t=0; t<10; t++) {
                ex = State.player.x + 60 + Math.random()*40;
                ez = (Math.random()*2-1) * (CFG.Z_LIMIT-5);
                if(isPositionClear(ex, ez, 5)) { ok=true; break; }
            }
            if(ok) {
                State.enemies.push(new Character(ex, ez, false));
                State.spawnedCount++;
            }
        }
    }

    function spawnBoss() {
        State.bossSpawned = true;
        State.enemies.push(new Character(State.player.x+80, 0, false, true));
    }

    // --- Input & Loop ---
    const keys={};
    window.addEventListener('keydown', e => {
        if(e.key.startsWith('F')) e.preventDefault();
        keys[e.code]=true;
        if(e.code==='KeyA' && (State.mode==='START' || State.mode==='GAME_OVER')) {
            if(State.mode==='GAME_OVER') location.reload();
            else { AudioSys.init(); State.player = new Character(0,0,true); startStage(); }
        }
        if(e.code==='F1') State.view='FPS';
        if(e.code==='F2') State.view='SIDE';
        if(e.code==='F3') State.view='TPS';
    });
    window.addEventListener('keyup', e => keys[e.code]=false);

    function animate() {
        requestAnimationFrame(animate);
        const pl = State.player;

        if(State.mode==='PLAY' && pl && pl.state!=='dead') {
            
            // --- Dash Logic ---
            if(pl.dashTarget) {
                const tPos = pl.dashTarget.mesh ? pl.dashTarget.mesh.position : pl.dashTarget;
                const dx = tPos.x - pl.x;
                const dz = tPos.z - pl.z;
                const dist = Math.sqrt(dx*dx + dz*dz);
                
                // Stop Dash if blocked by crate (Collision logic handles this but let's check intent)
                // If moving towards crate and hit it, update target
                if(pl.collidedItem && pl.collidedItem.type==='crate' && pl.collidedItem !== pl.dashTarget) {
                    pl.dashTarget = pl.collidedItem; // Switch target to the one blocking
                }

                // Hit distance check: larger for large crates
                const hitRange = 10 + (pl.dashTarget.size||0)/2;

                if(dist > hitRange) {
                    pl.vx = Math.sign(dx) * 2.5; 
                    pl.vz = Math.sign(dz) * 1.5;
                    pl.state = 'dash';
                } else {
                    pl.vx = 0; pl.vz = 0; pl.dashTarget = null;
                    pl.state = pl.dashAction; pl.stateTimer = 20;
                    if(pl.dashAction==='punch') { AudioSys.punch(); setTimeout(()=>checkHit(pl, pl.heldItem?15:10, 10), 100); }
                    else if(pl.dashAction==='throw') { AudioSys.heavyHit(); checkHit(pl, 12, 20, true); }
                    else if(pl.dashAction==='mega') { AudioSys.heavyHit(); checkHit(pl, 20, 30, true); }
                    else { AudioSys.kick(); setTimeout(()=>checkHit(pl, 12, 10), 150); }
                }
            } 
            else if(!pl.isAttacking) { // Allow input even in air (Fixed "Jump Freeze")
                let mx=0, mz=0; const spd=1.2;
                
                if(State.view==='SIDE') {
                    if(keys.ArrowRight) { mx=spd; pl.facing=1; }
                    if(keys.ArrowLeft) { mx=-spd; pl.facing=-1; }
                    if(keys.ArrowUp) mz=-spd; 
                    if(keys.ArrowDown) mz=spd; 
                } else { 
                    if(keys.ArrowUp) { mx=spd; pl.facing=1; }
                    if(keys.ArrowDown) { mx=-spd; pl.facing=-1; }
                    if(keys.ArrowRight) mz=spd;
                    if(keys.ArrowLeft) mz=-spd;
                }

                if(mx!==0 || mz!==0) {
                    if(pl.grounded) pl.state='walk'; 
                    pl.vx=mx; pl.vz=mz;
                } else if(pl.grounded) pl.state='idle';

                const startAttack = (type) => {
                    let target=null, minDist=60;
                    // Priority 1: Contact Range Crate
                    State.items.filter(i=>i.type==='crate').forEach(i => {
                        if(pl.mesh.position.distanceTo(i.mesh.position) < 15 + i.size/2) {
                            target=i; minDist=0;
                        }
                    });

                    // Priority 2: Enemies
                    if(!target) {
                        State.enemies.forEach(e => {
                            if(e.state==='dead') return;
                            const d = pl.mesh.position.distanceTo(e.mesh.position);
                            const dx = e.x - pl.x;
                            if(Math.sign(dx)===pl.facing && d < minDist) { minDist=d; target=e; }
                        });
                    }

                    // Priority 3: Far Crates
                    if(!target) {
                        State.items.filter(i=>i.type==='crate').forEach(i => {
                            const d = pl.mesh.position.distanceTo(i.mesh.position);
                            const dx = i.mesh.position.x - pl.x;
                            if(Math.sign(dx)===pl.facing && d < minDist) { minDist=d; target=i; }
                        });
                    }

                    const hitRange = 10 + (target ? (target.size||0)/2 : 0);

                    if(target && minDist > hitRange) {
                        pl.dashTarget = target; pl.dashAction = type;
                    } else {
                        pl.state=type; pl.stateTimer=20;
                        if(type==='punch') { AudioSys.punch(); setTimeout(()=>checkHit(pl, pl.heldItem?15:10, 10), 100); }
                        else if(type==='throw') { AudioSys.heavyHit(); checkHit(pl, 12, 20, true); }
                        else if(type==='mega') { AudioSys.heavyHit(); checkHit(pl, 20, 30, true); }
                        else { AudioSys.kick(); setTimeout(()=>checkHit(pl, 12, 10), 150); }
                    }
                };

                if(keys.KeyA) {
                    if((mx!==0||mz!==0)) startAttack('throw');
                    else startAttack('punch');
                }
                if(keys.KeyZ) {
                    if(keys.KeyX) startAttack('mega');
                    else if(pl.heldItem) { pl.dropWeapon(); AudioSys.punch(); }
                    else startAttack('kick');
                }
                if(keys.KeyX && !keys.KeyZ) { pl.vy=5; pl.state='jump'; AudioSys.jump(); }
            }
        }

        // Lock-on Cursor
        let lockedObj = null;
        if(pl && State.mode==='PLAY') {
            let minDist=60;
            // 1. Close Crate
            State.items.filter(i=>i.type==='crate').forEach(i => {
                if(pl.mesh.position.distanceTo(i.mesh.position) < 15 + i.size/2) lockedObj=i;
            });
            // 2. Enemy
            if(!lockedObj) {
                State.enemies.forEach(e => {
                    if(e.state==='dead') return;
                    const d = pl.mesh.position.distanceTo(e.mesh.position);
                    const dx = e.x - pl.x;
                    if(Math.sign(dx)===pl.facing && d < minDist) { minDist=d; lockedObj=e; }
                });
            }
            // 3. Far Crate
            if(!lockedObj) {
                State.items.filter(i=>i.type==='crate').forEach(i => {
                    const d = pl.mesh.position.distanceTo(i.mesh.position);
                    const dx = i.mesh.position.x - pl.x;
                    if(Math.sign(dx)===pl.facing && d < minDist) { minDist=d; lockedObj=i; }
                });
            }
        }
        if(lockedObj) {
            lockOnCursor.visible = true;
            const pos = lockedObj.mesh ? lockedObj.mesh.position : lockedObj;
            const h = lockedObj.isBoss?40 : (lockedObj.size ? lockedObj.size + 15 : 25);
            lockOnCursor.position.set(pos.x, pos.y + h, pos.z);
        } else {
            lockOnCursor.visible = false;
        }

        if(pl) {
            pl.setVisibleParts(State.view);
            pl.update();
            document.getElementById('hp-bar').style.width = pl.hp+'%';
            document.getElementById('score').innerText = State.score;
            
            // Wave Logic
            if(State.enemies.length === 0 && State.spawnedCount < TOTAL_ENEMIES) {
                spawnWave();
            } else if(State.enemies.length === 0 && State.spawnedCount >= TOTAL_ENEMIES && !State.bossSpawned) {
                spawnBoss();
            }
            
            // Camera
            if(State.view==='SIDE') {
                camera.position.set(pl.x, 25, 120);
                camera.lookAt(pl.x, 10, 0);
            } else if(State.view==='TPS') {
                camera.position.set(pl.x - pl.facing*50, 40, pl.z + 20);
                camera.lookAt(pl.x + pl.facing*20, 10, pl.z);
            } else { // FPS
                camera.position.set(pl.x + pl.facing*2, 17, pl.z);
                camera.lookAt(pl.x + pl.facing*100, 15, pl.z);
            }
        }
        
        State.enemies.forEach(e => {
            if(e.state!=='dead' && State.mode==='PLAY') {
                const dx = pl.x - e.x;
                if(Math.abs(dx)<60) {
                    if(e.state!=='punch' && e.state!=='kick') {
                        if(Math.abs(dx)>8) { 
                            e.state='walk'; e.vx=Math.sign(dx)*0.4; e.facing=Math.sign(dx); 
                            e.vz = (pl.z - e.z) * 0.05;
                        } else if(Math.random()<0.02) { 
                            e.vx=0; e.state='punch'; e.stateTimer=15; AudioSys.punch();
                            setTimeout(() => checkHit(e, 10, 5), 150);
                        } else { e.vx=0; e.state='idle'; }
                    } else { e.vx=0; e.vz=0; }
                }
            }
            e.update();
        });

        State.items.forEach(i => i.update(State.mode==='PLAY'?pl:null));

        for(let i=State.particles.length-1; i>=0; i--) {
            const p = State.particles[i];
            p.position.add(new THREE.Vector3(p.vx, p.vy, p.vz));
            p.vy -= 0.1; p.rotation.x+=0.1;
            if(p.position.y<0) { scene.remove(p); State.particles.splice(i,1); }
        }

        const ehud = document.getElementById('enemy-hud');
        if(State.activeEnemy && State.activeEnemyTimer > 0 && State.activeEnemy.state!=='dead') {
            State.activeEnemyTimer--;
            ehud.style.opacity = 1;
            document.getElementById('enemy-hp-bar').style.width = (State.activeEnemy.hp / State.activeEnemy.maxHp)*100 + '%';
        } else {
            ehud.style.opacity = 0;
        }

        renderer.render(scene, camera);
    }

    function checkHit(atk, range, dmg, blowAway=false) {
        const targets = atk.isPlayer ? State.enemies : [State.player];
        targets.forEach(t => {
            const dx = t.x - atk.x;
            if(Math.sign(dx)===atk.facing && Math.abs(dx)<range && Math.abs(t.z-atk.z)<6) {
                t.takeDamage(dmg, blowAway ? 15 : 3);
            }
        });
        if(atk.isPlayer) {
            State.items.forEach(i => {
                const dx = i.mesh.position.x - atk.x;
                // Extended range for large crates
                const r = range + (i.size||0)/2 + 5;
                if(i.type==='crate' && Math.sign(dx)===atk.facing && Math.abs(dx)<r && Math.abs(i.mesh.position.z-atk.z)<10) {
                    i.hit();
                }
            });
        }
    }

    window.onresize = () => {
        camera.aspect = window.innerWidth/window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
    };
    
    animate();
};
</script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
買うたび 抽選 ※条件・上限あり \note クリエイター感謝祭ポイントバックキャンペーン/最大全額もどってくる! 12.1 月〜1.14 水 まで
横スクロールアクションゲーム?「Street Brawler」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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