<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VR‑SNS Prototype v8 (Spatial Audio + PTT + エモート)</title>
<!-- Libraries -->
<script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/aframe-extras@6.1.1/dist/aframe-extras.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gun/gun.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/simple-peer@9.11.1/simplepeer.min.js"></script>
<style>
html,body{margin:0;padding:0;overflow:hidden;font-family:"Helvetica Neue",Arial,sans-serif;background:#111;color:#eee}
#ui{position:fixed;top:0;left:0;width:420px;height:100vh;overflow-y:auto;background:rgba(0,0,0,.9);backdrop-filter:blur(6px);padding:1rem;box-sizing:border-box;z-index:10;transition:transform .3s ease}
#ui.hidden{transform:translateX(-105%)}
#ui h1{font-size:1.5rem;margin:0 0 .8rem;text-align:center}
#ui label{display:block;font-size:.75rem;margin:.6rem 0 .15rem;color:#8fc}
#ui input,#ui select,#ui textarea{width:100%;box-sizing:border-box;margin-bottom:.7rem;padding:.56rem;border:none;border-radius:6px;font-size:.9rem;background:#222;color:#ddd}
#ui textarea{resize:vertical}
#ui button{width:100%;padding:.72rem;border:none;border-radius:6px;font-weight:bold;cursor:pointer;background:#06a;color:#fff;transition:background .2s}
#ui button:hover{background:#089}
#timeline{margin-top:1rem}
.post{background:#1a1a1a;border-radius:6px;padding:.75rem;margin-bottom:.75rem;word-break:break-word;font-size:.8rem;position:relative}
.post small{color:#999;font-size:.7rem}
.post img{max-width:100%;border-radius:4px;margin-top:.4rem}
.reactBar{display:flex;gap:5px;margin-top:.45rem}
.reactBtn{flex:1;background:#333;border:none;border-radius:4px;padding:3px 0;font-size:.75rem;cursor:pointer;color:#f88;display:flex;justify-content:center;align-items:center}
.reactBtn span{margin-left:4px;font-weight:bold;color:#ccc}
.hashTag{color:#6af;cursor:pointer}
#handle{position:absolute;top:50%;right:-18px;width:18px;height:80px;border-radius:0 6px 6px 0;background:#06a;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;font-size:.7rem;transform:translateY(-50%)}
#tagFilter{margin-bottom:.8rem;width:100%;padding:.56rem;border:none;border-radius:6px;background:#222;color:#ddd}
#onlineCount{font-size:.75rem;margin-bottom:.8rem;color:#9f9}
#roomInput{width:70%;display:inline-block}
#roomBtn{width:28%;display:inline-block;margin-left:2%}
#ttsToggleLbl,#muteToggleLbl,#pttInfo{display:flex;align-items:center;font-size:.75rem;margin-bottom:.7rem;gap:.5rem}
.sysMsg{color:#8ff;font-style:italic}
.speaking{animation:pulse 1s infinite}
@keyframes pulse{0%{opacity:1}50%{opacity:.4}100%{opacity:1}}
.emote{position:absolute;left:50%;transform:translateX(-50%);top:-0.4m;font-size:0.4m}
</style>
</head>
<body>
<div id="ui">
<div id="handle">⮜</div>
<h1>VR‑SNS</h1>
<label>ルーム ID</label>
<input id="roomInput" value="lobby"/><button id="roomBtn">入室</button>
<div id="onlineCount">オンライン: 0</div>
<label>ユーザー名</label>
<input id="usernameInput" placeholder="匿名" />
<label>アバター色</label>
<input id="avatarColor" type="color" value="#ff8800" />
<label>環境テーマ</label>
<select id="themeSelect"><option value="dusk">夕暮れ空</option><option value="midnight">真夜中</option><option value="day">晴れ昼</option></select>
<label>ハッシュタグ絞り込み</label>
<select id="tagFilter"><option value="all">すべて</option></select>
<label id="ttsToggleLbl"><input type="checkbox" id="ttsToggle"/> 投稿読み上げ</label>
<label id="muteToggleLbl"><input type="checkbox" id="muteToggle"/> マイク常時OFF</label>
<div id="pttInfo">Push‑To‑Talk: <strong>Vキー</strong> 押下中のみ送信</div>
<label>投稿</label>
<textarea id="postText" rows="3"></textarea>
<button id="postButton">ポスト</button>
<button id="clearButton" style="background:#a00;margin-top:.3rem">ローカル履歴削除</button>
<div id="timeline"></div>
</div>
<a-scene renderer="antialias:true" xr-mode-ui="true" background="color:#112" cursor="rayOrigin:mouse">
<a-entity environment="preset: forest; ground:y"></a-entity>
<a-entity id="ground" geometry="primitive:plane; width:150; height:150" rotation="-90 0 0" material="visible:false" static-body></a-entity>
<a-entity id="rig" position="0 1.6 4" movement-controls="fly:false; speed:0.22">
<a-entity id="head" camera look-controls></a-entity>
<a-entity id="leftHand" laser-controls="hand:left" raycaster="objects:.interactive" teleport-controls="button:trigger; collisionEntities:#ground; cameraRig:#rig"></a-entity>
<a-entity id="rightHand" laser-controls="hand:right" raycaster="objects:.interactive"></a-entity>
</a-entity>
</a-scene>
<script>
(()=>{
const gun=Gun({peers:['https://gun-manhattan.herokuapp.com/gun']});
const myId=Gun.text.random();
let room='lobby',roomRef;
const $=id=>document.getElementById(id);
const ui=$('ui'),handle=$('handle');
const roomInput=$('roomInput'),roomBtn=$('roomBtn'),onlineCount=$('onlineCount');
const usernameInput=$('usernameInput'),avatarColorIn=$('avatarColor');
const themeSelect=$('themeSelect'),tagFilter=$('tagFilter');
const ttsToggle=$('ttsToggle'),muteToggle=$('muteToggle');
const postText=$('postText'),postBtn=$('postButton'),clearBtn=$('clearButton');
const timelineEl=$('timeline');
const scene=document.querySelector('a-scene');
const head=$('head'),leftHand=$('leftHand'),rightHand=$('rightHand');
const store={get:(k,d)=>JSON.parse(localStorage.getItem('vrsns_'+k)||JSON.stringify(d)),set:(k,v)=>localStorage.setItem('vrsns_'+k,JSON.stringify(v))};
usernameInput.value=store.get('user','');avatarColorIn.value=store.get('color','#ff8800');themeSelect.value=store.get('theme','dusk');ttsToggle.checked=store.get('tts',false);
const postsMap=new Map();const tags=new Set();const remoteAvatars=new Map();const remotePeers=new Map();let micStream=null,aCtx=null;let pushTalking=false;
let presenceRef,posRef,presenceInterval,posInterval;
applyTheme(themeSelect.value);createOrUpdateAvatar();setupMic();enterRoom(room);
handle.onclick=()=>ui.classList.toggle('hidden');scene.addEventListener('enter-vr',()=>ui.classList.add('hidden'));scene.addEventListener('exit-vr',()=>ui.classList.remove('hidden'));
roomBtn.onclick=()=>{const r=roomInput.value.trim();if(r){leaveRoom();enterRoom(r)}};
themeSelect.onchange=e=>{const v=e.target.value;store.set('theme',v);applyTheme(v)};avatarColorIn.oninput=createOrUpdateAvatar;
ttsToggle.onchange=e=>store.set('tts',e.target.checked);
muteToggle.onchange=e=>updateMicState();
postBtn.onclick=submitPost;clearBtn.onclick=()=>{if(confirm('ローカル履歴を削除しますか?')){localStorage.removeItem('vrsns_posts_'+room);timelineEl.innerHTML='';}};
tagFilter.onchange=filterTimeline;
window.addEventListener('keydown',e=>{if(e.key==='v'&&!pushTalking){pushTalking=true;updateMicState();}});
window.addEventListener('keyup',e=>{if(e.key==='v'){pushTalking=false;updateMicState();}});
async function setupMic(){try{aCtx=new (window.AudioContext||window.webkitAudioContext)();micStream=await navigator.mediaDevices.getUserMedia({audio:true});updateMicState();}catch(err){alert('マイク利用不可');}}
function updateMicState(){if(!micStream)return;const enabled=!muteToggle.checked&&(pushTalking||muteToggle.checked===false&&ttsToggle);micStream.getAudioTracks()[0].enabled=enabled;}
function enterRoom(r){room=r;roomRef=gun.get('vrsns').get(room);roomInput.value=room;timelineEl.innerHTML='';postsMap.clear();tags.clear();rebuildTagFilter();onlineCount.textContent='オンライン: 0';
roomRef.get('posts').map().on(onPost);roomRef.get('posts').map().get('reactions').map().on(onReaction);
presenceRef=roomRef.get('presence').get(myId);presenceRef.put({ts:Gun.state(),color:avatarColorIn.value});presenceInterval=setInterval(()=>presenceRef.put({ts:Gun.state(),color:avatarColorIn.value}),10000);roomRef.get('presence').map().on(updateOnline);
posRef=roomRef.get('pos').get(myId);sendPos();posInterval=setInterval(sendPos,100);
roomRef.get('pos').map().on(onRemotePos);roomRef.get('signal').map().on(onSignal);sysMsg(`[${room}] 入室しました`);}
function leaveRoom(){clearInterval(presenceInterval);clearInterval(posInterval);presenceRef&&presenceRef.put(null);posRef&&posRef.put(null);roomRef.off();roomRef.get('signal').off();remoteAvatars.forEach(av=>av.remove());remoteAvatars.clear();remotePeers.forEach(p=>p.destroy());remotePeers.clear();}
function submitPost(){const user=usernameInput.value.trim()||'匿名',text=postText.value.trim();if(!text)return;store.set('user',user);store.set('color',avatarColorIn.value);const id=Gun.text.random();const post={id,user,text,time:Date.now(),color:avatarColorIn.value,reactions:{'❤':0,'😂':0,'😮':0,'😢':0},tags:extractTags(text)};roomRef.get('posts').get(id).put(post);postText.value='';if(ttsToggle.checked)speak(`${user} さん: ${text.replace(/#/g,'')}`);}
function onPost(post){if(!post||postsMap.has(post.id))return;postsMap.set(post.id,post);collectTags(post);rebuildTagFilter();renderPost2D(post);renderPost3D(post);filterTimeline();}
function onReaction(v,field,key){const pid=field,emoji=key;const p=postsMap.get(pid);if(!p)return;p.reactions[emoji]=v;updatePostCard(pid,p);updatePost3D(pid,p);}
function react(id,emo){roomRef.get('posts').get(id).get('reactions').get(emo).once(v=>roomRef.get('posts').get(id).get('reactions').get(emo).put((v||0)+1));}
function updateOnline(){let c=0;roomRef.get('presence').map().once(()=>c++);setTimeout(()=>onlineCount.textContent=`オンライン: ${c}`,200);}
function sendPos(){const p=head.object3D.position,l=leftHand.object3D.position,r=rightHand.object3D.position;posRef.put({x:p.x,y:p.y,z:p.z,l:{x:l.x,y:l.y,z:l.z},r:{x:r.x,y:r.y,z:r.z},color:avatarColorIn.value,ts:Gun.state()});}
function onRemotePos(data,id){if(id===myId||!data)return;let av=remoteAvatars.get(id);if(!av){av=createAvatar(id,data.color);remoteAvatars.set(id,av);connectVoice(id);}av.setAttribute('position',`${data.x} 0 ${data.z}`);av.querySelectorAll('[geometry]').forEach(g=>g.setAttribute('color',data.color||'#fff'));['l','r'].forEach(h=>{let el=av.querySelector('.'+h);if(!el){el=document.createElement('a-sphere');el.classList.add(h);el.setAttribute('radius',0.05);av.appendChild(el);}const hp=data[h];if(hp)el.setAttribute('position',`${hp.x} ${hp.y} ${hp.z}`);});}
function createAvatar(id,color){const root=document.createElement('a-entity');root.id='av_'+id;root.innerHTML=`<a-sphere radius='0.25' color='${color||'#fff'}' position='0 1.6 0'></a-sphere><a-cylinder height='0.8' radius='0.2' color='${color||'#fff'}' position='0 1 0'></a-cylinder>`;scene.appendChild(root);return root;}
function connectVoice(rid){if(remotePeers.has(rid)||!micStream)return;const init=myId>rid;const peer=new SimplePeer({initiator:init,trickle:true,stream:micStream});remotePeers.set(rid,peer);peer.on('signal',d=>roomRef.get('signal').get(myId).get(rid).put(JSON.stringify(d)));roomRef.get('signal').get(rid).get(myId).on(sig=>sig&&peer.signal(JSON.parse(sig)));peer.on('stream',s=>{const av=remoteAvatars.get(rid);if(!av)return;const a=new Audio();a.srcObject=s;const src=aCtx.createMediaStreamSource(s);const panner=aCtx.createPanner();panner.panningModel='HRTF';panner.distanceModel='inverse';panner.maxDistance=20;src.connect(panner).connect(aCtx.destination);function upd(){if(!av.parentNode)return;const pos=av.object3D.position;panner.setPosition(pos.x,pos.y,pos.z);requestAnimationFrame(upd);}upd();a.play();});peer.on('close',()=>remotePeers.delete(rid));peer.on('error',()=>remotePeers.delete(rid));}
function onSignal(){}
function renderPost2D(p){const isImg=/\.(gif|jpe?g|png|webp)$/i.test(p.text.trim());const card=document.createElement('div');card.className='post';card.dataset.id=p.id;card.dataset.tags=p.tags.join(',');card.innerHTML=`<strong style='color:${p.color}'>${escapeHTML(p.user)}</strong>: ${isImg?`<img src='${escapeHTML(p.text)}'/>`:linkify(escapeHTML(p.text))}${p.tags.map(t=>` <span class='hashTag'>#${t}</span>`).join('')}<br><small>${new Date(p.time).toLocaleString()}</small>`;const bar=document.createElement('div');bar.className='reactBar';['❤','😂','😮','😢'].forEach(e=>{const b=document.createElement('button');b.className='reactBtn';b.dataset.e=e;b.innerHTML=`${e} <span>${p.reactions[e]||0}</span>`;b.onclick=()=>react(p.id,e);bar.appendChild(b);});card.appendChild(bar);card.querySelectorAll('.hashTag').forEach(el=>el.onclick=()=>{tagFilter.value=el.textContent.slice(1).toLowerCase();filterTimeline();});timelineEl.insertBefore(card,timelineEl.firstChild);}
function updatePostCard(id,p){const card=document.querySelector(`.post[data-id='${id}']`);if(card)card.querySelectorAll('.reactBtn').forEach(btn=>btn.querySelector('span').textContent=p.reactions[btn.dataset.e]||0);}
function format3D(p){const top=Object.entries(p.reactions||{}).sort((a,b)=>b[1]-a[1])[0];const r=top&&top[1]>0?` ${top[0]}×${top[1]}`:'';return `${p.user}${r}\n${p.text}`;}
function renderPost3D(p){const ent=document.createElement('a-entity');ent.classList.add('interactive');ent.id='p3d_'+p.id;ent.setAttribute('text',{value:format3D(p),align:'center',width:4,color:p.color});ent.onclick=()=>react(p.id,'❤');positionEntity(ent);scene.appendChild(ent);setTimeout(()=>ent.remove(),300000);if(/\.(gif|jpe?g|png|webp)$/i.test(p.text.trim())){const plane=document.createElement('a-plane');plane.setAttribute('src',p.text);plane.setAttribute('width',1.8);plane.setAttribute('height',1.2);plane.classList.add('interactive');positionEntity(plane,0.6);scene.appendChild(plane);setTimeout(()=>plane.remove(),300000);}}
function updatePost3D(id,p){const e=$('p3d_'+id);if(e)e.setAttribute('text','value',format3D(p));}
function positionEntity(e,y=0){const ang=Math.random()*Math.PI,rad=5+Math.random()*5;e.setAttribute('position',`${Math.cos(ang)*rad} ${2+y} ${-Math.sin(ang)*rad}`);}
function collectTags(p){p.tags.forEach(t=>tags.add(t.toLowerCase()));}
function rebuildTagFilter(){const cur=tagFilter.value;tagFilter.innerHTML='<option value="all">すべて</option>'+[...tags].sort().map(t=>`<option value='${t}'>#${t}</option>`).join('');tagFilter.value=cur||'all';}
function filterTimeline(){const f=tagFilter.value;document.querySelectorAll('.post').forEach(el=>el.style.display=(f==='all'||el.dataset.tags.includes(f))?'':'none');}
function escapeHTML(s){return s.replace(/[&<>"']/g,ch=>({'&':'&','<':'<',">":">","\"":""","'":"'"}[ch]));}
function extractTags(t){return(t.match(/#(\w+)/g)||[]).map(x=>x.slice(1).toLowerCase());}
function linkify(t){return t.replace(/https?:\/\/\S+/g,u=>`<a href='${u}' target='_blank'>${u}</a>`);}
function applyTheme(t){let sky=scene.querySelector('a-sky');if(!sky){sky=document.createElement('a-sky');scene.appendChild(sky);}sky.setAttribute('color',t==='midnight'?'#000022':t==='day'?'#86cefa':'#112');}
function createOrUpdateAvatar(){const c=avatarColorIn.value;store.set('color',c);let av=$('myAvatar');const body=`<a-sphere radius='0.25' color='${c}' position='0 1.6 0'></a-sphere><a-cylinder height='0.8' radius='0.2' color='${c}' position='0 1 0'></a-cylinder>`;if(!av){av=document.createElement('a-entity');av.id='myAvatar';av.innerHTML=body;scene.appendChild(av);}else av.innerHTML=body;}
function speak(msg){speechSynthesis.speak(new SpeechSynthesisUtterance(msg));}
function sysMsg(t){const el=document.createElement('div');el.className='post sysMsg';el.textContent=t;timelineEl.insertBefore(el,timelineEl.firstChild);}
})();
</script>
</body>
</html>