<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Verse – プロダクション級ソーシャルVR</title>
<!-- A-Frame Core -->
<script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
<!-- Networked-AFrame for multi-user sync -->
<script src="https://unpkg.com/networked-aframe@0.8.0/dist/networked-aframe.min.js"></script>
<!-- GUI for VR buttons -->
<script src="https://unpkg.com/aframe-gui/dist/aframe-gui.min.js"></script>
<!-- Environment presets -->
<script src="https://unpkg.com/aframe-environment-component/dist/aframe-environment-component.min.js"></script>
<!-- Extras: teleport, locomotion, physics, super-hands -->
<script src="https://unpkg.com/aframe-extras@6.1.1/dist/aframe-extras.min.js"></script>
<script src="https://unpkg.com/aframe-super-hands-component@4.0.3/dist/aframe-super-hands.min.js"></script>
<!-- Socket.IO for signaling -->
<script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script>
<!-- Simple-Peer for WebRTC voice chat -->
<script src="https://unpkg.com/simple-peer@9.11.0/simplepeer.min.js"></script>
<style>
body { margin: 0; }
#vr-scene { width: 100%; height: 100vh; }
.interactive:hover { animation: pulse 0.5s infinite alternate; }
@keyframes pulse { to { scale: 1.05; } }
</style>
</head>
<body>
<a-scene id="vr-scene"
networked-scene="room: verse-room; serverURL: https://YOUR_SIGNAL_SERVER; app: verse-vr; debug: false"
environment="preset: forest; groundColor:#445; skyColor:#889"
extras="teleportControls: true; locomotionControls: true"
physics="gravity: -9.8; debug: false">
<!-- Assets -->
<a-assets>
<audio id="click-sound" src="click.mp3"></audio>
<a-asset-item id="avatarModel" src="avatar.glb"></a-asset-item>
<img id="panel-bg" src="panel-bg.png" />
</a-assets>
<!-- Camera Rig -->
<a-entity id="cameraRig" position="0 1.6 0"
locomotion-controls="fly:false; speed:4"
teleport-controls="button: trigger; collisionEntities: #ground">
<a-entity camera look-controls>
<a-cursor fuse="true" fuseTimeout="300" material="color:cyan; shader:flat"></a-cursor>
</a-entity>
<a-entity hand-tracking-controls="hand: left" super-hands></a-entity>
<a-entity hand-tracking-controls="hand: right" super-hands></a-entity>
</a-entity>
<!-- Ground -->
<a-plane id="ground" position="0 0 0" rotation="-90 0 0" width="50" height="50" color="#444" static-body></a-plane>
<!-- Avatar Sync Template -->
<template id="avatar-template">
<a-entity>
<a-gltf-model src="#avatarModel" scale="0.6 0.6 0.6"></a-gltf-model>
</a-entity>
</template>
<a-entity networked-avatar networked="template:#avatar-template; attachTemplateToLocal:false"></a-entity>
<!-- GUI Menu -->
<a-gui-flex-container id="menu" flex-direction="row" justify-content="space-around"
panel-width="2" panel-height="0.2"
component-padding="0.05 0.1"
position="0 2 -1.5" material="src:#panel-bg; transparent:true">
<a-gui-button id="btnTextPost" value="Text Post" on-click="openTextInput()" font-size="28px" color="black"></a-gui-button>
<a-gui-button id="btnVoicePost" value="Voice Post" on-click="startVoiceRecognition()" font-size="28px" color="black"></a-gui-button>
<a-gui-button id="btnGPTChat" value="GPT BOT" on-click="callGPTBot()" font-size="28px" color="black"></a-gui-button>
<a-gui-button id="btnLikeMode" value="Like/Delete" on-click="toggleLikeMode()" font-size="28px" color="black"></a-gui-button>
<a-gui-button id="btnSpawnCube" value="Spawn Cube" on-click="spawnCube()" font-size="28px" color="black"></a-gui-button>
<a-gui-button id="btnVoiceChat" value="Voice Chat" on-click="toggleVoiceChat()" font-size="28px" color="black"></a-gui-button>
</a-gui-flex-container>
<!-- Containers -->
<a-entity id="post-container"></a-entity>
<a-entity id="ugc-container"></a-entity>
</a-scene>
<script>
// ----------- データ -----------
let posts = JSON.parse(localStorage.getItem('posts')||'[]');
let likeMode = false;
let recognition;
let socket = io('https://YOUR_SIGNAL_SERVER');
let peers = {};
let localStream;
// ----------- 投稿レンダリング -----------
function renderPosts() {
const container = document.getElementById('post-container');
container.innerHTML = '';
posts.forEach((p,i)=>{
const angle = (i/posts.length)*Math.PI*2;
const x = Math.cos(angle)*2;
const z = Math.sin(angle)*2;
const postEl = document.createElement('a-entity');
postEl.classList.add('interactive');
postEl.setAttribute('geometry','primitive: plane; width:1.2; height:0.5');
postEl.setAttribute('material','color:#fff; shader:flat');
postEl.setAttribute('position',`${x} 1.3 ${z}`);
postEl.setAttribute('rotation',`0 ${-angle*180/Math.PI+90} 0`);
postEl.setAttribute('text',`value:${p.content}; width:1.1; align:center; color:#000`);
// クリック処理
postEl.addEventListener('click', ()=>{
if(likeMode) {
p.likes = (p.likes||0)+1;
savePosts(); renderPosts();
}
});
// 削除ボタン
if(likeMode) {
const delBtn = document.createElement('a-entity');
delBtn.setAttribute('geometry','primitive: plane; width:0.2; height:0.1');
delBtn.setAttribute('material','color:#f88; shader:flat');
delBtn.setAttribute('position','0.55 -0.25 0.01');
delBtn.setAttribute('text','value:Del; width:0.2; align:center; color:#000');
delBtn.addEventListener('click', ()=>{ posts.splice(i,1); savePosts(); renderPosts(); });
postEl.appendChild(delBtn);
}
container.appendChild(postEl);
});
}
function savePosts(){ localStorage.setItem('posts', JSON.stringify(posts)); }
// ----------- テキスト投稿 -----------
window.openTextInput = ()=>{
const txt = prompt('投稿内容を入力してください');
if(txt){ posts.unshift({content:txt, likes:0}); savePosts(); renderPosts(); socket.emit('new-post', txt); }
};
// ----------- 音声投稿 -----------
window.startVoiceRecognition = ()=>{
if(!('webkitSpeechRecognition' in window)) return alert('音声認識非対応');
recognition = new webkitSpeechRecognition();
recognition.lang='ja-JP'; recognition.interimResults=false;
recognition.onresult=e=>{ const txt=e.results[0][0].transcript; posts.unshift({content:txt,likes:0}); savePosts(); renderPosts(); };
recognition.onerror=()=>alert('認識エラー'); recognition.start();
};
// ----------- GPT BOT -----------
window.callGPTBot = async ()=>{
const key='YOUR_OPENAI_API_KEY';
const res=await fetch('https://api.openai.com/v1/chat/completions',{ method:'POST',
headers:{'Content-Type':'application/json','Authorization':`Bearer ${key}`},
body:JSON.stringify({ model:'gpt-4o-mini', messages:[{role:'system',content:'あなたはVR BOTです。'}]
.concat(posts.slice(0,5).map(p=>({role:'user',content:p.content}))), max_tokens:50 })
});
const js=await res.json();
const txt=js.choices[0].message.content.trim();
posts.unshift({content:`🤖 GPT: ${txt}`,likes:0}); savePosts(); renderPosts();
};
// ----------- いいね/削除モード -----------
window.toggleLikeMode = ()=>{ likeMode=!likeMode; renderPosts(); };
// ----------- UGC: キューブ生成 -----------
window.spawnCube = ()=>{
const c=document.createElement('a-box');
c.setAttribute('class','interactive');
c.setAttribute('position','0 1 -1'); c.setAttribute('depth','0.5'); c.setAttribute('height','0.5'); c.setAttribute('width','0.5');
c.setAttribute('material','color:#4CC3D9'); c.setAttribute('dynamic-body','');
c.setAttribute('grabbable',''); c.setAttribute('stretchable','');
document.getElementById('ugc-container').appendChild(c);
};
// ----------- Voice Chat -----------
window.toggleVoiceChat = ()=>{ localStream?stopVoiceChat():startVoiceChat(); };
async function startVoiceChat() {
localStream = await navigator.mediaDevices.getUserMedia({audio:true});
socket.emit('join-voice');
socket.on('signal', ({ id, signal })=>{
if(!peers[id]) createPeer(id, false);
peers[id].signal(signal);
});
socket.on('user-joined', id=>{ createPeer(id, true); });
function createPeer(id, initiator) {
const peer = new SimplePeer({ initiator, trickle:false, stream:localStream });
peer.on('signal', signal=>{ socket.emit('signal',{ id: socket.id, to:id, signal }); });
peer.on('stream', stream=>{ const e=document.createElement('audio'); e.srcObject=stream; e.autoplay=true; document.body.appendChild(e); });
peers[id]=peer;
}
}
function stopVoiceChat(){ Object.values(peers).forEach(p=>p.destroy()); peers={}; localStream.getTracks().forEach(t=>t.stop()); localStream=null; }
// ----------- Socket.IO イベント -----------
socket.on('new-post', txt=>{ posts.unshift({content:txt,likes:0}); savePosts(); renderPosts(); });
// ----------- 初期ロード -----------
document.querySelector('a-scene').addEventListener('loaded', ()=>{ renderPosts(); });
</script>
</body>
</html>