<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VRRPG - 拡張版 AR/VR RPG</title>
<!-- A-Frame ライブラリ -->
<script src="https://aframe.io/releases/1.4.0/aframe.min.js"></script>
<!-- Particle system コンポーネント(パーティクル演出用) -->
<script src="https://cdn.jsdelivr.net/gh/IdeaSpaceVR/aframe-particle-system-component@master/dist/aframe-particle-system-component.min.js"></script>
<style>
body { margin: 0; overflow: hidden; }
/* 各種オーバーレイ */
#mainMenuOverlay, #upgradeOverlay, #pauseOverlay, #gameOverOverlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
color: #FFF;
display: flex;
justify-content: center;
align-items: center;
font-size: 48px;
z-index: 999;
display: none;
text-align: center;
flex-direction: column;
}
#mainMenuOverlay button, #upgradeOverlay button, #gameOverOverlay button {
font-size: 36px;
padding: 20px 40px;
margin-top: 20px;
}
</style>
</head>
<body>
<!-- メインメニュー -->
<div id="mainMenuOverlay" style="display: flex;">
<div>
<div>VRRPG - 拡張版 AR/VR RPG</div>
<button id="startButton">Start Game</button>
</div>
</div>
<!-- アップグレードストア -->
<div id="upgradeOverlay">
<div>
<div>Upgrade Store</div>
<div>Press 1: Increase Sword Damage (+10) (Cost: 50 Score)</div>
<div>Press 2: Increase Max Health (+20) (Cost: 50 Score)</div>
<button id="closeUpgrade">Close</button>
</div>
</div>
<!-- ポーズオーバーレイ -->
<div id="pauseOverlay">Paused</div>
<!-- ゲームオーバーオーバーレイ -->
<div id="gameOverOverlay">
<div>
<div>Game Over!</div>
<div id="finalScore">Final Score: 0</div>
<button id="restartButton">Restart</button>
</div>
</div>
<!-- AR/VRシーン:XRモードを AR に設定(Oculusパススルー利用) -->
<a-scene xr="mode: ar; referenceSpaceType: local-floor">
<!-- 背景音楽 -->
<a-entity id="bg-music" sound="src: url(bg-music.mp3); autoplay: true; loop: true; volume: 0.3"></a-entity>
<!-- 環境 -->
<a-sky color="#88ccee"></a-sky>
<a-plane position="0 0 0" rotation="-90 0 0" width="30" height="30" color="#77aa55"></a-plane>
<a-light type="directional" intensity="0.8" position="0 10 5"></a-light>
<!-- プレイヤー(カメラ、HUD、ポーズ対応) -->
<a-entity id="player" weapon-switcher position="0 1.6 5">
<a-camera wasd-controls look-controls>
<!-- 右手:カメラ内右下に固定表示(装備品) -->
<a-entity id="rightHand" position="0.5 -0.3 -1"></a-entity>
</a-camera>
<!-- HUD -->
<a-entity id="hud" position="0 -0.5 -1.5">
<a-text id="scoreText" value="Score: 0" position="-1 0.7 0" color="#FFF" width="4"></a-text>
<a-text id="healthText" value="Health: 100" position="-1 0.4 0" color="#FFF" width="4"></a-text>
<a-text id="waveText" value="Wave: 1" position="-1 0.1 0" color="#FFF" width="4"></a-text>
<a-text id="levelText" value="Lv: 1 Exp: 0" position="-1 -0.2 0" color="#FFF" width="4"></a-text>
<a-text id="weaponText" value="Weapon: None" position="-1 -0.5 0" color="#FFF" width="4"></a-text>
</a-entity>
</a-entity>
<!-- 落ちている剣(Sword) -->
<a-entity id="sword" position="0.3 1 -2" sword-swing pickup>
<!-- ブレード -->
<a-entity geometry="primitive: box; height: 1; width: 0.1; depth: 0.05"
material="color: silver; metalness: 0.8; roughness: 0.2"
position="0 0.5 0"></a-entity>
<!-- ガード -->
<a-entity geometry="primitive: box; height: 0.2; width: 0.3; depth: 0.05"
material="color: gold"
position="0 0.05 0"></a-entity>
<!-- ハンドル -->
<a-entity geometry="primitive: cylinder; radius: 0.05; height: 0.4"
material="color: brown"
position="0 -0.3 0" rotation="90 0 0"></a-entity>
<!-- 回転アニメーション(拾われるまで) -->
<a-animation attribute="rotation" dur="3000" fill="forwards" to="0 360 0" repeat="indefinite"></a-animation>
</a-entity>
<!-- 魔法の杖(Magic Wand) ※ 未使用 -->
<a-entity id="magicWand" geometry="primitive: cylinder; height: 0.8; radius: 0.05"
material="color: purple; emissive: #aa00ff"
position="0.3 1 -0.5" rotation="0 0 0" wand-fire visible="false"></a-entity>
<!-- 敵スポーン用エリア -->
<a-entity id="enemy-spawn"></a-entity>
<!-- サウンド設定 -->
<a-entity id="sword-sound" sound="src: url(sword-swing.mp3); on: none"></a-entity>
<a-entity id="pickup-sound" sound="src: url(pickup.mp3); on: none"></a-entity>
<a-entity id="wand-sound" sound="src: url(wand-fire.mp3); on: none"></a-entity>
<!-- インストラクション表示(初回のみ) -->
<a-entity id="instructions" position="0 2 -3">
<a-text value="Controls: Oculus Touch / Gamepad / WASD+Mouse クリックで剣を拾い、剣をクリックで振る Pキーでポーズ / Uキーでアップグレード"
align="center" color="#FFF" width="6"></a-text>
</a-entity>
<!-- ウェーブ管理 -->
<a-entity wave-manager></a-entity>
</a-scene>
<script>
/************ ゲームデータ管理 ************/
var gameData = {
score: 0,
playerHealth: 100,
wave: 1,
playerLevel: 1,
experience: 0,
currentWeapon: "None",
swordDamage: 50,
maxHealth: 100,
hasSword: false,
paused: false,
gameState: "menu" // "menu", "playing", "paused", "gameover"
};
/************ HUD 更新関数 ************/
function updateHUD() {
document.querySelector('#scoreText').setAttribute('value', 'Score: ' + gameData.score);
document.querySelector('#healthText').setAttribute('value', 'Health: ' + gameData.playerHealth);
document.querySelector('#waveText').setAttribute('value', 'Wave: ' + gameData.wave);
document.querySelector('#levelText').setAttribute('value', 'Lv: ' + gameData.playerLevel + ' Exp: ' + gameData.experience);
document.querySelector('#weaponText').setAttribute('value', 'Weapon: ' + gameData.currentWeapon);
}
/************ ゲームオーバーチェック ************/
function checkGameOver() {
if (gameData.playerHealth <= 0) {
gameData.gameState = "gameover";
document.getElementById('gameOverOverlay').style.display = "flex";
document.getElementById('finalScore').innerText = "Final Score: " + gameData.score;
}
}
/************ 経験値加算&レベルアップ ************/
function addExperience(exp) {
gameData.experience += exp;
if (gameData.experience >= 100) {
gameData.experience -= 100;
gameData.playerLevel++;
gameData.playerHealth = Math.min(gameData.maxHealth, gameData.playerHealth + 20);
openUpgradeStore();
}
updateHUD();
}
/************ 敵撃破時の演出 ************/
function killEnemy(enemy) {
if (!enemy) return;
let healthBar = enemy.querySelector('.health-bar');
if (healthBar) { healthBar.parentNode.removeChild(healthBar); }
let explosion = document.createElement('a-entity');
explosion.setAttribute('particle-system', 'preset: dust; particleCount: 100; color: #FFAA00, #FF0000;');
explosion.setAttribute('position', enemy.getAttribute('position'));
enemy.parentNode.appendChild(explosion);
setTimeout(function(){ if(explosion.parentNode) explosion.parentNode.removeChild(explosion); }, 1000);
enemy.setAttribute('animation', 'property: scale; to: 0 0 0; dur: 500; easing: easeInOutQuad');
setTimeout(function(){ if(enemy.parentNode) enemy.parentNode.removeChild(enemy); }, 500);
}
/************ カメラシェイク ************/
function cameraShake() {
let camera = document.querySelector('a-camera');
if (!camera) return;
let origPos = camera.getAttribute('position');
let shakePos = {
x: origPos.x + (Math.random()-0.5)*0.1,
y: origPos.y + (Math.random()-0.5)*0.1,
z: origPos.z
};
camera.setAttribute('position', shakePos);
setTimeout(function(){ camera.setAttribute('position', origPos); }, 100);
}
/************ アップグレードストア ************/
function openUpgradeStore() {
document.getElementById('upgradeOverlay').style.display = "flex";
gameData.paused = true;
}
function closeUpgradeStore() {
document.getElementById('upgradeOverlay').style.display = "none";
gameData.paused = false;
}
document.getElementById('closeUpgrade').addEventListener('click', closeUpgradeStore);
/************ メインメニュー&リスタート ************/
document.getElementById('startButton').addEventListener('click', function(){
document.getElementById('mainMenuOverlay').style.display = "none";
gameData.gameState = "playing";
});
document.getElementById('restartButton').addEventListener('click', function(){
window.location.reload();
});
/************ キー操作 ************/
document.addEventListener('keydown', function(e) {
if(e.key.toLowerCase() === 'p') {
gameData.paused = !gameData.paused;
document.getElementById('pauseOverlay').style.display = gameData.paused ? "flex" : "none";
}
if(e.key === 'u' && gameData.gameState === "playing" && !gameData.paused) {
openUpgradeStore();
}
if(document.getElementById('upgradeOverlay').style.display === "flex") {
if(e.key === '1') {
if(gameData.score >= 50) {
gameData.swordDamage += 10;
gameData.score -= 50;
updateHUD();
}
}
if(e.key === '2') {
if(gameData.score >= 50) {
gameData.maxHealth += 20;
gameData.score -= 50;
updateHUD();
}
}
}
});
/************ pickup コンポーネント ************/
AFRAME.registerComponent('pickup', {
init: function() {
let el = this.el;
el.addEventListener('click', function () {
if(gameData.paused || gameData.gameState !== "playing") return;
let player = document.querySelector('#player');
let playerPos = new THREE.Vector3();
player.object3D.getWorldPosition(playerPos);
let itemPos = new THREE.Vector3();
el.object3D.getWorldPosition(itemPos);
if(playerPos.distanceTo(itemPos) < 2) {
if(!gameData.hasSword) {
let pickupSound = document.querySelector('#pickup-sound');
if(pickupSound && pickupSound.components.sound) {
pickupSound.components.sound.playSound();
}
let rightHand = document.querySelector('#rightHand');
if(rightHand) {
rightHand.appendChild(el);
el.setAttribute('position', '0 0 0');
} else {
player.appendChild(el);
el.setAttribute('position', '0.3 0 -0.5');
}
gameData.currentWeapon = "Sword";
gameData.hasSword = true;
updateHUD();
console.log("Sword picked up!");
el.removeAttribute('animation');
} else {
console.log("Already holding a sword.");
}
}
});
}
});
/************ enemy-ai コンポーネント ************/
AFRAME.registerComponent('enemy-ai', {
schema: {
speed: {type: 'number', default: 0.02},
damage: {type: 'number', default: 5}
},
init: function() { this.attackCooldown = 0; },
tick: function(time, timeDelta) {
if(gameData.paused) return;
let player = document.querySelector('#player');
if(!player) return;
let enemy = this.el;
let enemyPos = enemy.object3D.position;
let playerPos = player.object3D.position;
let direction = new THREE.Vector3().subVectors(playerPos, enemyPos);
let distance = direction.length();
if(distance > 0.1) {
direction.normalize();
enemy.object3D.position.add(direction.multiplyScalar(this.data.speed * (timeDelta/16)));
}
if(distance < 1 && this.attackCooldown <= 0) {
gameData.playerHealth -= this.data.damage;
updateHUD();
cameraShake();
checkGameOver();
this.attackCooldown = 1000;
} else {
this.attackCooldown -= timeDelta;
}
}
});
/************ enemy-health コンポーネント ************/
AFRAME.registerComponent('enemy-health', {
schema: {
hp: {type: 'number', default: 100},
maxHp: {type: 'number', default: 100}
},
init: function(){
let bar = document.createElement('a-plane');
bar.setAttribute('class', 'health-bar');
bar.setAttribute('width', '1');
bar.setAttribute('height', '0.1');
bar.setAttribute('color', 'green');
bar.setAttribute('position', '0 0.8 0');
this.el.appendChild(bar);
},
updateHealthBar: function(){
let healthBar = this.el.querySelector('.health-bar');
if(healthBar) {
let hp = this.data.hp, max = this.data.maxHp;
let scaleX = Math.max(0, hp/max);
healthBar.setAttribute('scale', `${scaleX} 1 1`);
let color = (scaleX > 0.5) ? "green" : (scaleX > 0.2 ? "yellow" : "red");
healthBar.setAttribute('color', color);
}
}
});
/************ sword-swing コンポーネント ************/
AFRAME.registerComponent('sword-swing', {
init: function(){
let sword = this.el;
let self = this;
sword.addEventListener('triggerdown', function(){ self.swing(); });
sword.addEventListener('click', function(){ self.swing(); });
},
swing: function(){
if(gameData.paused) return;
this.el.emit('swing');
let soundEl = document.querySelector('#sword-sound');
if(soundEl && soundEl.components.sound){
soundEl.components.sound.playSound();
}
let swordPos = new THREE.Vector3();
this.el.object3D.getWorldPosition(swordPos);
let enemies = document.querySelectorAll('.enemy');
enemies.forEach(function(enemy){
let enemyPos = new THREE.Vector3();
enemy.object3D.getWorldPosition(enemyPos);
if(swordPos.distanceTo(enemyPos) < 1){
let eh = enemy.getAttribute('enemy-health');
eh.hp -= gameData.swordDamage;
enemy.setAttribute('enemy-health', 'hp', eh.hp);
enemy.components['enemy-health'].updateHealthBar();
if(eh.hp <= 0){
killEnemy(enemy);
gameData.score += 10;
addExperience(20);
} else {
enemy.setAttribute('material', 'color', '#ff4444');
setTimeout(function(){ enemy.setAttribute('material', 'color', '#66ff66'); }, 200);
}
updateHUD();
}
});
}
});
/************ wand-fire コンポーネント ************/
AFRAME.registerComponent('wand-fire', {
init: function(){
let wand = this.el;
let self = this;
wand.addEventListener('triggerdown', function(){ self.fire(); });
wand.addEventListener('click', function(){ self.fire(); });
},
fire: function(){
if(gameData.paused) return;
let wandSound = document.querySelector('#wand-sound');
if(wandSound && wandSound.components.sound){
wandSound.components.sound.playSound();
}
let projectile = document.createElement('a-sphere');
projectile.setAttribute('radius', '0.1');
projectile.setAttribute('color', 'orange');
let startPos = new THREE.Vector3();
this.el.object3D.getWorldPosition(startPos);
projectile.setAttribute('position', startPos);
projectile.setAttribute('projectile', '');
this.el.sceneEl.appendChild(projectile);
}
});
/************ projectile コンポーネント ************/
AFRAME.registerComponent('projectile', {
schema: { speed: {type: 'number', default: 0.1}, damage: {type: 'number', default: 30} },
init: function(){
this.direction = new THREE.Vector3();
this.el.object3D.getWorldDirection(this.direction);
},
tick: function(time, timeDelta){
if(gameData.paused) return;
let distance = this.data.speed * (timeDelta/16);
this.el.object3D.position.add(this.direction.clone().multiplyScalar(distance));
let projectilePos = new THREE.Vector3();
this.el.object3D.getWorldPosition(projectilePos);
let enemies = document.querySelectorAll('.enemy');
for(let i=0; i<enemies.length; i++){
let enemy = enemies[i];
let enemyPos = new THREE.Vector3();
enemy.object3D.getWorldPosition(enemyPos);
if(projectilePos.distanceTo(enemyPos) < 0.5){
let eh = enemy.getAttribute('enemy-health');
eh.hp -= this.data.damage;
enemy.setAttribute('enemy-health', 'hp', eh.hp);
enemy.components['enemy-health'].updateHealthBar();
if(eh.hp <= 0){
killEnemy(enemy);
gameData.score += 10;
addExperience(20);
} else {
enemy.setAttribute('material', 'color', '#ff4444');
setTimeout(function(){ enemy.setAttribute('material', 'color', '#66ff66'); }, 200);
}
updateHUD();
this.el.parentNode.removeChild(this.el);
return;
}
}
if(projectilePos.length() > 50){
this.el.parentNode.removeChild(this.el);
}
}
});
/************ weapon-switcher コンポーネント ************/
AFRAME.registerComponent('weapon-switcher', {
init: function(){
window.addEventListener('keydown', function(event){
if(event.key === '1'){
if(gameData.hasSword){
gameData.currentWeapon = "Sword";
document.querySelector('#rightHand').setAttribute('visible', 'true');
document.querySelector('#magicWand').setAttribute('visible', 'false');
}
updateHUD();
} else if(event.key === '2'){
gameData.currentWeapon = "Magic";
document.querySelector('#sword').setAttribute('visible', 'false');
document.querySelector('#magicWand').setAttribute('visible', 'true');
updateHUD();
}
});
}
});
/************ wave-manager コンポーネント ************/
// 敵が全滅したら次のウェーブを生成。ウェーブ番号が5の倍数の場合はボス出現。
AFRAME.registerComponent('wave-manager', {
tick: function(){
if(gameData.paused) return;
let spawnZone = document.querySelector('#enemy-spawn');
if(spawnZone.children.length === 0){
gameData.wave += 1;
updateHUD();
this.spawnWave();
}
},
spawnWave: function(){
let spawnZone = document.querySelector('#enemy-spawn');
if(gameData.wave % 5 === 0){
// ボスウェーブ
let boss = document.createElement('a-entity');
boss.classList.add('enemy');
boss.setAttribute('position', '0 1 -6');
boss.setAttribute('geometry', 'primitive: sphere; radius: 1');
boss.setAttribute('material', 'color: #aa0000; opacity: 0.9; transparent: true');
boss.setAttribute('animation__rotate', 'property: rotation; to: 0 360 0; dur: 6000; loop: true');
boss.setAttribute('enemy-ai', 'speed: 0.015; damage: 10');
boss.setAttribute('enemy-health', 'hp: 300; maxHp: 300');
spawnZone.appendChild(boss);
} else {
let enemyCount = 3 + gameData.wave - 1;
for(let i=0; i<enemyCount; i++){
let enemy = document.createElement('a-entity');
enemy.classList.add('enemy');
let angle = Math.random() * Math.PI * 2;
let radius = 5 + Math.random() * 5;
let x = Math.cos(angle) * radius;
let z = Math.sin(angle) * radius;
enemy.setAttribute('position', `${x} 1 ${z}`);
enemy.setAttribute('geometry', 'primitive: sphere; radius: 0.5');
enemy.setAttribute('material', 'color: #66ff66; opacity: 0.8; transparent: true');
enemy.setAttribute('animation__wobble', 'property: scale; to: 1.1 0.9 1.1; dur: 1000; dir: alternate; loop: true');
enemy.setAttribute('enemy-ai', 'speed: 0.02; damage: 5');
enemy.setAttribute('enemy-health', 'hp: 100; maxHp: 100');
spawnZone.appendChild(enemy);
}
}
}
});
/************ 初回 HUD 更新 & インストラクション削除 ************/
document.addEventListener('DOMContentLoaded', function(){
updateHUD();
setTimeout(function(){
let instructions = document.getElementById('instructions');
if(instructions){ instructions.parentNode.removeChild(instructions); }
}, 5000);
});
</script>
</body>
</html>