3dVillageTour / app.js
Thursday88's picture
Update app.js
d1283c3 verified
Raw
History Blame Contribute Delete
35.4 kB
// ============================================================================
// app.js โ€” "Under the Palm Tree" village tour engine
// You normally never edit this file. All settings live in config.js.
// ============================================================================
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
import { CONFIG, PALETTE } from './config.js';
const CDN = 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/';
const isMobile = window.matchMedia('(pointer:coarse)').matches;
const W = CONFIG.world || {};
const $ = (id) => document.getElementById(id);
const v3 = (a) => new THREE.Vector3(a[0], a[1], a[2]);
const clamp = (x,a,b) => Math.max(a, Math.min(b,x));
const ease = (t) => t<.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2;
const lerp = THREE.MathUtils.lerp;
// ============================================================================
// 1. RENDERER ยท SCENE ยท CAMERA ยท LIGHTS
// ============================================================================
const renderer = new THREE.WebGLRenderer({ antialias:!isMobile, powerPreference:'high-performance' });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.95;
$('scene').appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(PALETTE.sky, 0.0025); // light haze so hills stay visible
const camera = new THREE.PerspectiveCamera(55, innerWidth/innerHeight, 0.1, 4000);
camera.position.copy(v3(CONFIG.startPosition));
const sun = new THREE.DirectionalLight(0xfff0d0, 1.2); scene.add(sun);
const hemi = new THREE.HemisphereLight(PALETTE.sky, PALETTE.mudClay, 0.75); scene.add(hemi);
scene.add(new THREE.AmbientLight(0xffffff, 0.08));
// ============================================================================
// 2. SKY ยท SUN ยท MOON ยท CLOUDS ยท STARS (+ day/night)
// ============================================================================
const SUN_DIR=new THREE.Vector3(8,14,6).normalize(), MOON_DIR=new THREE.Vector3(-6,12,-8).normalize();
const C_dayTop=new THREE.Color('#62a6ec'), C_dayBot=new THREE.Color('#ede4d0');
const C_nitTop=new THREE.Color('#0a1024'), C_nitBot=new THREE.Color('#1c2742');
const C_sunLi =new THREE.Color('#fff7e8'), C_moonLi=new THREE.Color('#b6c8ee');
const skyMat=new THREE.ShaderMaterial({ side:THREE.BackSide, depthWrite:false,
uniforms:{top:{value:C_dayTop.clone()}, bot:{value:C_dayBot.clone()}},
vertexShader:`varying vec3 vP; void main(){ vP=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} `,
fragmentShader:`varying vec3 vP; uniform vec3 top; uniform vec3 bot; void main(){ float h=normalize(vP).y*0.5+0.5; gl_FragColor=vec4(mix(bot,top,smoothstep(0.0,1.0,h)),1.0);} ` });
scene.add(new THREE.Mesh(new THREE.SphereGeometry(1500,24,16), skyMat));
function canvasTex(draw,w=128,h=128){ const c=document.createElement('canvas'); c.width=w;c.height=h; draw(c.getContext('2d'),w,h); const t=new THREE.CanvasTexture(c); t.colorSpace=THREE.SRGBColorSpace; return t; }
const sunTex=canvasTex(x=>{const g=x.createRadialGradient(64,64,0,64,64,64);g.addColorStop(0,'rgba(255,250,225,1)');g.addColorStop(.4,'rgba(255,224,160,.7)');g.addColorStop(1,'rgba(255,224,160,0)');x.fillStyle=g;x.fillRect(0,0,128,128);});
const moonTex=canvasTex(x=>{x.fillStyle='rgba(248,246,228,1)';x.beginPath();x.arc(64,64,40,0,7);x.fill();x.globalCompositeOperation='destination-out';x.beginPath();x.arc(82,54,36,0,7);x.fill();});
const cloudTex=canvasTex((x,w,h)=>{x.clearRect(0,0,w,h);for(let i=0;i<7;i++){const cx=30+Math.random()*(w-60),cy=h*.5+(Math.random()-.5)*30,r=22+Math.random()*26,g=x.createRadialGradient(cx,cy,0,cx,cy,r);g.addColorStop(0,'rgba(255,255,255,.9)');g.addColorStop(1,'rgba(255,255,255,0)');x.fillStyle=g;x.beginPath();x.arc(cx,cy,r,0,7);x.fill();}},256,128);
function sprite(tex,add){const m=new THREE.SpriteMaterial({map:tex,transparent:true,depthWrite:false,fog:false,blending:add?THREE.AdditiveBlending:THREE.NormalBlending});return new THREE.Sprite(m);}
const sunSprite=sprite(sunTex,true); sunSprite.scale.set(160,160,1); sunSprite.position.copy(SUN_DIR.clone().multiplyScalar(1100)); scene.add(sunSprite);
const moonSprite=sprite(moonTex,false); moonSprite.scale.set(100,100,1); moonSprite.position.copy(MOON_DIR.clone().multiplyScalar(1100)); scene.add(moonSprite);
const clouds=[]; for(let i=0;i<8;i++){const s=sprite(cloudTex,false);s.userData={ang:Math.random()*7,rad:500+Math.random()*400,hgt:200+Math.random()*250,spd:.004+Math.random()*.006};s.scale.set(260,130,1);scene.add(s);clouds.push(s);}
const starGeo=new THREE.BufferGeometry(); const SN=800,sp=new Float32Array(SN*3);
for(let i=0;i<SN;i++){const u=Math.random(),th=Math.acos(clamp(u,0,1)),ph=Math.random()*7,r=1200;sp[i*3]=r*Math.sin(th)*Math.cos(ph);sp[i*3+1]=r*Math.cos(th)*.9+60;sp[i*3+2]=r*Math.sin(th)*Math.sin(ph);}
starGeo.setAttribute('position',new THREE.BufferAttribute(sp,3));
const stars=new THREE.Points(starGeo,new THREE.PointsMaterial({color:0xffffff,size:2.4,sizeAttenuation:false,transparent:true,opacity:0,depthWrite:false,fog:false})); scene.add(stars);
let dayTarget=CONFIG.startTime==='night'?0:1, dayT=dayTarget;
function updateSky(){ dayT=lerp(dayT,dayTarget,0.12);
skyMat.uniforms.top.value.lerpColors(C_nitTop,C_dayTop,dayT); skyMat.uniforms.bot.value.lerpColors(C_nitBot,C_dayBot,dayT);
scene.fog.color.copy(skyMat.uniforms.bot.value);
sun.position.copy(MOON_DIR).lerp(SUN_DIR,dayT).multiplyScalar(60); sun.intensity=lerp(0.22,1.6,dayT); sun.color.lerpColors(C_moonLi,C_sunLi,dayT);
hemi.intensity=lerp(0.28,1.05,dayT); renderer.toneMappingExposure=lerp(0.7,1.12,dayT);
sunSprite.material.opacity=dayT; moonSprite.material.opacity=1-dayT; stars.material.opacity=(1-dayT)*0.9;
clouds.forEach(s=>{s.material.opacity=dayT*0.85; s.userData.ang+=s.userData.spd*0.016; const a=s.userData.ang; s.position.set(Math.cos(a)*s.userData.rad,s.userData.hgt,Math.sin(a)*s.userData.rad);});
}
// ============================================================================
// 3. WORLD / SURROUNDINGS โ€” desert floor + distant hills (immersion)
// ============================================================================
function buildWorld(){ const w=W;
const box=new THREE.Box3().setFromObject(worldRoot), ctr=new THREE.Vector3(), size=new THREE.Vector3();
let gy=0, cx=0, cz=0, modelR=200;
if(isFinite(box.min.y)){ box.getCenter(ctr); box.getSize(size); gy=box.min.y; cx=ctr.x; cz=ctr.z; modelR=Math.max(size.x,size.z)*0.5; }
if(w.groundY!==undefined && w.groundY!==null) gy=w.groundY; // manual override
if(w.groundPlane!==false){ const g=new THREE.Mesh(new THREE.PlaneGeometry(8000,8000), new THREE.MeshStandardMaterial({color:new THREE.Color(w.groundColor||'#c2a878'),roughness:1,metalness:0})); g.rotation.x=-Math.PI/2; g.position.set(cx,gy-0.5,cz); g.renderOrder=-1; scene.add(g); }
if(w.mountains!==false){ const R=Math.min(1350, Math.max(w.mountainRadius||0, modelR*7 + 300)), mat=new THREE.MeshStandardMaterial({color:new THREE.Color(w.mountainColor||'#8a6f50'),roughness:1,flatShading:true}), ring=new THREE.Group();
const mh=Math.max(modelR*1.6, 80);
for(let i=0;i<54;i++){ const a=(i/54)*Math.PI*2+(Math.random()-.5)*0.14, r=R+(Math.random()-.5)*R*0.3, h=mh*(0.7+Math.random()*0.9), base=mh*(0.7+Math.random()*0.7);
const m=new THREE.Mesh(new THREE.ConeGeometry(base,h,5+Math.floor(Math.random()*3)),mat); m.position.set(cx+Math.cos(a)*r, gy+h/2-8, cz+Math.sin(a)*r); m.rotation.y=Math.random()*7; m.scale.x=1+Math.random()*0.5; ring.add(m); }
scene.add(ring); }
}
// ============================================================================
// 4. LOAD MODEL
// ============================================================================
let worldRoot=null, animNames=[], skinnedNames=[];
let villageCenter=new THREE.Vector3(), villageMinY=0, villageR=20;
const draco=new DRACOLoader().setDecoderPath(CDN+'draco/'), ktx2=new KTX2Loader().setTranscoderPath(CDN+'basis/').detectSupport(renderer);
const loader=new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
loader.load(CONFIG.modelUrl,
(gltf)=>{ worldRoot=gltf.scene; scene.add(worldRoot);
{ const b=new THREE.Box3().setFromObject(worldRoot); if(isFinite(b.min.x)){ b.getCenter(villageCenter); villageMinY=b.min.y; const s=new THREE.Vector3(); b.getSize(s); villageR=Math.max(s.x,s.z)*0.5||20; } }
buildWorld(); tameLights(worldRoot); fixMaterials(worldRoot); setupPalmWind(worldRoot); collectNames(worldRoot,gltf.animations);
animateModelWater(worldRoot); if(CONFIG.falaj.enabled) buildFalaj(); setupCharacter(gltf); buildAnimals();
if(CONFIG.useProceduralGuide) buildProceduralGuide();
$('loadPct').textContent='Ready.'; $('beginBtn').style.display='inline-block'; },
(e)=>{ if(e.total){const p=Math.round(e.loaded/e.total*100); $('loadFill').style.width=p+'%'; $('loadPct').textContent=`Loading the villageโ€ฆ ${p}%`;} else $('loadPct').textContent=`Loadingโ€ฆ ${(e.loaded/1048576).toFixed(0)} MB`; },
(err)=>{ console.error(err); $('loadPct').textContent='Could not load the model. Check the file name in config.js.'; }
);
// ============================================================================
// 5. TAME MODEL LIGHTS + EMISSIVE (fixes the very bright shop interior)
// ============================================================================
function tameLights(root){ const dim=W.dimModelLights??0.25;
root.traverse(o=>{ if(o.isLight){ o.intensity*=dim; o.castShadow=false; }
if(o.isMesh){ const mats=Array.isArray(o.material)?o.material:[o.material];
mats.forEach(m=>{ if(!m)return; if(m.emissive){ if(!m.emissiveMap) m.emissive.multiplyScalar(0.15); if('emissiveIntensity'in m) m.emissiveIntensity=Math.min(m.emissiveIntensity,0.25); } }); } });
}
// ============================================================================
// 6. MATERIAL FIX โ€” earth tones, mute green "carpet", warm the whites
// ============================================================================
function pickEarthColor(name){ if(/(palm|leaf|frond|tree|green)/.test(name))return PALETTE.palmGreen; if(/(trunk|wood|log|stick)/.test(name))return PALETTE.trunk; if(/(door|window)/.test(name))return PALETTE.doorGreen; if(/(ground|floor|sand|terrain|path|dirt)/.test(name))return PALETTE.ground; if(/(stone|rock|brick)/.test(name))return PALETTE.stone; if(/(wall|house|mud|clay|build|roof|hut|mosque)/.test(name))return PALETTE.mudClay; if(/(chair|table|bed|furnit|cloth|fabric|rug|mat|pot)/.test(name))return PALETTE.trunk; return PALETTE.sand; }
function hsl(c){ const h={}; c.getHSL(h); return h; }
function isWashedOut(c){ const h=hsl(c); return (h.l>0.70&&h.s<0.22)||(h.s<0.06&&h.l>0.45); }
function isVeryWhite(c){ const h=hsl(c); return h.l>0.82&&h.s<0.25; }
function fixMaterials(root){ const ov=CONFIG.materialColors||{};
root.traverse(o=>{ if(!o.isMesh)return; const name=(o.name||'').toLowerCase()+' '+((Array.isArray(o.material)?'':o.material?.name)||'').toLowerCase();
const isPalm=/(palm|frond|leaf|tree|nakhl|ู†ุฎู„)/.test(name), isGround=/(ground|grass|terrain|lawn|floor|soil|dirt|sand|plane)/.test(name), isWater=/(water|falaj|ูู„ุฌ)/.test(name);
const mats=Array.isArray(o.material)?o.material:[o.material];
mats.forEach(m=>{ if(!m||!m.color)return; const forced=ov[m.name]||ov[o.name];
if(forced){ m.color.set(forced); m.map=null; }
else { if(!m.map&&isWashedOut(m.color)) m.color.set(pickEarthColor(name));
if(W.naturalGround!==false && !isPalm && !/(bush|shrub|plant|ุดุฌุฑ)/.test(name)){ const h=hsl(m.color); if(h.s>0.32 && h.h>0.20 && h.h<0.42) m.color.set(W.grassColor||'#9c9a5e'); }
if(W.warmWhites!==false&&!isPalm&&!isGround&&!isWater&&isVeryWhite(m.color)) m.color.lerp(new THREE.Color('#d8c39a'),0.55); }
if('roughness'in m)m.roughness=Math.max(m.roughness??1,0.82); if('metalness'in m)m.metalness=Math.min(m.metalness??0,0.05); m.needsUpdate=true; });
});
}
// ============================================================================
// 7. PALM WIND โ€” gentle sway + turn white palms green
// ============================================================================
const windU={value:0};
function setupPalmWind(root){ root.traverse(o=>{ if(!o.isMesh)return; const n=(o.name||'').toLowerCase(); if(!/(palm|frond|leaf|tree|nakhl|ู†ุฎู„)/.test(n))return;
const mats=Array.isArray(o.material)?o.material:[o.material];
const out=mats.map(m=>{ const c=m.clone(); const mn=(m.name||'').toLowerCase()+' '+n; const isTrunk=/(trunk|bark|wood|stem|stalk|jith|tronc|ุฌุฐุน)/.test(mn);
if(isTrunk) c.color.set(PALETTE.trunk); else if(isWashedOut(c.color)||isVeryWhite(c.color)) c.color.set(PALETTE.palmGreen);
c.onBeforeCompile=(sh)=>{ sh.uniforms.uTime=windU; sh.vertexShader='uniform float uTime;\n'+sh.vertexShader.replace('#include <begin_vertex>',
`#include <begin_vertex>
float sway=sin(uTime*1.5+transformed.x*0.4+transformed.z*0.4);
transformed.x+=sway*pow(max(transformed.y,0.0),1.2)*0.018;
transformed.z+=cos(uTime*1.3+transformed.x*0.4)*pow(max(transformed.y,0.0),1.2)*0.012;`); };
c.needsUpdate=true; return c; });
o.material=Array.isArray(o.material)?out:out[0]; });
}
// ============================================================================
// 8. FALAJ WATER (only built if CONFIG.falaj.enabled)
// ============================================================================
let water=null, hasWater=false; const waterPos=new THREE.Vector3();
function animateModelWater(root){ const nm=(CONFIG.falaj.modelWaterName||'').toLowerCase().trim(); if(!nm)return;
root.traverse(o=>{ if(o.isMesh && (o.name||'').toLowerCase().includes(nm)){ o.material=waterMat; water=o; o.getWorldPosition(waterPos); hasWater=true; } }); }
const waterMat=new THREE.ShaderMaterial({ transparent:true, uniforms:{uTime:{value:0}},
vertexShader:`uniform float uTime; varying vec2 vUv; varying float vWave; void main(){ vUv=uv; vec3 p=position; float w=sin(p.x*3.0+uTime*1.4)*0.5+cos(p.y*3.0+uTime*1.1)*0.5; p.z+=w*0.03; vWave=w; gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0);} `,
fragmentShader:`uniform float uTime; varying vec2 vUv; varying float vWave; float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);} float noise(vec2 p){vec2 i=floor(p),f=fract(p); float a=hash(i),b=hash(i+vec2(1,0)),c=hash(i+vec2(0,1)),d=hash(i+vec2(1,1)); vec2 u=f*f*(3.0-2.0*f); return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);} void main(){ float n1=noise(vUv*8.0+vec2(uTime*0.05,uTime*0.03)),n2=noise(vUv*16.0-vec2(uTime*0.04,uTime*0.06)),r=n1*0.6+n2*0.4; vec3 deep=vec3(0.16,0.30,0.32),lite=vec3(0.46,0.52,0.44); vec3 col=mix(deep,lite,r*0.55+vWave*0.2+0.2); col+=smoothstep(0.86,1.0,r)*0.22; gl_FragColor=vec4(col,0.82);} ` });
function buildFalaj(){ const f=CONFIG.falaj; water=new THREE.Mesh(new THREE.PlaneGeometry(f.width,f.length,24,64),waterMat); water.rotation.x=-Math.PI/2; water.rotation.z=f.rotationY||0; water.position.set(f.position[0],f.position[1],f.position[2]); scene.add(water); waterPos.copy(water.position); hasWater=true; }
// ============================================================================
// 9. EMBEDDED GIRL โ€” auto-detect + auto-animate (idle/walk/talk)
// ============================================================================
let mixer=null,idleA=null,walkA=null,talkA=null,current=null,talking=false,lastMoving=null,charNode=null,charFollow=false,charTarget=null;
function setupCharacter(gltf){ const ch=CONFIG.character||{}; if(!ch.useEmbedded)return; const clips=gltf.animations||[]; if(!clips.length){console.warn('No animations in model.');return;}
mixer=new THREE.AnimationMixer(worldRoot); const byName=n=>n?clips.find(c=>c.name===n):null, find=re=>clips.find(c=>re.test(c.name)), make=c=>c?mixer.clipAction(c):null;
idleA=make(byName(ch.idleClip)||find(/idle|stand|breath|loop/i)||clips[0]); walkA=make(byName(ch.walkClip)||find(/walk|run|move/i)); talkA=make(byName(ch.talkClip)||find(/talk|speak|wave|hello|greet/i));
mixer.addEventListener('finished',()=>{talking=false;lastMoving=null;playIdle();});
if(ch.follow){ if(ch.nodeName) charNode=worldRoot.getObjectByName(ch.nodeName);
if(!charNode){ let sk=null; worldRoot.traverse(o=>{ if(!sk&&o.isSkinnedMesh)sk=o; }); if(sk){ let p=sk; while(p.parent&&p.parent!==worldRoot)p=p.parent; charNode=p; } }
charNode?charFollow=true:console.warn('follow on but no character node found'); }
playIdle(); }
function crossfade(to){ if(!to||current===to)return; if(current)current.fadeOut(0.3); to.reset().fadeIn(0.3).play(); current=to; }
function playIdle(){ if(idleA){idleA.setLoop(THREE.LoopRepeat);crossfade(idleA);} }
function playWalk(){ if(walkA){walkA.setLoop(THREE.LoopRepeat);crossfade(walkA);} else playIdle(); }
function playTalk(){ if(!talkA)return; talking=true; talkA.setLoop(THREE.LoopOnce,1); talkA.clampWhenFinished=true; crossfade(talkA); }
function setMotion(w){ if(talking||w===lastMoving)return; lastMoving=w; w?playWalk():playIdle(); }
function updateCharacter(dt){ if(mixer)mixer.update(dt); if(charNode&&charFollow&&charTarget){ const to=new THREE.Vector3(charTarget[0],charNode.position.y,charTarget[2]),d=to.distanceTo(charNode.position); if(d>0.06){charNode.position.lerp(to,Math.min(1,dt*1.5));charNode.lookAt(to.x,charNode.position.y,to.z);setMotion(true);}else setMotion(false);} }
let guide=null,gArmR=null,gArmL=null,gWalking=false,gTarget=null;
function buildProceduralGuide(){ guide=new THREE.Group(); const black=new THREE.MeshStandardMaterial({color:0x14110d,roughness:.9}),skin=new THREE.MeshStandardMaterial({color:0xc89a6a,roughness:.8}),white=new THREE.MeshStandardMaterial({color:0xf2efe6,roughness:.8});
const body=new THREE.Mesh(new THREE.CylinderGeometry(.18,.42,1.1,14),black);body.position.y=.55;guide.add(body); const head=new THREE.Mesh(new THREE.SphereGeometry(.16,16,16),skin);head.position.y=1.28;guide.add(head); const arm=new THREE.CylinderGeometry(.05,.05,.5,8);
gArmR=new THREE.Group();gArmR.add(new THREE.Mesh(arm,black));gArmR.children[0].position.y=-.25;gArmR.position.set(.2,1,0);guide.add(gArmR); gArmL=new THREE.Group();gArmL.add(new THREE.Mesh(arm,black));gArmL.children[0].position.y=-.25;gArmL.position.set(-.2,1,0);guide.add(gArmL);
guide.add(new THREE.Mesh(new THREE.BoxGeometry(.12,.07,.22),white)).position.set(.1,.035,.05); guide.scale.setScalar(CONFIG.character.scale||1); guide.position.copy(v3(CONFIG.stops[0].guidePos)); scene.add(guide); }
function updateGuide(t,dt){ if(!guide)return; if(gTarget){const to=new THREE.Vector3(gTarget[0],guide.position.y,gTarget[2]); if(to.distanceTo(guide.position)>.1){gWalking=true;guide.position.lerp(to,Math.min(1,dt*1.5));guide.lookAt(to.x,guide.position.y,to.z);}else gWalking=false;}
if(gWalking){const sw=Math.sin(t*8)*.6;gArmR.rotation.x=sw;gArmL.rotation.x=-sw;guide.position.y=Math.abs(Math.sin(t*8))*.03;} else {guide.position.y=Math.sin(t*2)*.015;gArmR.rotation.x=lerp(gArmR.rotation.x,-1.1,dt*3);const look=new THREE.Vector3(camera.position.x,guide.position.y,camera.position.z),m=new THREE.Matrix4().lookAt(guide.position,look,guide.up),q=new THREE.Quaternion().setFromRotationMatrix(m);guide.quaternion.slerp(q,dt*2);} }
// ============================================================================
// 10. ANIMALS โ€” invisible hover zones
// ============================================================================
const animalGroup=new THREE.Group(); scene.add(animalGroup);
function buildAnimals(){ (CONFIG.animals||[]).forEach(a=>{ const m=new THREE.Mesh(new THREE.SphereGeometry(a.radius,8,8),new THREE.MeshBasicMaterial({transparent:true,opacity:0,colorWrite:false,depthWrite:false})); m.position.set(a.position[0],a.position[1],a.position[2]); m.userData.animal=a; animalGroup.add(m); }); }
function playAnimal(a){ if(a.soundUrl){try{const s=new Audio(a.soundUrl);s.volume=audio.muted?0:1;s.play();}catch(e){}} else audio.animal(a.synth); showToast(state.lang==='ar'?a.name_ar:a.name_en); }
// ============================================================================
// 11. AUDIO
// ============================================================================
const audio={ ctx:null,master:null,waterGain:null,buf:null,narr:new Audio(),started:false,muted:false,
start(){ if(this.started)return; this.started=true; const C=this.ctx=new (window.AudioContext||window.webkitAudioContext)(); this.master=C.createGain(); this.master.connect(C.destination);
const len=C.sampleRate*2,b=C.createBuffer(1,len,C.sampleRate),d=b.getChannelData(0); for(let i=0;i<len;i++)d[i]=Math.random()*2-1; this.buf=b;
const wS=C.createBufferSource();wS.buffer=b;wS.loop=true;const wF=C.createBiquadFilter();wF.type='lowpass';wF.frequency.value=350;const wG=C.createGain();wG.gain.value=.05;wS.connect(wF).connect(wG).connect(this.master);wS.start();
const aS=C.createBufferSource();aS.buffer=b;aS.loop=true;const aF=C.createBiquadFilter();aF.type='bandpass';aF.frequency.value=1100;aF.Q.value=.8;this.waterGain=C.createGain();this.waterGain.gain.value=0;aS.connect(aF).connect(this.waterGain).connect(this.master);aS.start(); },
step(){ if(!this.ctx||this.muted)return; const C=this.ctx,t=C.currentTime;const s=C.createBufferSource();s.buffer=this.buf;const f=C.createBiquadFilter();f.type='lowpass';f.frequency.value=700;const g=C.createGain();s.connect(f).connect(g).connect(this.master);g.gain.setValueAtTime(.0001,t);g.gain.linearRampToValueAtTime(.06,t+.01);g.gain.exponentialRampToValueAtTime(.0001,t+.14);s.start(t);s.stop(t+.16); },
tone(fq,wn,du,ty='sine',vo=.2){ const C=this.ctx,o=C.createOscillator(),g=C.createGain();o.type=ty;o.frequency.value=fq;o.connect(g);g.connect(this.master);g.gain.setValueAtTime(.0001,wn);g.gain.linearRampToValueAtTime(vo,wn+.02);g.gain.exponentialRampToValueAtTime(.0001,wn+du);o.start(wn);o.stop(wn+du+.02); },
success(){ if(!this.ctx)return; const t=this.ctx.currentTime;this.tone(660,t,.18);this.tone(880,t+.12,.22); },
wrong(){ if(!this.ctx)return; this.tone(200,this.ctx.currentTime,.25,'sine',.15); },
animal(kind){ if(!this.ctx||this.muted)return; const C=this.ctx,t=C.currentTime;
if(kind==='rooster'){[[520,0],[660,.13],[820,.26],[600,.45]].forEach(([f,o])=>this.tone(f,t+o,.16,'sawtooth',.13));}
else if(kind==='horse'){const s=C.createBufferSource();s.buffer=this.buf;const f=C.createBiquadFilter();f.type='bandpass';f.Q.value=2;f.frequency.setValueAtTime(1100,t);f.frequency.exponentialRampToValueAtTime(300,t+.5);const g=C.createGain();s.connect(f).connect(g).connect(this.master);g.gain.setValueAtTime(.0001,t);g.gain.linearRampToValueAtTime(.18,t+.04);g.gain.exponentialRampToValueAtTime(.0001,t+.6);s.start(t);s.stop(t+.65);this.tone(170,t,.5,'sawtooth',.06);}
else if(kind==='goat'){const o=C.createOscillator(),g=C.createGain();o.type='sawtooth';o.frequency.value=380;o.connect(g);g.connect(this.master);const arr=new Float32Array(12);for(let i=0;i<12;i++)arr[i]=(i%2?.16:.03);g.gain.setValueCurveAtTime(arr,t,.5);o.start(t);o.stop(t+.52);} },
setWater(d){ if(!this.waterGain)return; this.waterGain.gain.setTargetAtTime(clamp((14-d)/12,0,1)*.12,this.ctx.currentTime,.3); },
play(url){ try{this.narr.pause(); if(!url||this.muted)return; this.narr.src=url; this.narr.play().catch(()=>{});}catch(e){} },
setMuted(m){ this.muted=m; if(this.master)this.master.gain.value=m?0:1; if(m)this.narr.pause(); } };
// ============================================================================
// 12. CONTROLS
// ============================================================================
const controls=new OrbitControls(camera,renderer.domElement); controls.enableDamping=true; controls.dampingFactor=0.08; controls.target.copy(v3(CONFIG.startTarget)); controls.minDistance=0.5; controls.maxDistance=120; controls.maxPolarAngle=Math.PI*0.49; controls.enablePan=false;
const keys={}; addEventListener('keydown',e=>keys[e.key.toLowerCase()]=true); addEventListener('keyup',e=>keys[e.key.toLowerCase()]=false);
let joyX=0,joyZ=0,joyId=null; const joy=$('joy'),nub=joy.firstElementChild;
function joyMove(e){ if(e.pointerId!==joyId)return; const r=joy.getBoundingClientRect(); let dx=(e.clientX-(r.left+r.width/2))/(r.width/2),dy=(e.clientY-(r.top+r.height/2))/(r.height/2); const m=Math.hypot(dx,dy); if(m>1){dx/=m;dy/=m;} joyX=dx;joyZ=dy; nub.style.transform=`translate(${dx*32}px,${dy*32}px)`; }
joy.addEventListener('pointerdown',e=>{joyId=e.pointerId;joy.setPointerCapture(e.pointerId);joyMove(e);}); joy.addEventListener('pointermove',joyMove); joy.addEventListener('pointerup',()=>{joyId=null;joyX=joyZ=0;nub.style.transform='';});
let moving=false;
function applyMovement(dt){ let f=0,s=0; if(keys['w']||keys['arrowup'])f+=1; if(keys['s']||keys['arrowdown'])f-=1; if(keys['a']||keys['arrowleft'])s-=1; if(keys['d']||keys['arrowright'])s+=1; f-=joyZ; s+=joyX;
if(!f&&!s){moving=false;return;} moving=true; const dir=new THREE.Vector3();camera.getWorldDirection(dir);dir.y=0;dir.normalize(); const right=new THREE.Vector3().crossVectors(dir,camera.up).normalize();
const step=new THREE.Vector3().addScaledVector(dir,f).addScaledVector(right,s).normalize().multiplyScalar(CONFIG.moveSpeed*dt); camera.position.add(step); controls.target.add(step); }
// ============================================================================
// 13. TOUR
// ============================================================================
const state={stop:0,lang:'en',freeWalk:false,tween:null}; let stepTimer=0;
function showPanel(s){ $('pTitle').textContent=state.lang==='ar'?s.title_ar:s.title_en; $('pAr').textContent=s.text_ar; $('pEn').textContent=s.text_en; $('panel').classList.add('show'); }
function frameModel(){ if(!worldRoot)return false; const box=new THREE.Box3().setFromObject(worldRoot); if(!isFinite(box.min.x))return false; const c=new THREE.Vector3(),s=new THREE.Vector3(); box.getCenter(c); box.getSize(s); console.log('Model size:',s.x.toFixed(1),s.y.toFixed(1),s.z.toFixed(1),'| center:',c.x.toFixed(1),c.y.toFixed(1),c.z.toFixed(1)); const r=Math.max(s.x,s.y,s.z)*0.5||10, d=r*2.2; camera.position.set(c.x+d*0.5, c.y+r*0.8, c.z+d); controls.target.set(c.x,c.y,c.z); controls.update(); return true; }
function camFor(look){ const c=villageCenter, gy=villageMinY; const dir=new THREE.Vector3(look[0]-c.x,0,look[2]-c.z); if(dir.lengthSq()<0.01)dir.set(0,0,1); dir.normalize(); const D=Math.max(9,Math.min(villageR*0.5,16));
return { pos:[look[0]+dir.x*D, gy+2.6, look[2]+dir.z*D], target:[look[0], Math.max(gy+1.5, look[1]*0.5+0.5), look[2]] }; }
function goToStop(i){ i=clamp(i,0,CONFIG.stops.length-1); state.stop=i; const s=CONFIG.stops[i]; $('stopLabel').textContent=`${i+1} / ${CONFIG.stops.length}`; showPanel(s); charTarget=s.guidePos; gTarget=s.guidePos; s.startsGame?startGame():stopGame();
if(s.overview || (i===0 && !s.look)){ controls.enabled=true; state.tween=null; frameModel(); arrive(s); return; }
let toPos, toTgt; if(s.look){ const cf=camFor(s.look); toPos=v3(cf.pos); toTgt=v3(cf.target); } else { toPos=v3(s.camPos); toTgt=v3(s.target); }
controls.enabled=false; state.tween={fromPos:camera.position.clone(),toPos,fromTgt:controls.target.clone(),toTgt,t:0,dur:2.2,stop:s}; }
function arrive(s){ controls.enabled=true; audio.play(state.lang==='ar'?s.narration_ar:s.narration_en); playTalk(); if(!audio.narr.src){setTimeout(()=>{talking=false;lastMoving=null;playIdle();},3000);} else audio.narr.onended=()=>{talking=false;lastMoving=null;playIdle();}; }
// ============================================================================
// 14. MINI-GAME
// ============================================================================
const game={active:false,found:new Set(),current:null}; const HINTS=['Look again โ€” try a different place.','Getting warmerโ€ฆ look around you.','Listen to the word and tap that spot.'];
function startGame(){ game.active=true; game.found.clear(); $('game').classList.add('show'); nextWord(); }
function stopGame(){ game.active=false; $('game').classList.remove('show'); }
function nextWord(){ const left=CONFIG.vocab.filter(w=>!game.found.has(w.word_en)); if(!left.length){winGame();return;} game.current=left[Math.floor(Math.random()*left.length)]; $('gWord').innerHTML=`Find: <b>${game.current.word_en}</b>`; $('gHint').textContent='Tap the right place in the village.'; $('gScore').textContent=`${game.found.size} / ${CONFIG.vocab.length} found`; }
function guessWorld(p){ const w=game.current; if(!w)return; if(p.distanceTo(v3(w.position))<=w.radius){ audio.success(); $('gWord').innerHTML=`โœ… ${w.word_en} = <span class="ar">${w.word_ar}</span>`; game.found.add(w.word_en); $('gScore').textContent=`${game.found.size} / ${CONFIG.vocab.length} found`; setTimeout(nextWord,1400);} else { audio.wrong(); $('gHint').textContent=HINTS[Math.floor(Math.random()*HINTS.length)]; } }
function winGame(){ $('gWord').innerHTML='๐ŸŒŸ You found every word!'; $('gHint').textContent='Read the chapter and play more games on the website.'; $('gScore').textContent=''; }
// ============================================================================
// 15. POINTER โ€” tap (game/debug/animal) + hover (animal)
// ============================================================================
const ray=new THREE.Raycaster(),ndc=new THREE.Vector2(); let downX=0,downY=0,downT=0,pointerDown=false,hovered=null,hoverClock=0;
function pick(group,cx,cy){ ndc.x=(cx/innerWidth)*2-1; ndc.y=-(cy/innerHeight)*2+1; ray.setFromCamera(ndc,camera); return ray.intersectObject(group,true)[0]; }
renderer.domElement.addEventListener('pointerdown',e=>{downX=e.clientX;downY=e.clientY;downT=performance.now();pointerDown=true;});
renderer.domElement.addEventListener('pointerup',e=>{ pointerDown=false; if(performance.now()-downT>350)return; if(Math.hypot(e.clientX-downX,e.clientY-downY)>8)return; if(!worldRoot)return;
const ah=pick(animalGroup,e.clientX,e.clientY); if(ah){playAnimal(ah.object.userData.animal);return;} const hit=pick(worldRoot,e.clientX,e.clientY); if(!hit)return; if(debugOn)debugTap(hit); else if(game.active)guessWorld(hit.point); });
renderer.domElement.addEventListener('pointermove',e=>{ if(pointerDown||isMobile)return; const now=performance.now(); if(now-hoverClock<80)return; hoverClock=now; const ah=pick(animalGroup,e.clientX,e.clientY),a=ah?ah.object.userData.animal:null; if(a&&a!==hovered){hovered=a;playAnimal(a);}else if(!a)hovered=null; });
// ============================================================================
// 16. DEBUG โ€” copy [x,y,z] on tap + "Copy camera view"; lists names
// ============================================================================
let debugOn=false,debugFilled=false;
function copyText(t){ try{ navigator.clipboard.writeText(t); }catch(e){} showToast('Copied: '+t); }
function collectNames(root,anims){ animNames=(anims||[]).map(c=>c.name||'(unnamed clip)'); const set=new Set(); root.traverse(o=>{ if(o.isSkinnedMesh)skinnedNames.push(o.name||'(unnamed skinned)'); if(o.isMesh)set.add(o.name||'(unnamed)'); }); console.log('Animations:',animNames); console.log('Character meshes:',skinnedNames); console.log('Mesh names:',[...set]); }
function debugInfo(){ if(debugFilled)return; debugFilled=true; const r=document.createElement('div');r.className='row'; r.innerHTML=`<b>Animations (${animNames.length}):</b><br>${animNames.join(', ')||'none'}<br><br><b>Character node(s):</b><br>${skinnedNames.join(', ')||'none'}`; $('debugLog').prepend(r); }
function debugTap(hit){ const p=hit.point,n=hit.object.name||'(unnamed)',mat=Array.isArray(hit.object.material)?hit.object.material[0]:hit.object.material; const txt=`[${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)}]`; const r=document.createElement('div');r.className='row';r.innerHTML=`<b>${txt}</b><br>mesh: ${n}<br>mat: ${mat?.name||'โ€”'}`; $('debugLog').prepend(r); copyText(txt); }
function copyCameraView(){ const p=camera.position,t=controls.target; const s=`camPos:[${p.x.toFixed(1)},${p.y.toFixed(1)},${p.z.toFixed(1)}], target:[${t.x.toFixed(1)},${t.y.toFixed(1)},${t.z.toFixed(1)}]`; copyText(s); const r=document.createElement('div');r.className='row';r.innerHTML='<b>view ยป</b><br>'+s; $('debugLog').prepend(r); }
// ============================================================================
// 17. UI WIRING
// ============================================================================
function showToast(t){ const el=$('toast'); el.textContent=t; el.classList.add('show'); clearTimeout(showToast._t); showToast._t=setTimeout(()=>el.classList.remove('show'),1500); }
$('beginBtn').onclick=()=>{ $('loader').style.display='none'; audio.start(); goToStop(0); };
$('nextBtn').onclick=()=>goToStop(state.stop+1);
$('prevBtn').onclick=()=>goToStop(state.stop-1);
$('muteBtn').onclick=e=>{ audio.setMuted(!audio.muted); e.target.textContent=audio.muted?'๐Ÿ”‡':'๐Ÿ”Š'; e.target.classList.toggle('on',audio.muted); };
$('langBtn').onclick=()=>{ state.lang=state.lang==='en'?'ar':'en'; const s=CONFIG.stops[state.stop]; showPanel(s); audio.play(state.lang==='ar'?s.narration_ar:s.narration_en); };
$('dayBtn').onclick=e=>{ dayTarget=dayTarget>0.5?0:1; e.target.textContent=dayTarget>0.5?'๐ŸŒ™ Night':'โ˜€๏ธ Day'; };
$('debugBtn').onclick=e=>{ debugOn=!debugOn; $('debug').classList.toggle('show',debugOn); e.target.classList.toggle('on',debugOn); if(debugOn)debugInfo(); };
$('copyViewBtn').onclick=copyCameraView;
$('modeBtn').onclick=e=>{ state.freeWalk=!state.freeWalk; $('tourbar').style.display=state.freeWalk?'none':'flex'; $('panel').style.display=state.freeWalk?'none':'block'; e.target.textContent=state.freeWalk?'Tour':'Free walk'; e.target.classList.toggle('on',state.freeWalk); };
if(CONFIG.startTime==='night') $('dayBtn').textContent='โ˜€๏ธ Day';
// ============================================================================
// 18. MAIN LOOP
// ============================================================================
const clock=new THREE.Clock();
function tick(){ requestAnimationFrame(tick); const dt=Math.min(clock.getDelta(),0.05),t=clock.elapsedTime;
if(state.tween){ const tw=state.tween; tw.t+=dt/tw.dur; const e=ease(Math.min(tw.t,1)); camera.position.lerpVectors(tw.fromPos,tw.toPos,e); controls.target.lerpVectors(tw.fromTgt,tw.toTgt,e); if(tw.t>=1){arrive(tw.stop);state.tween=null;} } else applyMovement(dt);
if(moving||gWalking||lastMoving){ stepTimer+=dt; if(stepTimer>0.38){stepTimer=0;audio.step();} } else stepTimer=0.3;
windU.value=t; waterMat.uniforms.uTime.value=t; if(hasWater)audio.setWater(camera.position.distanceTo(waterPos));
updateSky(); updateCharacter(dt); updateGuide(t,dt); controls.update(); renderer.render(scene,camera); }
tick();
addEventListener('resize',()=>{ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth,innerHeight); });