VerseVR

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

投稿者: chosuke

趣味はゲームやアニメや漫画などです

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です