<!-- PLAYA 2.0'S WORLD · part 13 -->
;                                       // head 1.24..1.54
  part(new THREE.BoxGeometry(0.22, 0.13, 0.13), glw, 0, 1.42, -0.13);                                  // visor
  part(new THREE.BoxGeometry(0.3, 0.13, 0.3), red, 0, 1.495, 0);                                       // cap top -> 1.56
  const bb = new THREE.Box3().setFromObject(avatar); avatar.userData.height = +(bb.max.y - bb.min.y).toFixed(3); }

// ---- state & input
const P = { x: 0, y: 0, z: 18, vy: 0, yaw: 0, pitch: 0.32, onGround: true, dist: 6.5 };
const keys = new Set(); const joy = { x: 0, y: 0, id: null }; let lookId = null, lx = 0, ly = 0;
addEventListener('keydown', e => { keys.add(e.code); if (e.code === 'Space') { doJump(); e.preventDefault(); } });
addEventListener('keyup', e => keys.delete(e.code)); addEventListener('blur', () => keys.clear());
const joyEl = document.getElementById('joy'), knob = document.getElementById('knob');
function joyUpd(e) { const r = joyEl.getBoundingClientRect(), R = r.width / 2; let dx = e.clientX - r.left - R, dy = e.clientY - r.top - R; const m = Math.hypot(dx, dy);
  if (m > R) { dx *= R / m; dy *= R / m; } joy.x = dx / R; joy.y = dy / R; knob.style.transform = `translate(${dx}px,${dy}px)`; }
joyEl.addEventListener('pointerdown', e => { joy.id = e.pointerId; joyEl.setPointerCapture(e.pointerId); joyUpd(e); e.preventDefault(); });
joyEl.addEventListener('pointermove', e => { if (e.pointerId === joy.id) joyUpd(e); });
const joyEnd = e => { if (e.pointerId === joy.id) { joy.id = null; joy.x = joy.y = 0; knob.style.transform = ''; } };
joyEl.addEventListener('pointerup', joyEnd); joyEl.addEventListener('pointercancel', joyEnd); joyEl.addEventListener('lostpointercapture', joyEnd);
canvas.addEventListener('pointerdown', e => { if (lookId !== null) return; lookId = e.pointerId; lx = e.clientX; ly = e.clientY; canvas.setPointerCapture(e.pointerId); });
canvas.addEventListener('pointermove', e => { if (e.pointerId !== lookId) return; P.yaw -= (e.clientX - lx) * 0.006; P.pitch = Math.max(-0.25, Math.min(1.25, P.pitch + (e.clientY - ly) * 0.005)); lx = e.clientX; ly = e.clientY; });
const lookEnd = e => { if (e.pointerId === lookId) lookId = null; };
canvas.addEventListener('pointerup', lookEnd); canvas.addEventListener('pointercancel', lookEnd);
canvas.addEventListener('wheel', e => { P.dist = Math.max(3, Math.min(14, P.dist + e.deltaY * 0.01)); e.preventDefault(); }, { passive: false });
function doJump() { if (P.onGround) { P.vy = 6.2; P.onGround = false; } }
const jb = document.getElementById('jump');
jb.addEventListener('pointerdown', e => { jb.classList.add('on'); doJump(); e.preventDefault(); });
['pointerup', 'pointercancel', 'pointerleave'].forEach(t => jb.addEventListener(t, () => jb.classList.remove('on')));
document.getElementById('cam').addEventListener('click', () => { P.pitch = 0.32; P.dist = 6.5; });
if (matchMedia('(hover:hover) and (pointer:fine)').matches) document.getElementById('info').textContent = 'WASD / arrows · drag to look · Space jump';
addEventListener('contextmenu', e => e.preventDefault());

function resize() { const w = innerWidth, h = innerHeight; renderer.setSize(w, h, false); camera.aspect = w / h; camera.fov = w < h ? 72 : 60; camera.updateProjectionMatrix(); }
addEventListener('resize', resize); resize();

let last = performance.now(), walked = 0, camPull = 0, maxY = 0;
// ---- camera collision: ray head->camera vs collider AABBs (+ monument/bridge boxes); pull camera in front of the first hit
const _head = new THREE.Vector3(), _dir = new THREE.Vector3(), _ray = new THREE.Ray(), _box = new THREE.Box3(), _hit = new THREE.Vector3();
const camBoxes = () => colliders.map(c => new THREE.Box3(new THREE.Vector3(c[0], c[5], c[2]), new THREE.Vector3(c[1], c[4], c[3])));
let CB = null;
function camClear(o, d, dist) {
  CB ||= camBoxes(); _ray.set(o, d); let best = dist;
  for (const b of CB) { if (b.containsPoint(o)) continue; if (_ray.intersectBox(b, _hit)) { const t = _hit.distanceTo(o); if (t < best) best = t; } }
  return best < dist ? Math.max(0.5, best - 0.35) : dist;
}
function avatarVisible() { // no collider between camera and avatar chest/head, and avatar inside the view
  const pts = [new THREE.Vector3(P.x, P.y + 0.9, P.z), new THREE.Vector3(P.x, P.y + 1.4, P.z)]; CB ||= camBoxes();
  for (const p of pts) { const ndc = p.clone().project(camera); if (Math.abs(ndc.x) > 1 || Math.abs(ndc.y) > 1 || ndc.z > 1) return false;
    const d = p.clone().sub(camera.position), L = d.length(); _ray.set(camera.position, d.normalize());
    for (const b of CB) { if (b.containsPoint(camera.position)) return false; if (_ray.intersectBox(b, _hit) && _hit.distanceTo(camera.position) < L - 0.4) return false; } }
  return true;
}
function step(dt, t) {
  let f = 0, s = 0;
  if (keys.has('KeyW') || keys.has('ArrowUp')) f += 1; if (keys.has('KeyS') || keys.has('ArrowDown')) f -= 1;
  if (keys.has('KeyD') || keys.has('ArrowRight')) s += 1; if (keys.has('KeyA') || 