ARENA FPS

function init3DGame() { gameStarted = true; matchPerfectEnded = false; setUIScreen("GAME"); crosshair.classList.remove("hidden"); hudContainer.classList.remove("hidden"); scoreBoard.classList.remove("hidden"); blocker.classList.remove("hidden"); scoreYou = 0; scoreEnemy = 0; scoreYouEl.innerText = 0; scoreEnemyEl.innerText = 0; document.getElementById("sbMyName").innerText = myPlayerName; document.getElementById("sbEnemyName").innerText = enemyPlayerName; scene = new THREE.Scene(); scene.background = new THREE.Color(0xf0f3f5); scene.fog = new THREE.FogExp2(0xf0f3f5, 0.025); camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500); raycaster = new THREE.Raycaster(); const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.7); dirLight.position.set(40, 100, 20); scene.add(dirLight); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); const container = document.getElementById("gameCanvas"); container.innerHTML = ""; container.appendChild(renderer.domElement); const floor = new THREE.Mesh(new THREE.PlaneGeometry(120, 120), new THREE.MeshLambertMaterial({ color: 0xe8ecef })); floor.rotation.x = -Math.PI / 2; scene.add(floor); const grid = new THREE.GridHelper(120, 40, 0x00ffaa, 0xd0d5dd); grid.position.y = 0.01; scene.add(grid); const wallMat = new THREE.MeshLambertMaterial({ color: 0xf5f6fa }); const wallGeoNS = new THREE.BoxGeometry(120, 120, 2); const wallGeoWE = new THREE.BoxGeometry(2, 120, 120); const wallNorth = new THREE.Mesh(wallGeoNS, wallMat); wallNorth.position.set(0, 5, -60); scene.add(wallNorth); const wallSouth = new THREE.Mesh(wallGeoNS, wallMat); wallSouth.position.set(0, 5, 60); scene.add(wallSouth); const wallWest = new THREE.Mesh(wallGeoWE, wallMat); wallWest.position.set(-60, 5, 0); scene.add(wallWest); const wallEast = new THREE.Mesh(wallGeoWE, wallMat); wallEast.position.set(60, 5, 0); scene.add(wallEast); const objectMat = new THREE.MeshLambertMaterial({ color: 0xb0b8c4 }); const edgeMat = new THREE.MeshBasicMaterial({ color: 0x00ffaa, wireframe: true }); collidableBoxes = []; let mapSeed = currentRoomNumber * 54321; const obstacleCount = 18; for (let i = 0; i < obstacleCount; i++) { let rand1 = seededRandom(mapSeed++); let rand2 = seededRandom(mapSeed++); let rand3 = seededRandom(mapSeed++); let rand4 = seededRandom(mapSeed++); let posX = -45 + rand1 * 90; let posZ = -42 + rand2 * 84; if (Math.abs(posZ) > 33 && Math.abs(posX) < 15) { posZ += (posZ > 0 ? -15 : 15); } let width = 3.5 + rand3 * 4.5; let height = 6.0 + rand4 * 7.0; let depth = 3.5 + rand1 * 4.5; let geom, edgeGeom; if (rand2 > 0.5) { geom = new THREE.CylinderGeometry(width / 2, width / 2, height, 12); edgeGeom = new THREE.CylinderGeometry(width / 2, width / 2, height, 12); } else { geom = new THREE.BoxGeometry(width, height, depth); edgeGeom = new THREE.BoxGeometry(width, height, depth); } const obstacle = new THREE.Mesh(geom, objectMat); obstacle.position.set(posX, height / 2, posZ); scene.add(obstacle); const edge = new THREE.Mesh(edgeGeom, edgeMat); edge.position.set(posX, height / 2, posZ); edge.scale.multiplyScalar(1.002); scene.add(edge); obstacle.geometry.computeBoundingBox(); obstacle.userData = { radius: width / 2, height: height, isCylinder: (rand2 > 0.5), width: width, depth: depth }; collidableBoxes.push(obstacle); } enemyPlayerMesh = new THREE.Group(); const mechRed = new THREE.MeshLambertMaterial({ color: 0xff4757 }); const mechDark = new THREE.MeshLambertMaterial({ color: 0x2f3542 }); const glowGreen = new THREE.MeshBasicMaterial({ color: 0x00ffaa }); const visorMat = new THREE.MeshBasicMaterial({ color: 0x111116 }); const hitBoxMesh = new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.2, 3.4, 8), new THREE.MeshBasicMaterial({ visible: false })); hitBoxMesh.position.y = 1.7; enemyPlayerMesh.add(hitBoxMesh); const chest = new THREE.Mesh(new THREE.BoxGeometry(1.6, 1.3, 1.1), mechRed); chest.position.y = 2.1; const core = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.4, 8), glowGreen); core.rotation.x = Math.PI / 2; core.position.set(0, 0.2, 0.5); chest.add(core); enemyPlayerMesh.add(chest); const belly = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.6, 0.5, 12), mechDark); belly.position.y = 1.3; enemyPlayerMesh.add(belly); const headGroup = new THREE.Group(); headGroup.position.y = 3.0; const headCube = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.7, 0.8), mechRed); headGroup.add(headCube); const visor = new THREE.Mesh(new THREE.BoxGeometry(0.84, 0.2, 0.4), visorMat); visor.position.set(0, 0.1, -0.3); headGroup.add(visor); const visorLine = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.04, 0.42), glowGreen); visorLine.position.set(0, 0.1, -0.3); headGroup.add(visorLine); const antenna = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.7, 4), mechDark); antenna.position.set(0.3, 0.5, 0.1); antenna.rotation.z = -0.2; headGroup.add(antenna); enemyPlayerMesh.add(headGroup); enemyPlayerMesh.position.set(0, 0, -20); scene.add(enemyPlayerMesh); shootTargets = [hitBoxMesh, ...collidableBoxes, wallNorth, wallSouth, wallWest, wallEast]; weaponGroup = new THREE.Group(); const gunBarrel = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 1.8, 8), new THREE.MeshLambertMaterial({ color: 0x33333b })); gunBarrel.rotateX(Math.PI / 2); weaponGroup.add(gunBarrel); const magazine = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.7, 0.3), new THREE.MeshLambertMaterial({ color: 0xffffff })); magazine.name = "magazine"; magazine.position.set(0, -0.4, 0.2); weaponGroup.add(magazine); weaponGroup.position.set(0.4, -0.4, -1.0); camera.add(weaponGroup); scene.add(camera); window.removeEventListener('keydown', onGameKeyDown); window.removeEventListener('keyup', onGameKeyUp); window.removeEventListener('mousedown', onMouseDown); window.removeEventListener('mouseup', onMouseUp); document.removeEventListener('pointerlockchange', onPointerLockChange); window.removeEventListener('resize', onWindowResize); window.addEventListener('keydown', onGameKeyDown); window.addEventListener('keyup', onGameKeyUp); window.addEventListener('mousedown', onMouseDown); window.addEventListener('mouseup', onMouseUp); document.addEventListener('pointerlockchange', onPointerLockChange); window.addEventListener('resize', onWindowResize); blocker.onclick = () => { document.body.requestPointerLock(); }; startNewRound(); prevTime = performance.now(); animate(); } function startNewRound() { roundActive = false; currentAmmo = maxAmmo; isReloading = false; myShield = 100; myHp = 100; updateVitalsHud(); isCrouching = false; currentContinuousShots = 0; ammoPanel.innerText = `AMMO: ${currentAmmo}/${maxAmmo}`; velocity.set(0,0,0); switchSlot(1); myGrenadesLeft = 3; grenadeCountEl.innerText = myGrenadesLeft; activeGrenades.forEach(g => scene.remove(g.mesh)); activeGrenades = []; clearInterval(rInterval); if (playerRole === "HOST") { camera.position.set(0, 2, 40); euler.set(0, Math.PI, 0); } else { camera.position.set(0, 2, -40); euler.set(0, 0, 0); } camera.quaternion.setFromEuler(euler); let rCount = 3; roundOverlay.innerText = rCount; rInterval = setInterval(() => { rCount--; if (rCount > 0) { roundOverlay.innerText = rCount; } else if (rCount === 0) { roundOverlay.innerText = "START!"; roundActive = true; blocker.classList.add("hidden"); } else { clearInterval(rInterval); roundOverlay.innerText = ""; } }, 1000); } function onPointerLockChange() { if (document.pointerLockElement === document.body) { blocker.classList.add("hidden"); document.addEventListener('mousemove', onMouseMove); } else { blocker.classList.remove("hidden"); document.removeEventListener('mousemove', onMouseMove); onMouseUp(); } } function onMouseMove(event) { if(!roundActive || document.pointerLockElement !== document.body) return; euler.setFromQuaternion(camera.quaternion); euler.y -= event.movementX * configGame.sensitivity; euler.x -= event.movementY * configGame.sensitivity; euler.x = Math.max(-Math.PI / 2.3, Math.min(Math.PI / 2.3, euler.x)); camera.quaternion.setFromEuler(euler); } function onGameKeyDown(e) { if(!roundActive) return; if (e.code === configGame.binds.forward) moveState.f = true; if (e.code === configGame.binds.backward) moveState.b = true; if (e.code === configGame.binds.left) moveState.l = true; if (e.code === configGame.binds.right) moveState.r = true; if (e.code === configGame.binds.jump && canJump && !isCrouching) { velocity.y = 55.0; canJump = false; } if (e.code === configGame.binds.reload && !isReloading && currentAmmo < maxAmmo && currentSlot === 1) startReload(); if (e.code === "ControlLeft") { isCrouching = true; } if (e.code === "Digit1") switchSlot(1); if (e.code === "Digit2") switchSlot(2); } function onGameKeyUp(e) { if (e.code === configGame.binds.forward) moveState.f = false; if (e.code === configGame.binds.backward) moveState.b = false; if (e.code === configGame.binds.left) moveState.l = false; if (e.code === configGame.binds.right) moveState.r = false; if (e.code === "ControlLeft") { isCrouching = false; } } function onMouseDown(e) { if(roundActive && e.button === 0 && document.pointerLockElement === document.body) { if(currentSlot === 1) { isShooting = true; } else if(currentSlot === 2) { throwGrenade(); } } } function onMouseUp() { isShooting = false; currentContinuousShots = 0; } function onWindowResize() { if(camera && renderer) { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } } function updateVitalsHud() { shieldBar.style.width = `${myShield}%`; shieldTxt.innerText = Math.ceil(myShield); hpBar.style.width = `${myHp}%`; hpTxt.innerText = Math.ceil(myHp); } function startReload() { isReloading = true; ammoPanel.classList.add("reloading"); ammoPanel.innerText = "RELOADING..."; reloadTimer = performance.now(); currentContinuousShots = 0; } function switchSlot(slotNum) { if (isReloading) return; currentSlot = slotNum; if(slotNum === 1) { slot1El.classList.add("active"); slot2El.classList.remove("active"); weaponGroup.visible = true; ammoPanel.style.opacity = "1"; } else { slot1El.classList.remove("active"); slot2El.classList.add("active"); weaponGroup.visible = false; ammoPanel.style.opacity = "0.3"; isShooting = false; } } function throwGrenade() { if (myGrenadesLeft <= 0) return; myGrenadesLeft--; grenadeCountEl.innerText = myGrenadesLeft; let spawnPos = new THREE.Vector3(); camera.getWorldPosition(spawnPos); let lookDir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); spawnPos.addScaledVector(lookDir, 1.2); let grenadeVelocity = new THREE.Vector3().copy(lookDir).multiplyScalar(28); grenadeVelocity.y += 6.5; let grenadeId = "g_" + playerRole + "_" + performance.now(); spawnLocalGrenade(grenadeId, spawnPos, grenadeVelocity, true); if(activeConn && activeConn.open) { activeConn.send({ type: "GRENADE", id: grenadeId, pos: spawnPos, vel: grenadeVelocity }); } setTimeout(() => { if(gameStarted && currentSlot === 2) switchSlot(1); }, 150); } function spawnLocalGrenade(id, pos, vel, isMine) { const gGeo = new THREE.SphereGeometry(0.35, 8, 8); const gMat = new THREE.MeshStandardMaterial({ color: 0xff3333, roughness: 0.4, emissive: 0x440000 }); const mesh = new THREE.Mesh(gGeo, gMat); mesh.position.copy(pos); scene.add(mesh); activeGrenades.push({ id: id, mesh: mesh, vel: vel, timer: 2.2, isMine: isMine }); } function resolveGrenadeCollision(pos, vel, radius = 0.35) { const limit = 58.0 - radius; if (pos.x < -limit) { pos.x = -limit; vel.x = -vel.x * 0.5; } if (pos.x > limit) { pos.x = limit; vel.x = -vel.x * 0.5; } if (pos.z < -limit) { pos.z = -limit; vel.z = -vel.z * 0.5; } if (pos.z > limit) { pos.z = limit; vel.z = -vel.z * 0.5; } for (let box of collidableBoxes) { const uData = box.userData; const bx = box.position.x; const bz = box.position.z; if (uData && uData.isCylinder) { let dx = pos.x - bx; let dz = pos.z - bz; let dist = Math.sqrt(dx * dx + dz * dz); let minDist = uData.radius + radius; if (dist < minDist) { let overlap = minDist - dist; let nx = dx / (dist || 1); let nz = dz / (dist || 1); pos.x += nx * overlap; pos.z += nz * overlap; let dot = vel.x * nx + vel.z * nz; if (dot < 0) { vel.x -= 1.5 * dot * nx; vel.z -= 1.5 * dot * nz; vel.x *= 0.6; vel.z *= 0.6; } } } else { let halfW = (uData ? uData.width : 5) / 2 + radius; let halfD = (uData ? uData.depth : 5) / 2 + radius; let minX = bx - halfW, maxX = bx + halfW; let minZ = bz - halfD, maxZ = bz + halfD; if (pos.x > minX && pos.x < maxX && pos.z > minZ && pos.z < maxZ) { let pLeft = pos.x - minX; let pRight = maxX - pos.x; let pFront = pos.z - minZ; let pBack = maxZ - pos.z; let minPen = Math.min(pLeft, pRight, pFront, pBack); if (minPen === pLeft) { pos.x = minX; vel.x = -vel.x * 0.5; } else if (minPen === pRight) { pos.x = maxX; vel.x = -vel.x * 0.5; } else if (minPen === pFront) { pos.z = minZ; vel.z = -vel.z * 0.5; } else if (minPen === pBack) { pos.z = maxZ; vel.z = -vel.z * 0.5; } } } } } function explodeGrenade(grenade) { scene.remove(grenade.mesh); let exPos = grenade.mesh.position; triggerGrenadeExplosionEffects(exPos); if (grenade.isMine && enemyPlayerMesh) { let dist = exPos.distanceTo(enemyPlayerMesh.position); if (dist < 6.5) { let dmg = Math.floor((1.0 - (dist / 6.5)) * 60) + 20; if(activeConn && activeConn.open) { activeConn.send({ type: "HIT", damage: dmg, isHeadshot: false }); } } } } function triggerGrenadeExplosionEffects(pos) { const expGeo = new THREE.SphereGeometry(4.0, 12, 12); const expMat = new THREE.MeshBasicMaterial({ color: 0xffaa00, transparent: true, opacity: 0.7, wireframe: true }); const expMesh = new THREE.Mesh(expGeo, expMat); expMesh.position.copy(pos); scene.add(expMesh); let startTime = performance.now(); function animateExplosion() { let el = performance.now() - startTime; if(el < 350) { expMesh.scale.multiplyScalar(1.06); expMat.opacity *= 0.9; requestAnimationFrame(animateExplosion); } else { scene.remove(expMesh); expGeo.dispose(); expMat.dispose(); } } animateExplosion(); } function shootWeapon() { if (isReloading || currentAmmo <= 0) { if(currentAmmo <= 0 && !isReloading) startReload(); return; } currentAmmo--; ammoPanel.innerText = `AMMO: ${currentAmmo}/${maxAmmo}`; currentContinuousShots++; euler.setFromQuaternion(camera.quaternion); let recoilX = 0.008 + (currentContinuousShots * 0.002); let recoilY = (Math.random() - 0.5) * (0.004 + currentContinuousShots * 0.001); euler.x += recoilX; euler.y += recoilY; euler.x = Math.max(-Math.PI / 2.3, Math.min(Math.PI / 2.3, euler.x)); camera.quaternion.setFromEuler(euler); let baseSpread = canJump ? (isCrouching ? 0.002 : (moveState.f || moveState.b || moveState.l || moveState.r ? 0.03 : 0.01)) : 0.07; let totalSpread = baseSpread + (currentContinuousShots * 0.004); const fromPos = new THREE.Vector3(); weaponGroup.getWorldPosition(fromPos); const shootDir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); let spreadOffset = new THREE.Vector3((Math.random() - 0.5) * totalSpread, (Math.random() - 0.5) * totalSpread, 0).applyQuaternion(camera.quaternion); shootDir.add(spreadOffset).normalize(); let toPos = new THREE.Vector3().copy(shootDir).multiplyScalar(120).add(camera.position); raycaster.set(camera.position, shootDir); const intersects = raycaster.intersectObjects(shootTargets, true); if (intersects.length > 0 && intersects[0].distance < 120) { toPos.copy(intersects[0].point); if (intersects[0].object === enemyPlayerMesh.children[0]) { let isHeadshot = intersects[0].point.y > (enemyPlayerMesh.position.y + 2.4); let dmg = isHeadshot ? 45 : 22; if(activeConn && activeConn.open) { activeConn.send({ type: "HIT", damage: dmg, isHeadshot: isHeadshot }); } } } spawnBulletLaser(fromPos, toPos); spawnShellEject(fromPos); if(activeConn && activeConn.open) { activeConn.send({ type: "SHOOT", from: fromPos, to: toPos }); } } function spawnBulletLaser(from, to) { const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]); const lineMat = new THREE.LineBasicMaterial({ color: 0xffd700, linewidth: 2, transparent: true, opacity: 0.8 }); const laser = new THREE.Line(lineGeo, lineMat); scene.add(laser); setTimeout(() => { scene.remove(laser); lineGeo.dispose(); lineMat.dispose(); }, 70); } function spawnShellEject(weaponPos) { const shellGeo = new THREE.CylinderGeometry(0.02, 0.02, 0.08, 6); const shellMat = new THREE.MeshLambertMaterial({ color: 0xd4af37 }); const shell = new THREE.Mesh(shellGeo, shellMat); shell.position.copy(weaponPos); shell.rotation.set(Math.random()*Math.PI, Math.random()*Math.PI, Math.random()*Math.PI); const rightDir = new THREE.Vector3(1, 0.3, 0.2).applyQuaternion(camera.quaternion).normalize(); const shellVelocity = rightDir.multiplyScalar(3.5 + Math.random()*1.5); shellVelocity.y += 2.0; scene.add(shell); activeShells.push({ mesh: shell, vel: shellVelocity, spawnTime: performance.now() }); } function applyDamage(dmg, isHeadshot) { hitFlashEl.style.background = "rgba(255, 0, 0, 0.35)"; setTimeout(() => { hitFlashEl.style.background = "rgba(255, 0, 0, 0)"; }, 80); if (myShield > 0) { myShield -= dmg; if (myShield < 0) { myHp += myShield; myShield = 0; } } else { myHp -= dmg; } updateVitalsHud(); if (myHp <= 0) { roundActive = false; scoreEnemy++; scoreEnemyEl.innerText = scoreEnemy; if(scoreEnemy >= 3) { triggerMatchResult(false, "相手が3勝先取しました。"); } else { if(activeConn && activeConn.open) activeConn.send({ type: "ROUND_WIN" }); roundOverlay.innerText = "ROUND LOST"; setTimeout(startNewRound, 2500); } } } function triggerMatchResult(isWin, reason) { matchPerfectEnded = true; gameStarted = false; screenResult.classList.remove("hidden"); setUIScreen("RESULT"); document.getElementById("resultTitle").innerText = isWin ? "VICTORY" : "DEFEAT"; document.getElementById("resultTitle").style.color = isWin ? "#00ffaa" : "#ff4757"; document.getElementById("resultReason").innerText = reason; try { document.exitPointerLock(); } catch(e){} // 相手にマッチ終了およびルーム状態同期(リセット)の信号を送信 if(activeConn && activeConn.open) { activeConn.send({ type: "SYNC_RESET" }); } } function syncResetRoomState() { // ルーム情報を同期してクリーンアップし、ロビー状態へ復帰 gameStarted = false; matchPerfectEnded = true; try { document.exitPointerLock(); } catch(e){} screenResult.classList.add("hidden"); crosshair.classList.add("hidden"); hudContainer.classList.add("hidden"); scoreBoard.classList.add("hidden"); blocker.classList.add("hidden"); const container = document.getElementById("gameCanvas"); container.innerHTML = ""; // ロビー画面へ戻して再戦準備 showLobbyScreen(); } function resetToMenu() { matchPerfectEnded = true; gameStarted = false; if(peer) peer.destroy(); screenResult.classList.add("hidden"); screenMenu.classList.remove("hidden"); setUIScreen("MENU"); crosshair.classList.add("hidden"); hudContainer.classList.add("hidden"); scoreBoard.classList.add("hidden"); blocker.classList.add("hidden"); const container = document.getElementById("gameCanvas"); container.innerHTML = ""; } function checkWallCollision(nextPos) { if (nextPos.x < -57 || nextPos.x > 57 || nextPos.z < -57 || nextPos.z > 57) return true; const pr = 1.0; for (let box of collidableBoxes) { const uData = box.userData; const bx = box.position.x; const bz = box.position.z; if (uData && uData.isCylinder) { let dx = nextPos.x - bx; let dz = nextPos.z - bz; let dist = Math.sqrt(dx * dx + dz * dz); if (dist < uData.radius + pr) return true; } else { let halfW = (uData ? uData.width : 5) / 2; let halfD = (uData ? uData.depth : 5) / 2; const minX = bx - halfW - pr, maxX = bx + halfW + pr; const minZ = bz - halfD - pr, maxZ = bz + halfD + pr; if (nextPos.x > minX && nextPos.x < maxX && nextPos.z > minZ && nextPos.z < maxZ) return true; } } return false; } function animate() { if (!gameStarted) return; requestAnimationFrame(animate); const time = performance.now(); const delta = Math.min((time - prevTime) / 1000, 0.1); let speedModifier = isCrouching ? 65.0 : 180.0; velocity.x -= velocity.x * 12.0 * delta; velocity.z -= velocity.z * 12.0 * delta; velocity.y -= 9.8 * 15.0 * delta; if (roundActive) { let front = Number(moveState.f) - Number(moveState.b); let side = Number(moveState.r) - Number(moveState.l); let moveVector = new THREE.Vector3(side, 0, -front).normalize(); moveVector.applyEuler(new THREE.Euler(0, euler.y, 0, 'YXZ')); if (moveState.f || moveState.b || moveState.l || moveState.r) { velocity.x += moveVector.x * speedModifier * delta; velocity.z += moveVector.z * speedModifier * delta; } } const nextX = camera.position.x + velocity.x * delta; const nextZ = camera.position.z + velocity.z * delta; if (!checkWallCollision(new THREE.Vector3(nextX, camera.position.y, camera.position.z))) { camera.position.x = nextX; } else { velocity.x = 0; } if (!checkWallCollision(new THREE.Vector3(camera.position.x, camera.position.y, nextZ))) { camera.position.z = nextZ; } else { velocity.z = 0; } camera.position.y += velocity.y * delta; let floorLevel = isCrouching ? 1.0 : 2.0; if (camera.position.y < floorLevel) { velocity.y = 0; camera.position.y += (floorLevel - camera.position.y) * 0.3; if(Math.abs(camera.position.y - floorLevel) < 0.05) camera.position.y = floorLevel; canJump = true; } if (activeConn && activeConn.open && gameStarted) { activeConn.send({ type: "SYNC", pos: camera.position, quat: camera.quaternion }); } for (let i = activeGrenades.length - 1; i >= 0; i--) { let g = activeGrenades[i]; g.vel.y -= 9.8 * 2.5 * delta; g.mesh.position.x += g.vel.x * delta; g.mesh.position.y += g.vel.y * delta; g.mesh.position.z += g.vel.z * delta; if (g.mesh.position.y < 0.35) { g.mesh.position.y = 0.35; g.vel.y = -g.vel.y * 0.45; g.vel.x *= 0.7; g.vel.z *= 0.7; } resolveGrenadeCollision(g.mesh.position, g.vel, 0.35); g.mesh.rotation.x += 3 * delta; g.timer -= delta; if (g.timer <= 0) { explodeGrenade(g); activeGrenades.splice(i, 1); } } let currentCrosshairSize = canJump ? (isCrouching ? 4 : (moveState.f || moveState.b || moveState.l || moveState.r ? 18 : 10)) : 35; currentCrosshairSize += (currentContinuousShots * 4); chT.style.transform = `translate(-50%, -${currentCrosshairSize}px)`; chB.style.transform = `translate(-50%, ${currentCrosshairSize}px)`; chL.style.transform = `translate(-${currentCrosshairSize}px, -50%)`; chR.style.transform = `translate(${currentCrosshairSize}px, -50%)`; if (roundActive && isShooting && currentSlot === 1 && time - lastShootTime > shootInterval) { shootWeapon(); lastShootTime = time; weaponGroup.position.z = -0.92; } else { weaponGroup.position.z += (-1.0 - weaponGroup.position.z) * 0.2; } if (isReloading && currentSlot === 1) { const elapsed = (time - reloadTimer) / 1000; const mag = weaponGroup.getObjectByName("magazine"); if (elapsed < 0.8) { weaponGroup.rotation.x = -0.4 * (elapsed / 0.8); if(mag) mag.position.y = -0.4 - 0.8 * (elapsed / 0.8); } else if (elapsed < 1.8) { const progress = (elapsed - 0.8) / 1.0; if(mag) mag.position.y = -1.2 + 0.8 * progress; } else if (elapsed < 3.0) { const progress = (elapsed - 1.8) / 1.2; weaponGroup.rotation.x = -0.4 * (1.0 - progress); } else { isReloading = false; currentAmmo = maxAmmo; ammoPanel.classList.remove("reloading"); ammoPanel.innerText = `AMMO: ${currentAmmo}/${maxAmmo}`; weaponGroup.rotation.x = 0; if(mag) mag.position.y = -0.4; } } for (let i = activeShells.length - 1; i >= 0; i--) { const s = activeShells[i]; s.vel.y -= 9.8 * 2.0 * delta; s.mesh.position.addScaledVector(s.vel, delta); s.mesh.rotation.x += delta * 5; s.mesh.rotation.y += delta * 3; if (s.mesh.position.y < 0.05) { s.mesh.position.y = 0.05; s.vel.set(0, 0, 0); } if (time - s.spawnTime > 3000) { scene.remove(s.mesh); s.mesh.geometry.dispose(); s.mesh.material.dispose(); activeShells.splice(i, 1); } } renderer.render(scene, camera); prevTime = time; } // --- マッチングシステム --- function startMatching() { if (isConnecting) return; isConnecting = true; const targetRoomId = ROOM_BASE_NAME + currentRoomNumber; statusEl.innerText = `ACCESSING ROOM ${currentRoomNumber}...`; if (peer) peer.destroy(); peer = new Peer(PEER_CONFIG); peer.on('open', () => { const conn = peer.connect(targetRoomId); const connectTimeout = setTimeout(() => { if (!activeConn) { conn.close(); peer.destroy(); initHost(targetRoomId); } }, 1500); conn.on('open', () => { clearTimeout(connectTimeout); setTimeout(() => { if (!activeConn) { isConnecting = false; playerRole = "GUEST"; goToLobby(conn, "GUEST"); } }, 500); }); conn.on('data', (data) => { if (data.type === "FULL") { clearTimeout(connectTimeout); conn.close(); currentRoomNumber++; isConnecting = false; startMatching(); } else { handleNetworkData(data); } }); peer.on('error', (err) => { clearTimeout(connectTimeout); if (err.type === 'peer-unavailable') { peer.destroy(); initHost(targetRoomId); } else { isConnecting = false; setTimeout(startMatching, 1000); } }); }); } function initHost(targetRoomId) { peer = new Peer(targetRoomId, PEER_CONFIG); peer.on('open', () => { statusEl.innerText = "AWAITING OPPONENT..."; }); peer.on('connection', (conn) => { if (activeConn) { conn.on('open', () => { conn.send({ type: "FULL" }); setTimeout(() => conn.close(), 500); }); return; } isConnecting = false; playerRole = "HOST"; goToLobby(conn, "HOST"); }); peer.on('error', (err) => { isConnecting = false; currentRoomNumber++; startMatching(); }); } function goToLobby(conn, role) { activeConn = conn; screenMatching.classList.add("hidden"); screenLobby.classList.remove("hidden"); matchPerfectEnded = false; document.getElementById("lobbyRoomName").innerText = `ROOM 0${currentRoomNumber} // ${role}`; document.getElementById("lobbyMyName").innerText = myPlayerName; setUIScreen("LOBBY"); if (activeConn) activeConn.send({ type: "NAME_EXCHANGE", name: myPlayerName }); conn.on('data', (data) => { handleNetworkData(data); }); conn.on('close', () => { handleOpponentDisconnect(); }); startLobbyTimer(); // Ping定期計測開始 clearInterval(pingIntervalToken); pingIntervalToken = setInterval(() => { if(activeConn && gameStarted) { lastPingSentTime = performance.now(); activeConn.send({ type: "NET_PING" }); } }, 2000); } function handleOpponentDisconnect() { if (matchPerfectEnded) return; clearInterval(lobbyTimerInterval); clearInterval(matchCountdownInterval); clearInterval(pingIntervalToken); cleanup3DGame(); screenLobby.classList.add("hidden"); blocker.classList.add("hidden"); crosshair.classList.add("hidden"); hudContainer.classList.add("hidden"); scoreBoard.classList.add("hidden"); roundOverlay.innerText = ""; vsOverlay.classList.add("hidden"); document.getElementById("resultTitle").innerText = "VICTORY"; document.getElementById("resultTitle").style.color = "#00ffaa"; document.getElementById("resultReason").innerText = "対戦相手が抜けました。不戦勝です。"; screenResult.classList.remove("hidden"); setUIScreen("RESULT"); } function resetToMenu() { clearInterval(pingIntervalToken); if (activeConn) { activeConn.close(); activeConn = null; } if (peer) { peer.destroy(); peer = null; } amIReady = false; isEnemyReady = false; gameStarted = false; isConnecting = false; lobbyTimeoutCount = 10; currentRoomNumber = 1; matchPerfectEnded = false; enemyPlayerName = "OPPONENT"; myStatusInd.innerText = "WAITING"; myStatusInd.classList.remove("ready"); enemyStatusInd.innerText = "WAITING"; enemyStatusInd.classList.remove("ready"); readyBtn.innerText = "READY"; readyBtn.classList.remove("is-ready"); readyBtn.classList.remove("hidden"); lobbyTimerEl.innerText = "AUTO START IN: 10s"; lobbyTimerEl.classList.remove("hidden"); countdownEl.classList.add("hidden"); document.getElementById("lobbyEnemyName").innerText = "ENEMY"; cleanup3DGame(); screenResult.classList.add("hidden"); blocker.classList.add("hidden"); crosshair.classList.add("hidden"); hudContainer.classList.add("hidden"); scoreBoard.classList.add("hidden"); roundOverlay.innerText = ""; vsOverlay.classList.add("hidden"); screenMenu.classList.remove("hidden"); setUIScreen("MENU"); } function startLobbyTimer() { lobbyTimeoutCount = 10; clearInterval(lobbyTimerInterval); lobbyTimerInterval = setInterval(() => { lobbyTimeoutCount--; lobbyTimerEl.innerText = `AUTO START IN: ${lobbyTimeoutCount}s`; if (lobbyTimeoutCount <= 0) { clearInterval(lobbyTimerInterval); startMatchCountdown(); } }, 1000); } readyBtn.addEventListener('click', () => { amIReady = !amIReady; readyBtn.innerText = amIReady ? "CANCEL READY" : "READY"; if (amIReady) { readyBtn.classList.add("is-ready"); myStatusInd.innerText = "READY"; myStatusInd.classList.add("ready"); } else { readyBtn.classList.remove("is-ready"); myStatusInd.innerText = "WAITING"; myStatusInd.classList.remove("ready"); } if (activeConn) activeConn.send({ type: "READY_CHANGE", isReady: amIReady }); checkDirectStart(); }); function handleNetworkData(data) { if (data.type === "NAME_EXCHANGE") { enemyPlayerName = data.name; document.getElementById("lobbyEnemyName").innerText = enemyPlayerName; } else if (data.type === "READY_CHANGE") { isEnemyReady = data.isReady; if (isEnemyReady) { enemyStatusInd.innerText = "READY"; enemyStatusInd.classList.add("ready"); } else { enemyStatusInd.innerText = "WAITING"; enemyStatusInd.classList.remove("ready"); } checkDirectStart(); } else if (data.type === "GAME_SYNC") { if (enemyPlayerMesh) { enemyPlayerMesh.position.set(data.pos.x, data.pos.y - 2.0, data.pos.z); enemyPlayerMesh.rotation.y = data.rotY; } } else if (data.type === "GAME_SHOOT") { spawnBulletLaser(new THREE.Vector3().copy(data.from), new THREE.Vector3().copy(data.to)); } else if (data.type === "GAME_GRENADE_SPAWN") { spawnNetworkGrenade(data.id, new THREE.Vector3().copy(data.pos), new THREE.Vector3().copy(data.vel)); } else if (data.type === "GAME_GRENADE_EXPLODE") { triggerGrenadeExplosionEffects(new THREE.Vector3().copy(data.pos)); } else if (data.type === "GAME_DAMAGE") { let currentPos = new THREE.Vector3(camera.position.x, camera.position.y - (isCrouching ? 0.9 : 0.3), camera.position.z); let hitOffset = new THREE.Vector3().copy(data.hitOffset); let predictedHitPoint = new THREE.Vector3().copy(currentPos).add(hitOffset); let distance = currentPos.distanceTo(predictedHitPoint); if (distance < 3.2) { processReceivedDamage(data.amount); } } else if (data.type === "GAME_EXPLOSION_DAMAGE") { let currentPos = new THREE.Vector3(camera.position.x, camera.position.y - (isCrouching ? 0.9 : 0.3), camera.position.z); let dist = currentPos.distanceTo(new THREE.Vector3().copy(data.pos)); if (dist < 12.0) { let damageCalc = Math.floor(65 * (1.0 - (dist / 12.0))); if (damageCalc > 5) processReceivedDamage(damageCalc); } } else if (data.type === "GAME_POPUP") { triggerFortnitePopup(data.amount, data.isShield, new THREE.Vector3().copy(data.pos)); } else if (data.type === "ROUND_WINNER") { processRoundEnd(data.winner); } else if (data.type === "GAME_OVER") { matchPerfectEnded = true; endGame(data.winner === playerRole); } else if (data.type === "NET_PING") { if(activeConn) activeConn.send({ type: "NET_PONG" }); } else if (data.type === "NET_PONG") { let rtt = performance.now() - lastPingSentTime; pingDisplayEl.innerText = `PING: ${Math.round(rtt)}ms`; } } function checkDirectStart() { if (amIReady && isEnemyReady) { clearInterval(lobbyTimerInterval); startMatchCountdown(); } } function startMatchCountdown() { clearInterval(lobbyTimerInterval); clearInterval(matchCountdownInterval); lobbyTimerEl.classList.add("hidden"); readyBtn.classList.add("hidden"); countdownEl.classList.remove("hidden"); let count = 3; countdownEl.innerText = count; matchCountdownInterval = setInterval(() => { count--; if (count > 0) { countdownEl.innerText = count; } else { clearInterval(matchCountdownInterval); screenLobby.classList.add("hidden"); triggerVSEffect(); } }, 1000); } function triggerVSEffect() { document.getElementById("vsPlayer1").innerText = myPlayerName; document.getElementById("vsPlayer2").innerText = enemyPlayerName; vsOverlay.classList.remove("hidden"); setTimeout(() => { vsOverlay.classList.add("hidden"); init3DGame(); }, 3000); } // ========================================== // 3D FPS アリーナコア // ========================================== let scene, camera, renderer, raycaster; let moveState = { f: false, b: false, l: false, r: false }; let velocity = new THREE.Vector3(), prevTime = performance.now(), euler = new THREE.Euler(0, 0, 0, 'YXZ'); let weaponGroup, maxAmmo = 30, currentAmmo = 30, isReloading = false, reloadTimer = 0, isShooting = false, lastShootTime = 0, shootInterval = 110; let enemyPlayerMesh = null, shootTargets = [], collidableBoxes = []; let myShield = 100, myHp = 100, canJump = false; let scoreYou = 0, scoreEnemy = 0, roundActive = false; let activeShells = [], rInterval = null; let isCrouching = false, currentCameraTargetHeight = 2.0; let currentContinuousShots = 0, currentSpreadIntensity = 0.0; let hitPunchIntensity = 0.0, hitPunchAngle = new THREE.Vector3(); // 新規追加:アイテムスロット・手榴弾システム変数 let currentSlot = 1; // 1: SMG, 2: GRENADE let myGrenadesLeft = 3; let activeGrenades = []; // { mesh: Mesh, vel: Vector3, timer: number, id: string, isMine: boolean } function seededRandom(seed) { let x = Math.sin(seed++) * 10000; return x - Math.floor(x); } function init3DGame() { gameStarted = true; blocker.classList.remove("hidden"); crosshair.classList.remove("hidden"); hudContainer.classList.remove("hidden"); scoreBoard.classList.remove("hidden"); currentUIScreen = "GAME"; scoreYou = 0; scoreEnemy = 0; scoreYouEl.innerText = 0; scoreEnemyEl.innerText = 0; document.getElementById("sbMyName").innerText = myPlayerName; document.getElementById("sbEnemyName").innerText = enemyPlayerName; scene = new THREE.Scene(); scene.background = new THREE.Color(0xf0f3f5); scene.fog = new THREE.FogExp2(0xf0f3f5, 0.025); camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500); raycaster = new THREE.Raycaster(); const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.7); dirLight.position.set(40, 100, 20); scene.add(dirLight); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.getElementById("gameCanvas").innerHTML = ""; document.getElementById("gameCanvas").appendChild(renderer.domElement); const floor = new THREE.Mesh(new THREE.PlaneGeometry(120, 120), new THREE.MeshLambertMaterial({ color: 0xe8ecef })); floor.rotation.x = -Math.PI / 2; scene.add(floor); const grid = new THREE.GridHelper(120, 40, 0x00ffaa, 0xd0d5dd); grid.position.y = 0.01; scene.add(grid); const wallMat = new THREE.MeshLambertMaterial({ color: 0xf5f6fa }); const wallGeoNS = new THREE.BoxGeometry(120, 120, 2); const wallGeoWE = new THREE.BoxGeometry(2, 120, 120); const wallNorth = new THREE.Mesh(wallGeoNS, wallMat); wallNorth.position.set(0, 5, -60); scene.add(wallNorth); const wallSouth = new THREE.Mesh(wallGeoNS, wallMat); wallSouth.position.set(0, 5, 60); scene.add(wallSouth); const wallWest = new THREE.Mesh(wallGeoWE, wallMat); wallWest.position.set(-60, 5, 0); scene.add(wallWest); const wallEast = new THREE.Mesh(wallGeoWE, wallMat); wallEast.position.set(60, 5, 0); scene.add(wallEast); const objectMat = new THREE.MeshLambertMaterial({ color: 0xb0b8c4 }); const edgeMat = new THREE.MeshBasicMaterial({ color: 0x00ffaa, wireframe: true }); collidableBoxes = []; let mapSeed = currentRoomNumber * 54321; const obstacleCount = 18; for (let i = 0; i < obstacleCount; i++) { let rand1 = seededRandom(mapSeed++); let rand2 = seededRandom(mapSeed++); let rand3 = seededRandom(mapSeed++); let rand4 = seededRandom(mapSeed++); let posX = -45 + rand1 * 90; let posZ = -42 + rand2 * 84; if (Math.abs(posZ) > 33 && Math.abs(posX) < 15) { posZ += (posZ > 0 ? -15 : 15); } let width = 3.5 + rand3 * 4.5; let height = 6.0 + rand4 * 7.0; let depth = 3.5 + rand1 * 4.5; let geom, edgeGeom; if (rand2 > 0.5) { geom = new THREE.CylinderGeometry(width / 2, width / 2, height, 12); edgeGeom = new THREE.CylinderGeometry(width / 2, width / 2, height, 12); } else { geom = new THREE.BoxGeometry(width, height, depth); edgeGeom = new THREE.BoxGeometry(width, height, depth); } const obstacle = new THREE.Mesh(geom, objectMat); obstacle.position.set(posX, height / 2, posZ); scene.add(obstacle); const edge = new THREE.Mesh(edgeGeom, edgeMat); edge.position.set(posX, height / 2, posZ); edge.scale.multiplyScalar(1.002); scene.add(edge); obstacle.geometry.computeBoundingBox(); obstacle.userData = { radius: width / 2, height: height, isCylinder: (rand2 > 0.5), width: width, depth: depth }; collidableBoxes.push(obstacle); } enemyPlayerMesh = new THREE.Group(); const mechRed = new THREE.MeshLambertMaterial({ color: 0xff4757 }); const mechDark = new THREE.MeshLambertMaterial({ color: 0x2f3542 }); const glowGreen = new THREE.MeshBasicMaterial({ color: 0x00ffaa }); const visorMat = new THREE.MeshBasicMaterial({ color: 0x111116 }); const hitBoxMesh = new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.2, 3.4, 8), new THREE.MeshBasicMaterial({ visible: false })); hitBoxMesh.position.y = 1.7; enemyPlayerMesh.add(hitBoxMesh); const chest = new THREE.Mesh(new THREE.BoxGeometry(1.6, 1.3, 1.1), mechRed); chest.position.y = 2.1; const core = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.4, 8), glowGreen); core.rotation.x = Math.PI / 2; core.position.set(0, 0.2, 0.5); chest.add(core); enemyPlayerMesh.add(chest); const belly = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.6, 0.5, 12), mechDark); belly.position.y = 1.3; enemyPlayerMesh.add(belly); const headGroup = new THREE.Group(); headGroup.position.y = 3.0; const headCube = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.7, 0.8), mechRed); headGroup.add(headCube); const visor = new THREE.Mesh(new THREE.BoxGeometry(0.84, 0.2, 0.4), visorMat); visor.position.set(0, 0.1, -0.3); headGroup.add(visor); const visorLine = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.04, 0.42), glowGreen); visorLine.position.set(0, 0.1, -0.3); headGroup.add(visorLine); const antenna = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.7, 4), mechDark); antenna.position.set(0.3, 0.5, 0.1); antenna.rotation.z = -0.2; headGroup.add(antenna); enemyPlayerMesh.add(headGroup); const leftArm = new THREE.Group(); leftArm.position.set(-1.0, 2.4, 0); const rightArm = new THREE.Group(); rightArm.position.set(1.0, 2.4, 0); const shoulderGeo = new THREE.SphereGeometry(0.3, 8, 8); const upperArmGeo = new THREE.CylinderGeometry(0.18, 0.15, 0.8, 8); const sL = new THREE.Mesh(shoulderGeo, mechRed); leftArm.add(sL); const aL = new THREE.Mesh(upperArmGeo, mechDark); aL.position.set(0, -0.5, -0.2); aL.rotation.x = 0.4; leftArm.add(aL); const sR = new THREE.Mesh(shoulderGeo, mechRed); rightArm.add(sR); const aR = new THREE.Mesh(upperArmGeo, mechDark); aR.position.set(0, -0.5, -0.2); aR.rotation.x = 0.4; rightArm.add(aR); enemyPlayerMesh.add(leftArm); enemyPlayerMesh.add(rightArm); const pack = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.8, 0.4), mechDark); pack.position.set(0, 2.2, -0.6); const jetL = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.15, 0.3, 8), glowGreen); jetL.position.set(-0.25, -0.4, 0); pack.add(jetL); const jetR = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.15, 0.3, 8), glowGreen); jetR.position.set(0.25, -0.4, 0); pack.add(jetR); enemyPlayerMesh.add(pack); enemyPlayerMesh.position.set(0, -100, 0); scene.add(enemyPlayerMesh); shootTargets = [hitBoxMesh, ...collidableBoxes, wallNorth, wallSouth, wallWest, wallEast]; weaponGroup = new THREE.Group(); const gunBarrel = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 1.8, 8), new THREE.MeshLambertMaterial({ color: 0x33333b })); gunBarrel.rotateX(Math.PI / 2); weaponGroup.add(gunBarrel); const magazine = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.7, 0.3), new THREE.MeshLambertMaterial({ color: 0xffffff })); magazine.name = "magazine"; magazine.position.set(0, -0.4, 0.2); weaponGroup.add(magazine); weaponGroup.position.set(0.4, -0.4, -1.0); camera.add(weaponGroup); scene.add(camera); window.addEventListener('keydown', onGameKeyDown); window.addEventListener('keyup', onGameKeyUp); window.addEventListener('mousedown', onMouseDown); window.addEventListener('mouseup', onMouseUp); document.addEventListener('pointerlockchange', onPointerLockChange); window.addEventListener('resize', onWindowResize); try { document.body.requestPointerLock(); } catch(err){} startNewRound(); animate(); } function startNewRound() { roundActive = false; currentAmmo = maxAmmo; isReloading = false; myShield = 100; myHp = 100; updateVitalsHud(); isCrouching = false; currentCameraTargetHeight = 2.0; currentContinuousShots = 0; currentSpreadIntensity = 0.0; hitPunchIntensity = 0.0; ammoPanel.innerText = `AMMO: ${currentAmmo}/${maxAmmo}`; velocity.set(0,0,0); // ラウンド毎にスロットリセット、手榴弾を3つに補給 switchSlot(1); myGrenadesLeft = 3; grenadeCountEl.innerText = myGrenadesLeft; // 残っている手榴弾オブジェクトがあれば消去 activeGrenades.forEach(g => scene.remove(g.mesh)); activeGrenades = []; clearInterval(rInterval); if (playerRole === "HOST") { camera.position.set(0, 2, 40); euler.set(0, Math.PI, 0); } else { camera.position.set(0, 2, -40); euler.set(0, 0, 0); } camera.quaternion.setFromEuler(euler); let rCount = 3; roundOverlay.innerText = rCount; rInterval = setInterval(() => { rCount--; if (rCount > 0) { roundOverlay.innerText = rCount; } else if (rCount === 0) { roundOverlay.innerText = "START!"; roundActive = true; } else { clearInterval(rInterval); roundOverlay.innerText = ""; } }, 1000); } function onPointerLockChange() { if (document.pointerLockElement === document.body) { blocker.classList.add("hidden"); document.addEventListener('mousemove', onMouseMove); } else { blocker.classList.remove("hidden"); document.removeEventListener('mousemove', onMouseMove); onMouseUp(); } } function onMouseMove(event) { if(!roundActive) return; euler.setFromQuaternion(camera.quaternion); euler.y -= event.movementX * configGame.sensitivity; euler.x -= event.movementY * configGame.sensitivity; euler.x = Math.max(-Math.PI / 2.3, Math.min(Math.PI / 2.3, euler.x)); camera.quaternion.setFromEuler(euler); } function onGameKeyDown(e) { if(!roundActive) return; if (e.code === configGame.binds.forward) moveState.f = true; if (e.code === configGame.binds.backward) moveState.b = true; if (e.code === configGame.binds.left) moveState.l = true; if (e.code === configGame.binds.right) moveState.r = true; if (e.code === configGame.binds.jump && canJump && !isCrouching) { velocity.y = 55.0; canJump = false; } if (e.code === configGame.binds.reload && !isReloading && currentAmmo < maxAmmo && currentSlot === 1) startReload(); if (e.code === "ControlLeft") { isCrouching = true; currentCameraTargetHeight = 1.0; } // アイテムスロット切り替えキー判定 if (e.code === "Digit1") switchSlot(1); if (e.code === "Digit2") switchSlot(2); } function onGameKeyUp(e) { if (e.code === configGame.binds.forward) moveState.f = false; if (e.code === configGame.binds.backward) moveState.b = false; if (e.code === configGame.binds.left) moveState.l = false; if (e.code === configGame.binds.right) moveState.r = false; if (e.code === "ControlLeft") { isCrouching = false; currentCameraTargetHeight = 2.0; } } function onMouseDown(e) { if(roundActive && e.button === 0 && document.pointerLockElement === document.body) { if(currentSlot === 1) { isShooting = true; } else if(currentSlot === 2) { throwGrenade(); } } } function onMouseUp() { isShooting = false; currentContinuousShots = 0; } function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } function updateVitalsHud() { shieldBar.style.width = `${myShield}%`; shieldTxt.innerText = Math.ceil(myShield); hpBar.style.width = `${myHp}%`; hpTxt.innerText = Math.ceil(myHp); } function startReload() { isReloading = true; ammoPanel.classList.add("reloading"); ammoPanel.innerText = "RELOADING..."; reloadTimer = performance.now(); currentContinuousShots = 0; } // スロット変更ロジック function switchSlot(slotNum) { if (isReloading) return; currentSlot = slotNum; if(slotNum === 1) { slot1El.classList.add("active"); slot2El.classList.remove("active"); weaponGroup.visible = true; ammoPanel.style.opacity = "1"; } else { slot1El.classList.remove("active"); slot2El.classList.add("active"); weaponGroup.visible = false; // 手榴弾時はSMGを隠す ammoPanel.style.opacity = "0.3"; isShooting = false; } } // 手榴弾投擲ロジック function throwGrenade() { if (myGrenadesLeft <= 0) return; myGrenadesLeft--; grenadeCountEl.innerText = myGrenadesLeft; let spawnPos = new THREE.Vector3(); camera.getWorldPosition(spawnPos); // カメラの少し前方に生成 let lookDir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); spawnPos.addScaledVector(lookDir, 1.2); let grenadeVelocity = new THREE.Vector3().copy(lookDir).multiplyScalar(28); grenadeVelocity.y += 6.5; // 少し上向きに放り投げる let grenadeId = "g_" + playerRole + "_" + performance.now(); spawnLocalGrenade(grenadeId, spawnPos, grenadeVelocity, true); if (activeConn) { activeConn.send({ type: "GAME_GRENADE_SPAWN", id: grenadeId, pos: { x: spawnPos.x, y: spawnPos.y, z: spawnPos.z }, vel: { x: grenadeVelocity.x, y: grenadeVelocity.y, z: grenadeVelocity.z } }); } // 投げたら自動的にメイン武器スロットに戻る setTimeout(() => { if(gameStarted && currentSlot === 2) switchSlot(1); }, 150); } function spawnLocalGrenade(id, pos, vel, isMine) { const gGeo = new THREE.SphereGeometry(0.35, 8, 8); const gMat = new THREE.MeshStandardMaterial({ color: 0xff3333, roughness: 0.4, emissive: 0x440000 }); const mesh = new THREE.Mesh(gGeo, gMat); mesh.position.copy(pos); scene.add(mesh); activeGrenades.push({ id: id, mesh: mesh, vel: vel, timer: 2.2, // 2.2秒後に爆発 isMine: isMine }); } function spawnNetworkGrenade(id, pos, vel) { spawnLocalGrenade(id, pos, vel, false); } // 手榴弾爆発処理 function explodeGrenade(grenade) { scene.remove(grenade.mesh); let exPos = grenade.mesh.position; triggerGrenadeExplosionEffects(exPos); if (grenade.isMine) { // 自分が投げた爆弾のみが、相手へのダメージ判定責任を持つ (二重ダメージ防止) let enemyPos = new THREE.Vector3(); enemyPlayerMesh.getWorldPosition(enemyPos); // 敵のモデル中心の高さへ補正 enemyPos.y += 1.7; let dist = exPos.distanceTo(enemyPos); if (dist < 12.0) { // 爆風半径12ユニット // 近いほど大ダメージ (最大65ダメージ) let damageAmount = Math.floor(65 * (1.0 - (dist / 12.0))); if (damageAmount > 5) { if (activeConn) { activeConn.send({ type: "GAME_EXPLOSION_DAMAGE", pos: { x: exPos.x, y: exPos.y, z: exPos.z } }); } } } if (activeConn) { activeConn.send({ type: "GAME_GRENADE_EXPLODE", pos: { x: exPos.x, y: exPos.y, z: exPos.z } }); } } } function triggerGrenadeExplosionEffects(pos) { // 爆発の瞬間的な光球エフェクト const expGeo = new THREE.SphereGeometry(4.0, 12, 12); const expMat = new THREE.MeshBasicMaterial({ color: 0xffaa00, transparent: true, opacity: 0.7, wireframe: true }); const expMesh = new THREE.Mesh(expGeo, expMat); expMesh.position.copy(pos); scene.add(expMesh); let startTime = performance.now(); function animateExplosion() { let el = performance.now() - startTime; if(el < 350) { expMesh.scale.multiplyScalar(1.06); expMat.opacity *= 0.9; requestAnimationFrame(animateExplosion); } else { scene.remove(expMesh); expGeo.dispose(); expMat.dispose(); } } animateExplosion(); } function shootWeapon() { if (isReloading || currentAmmo <= 0) { if(currentAmmo <= 0 && !isReloading) startReload(); return; } currentAmmo--; ammoPanel.innerText = `AMMO: ${currentAmmo}/${maxAmmo}`; currentContinuousShots++; euler.setFromQuaternion(camera.quaternion); let recoilX = 0.008 + (currentContinuousShots * 0.002); let recoilY = (Math.random() - 0.5) * (0.004 + currentContinuousShots * 0.001); euler.x += recoilX; euler.y += recoilY; euler.x = Math.max(-Math.PI / 2.3, Math.min(Math.PI / 2.3, euler.x)); camera.quaternion.setFromEuler(euler); let baseSpread = 0.01; if (!canJump) { baseSpread = 0.07; } else if (isCrouching) { baseSpread = 0.002; } else if (moveState.f || moveState.b || moveState.l || moveState.r) { baseSpread = 0.03; } let totalSpread = baseSpread + (currentContinuousShots * 0.004); const fromPos = new THREE.Vector3(); weaponGroup.getWorldPosition(fromPos); const shootDir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); let spreadOffset = new THREE.Vector3( (Math.random() - 0.5) * totalSpread, (Math.random() - 0.5) * totalSpread, 0 ).applyQuaternion(camera.quaternion); shootDir.add(spreadOffset).normalize(); let toPos = new THREE.Vector3().copy(shootDir).multiplyScalar(120).add(camera.position); raycaster.set(camera.position, shootDir); const intersects = raycaster.intersectObjects(shootTargets, true); if (intersects.length > 0 && intersects[0].distance < 120) { toPos.copy(intersects[0].point); let hitObj = intersects[0].object; while (hitObj) { if (hitObj === enemyPlayerMesh) { let relativeHitOffset = new THREE.Vector3().copy(intersects[0].point).sub(enemyPlayerMesh.position); if (activeConn) activeConn.send({ type: "GAME_DAMAGE", amount: 16, hitOffset: { x: relativeHitOffset.x, y: relativeHitOffset.y, z: relativeHitOffset.z } }); break; } hitObj = hitObj.parent; } } spawnBulletLaser(fromPos, toPos); spawnShellEject(fromPos); if (activeConn) activeConn.send({ type: "GAME_SHOOT", from: { x: fromPos.x, y: fromPos.y, z: fromPos.z }, to: { x: toPos.x, y: toPos.y, z: toPos.z } }); } function spawnBulletLaser(from, to) { const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]); const lineMat = new THREE.LineBasicMaterial({ color: 0xffd700, linewidth: 2, transparent: true, opacity: 0.8 }); const laser = new THREE.Line(lineGeo, lineMat); scene.add(laser); setTimeout(() => { scene.remove(laser); lineGeo.dispose(); lineMat.dispose(); }, 70); } function spawnShellEject(weaponPos) { const shellGeo = new THREE.CylinderGeometry(0.02, 0.02, 0.08, 6); const shellMat = new THREE.MeshLambertMaterial({ color: 0xd4af37 }); const shell = new THREE.Mesh(shellGeo, shellMat); shell.position.copy(weaponPos); shell.rotation.set(Math.random()*Math.PI, Math.random()*Math.PI, Math.random()*Math.PI); const rightDir = new THREE.Vector3(1, 0.3, 0.2).applyQuaternion(camera.quaternion).normalize(); const shellVelocity = rightDir.multiplyScalar(3.5 + Math.random()*1.5); shellVelocity.y += 2.0; scene.add(shell); activeShells.push({ mesh: shell, vel: shellVelocity, spawnTime: performance.now() }); } function processReceivedDamage(amount) { if (!roundActive) return; hitPunchIntensity = 0.22; hitPunchAngle.set((Math.random() - 0.3) * 0.15, (Math.random() - 0.5) * 0.15, 0); hitFlashEl.style.backgroundColor = "rgba(255, 0, 0, 0.45)"; setTimeout(() => { hitFlashEl.style.backgroundColor = "rgba(255, 0, 0, 0)"; }, 60); let isShieldHit = myShield > 0; if (myShield > 0) { myShield -= amount; if (myShield < 0) { myHp += myShield; myShield = 0; } } else { myHp -= amount; } if (myHp < 0) myHp = 0; updateVitalsHud(); if (activeConn) { activeConn.send({ type: "GAME_POPUP", amount: amount, isShield: isShieldHit, pos: { x: camera.position.x, y: camera.position.y + 1.2, z: camera.position.z } }); } if (myHp <= 0 && roundActive) { roundActive = false; const winnerRole = (playerRole === "HOST") ? "GUEST" : "HOST"; if (activeConn) activeConn.send({ type: "ROUND_WINNER", winner: winnerRole }); processRoundEnd(winnerRole); } } // 修正版:他方のスコア引きずられバグを完全にシャットアウト function processRoundEnd(winner) { if (!gameStarted || matchPerfectEnded) return; roundActive = false; isShooting = false; if (winner === playerRole) { scoreYou++; scoreYouEl.innerText = scoreYou; } else { scoreEnemy++; scoreEnemyEl.innerText = scoreEnemy; } if (scoreYou >= 3 || scoreEnemy >= 3) { matchPerfectEnded = true; setTimeout(() => { if (activeConn) activeConn.send({ type: "GAME_OVER", winner: winner }); endGame(scoreYou >= 3); }, 1000); } else { roundOverlay.innerText = "ROUND OVER"; setTimeout(() => { if(!matchPerfectEnded) startNewRound(); }, 2000); } } function triggerFortnitePopup(amount, isShield, worldPos) { const vector = worldPos.project(camera); const x = (vector.x * .5 + .5) * window.innerWidth; const y = (vector.y * -.5 + .5) * window.innerHeight; if(vector.z > 1) return; const el = document.createElement("div"); el.className = "damage-indicator"; el.innerText = amount; el.style.color = isShield ? "#00a2ff" : "#ffffff"; el.style.left = `${x}px`; el.style.top = `${y}px`; damageOverlay.appendChild(el); setTimeout(() => { el.remove(); }, 600); } function endGame(isWin) { cleanup3DGame(); screenLobby.classList.add("hidden"); blocker.classList.add("hidden"); crosshair.classList.add("hidden"); hudContainer.classList.add("hidden"); scoreBoard.classList.add("hidden"); document.getElementById("resultTitle").innerText = isWin ? "VICTORY" : "DEFEATED"; document.getElementById("resultTitle").style.color = isWin ? "#00ffaa" : "#ff4757"; document.getElementById("resultReason").innerText = isWin ? "3勝先取し、マッチを制覇しました!" : "相手に3勝先取されました。"; screenResult.classList.remove("hidden"); setUIScreen("RESULT"); } function checkWallCollision(nextPos) { if (nextPos.x < -57 || nextPos.x > 57 || nextPos.z < -57 || nextPos.z > 57) return true; const pr = 1.0; for (let box of collidableBoxes) { const uData = box.userData; const bx = box.position.x; const bz = box.position.z; if (uData && uData.isCylinder) { let dx = nextPos.x - bx; let dz = nextPos.z - bz; let dist = Math.sqrt(dx * dx + dz * dz); if (dist < uData.radius + pr) return true; } else { let halfW = (uData ? uData.width : 5) / 2; let halfD = (uData ? uData.depth : 5) / 2; const minX = bx - halfW - pr, maxX = bx + halfW + pr; const minZ = bz - halfD - pr, maxZ = bz + halfD + pr; if (nextPos.x > minX && nextPos.x < maxX && nextPos.z > minZ && nextPos.z < maxZ) return true; } } return false; } function animate() { if (!gameStarted) return; requestAnimationFrame(animate); const time = performance.now(); const delta = Math.min((time - prevTime) / 1000, 0.1); let speedModifier = isCrouching ? 65.0 : 180.0; velocity.x -= velocity.x * 12.0 * delta; velocity.z -= velocity.z * 12.0 * delta; velocity.y -= 9.8 * 15.0 * delta; if (roundActive) { let front = Number(moveState.f) - Number(moveState.b); let side = Number(moveState.r) - Number(moveState.l); let moveVector = new THREE.Vector3(side, 0, -front).normalize(); moveVector.applyEuler(new THREE.Euler(0, euler.y, 0, 'YXZ')); if (moveState.f || moveState.b || moveState.l || moveState.r) { velocity.x += moveVector.x * speedModifier * delta; velocity.z += moveVector.z * speedModifier * delta; } } const nextX = camera.position.x + velocity.x * delta; const nextZ = camera.position.z + velocity.z * delta; if (!checkWallCollision(new THREE.Vector3(nextX, camera.position.y, camera.position.z))) { camera.position.x = nextX; } else { velocity.x = 0; } if (!checkWallCollision(new THREE.Vector3(camera.position.x, camera.position.y, nextZ))) { camera.position.z = nextZ; } else { velocity.z = 0; } camera.position.y += velocity.y * delta; let floorLevel = 2.0; if (isCrouching) floorLevel = 1.0; if (camera.position.y < floorLevel) { velocity.y = 0; camera.position.y += (floorLevel - camera.position.y) * 0.3; if(Math.abs(camera.position.y - floorLevel) < 0.05) { camera.position.y = floorLevel; } canJump = true; } // 手榴弾の物理移動・コリジョン・タイマー処理 for (let i = activeGrenades.length - 1; i >= 0; i--) { let g = activeGrenades[i]; g.vel.y -= 9.8 * 2.5 * delta; // 重力加速度 let gNextX = g.mesh.position.x + g.vel.x * delta; let gNextY = g.mesh.position.y + g.vel.y * delta; let gNextZ = g.mesh.position.z + g.vel.z * delta; // 床バウンド if (gNextY < 0.2) { gNextY = 0.2; g.vel.y = -g.vel.y * 0.45; // 跳ね返り係数 g.vel.x *= 0.7; // 摩擦減速 g.vel.z *= 0.7; } // 壁バウンド簡易チェック if (gNextX < -58.5 || gNextX > 58.5) g.vel.x = -g.vel.x * 0.5; if (gNextZ < -58.5 || gNextZ > 58.5) g.vel.z = -g.vel.z * 0.5; g.mesh.position.set(gNextX, gNextY, gNextZ); g.mesh.rotation.x += 3 * delta; g.timer -= delta; if (g.timer <= 0) { explodeGrenade(g); activeGrenades.splice(i, 1); } } let currentCrosshairSize = 10; if (!canJump) currentCrosshairSize = 35; else if (isCrouching) currentCrosshairSize = 4; else if (moveState.f || moveState.b || moveState.l || moveState.r) currentCrosshairSize = 18; currentCrosshairSize += (currentContinuousShots * 4); chT.style.transform = `translateY(-${currentCrosshairSize}px)`; chB.style.transform = `translateY(${currentCrosshairSize}px)`; chL.style.transform = `translateX(-${currentCrosshairSize}px)`; chR.style.transform = `translateX(${currentCrosshairSize}px)`; if (roundActive && isShooting && currentSlot === 1 && time - lastShootTime > shootInterval) { shootWeapon(); lastShootTime = time; weaponGroup.position.z = -0.92; } else { weaponGroup.position.z += (-1.0 - weaponGroup.position.z) * 0.2; } if (isReloading && currentSlot === 1) { const elapsed = (time - reloadTimer) / 1000; const mag = weaponGroup.getObjectByName("magazine"); if (elapsed < 0.8) { weaponGroup.rotation.x = -0.4 * (elapsed / 0.8); if(mag) mag.position.y = -0.4 - 0.8 * (elapsed / 0.8); } else if (elapsed < 1.8) { const progress = (elapsed - 0.8) / 1.0; if(mag) mag.position.y = -1.2 + 0.8 * progress; } else if (elapsed < 3.0) { const progress = (elapsed - 1.8) / 1.2; weaponGroup.rotation.x = -0.4 * (1.0 - progress); } else { isReloading = false; currentAmmo = maxAmmo; ammoPanel.classList.remove("reloading"); ammoPanel.innerText = `AMMO: ${currentAmmo}/${maxAmmo}`; weaponGroup.rotation.x = 0; if(mag) mag.position.set(0, -0.4, 0.2); } } for (let i = activeShells.length - 1; i >= 0; i--) { let s = activeShells[i]; s.vel.y -= 9.8 * 2.0 * delta; s.mesh.position.addScaledVector(s.vel, delta); s.mesh.rotation.x += 5 * delta; s.mesh.rotation.y += 5 * delta; if (s.mesh.position.y < 0.04) { s.mesh.position.y = 0.04; s.vel.set(0,0,0); } if (time - s.spawnTime > 1500) { scene.remove(s.mesh); s.mesh.geometry.dispose(); s.mesh.material.dispose(); activeShells.splice(i, 1); } } euler.setFromQuaternion(camera.quaternion); if (hitPunchIntensity > 0.001) { euler.x += hitPunchAngle.x * hitPunchIntensity; euler.y += hitPunchAngle.y * hitPunchIntensity; hitPunchIntensity *= 0.82; } if (activeConn) { activeConn.send({ type: "GAME_SYNC", pos: { x: camera.position.x, y: camera.position.y, z: camera.position.z }, rotY: euler.y }); } prevTime = time; renderer.render(scene, camera); } function cleanup3DGame() { gameStarted = false; isShooting = false; roundActive = false; isCrouching = false; clearInterval(rInterval); window.removeEventListener('keydown', onGameKeyDown); window.removeEventListener('keyup', onGameKeyUp); document.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mousedown', onMouseDown); window.removeEventListener('mouseup', onMouseUp); try { document.exitPointerLock(); } catch(e){} document.getElementById("gameCanvas").innerHTML = ""; damageOverlay.innerHTML = ""; moveState = { f: false, b: false, l: false, r: false }; velocity.set(0,0,0); euler.set(0,0,0); enemyPlayerMesh = null; shootTargets = []; collidableBoxes = []; activeShells.forEach(s => { scene.remove(s.mesh); s.mesh.geometry.dispose(); s.mesh.material.dispose(); }); activeShells = []; activeGrenades.forEach(g => { scene.remove(g.mesh); }); activeGrenades = []; }