← back to Animals

public/dogpark/dogpark.js

1105 lines

// Three.js dog-park client.
// Server is at /dog-park/ws — see src/server/dogpark.js for the protocol.

import * as THREE from 'three';
import { buildHuman, buildDog, animateHuman, animateDog } from '/static/dogpark/rigs.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const PARK_HALF = 30;

// ── Scene ──────────────────────────────────────────────────────────────────
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xc8dce8);
scene.fog = new THREE.Fog(0xd4e2eb, 55, 100);

const camera = new THREE.PerspectiveCamera(55, window.innerWidth/window.innerHeight, 0.1, 200);
camera.position.set(0, 8, 18);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
document.body.appendChild(renderer.domElement);

// HDRI-style environment for image-based lighting
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(renderer), 0.04).texture;

// Post-processing: bloom + ACES tone mapping
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.18, 0.6, 0.92);
composer.addPass(bloom);
composer.addPass(new OutputPass());

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  composer.setSize(window.innerWidth, window.innerHeight);
});

// Three-point lighting: warm key, cool sky fill, warm rim
const sun = new THREE.DirectionalLight(0xfff0d4, 2.0);
sun.position.set(22, 32, 18); sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -42; sun.shadow.camera.right = 42;
sun.shadow.camera.top = 42; sun.shadow.camera.bottom = -42;
sun.shadow.bias = -0.0003;
sun.shadow.normalBias = 0.04;
scene.add(sun);
scene.add(new THREE.HemisphereLight(0xa5c8e8, 0x556b3a, 0.6));
const rim = new THREE.DirectionalLight(0xffcfa8, 0.7);
rim.position.set(-18, 14, -20);
scene.add(rim);

// Grass ground — procedural canvas texture for variation under PBR shading
function _makeGrassTexture() {
  const c = document.createElement('canvas'); c.width = c.height = 512;
  const ctx = c.getContext('2d');
  ctx.fillStyle = '#7fbe60'; ctx.fillRect(0, 0, 512, 512);
  for (let i = 0; i < 4000; i++) {
    const x = Math.random() * 512, y = Math.random() * 512;
    const v = Math.random();
    ctx.fillStyle = v < 0.5 ? `rgba(70,130,55,${0.3 + Math.random() * 0.3})`
                  : v < 0.85 ? `rgba(140,190,100,${0.2 + Math.random() * 0.3})`
                             : `rgba(195,210,130,${0.15 + Math.random() * 0.25})`;
    ctx.fillRect(x, y, 1 + Math.random() * 2, 1 + Math.random() * 2);
  }
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(8, 8);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(PARK_HALF*2, PARK_HALF*2),
  new THREE.MeshStandardMaterial({ map: _makeGrassTexture(), roughness: 0.95, metalness: 0.0 })
);
ground.rotation.x = -Math.PI/2; ground.receiveShadow = true;
scene.add(ground);

// Café — wood kiosk with red awning + sign + stools
function _makeCafe(x, z) {
  const g = new THREE.Group();
  const wood = new THREE.MeshLambertMaterial({ color: 0xa37146 });
  const awn = new THREE.MeshLambertMaterial({ color: 0xc8543a });
  const cream = new THREE.MeshLambertMaterial({ color: 0xf2e5cc });
  const body = new THREE.Mesh(new THREE.BoxGeometry(4, 2.4, 2.4), wood);
  body.position.y = 1.2; body.castShadow = true; body.receiveShadow = true; g.add(body);
  const counter = new THREE.Mesh(new THREE.BoxGeometry(3.6, 0.8, 0.2), cream);
  counter.position.set(0, 1.0, 1.25); g.add(counter);
  const awning = new THREE.Mesh(new THREE.BoxGeometry(4.4, 0.18, 1.6), awn);
  awning.position.set(0, 2.55, 1.0); awning.rotation.x = -0.15; awning.castShadow = true; g.add(awning);
  for (let i = -1.5; i <= 1.5; i += 0.6) {
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 1.55), cream);
    stripe.position.set(i, 2.55, 1.0); stripe.rotation.x = -0.15; g.add(stripe);
  }
  const sc = document.createElement('canvas'); sc.width = 256; sc.height = 96;
  const cctx = sc.getContext('2d');
  cctx.fillStyle = '#c8543a'; cctx.fillRect(0, 0, 256, 96);
  cctx.fillStyle = '#fff8eb'; cctx.font = 'bold 56px serif'; cctx.textAlign = 'center';
  cctx.fillText('CAFÉ', 128, 64);
  const sign = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 0.9), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(sc) }));
  sign.position.set(0, 3.1, 1.79); g.add(sign);
  for (const sx of [-1.2, 1.2]) {
    const seat = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.28, 0.1), cream);
    seat.position.set(sx, 0.7, 2.6); seat.castShadow = true; g.add(seat);
    const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.7), wood);
    leg.position.set(sx, 0.35, 2.6); g.add(leg);
  }
  g.position.set(x, 0, z); scene.add(g); return g;
}
_makeCafe(-22, 12);

// Stone fountain with shimmering water disc
function _makeFountain(x, z) {
  const g = new THREE.Group();
  const stone = new THREE.MeshLambertMaterial({ color: 0xb8b3a8 });
  const water = new THREE.MeshStandardMaterial({ color: 0x4d9fc6, transparent: true, opacity: 0.85, roughness: 0.15 });
  const base = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.5, 0.6), stone);
  base.position.y = 0.3; base.castShadow = true; base.receiveShadow = true; g.add(base);
  const basin = new THREE.Mesh(new THREE.CylinderGeometry(1.1, 1.0, 0.3), stone);
  basin.position.y = 0.72; g.add(basin);
  const w = new THREE.Mesh(new THREE.CylinderGeometry(1.0, 1.0, 0.05), water);
  w.position.y = 0.86; g.add(w);
  const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.22, 0.9), stone);
  ped.position.y = 1.3; g.add(ped);
  const top = new THREE.Mesh(new THREE.SphereGeometry(0.22, 16, 12), stone);
  top.position.y = 1.85; g.add(top);
  g.position.set(x, 0, z); g.userData.water = w; scene.add(g); return g;
}
const _fountain = _makeFountain(8, -16);

// Frisbee rack — wooden post with 4 colored frisbees
function _makeRack(x, z) {
  const g = new THREE.Group();
  const wood = new THREE.MeshLambertMaterial({ color: 0x6b4a2a });
  const colors = [0xff6b35, 0xfdca40, 0x6dbe45, 0x4ea0d4];
  const post = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 1.2), wood);
  post.position.y = 0.6; g.add(post);
  for (let i = 0; i < 4; i++) {
    const f = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.32, 0.04, 24), new THREE.MeshLambertMaterial({color: colors[i]}));
    f.position.set(0.18, 0.4 + i * 0.18, 0); f.rotation.z = Math.PI/2; g.add(f);
  }
  g.position.set(x, 0, z); scene.add(g); return g;
}
_makeRack(18, -14);

// Park fence (just a low brown ring of cylinders)
const fenceMat = new THREE.MeshLambertMaterial({ color: 0x8b6f47 });
for (let i = -PARK_HALF; i <= PARK_HALF; i += 4) {
  for (const [x, z] of [[i, -PARK_HALF],[i, PARK_HALF],[-PARK_HALF, i],[PARK_HALF, i]]) {
    const post = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 1.6), fenceMat);
    post.position.set(x, 0.8, z); post.castShadow = true;
    scene.add(post);
  }
}

// Trees (random)
function tree(x, z) {
  const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.4, 2.4), fenceMat);
  trunk.position.set(x, 1.2, z); trunk.castShadow = true;
  const leaves = new THREE.Mesh(new THREE.SphereGeometry(2, 12, 8), new THREE.MeshLambertMaterial({color:0x4a8b3a}));
  leaves.position.set(x, 3.5, z); leaves.castShadow = true;
  scene.add(trunk, leaves);
}
[[-22, 22],[22, 22],[-22, -22],[22, -22],[-15, 8],[15, -8]].forEach(([x,z]) => tree(x, z));

// Hydrants (must match server array)
const hydrantMat = new THREE.MeshLambertMaterial({ color: 0xc0392b });
const HYDRANTS = [
  { x:-10, z:-10 }, { x:10, z:-10 }, { x:0, z:12 },
  { x:-18, z:18 }, { x:18, z:18 }, { x:-22, z:-2 },
];
for (const h of HYDRANTS) {
  const body = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.4, 1.0), hydrantMat);
  body.position.set(h.x, 0.5, h.z); body.castShadow = true;
  const cap = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 8), hydrantMat);
  cap.position.set(h.x, 1.05, h.z); cap.castShadow = true;
  scene.add(body, cap);
}

// ── SimCity-style park props ──────────────────────────────────────────────
// Café (small kiosk): wood-toned box w/ red awning, signpost, two stools.
function makeCafe(x, z) {
  const g = new THREE.Group();
  const wood = new THREE.MeshLambertMaterial({ color: 0xa37146 });
  const awn = new THREE.MeshLambertMaterial({ color: 0xc8543a });
  const cream = new THREE.MeshLambertMaterial({ color: 0xf2e5cc });
  // Body
  const body = new THREE.Mesh(new THREE.BoxGeometry(4, 2.4, 2.4), wood);
  body.position.y = 1.2; body.castShadow = true; body.receiveShadow = true; g.add(body);
  // Counter front (cream)
  const counter = new THREE.Mesh(new THREE.BoxGeometry(3.6, 0.8, 0.2), cream);
  counter.position.set(0, 1.0, 1.25); g.add(counter);
  // Awning (sloped box)
  const awning = new THREE.Mesh(new THREE.BoxGeometry(4.4, 0.18, 1.6), awn);
  awning.position.set(0, 2.55, 1.0); awning.rotation.x = -0.15; awning.castShadow = true; g.add(awning);
  // Awning stripes (decorative thin bars)
  for (let i = -1.5; i <= 1.5; i += 0.6) {
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 1.55), cream);
    stripe.position.set(i, 2.55, 1.0); stripe.rotation.x = -0.15; g.add(stripe);
  }
  // Sign
  const signCanvas = document.createElement('canvas'); signCanvas.width = 256; signCanvas.height = 96;
  const sx = signCanvas.getContext('2d');
  sx.fillStyle = '#c8543a'; sx.fillRect(0, 0, 256, 96);
  sx.fillStyle = '#fff8eb'; sx.font = 'bold 56px serif'; sx.textAlign = 'center';
  sx.fillText('CAFÉ', 128, 64);
  const signTex = new THREE.CanvasTexture(signCanvas);
  const sign = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 0.9), new THREE.MeshBasicMaterial({ map: signTex }));
  sign.position.set(0, 3.1, 1.79); g.add(sign);
  // Two stools out front
  for (const sx2 of [-1.2, 1.2]) {
    const seat = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.28, 0.1), cream);
    seat.position.set(sx2, 0.7, 2.6); seat.castShadow = true; g.add(seat);
    const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.7), wood);
    leg.position.set(sx2, 0.35, 2.6); g.add(leg);
  }
  g.position.set(x, 0, z);
  scene.add(g);
  return g;
}
makeCafe(-22, 12);

// Water fountain — pedestal with basin and animated water disc
function makeFountain(x, z) {
  const g = new THREE.Group();
  const stone = new THREE.MeshLambertMaterial({ color: 0xb8b3a8 });
  const water = new THREE.MeshStandardMaterial({ color: 0x4d9fc6, transparent: true, opacity: 0.85, roughness: 0.15 });
  const base = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.5, 0.6), stone);
  base.position.y = 0.3; base.castShadow = true; base.receiveShadow = true; g.add(base);
  const basin = new THREE.Mesh(new THREE.CylinderGeometry(1.1, 1.0, 0.3), stone);
  basin.position.y = 0.72; g.add(basin);
  const waterDisc = new THREE.Mesh(new THREE.CylinderGeometry(1.0, 1.0, 0.05), water);
  waterDisc.position.y = 0.86; g.add(waterDisc);
  const pedestal = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.22, 0.9), stone);
  pedestal.position.y = 1.3; g.add(pedestal);
  const top = new THREE.Mesh(new THREE.SphereGeometry(0.22, 16, 12), stone);
  top.position.y = 1.85; g.add(top);
  g.position.set(x, 0, z);
  g.userData.water = waterDisc;
  scene.add(g);
  return g;
}
const fountain = makeFountain(8, -16);

// Dog water bowls (3 around the park)
function makeBowl(x, z) {
  const g = new THREE.Group();
  const metal = new THREE.MeshStandardMaterial({ color: 0xc7c4bd, metalness: 0.7, roughness: 0.3 });
  const water = new THREE.MeshStandardMaterial({ color: 0x4d9fc6, transparent: true, opacity: 0.85 });
  const bowl = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.32, 0.18), metal);
  bowl.position.y = 0.09; bowl.castShadow = true; g.add(bowl);
  const w = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.04), water);
  w.position.y = 0.16; g.add(w);
  g.position.set(x, 0, z);
  scene.add(g);
  return g;
}
[[-3, -8],[14, 6],[-14, 14]].forEach(([x,z]) => makeBowl(x, z));

// Frisbee rack — wooden post with 4 frisbees stacked
function makeFrisbeeRack(x, z) {
  const g = new THREE.Group();
  const wood = new THREE.MeshLambertMaterial({ color: 0x6b4a2a });
  const colors = [0xff6b35, 0xfdca40, 0x6dbe45, 0x4ea0d4];
  const post = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 1.2), wood);
  post.position.y = 0.6; g.add(post);
  for (let i = 0; i < 4; i++) {
    const f = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.32, 0.04, 24), new THREE.MeshLambertMaterial({color: colors[i]}));
    f.position.set(0.18, 0.4 + i * 0.18, 0); f.rotation.z = Math.PI/2;
    g.add(f);
  }
  g.position.set(x, 0, z);
  scene.add(g);
  return g;
}
makeFrisbeeRack(18, -14);

// Picnic bench (long wooden plank seat)
function makeBench(x, z, ry = 0) {
  const g = new THREE.Group();
  const wood = new THREE.MeshLambertMaterial({ color: 0x8b5a2b });
  const plank = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.12, 0.5), wood);
  plank.position.y = 0.5; plank.castShadow = true; g.add(plank);
  const back = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.5, 0.08), wood);
  back.position.set(0, 0.85, -0.22); g.add(back);
  for (const lx of [-1.05, 1.05]) {
    const leg = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.5, 0.4), wood);
    leg.position.set(lx, 0.25, 0); g.add(leg);
  }
  g.position.set(x, 0, z); g.rotation.y = ry;
  scene.add(g);
  return g;
}
makeBench(-6, 18, Math.PI);
makeBench(20, 4, -Math.PI/2);

// Music speaker post (visual only — actual audio is HTML5 in index.html)
function makeSpeaker(x, z) {
  const g = new THREE.Group();
  const dark = new THREE.MeshLambertMaterial({ color: 0x222222 });
  const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 1.8), dark);
  post.position.y = 0.9; g.add(post);
  const speaker = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.8, 0.5), dark);
  speaker.position.y = 1.85; speaker.castShadow = true; g.add(speaker);
  // Note sprite (animated upward over time)
  const noteCanvas = document.createElement('canvas'); noteCanvas.width = noteCanvas.height = 64;
  const nx = noteCanvas.getContext('2d');
  nx.font = '48px serif'; nx.textAlign = 'center'; nx.fillStyle = '#fff';
  nx.fillText('♪', 32, 48);
  const noteTex = new THREE.CanvasTexture(noteCanvas);
  const note = new THREE.Sprite(new THREE.SpriteMaterial({ map: noteTex, transparent: true }));
  note.scale.set(0.5, 0.5, 1); note.position.y = 2.4;
  g.add(note); g.userData.note = note;
  g.position.set(x, 0, z);
  scene.add(g);
  return g;
}
const speaker = makeSpeaker(-20, -18);

// Animate fountain water shimmer + music note float each frame
const _propAnimStart = performance.now();
function animateProps() {
  const t = (performance.now() - _propAnimStart) / 1000;
  if (fountain?.userData?.water) {
    fountain.userData.water.position.y = 0.86 + Math.sin(t * 2) * 0.02;
  }
  if (speaker?.userData?.note) {
    const ph = (t * 0.6) % 1;
    speaker.userData.note.position.y = 2.4 + ph * 1.5;
    speaker.userData.note.material.opacity = 1 - ph;
  }
}

// ── Networking ────────────────────────────────────────────────────────────
let myId = null;
let ws = null;
const others = new Map();   // id → { ownerGroup, dogGroup, label, lastUpdate }
const ballMeshes = new Map();

const params = { ownerName:'Owner', dogName:'Buddy', dogColor:'#c97a3e', dogAvatarUrl: null, ownerAvatarUrl: null, petPhotoUrl: null };

// Captured from owner_pets.hero_image_url when the prefill picks (or the user
// switches between) a logged-in user's pets. Sent in the WS join message so
// other clients can render this dog's name tag with the real pet photo.
let _selectedPetPhotoUrl = null;

// Pre-fill the join modal from /api/dog-park/me when the user is logged in.
// Anonymous callers get { pets: [] } and the manual form is left untouched.
// One pet → fill name + dog-color select. Multiple → render a chip picker
// above the form; clicking a chip re-fills the inputs. hero_image_url is
// shown in the existing #avatar-banner for visual confirmation only — it's
// NOT forwarded as the WS dogAvatarUrl because the dog-park server validates
// avatar paths against /static/uploads/avatars/... and would silently drop
// arbitrary hero URLs anyway.
(async function prefillJoinFromOwnerPets() {
  let pets = [];
  try {
    const r = await fetch('/api/dog-park/me', { credentials: 'same-origin' });
    if (!r.ok) return;
    const j = await r.json();
    pets = Array.isArray(j.pets) ? j.pets : [];
  } catch { return; }
  if (!pets.length) return;

  const dogNameInput = document.getElementById('dogName');
  const dogColorSelect = document.getElementById('dogColor');
  const banner = document.getElementById('avatar-banner');
  const thumb = document.getElementById('avatar-thumb');
  const tag = document.getElementById('avatar-tag');

  // Whether a localStorage-supplied custom avatar is already in the banner —
  // if so, leave it alone (user explicitly picked it via avatars-gallery).
  const hasExistingCustomAvatar = !!localStorage.getItem('dogpark_avatar_url');

  function applyPet(p) {
    if (p.name && dogNameInput && !dogNameInput.dataset.userTouched) {
      dogNameInput.value = p.name;
    }
    if (p.suggested_color && dogColorSelect) {
      const opt = [...dogColorSelect.options].find(o => o.value.toLowerCase() === String(p.suggested_color).toLowerCase());
      if (opt) dogColorSelect.value = opt.value;
    }
    if (p.hero_image_url && banner && !hasExistingCustomAvatar) {
      thumb.src = p.hero_image_url;
      tag.textContent = `${p.name} (your pet)`;
      banner.classList.add('show');
    }
    _selectedPetPhotoUrl = p.hero_image_url || null;
  }

  // Track that the user typed something so we don't clobber it on chip switch.
  if (dogNameInput) {
    dogNameInput.addEventListener('input', () => { dogNameInput.dataset.userTouched = '1'; });
  }

  if (pets.length === 1) {
    applyPet(pets[0]);
    return;
  }

  // Multi-pet picker: render chips above the dogName label inside .panel.
  const panel = document.querySelector('#join .panel');
  if (!panel) { applyPet(pets[0]); return; }
  const wrap = document.createElement('div');
  wrap.id = 'pet-picker';
  wrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;margin:.6em 0 .2em';
  const lead = document.createElement('div');
  lead.textContent = 'Which dog?';
  lead.style.cssText = 'flex-basis:100%;font-size:.85em;color:#666;font-weight:600;margin-bottom:.2em';
  wrap.appendChild(lead);
  pets.forEach((p, i) => {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.textContent = p.name;
    btn.dataset.petId = String(p.id);
    btn.style.cssText = 'background:#f3f3f3;color:#1a1a1a;border:1px solid #ddd;padding:.4em .8em;border-radius:999px;font:inherit;font-size:.85em;cursor:pointer';
    btn.addEventListener('click', () => {
      [...wrap.querySelectorAll('button')].forEach(b => {
        b.style.background = '#f3f3f3';
        b.style.borderColor = '#ddd';
        b.style.color = '#1a1a1a';
      });
      btn.style.background = '#0f4d3a';
      btn.style.borderColor = '#0f4d3a';
      btn.style.color = '#fff';
      // Allow switching pets to overwrite the name field even if user touched it once.
      if (dogNameInput) delete dogNameInput.dataset.userTouched;
      applyPet(p);
      if (dogNameInput) dogNameInput.dataset.userTouched = '1';
    });
    if (i === 0) {
      btn.style.background = '#0f4d3a';
      btn.style.borderColor = '#0f4d3a';
      btn.style.color = '#fff';
    }
    wrap.appendChild(btn);
  });
  // Insert before the first <label>
  const firstLabel = panel.querySelector('label');
  panel.insertBefore(wrap, firstLabel);
  applyPet(pets[0]);
})();

document.getElementById('joinBtn').addEventListener('click', () => {
  params.ownerName = document.getElementById('ownerName').value || 'Owner';
  params.dogName = document.getElementById('dogName').value || 'Buddy';
  params.dogColor = document.getElementById('dogColor').value;
  params.dogAvatarUrl = localStorage.getItem('dogpark_avatar_url') || null;
  params.ownerAvatarUrl = localStorage.getItem('dogpark_owner_avatar_url') || null;
  params.petPhotoUrl = _selectedPetPhotoUrl;
  document.getElementById('join').style.display = 'none';
  document.getElementById('hud').style.display = 'block';
  document.getElementById('counter').style.display = 'block';
  document.getElementById('log').style.display = 'block';
  connect();
});

function connect() {
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
  ws = new WebSocket(`${proto}//${location.host}/dog-park/ws`);
  ws.addEventListener('open', () => {
    ws.send(JSON.stringify({ t:'join', name: params.ownerName, dogName: params.dogName, dogColor: params.dogColor, dogAvatarUrl: params.dogAvatarUrl, ownerAvatarUrl: params.ownerAvatarUrl, petPhotoUrl: params.petPhotoUrl }));
    addLog(`Joined park as ${params.ownerName} (dog: ${params.dogName})${params.dogAvatarUrl ? ' [custom avatar]' : ''}`);
  });
  ws.addEventListener('message', (e) => {
    const msg = JSON.parse(e.data);
    if (msg.t === 'welcome') { myId = msg.id; }
    else if (msg.t === 'state') applyState(msg);
    else if (msg.t === 'chat_emit') showSpeechBubble(msg);
  });
  ws.addEventListener('close', () => { addLog('Disconnected'); });
}

// ── Avatar builder ────────────────────────────────────────────────────────
// When an ownerAvatarUrl is provided, render as a billboard sprite (recognizable
// portrait). Otherwise build a procedural rigged human with bone-driven walk
// cycles via rigs.js — no more cylinder primitives.
const _ownerPalettes = [
  { skin: 0xf2cba6, hair: 0x3a1f0f, shirt: 0x2c3a5e, pants: 0x4a4a4a, shoe: 0xf2efe6 }, // me default
  { skin: 0xe8c8a0, hair: 0xa05a3a, shirt: 0x5da064, pants: 0x6a8fb5, shoe: 0xf2efe6 },
  { skin: 0xd8b896, hair: 0x2a1a14, shirt: 0xc8543a, pants: 0x2a3a5e, shoe: 0xffffff },
];
function makeOwner(name, isMe, ownerAvatarUrl) {
  if (ownerAvatarUrl) return makeOwnerBillboard(name, ownerAvatarUrl, isMe);
  // Procedural rig (proper limbs that swing on the walk cycle)
  const palette = isMe ? _ownerPalettes[0] : _ownerPalettes[1 + (Math.floor(Math.random() * 2))];
  const rig = buildHuman({ name, palette });
  rig.userData.isProceduralHuman = true;  // flag so applyState can drive walk-cycle
  return rig;
}
function makeOwnerBillboard(name, avatarUrl, isMe) {
  const g = new THREE.Group();
  const tex = getAvatarTexture(avatarUrl);
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true }));
  const W = 1.4, H = 2.4; // human aspect, taller than dog
  sprite.scale.set(W, H, 1);
  sprite.position.y = H / 2;
  g.add(sprite);
  g.userData.sprite = sprite;
  const lbl = makeLabel(name, isMe ? '#c97a3e' : '#fff');
  lbl.position.y = H + 0.25;
  g.add(lbl);
  g.userData.label = lbl;
  return g;
}

// Texture cache so we don't re-fetch the same avatar PNG for every state tick
const _avatarTexCache = new Map();
function getAvatarTexture(url) {
  if (_avatarTexCache.has(url)) return _avatarTexCache.get(url);
  const tex = new THREE.TextureLoader().load(url);
  tex.colorSpace = THREE.SRGBColorSpace;
  _avatarTexCache.set(url, tex);
  return tex;
}

// When a custom avatar URL is provided, render the dog as a billboard sprite
// of the avatar instead of the procedural cylinder/sphere/cone primitives.
// Sprite is anchored at the dog group's origin with feet at y=0.
function makeDogBillboard(name, avatarUrl) {
  const g = new THREE.Group();
  const tex = getAvatarTexture(avatarUrl);
  const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: true });
  const sprite = new THREE.Sprite(mat);
  const W = 1.6, H = 1.6;
  sprite.scale.set(W, H, 1);
  sprite.position.y = H / 2;
  sprite.castShadow = false; // sprites don't cast useful shadows
  g.add(sprite);
  g.userData.sprite = sprite;
  // Label
  const lbl = makeLabel(name, '#fff'); lbl.position.y = H + 0.3; g.add(lbl);
  g.userData.label = lbl;
  return g;
}

function makeDog(name, colorHex, avatarUrl) {
  if (avatarUrl) return makeDogBillboard(name, avatarUrl);
  // Procedural rigged dog with bone-driven 4-leg trot cycle
  const coatNum = parseInt(String(colorHex).replace('#', ''), 16);
  const rig = buildDog({ name, palette: { coat: Number.isFinite(coatNum) ? coatNum : 0xc97a3e }, breedShape: 'lab' });
  rig.userData.isProceduralDog = true;
  return rig;
}
// Legacy primitive-dog builder kept below for reference but no longer used.
function _makeDogLegacy(name, colorHex, avatarUrl) {
  const g = new THREE.Group();
  const c = new THREE.Color(colorHex);
  const bodyMat = new THREE.MeshLambertMaterial({ color: c });
  // Body
  const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.32, 0.7, 6, 12), bodyMat);
  body.rotation.z = Math.PI/2; body.position.set(0, 0.45, 0); body.castShadow = true; g.add(body);
  // Head
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.28, 12, 10), bodyMat);
  head.position.set(0, 0.6, 0.55); head.castShadow = true; g.add(head);
  // Snout
  const snout = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, 0.3), bodyMat);
  snout.rotation.x = Math.PI/2; snout.position.set(0, 0.55, 0.85); g.add(snout);
  // Ears
  const ear = new THREE.ConeGeometry(0.1, 0.25, 8);
  const ear1 = new THREE.Mesh(ear, bodyMat); ear1.position.set(-0.18, 0.85, 0.5); g.add(ear1);
  const ear2 = new THREE.Mesh(ear, bodyMat); ear2.position.set(0.18, 0.85, 0.5); g.add(ear2);
  // Legs
  const legGeom = new THREE.CylinderGeometry(0.07, 0.07, 0.45);
  for (const [x, z] of [[-0.22,0.3],[0.22,0.3],[-0.22,-0.3],[0.22,-0.3]]) {
    const l = new THREE.Mesh(legGeom, bodyMat); l.position.set(x, 0.22, z); l.castShadow = true; g.add(l);
  }
  // Tail
  const tail = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.08, 0.45), bodyMat);
  tail.position.set(0, 0.6, -0.5); tail.rotation.x = -0.6; g.add(tail);
  g.userData.tail = tail;
  // Label
  const lbl = makeLabel(name, '#fff'); lbl.position.y = 1.3; g.add(lbl);
  g.userData.label = lbl;
  return g;
}

// Pet name tag — a portrait card (photo above, name below) drawn into a
// CanvasTexture and rendered as a Sprite that floats above the dog group.
// Photo loads asynchronously via Image; the canvas is re-uploaded once it
// arrives. Cross-origin photos that don't send CORS headers will silently
// keep the placeholder square — the name still renders. Returned sprite is
// pre-positioned at y=2.6 (clears procedural-rig labels at ~y=1.3).
function makePetNameTag(name, photoUrl) {
  const W = 256, H = 320, PHOTO = 240, PAD = 8, RADIUS = 18;
  const c = document.createElement('canvas'); c.width = W; c.height = H;
  const ctx = c.getContext('2d');
  // Card background
  ctx.fillStyle = 'rgba(14,14,16,0.85)';
  if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(0, 0, W, H, RADIUS); ctx.fill(); }
  else ctx.fillRect(0, 0, W, H);
  // Accent border
  ctx.strokeStyle = 'rgba(212,160,74,0.9)'; ctx.lineWidth = 3;
  if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(1.5, 1.5, W - 3, H - 3, RADIUS); ctx.stroke(); }
  else ctx.strokeRect(1.5, 1.5, W - 3, H - 3);
  // Photo placeholder (filled in once image loads)
  ctx.fillStyle = '#3a3a3e';
  if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(PAD, PAD, PHOTO, PHOTO, 12); ctx.fill(); }
  else ctx.fillRect(PAD, PAD, PHOTO, PHOTO);
  // Name beneath the photo
  ctx.fillStyle = '#fff';
  ctx.font = '700 36px -apple-system, system-ui, sans-serif';
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  ctx.fillText((name || 'Pup').slice(0, 14), W / 2, PAD + PHOTO + (H - PAD - PHOTO - PAD) / 2);

  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
  sprite.scale.set(1.0, 1.25, 1);
  sprite.position.y = 2.6;
  sprite.renderOrder = 10; // draw atop dog body even when overlap

  if (photoUrl) {
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      ctx.save();
      if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(PAD, PAD, PHOTO, PHOTO, 12); ctx.clip(); }
      else { ctx.beginPath(); ctx.rect(PAD, PAD, PHOTO, PHOTO); ctx.clip(); }
      // Cover-fit so the photo fills the square without stretching
      const ar = img.width / Math.max(1, img.height);
      let dw = PHOTO, dh = PHOTO, dx = PAD, dy = PAD;
      if (ar > 1) { dh = PHOTO; dw = PHOTO * ar; dx = PAD - (dw - PHOTO) / 2; }
      else if (ar < 1) { dw = PHOTO; dh = PHOTO / ar; dy = PAD - (dh - PHOTO) / 2; }
      ctx.drawImage(img, dx, dy, dw, dh);
      ctx.restore();
      tex.needsUpdate = true;
    };
    img.onerror = () => { /* keep placeholder + name */ };
    img.src = photoUrl;
  }
  return sprite;
}

function makeLabel(text, color = '#fff') {
  const c = document.createElement('canvas'); c.width = 256; c.height = 64;
  const ctx = c.getContext('2d');
  ctx.font = 'bold 32px -apple-system, sans-serif';
  ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(0,0,0,.45)';
  ctx.fillRect(0,0,256,64);
  ctx.fillStyle = color; ctx.fillText(text.slice(0,18), 128, 42);
  const tex = new THREE.CanvasTexture(c);
  const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false });
  const s = new THREE.Sprite(mat); s.scale.set(2.5, 0.6, 1);
  return s;
}

// ── NPC tracking ──────────────────────────────────────────────────────────
const npcEntities = new Map(); // npcId → { humanRig, dogRig, lastChatBubble }

function applyNpcs(npcsArr) {
  if (!npcsArr) return;
  const seen = new Set();
  for (const n of npcsArr) {
    seen.add(n.id);
    let entity = npcEntities.get(n.id);
    if (!entity) {
      const humanRig = buildHuman({ name: n.name, palette: n.palette });
      const dogRig = buildDog({ name: n.dogName, palette: { coat: n.dogCoat }, breedShape: 'lab' });
      scene.add(humanRig); scene.add(dogRig);
      entity = { humanRig, dogRig, prevX: n.x, prevZ: n.z, prevDx: n.dx, prevDz: n.dz };
      npcEntities.set(n.id, entity);
    }
    // Position + face
    entity.humanRig.position.x = n.x;
    entity.humanRig.position.z = n.z;
    entity.humanRig.rotation.y = n.ry;
    entity.dogRig.position.x = n.dx;
    entity.dogRig.position.z = n.dz;
    entity.dogRig.rotation.y = n.dry;
    // Animate based on movement amount this tick
    const moved = Math.hypot(n.x - (entity.prevX ?? n.x), n.z - (entity.prevZ ?? n.z));
    const dogMoved = Math.hypot(n.dx - (entity.prevDx ?? n.dx), n.dz - (entity.prevDz ?? n.dz));
    const stride = n.paused ? 0 : Math.min(1, moved * 12);
    const dogStride = Math.min(1, dogMoved * 8);
    animateHuman(entity.humanRig, performance.now() / 1000, stride, false);
    animateDog(entity.dogRig, performance.now() / 1000, dogStride, false);
    entity.prevX = n.x; entity.prevZ = n.z;
    entity.prevDx = n.dx; entity.prevDz = n.dz;
  }
  // Despawn
  for (const [id, e] of npcEntities) {
    if (!seen.has(id)) {
      scene.remove(e.humanRig); scene.remove(e.dogRig);
      npcEntities.delete(id);
    }
  }
}

// ── Speech bubbles ────────────────────────────────────────────────────────
const activeBubbles = new Map(); // entityId → { sprite, mountTo, expiresAt }

function makeSpeechBubble(text) {
  const c = document.createElement('canvas'); c.width = 360; c.height = 120;
  const ctx = c.getContext('2d');
  ctx.fillStyle = 'rgba(255,255,255,0.97)';
  ctx.beginPath();
  if (ctx.roundRect) ctx.roundRect(8, 8, 344, 90, 18); else ctx.rect(8, 8, 344, 90);
  ctx.fill();
  ctx.strokeStyle = '#d4a04a'; ctx.lineWidth = 3; ctx.stroke();
  // Tail
  ctx.beginPath(); ctx.moveTo(170, 98); ctx.lineTo(180, 115); ctx.lineTo(190, 98); ctx.closePath();
  ctx.fillStyle = 'rgba(255,255,255,0.97)'; ctx.fill();
  ctx.strokeStyle = '#d4a04a'; ctx.stroke();
  // Text
  ctx.fillStyle = '#0e0e10';
  ctx.font = '500 28px -apple-system, system-ui, sans-serif';
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  // Wrap to 2 lines
  const max = 14, lines = [];
  const words = text.split(/\s+/); let line = '';
  for (const w of words) {
    if ((line + ' ' + w).trim().length > max && line) { lines.push(line); line = w; }
    else line = (line + ' ' + w).trim();
  }
  if (line) lines.push(line);
  const useLines = lines.slice(0, 2);
  if (useLines.length === 1) ctx.fillText(useLines[0], 180, 53);
  else { ctx.fillText(useLines[0], 180, 38); ctx.fillText(useLines[1], 180, 70); }
  const tex = new THREE.CanvasTexture(c);
  const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
  sp.scale.set(2.6, 0.86, 1);
  return sp;
}

function showSpeechBubble(msg) {
  // Find which entity to mount the bubble on
  let mount = null;
  if (msg.fromId?.startsWith?.('npc-')) {
    mount = npcEntities.get(msg.fromId)?.humanRig;
  } else {
    mount = others.get(msg.fromId)?.ownerGroup;
  }
  if (!mount) return;
  // Remove existing bubble if present
  const existing = activeBubbles.get(msg.fromId);
  if (existing) { mount.remove(existing.sprite); }
  const bubble = makeSpeechBubble(msg.text);
  bubble.position.y = 3.4;
  mount.add(bubble);
  activeBubbles.set(msg.fromId, { sprite: bubble, mountTo: mount, expiresAt: performance.now() + 4500 });
  // Also pulse the chat bar input briefly with the inbound text
  if (msg.toId === myId) {
    const log = document.getElementById('log');
    if (log) {
      const div = document.createElement('div');
      div.innerHTML = `<b style="color:#d4a04a">${msg.fromName}:</b> ${msg.text.replace(/[<>]/g, '')}`;
      log.prepend(div);
    }
  }
}

// Reap expired speech bubbles
setInterval(() => {
  const now = performance.now();
  for (const [id, b] of activeBubbles) {
    if (b.expiresAt < now) {
      b.mountTo.remove(b.sprite);
      activeBubbles.delete(id);
    }
  }
}, 250);

// ── Proximity chat HUD ────────────────────────────────────────────────────
const chatbar = document.getElementById('chatbar');
const chatWho = document.getElementById('chatWho');
const chatInput = document.getElementById('chatInput');
const chatSend = document.getElementById('chatSend');
let proximityTarget = null;          // { id, name }
const PROXIMITY_RADIUS = 4.5;

function updateProximityHud() {
  // Find local player
  const me = myId ? others.get(myId) : null;
  if (!me) { hideChatbar(); return; }
  const mx = me.ownerGroup.position.x, mz = me.ownerGroup.position.z;
  let nearest = null, bestD2 = PROXIMITY_RADIUS * PROXIMITY_RADIUS;
  // Check other users
  for (const [oid, o] of others) {
    if (oid === myId) continue;
    const d2 = (o.ownerGroup.position.x - mx) ** 2 + (o.ownerGroup.position.z - mz) ** 2;
    if (d2 < bestD2) { bestD2 = d2; nearest = { id: oid, name: o.name || 'Player' }; }
  }
  // Check NPCs
  for (const [nid, n] of npcEntities) {
    const d2 = (n.humanRig.position.x - mx) ** 2 + (n.humanRig.position.z - mz) ** 2;
    if (d2 < bestD2) { bestD2 = d2; nearest = { id: nid, name: n.humanRig.name?.replace(/^human:/, '') || 'Stranger' }; }
  }
  if (nearest) {
    if (proximityTarget?.id !== nearest.id) {
      proximityTarget = nearest;
      chatWho.textContent = nearest.name;
      chatInput.placeholder = `Say something to ${nearest.name}…`;
    }
    chatbar.classList.add('show');
  } else {
    hideChatbar();
  }
}
function hideChatbar() {
  chatbar.classList.remove('show');
  proximityTarget = null;
}
function sendChat() {
  if (!proximityTarget || !ws || ws.readyState !== ws.OPEN) return;
  const text = chatInput.value.trim();
  if (!text) return;
  ws.send(JSON.stringify({ t: 'chat', toId: proximityTarget.id, text }));
  chatInput.value = '';
}
chatSend.addEventListener('click', sendChat);
chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChat(); });
// Auto-walk HUD button — toggles the same flag the 'T' key drives.
document.getElementById('autoBtn')?.addEventListener('click', () => setAutoMode(!autoMode));
setInterval(updateProximityHud, 200);

// ── State sync ────────────────────────────────────────────────────────────
function applyState(msg) {
  applyNpcs(msg.npcs);
  // users
  const seen = new Set();
  for (const u of msg.users) {
    seen.add(u.id);
    let avatar = others.get(u.id);
    if (!avatar) {
      const ownerGroup = makeOwner(u.name, u.id === myId, u.ownerAvatarUrl || null);
      const dogGroup = makeDog(u.dogName, u.dogColor || '#c97a3e', u.dogAvatarUrl || null);
      scene.add(ownerGroup); scene.add(dogGroup);
      avatar = { ownerGroup, dogGroup, lastAction: 'idle' };
      others.set(u.id, avatar);
    }
    // Pet name tag: real owner_pets photo + dog name floating above the dog.
    // Built once on first sight, cached on the avatar record. Only fires when
    // the join carried a same-origin or trusted-https photo URL — anonymous
    // users never set petPhotoUrl, so they keep the procedural/billboard dog.
    if (u.petPhotoUrl && avatar.petTag?.userData?.url !== u.petPhotoUrl) {
      if (avatar.petTag) avatar.dogGroup.remove(avatar.petTag);
      const tag = makePetNameTag(u.dogName || 'Pup', u.petPhotoUrl);
      tag.userData.url = u.petPhotoUrl;
      avatar.dogGroup.add(tag);
      avatar.petTag = tag;
      // Hide the procedural rig's built-in name pill so we don't double-label
      const rigLabel = avatar.dogGroup.userData?.bones?.label || avatar.dogGroup.userData?.label;
      if (rigLabel) rigLabel.visible = false;
    }
    // Track movement so procedural rigs animate when actually walking.
    const prevX = avatar._prevX ?? u.x, prevZ = avatar._prevZ ?? u.z;
    const moved = Math.hypot(u.x - prevX, u.z - prevZ);
    avatar._prevX = u.x; avatar._prevZ = u.z;
    const dPrevX = avatar._dPrevX ?? u.dx, dPrevZ = avatar._dPrevZ ?? u.dz;
    const dogMoved = Math.hypot(u.dx - dPrevX, u.dz - dPrevZ);
    avatar._dPrevX = u.dx; avatar._dPrevZ = u.dz;
    avatar.ownerGroup.position.x = u.x;
    avatar.ownerGroup.position.z = u.z;
    avatar.ownerGroup.rotation.y = u.ry;
    avatar.dogGroup.position.x = u.dx;
    avatar.dogGroup.position.z = u.dz;
    avatar.dogGroup.rotation.y = u.dry;
    // Drive rigged-character walk cycle (NPC-style) when this avatar is procedural
    const t = performance.now() / 1000;
    if (avatar.ownerGroup.userData.isProceduralHuman) {
      const stride = Math.min(1, moved * 12);
      const runMode = u.action === 'come' || u.action === 'chase';
      animateHuman(avatar.ownerGroup, t, stride, runMode);
    }
    if (avatar.dogGroup.userData.isProceduralDog) {
      const stride = Math.min(1, dogMoved * 8);
      const runMode = u.action === 'chase' || u.action === 'come' || u.action === 'jump_catch';
      animateDog(avatar.dogGroup, t, stride, runMode);
    }
    // Legacy primitive-dog tail wag (only fires for billboards, which still set userData.tail = sprite)
    const tail = avatar.dogGroup.userData.tail;
    if (tail && !avatar.dogGroup.userData.isProceduralDog) tail.rotation.z = Math.sin(performance.now()/100) * (u.action === 'idle' ? 0.1 : 0.5);
    if (u.action !== avatar.lastAction) {
      avatar.lastAction = u.action;
      // Bounce on chase / jump on jump_catch
    }
    if (u.action === 'jump_catch') {
      avatar.dogGroup.position.y = 0.5 + Math.abs(Math.sin(performance.now()/180)) * 0.8;
    } else if (u.action === 'chase' || u.action === 'come' || u.action === 'follow') {
      avatar.dogGroup.position.y = 0.05 + Math.abs(Math.sin(performance.now()/120)) * 0.06;
    } else {
      avatar.dogGroup.position.y = 0;
    }
    if (u.action === 'pee') {
      // Tip dog rotation slightly
      avatar.dogGroup.rotation.z = 0.3;
      // Tiny yellow drop
      if (!avatar.peeMesh) {
        const m = new THREE.Mesh(new THREE.SphereGeometry(0.08), new THREE.MeshBasicMaterial({color:0xfaca10}));
        avatar.peeMesh = m; scene.add(m);
      }
      avatar.peeMesh.position.set(avatar.dogGroup.position.x + 0.4, 0.1, avatar.dogGroup.position.z);
      avatar.peeMesh.visible = true;
    } else {
      avatar.dogGroup.rotation.z = 0;
      if (avatar.peeMesh) avatar.peeMesh.visible = false;
    }
  }
  for (const [id, av] of others) {
    if (!seen.has(id) && id !== myId) {
      scene.remove(av.ownerGroup); scene.remove(av.dogGroup);
      if (av.peeMesh) scene.remove(av.peeMesh);
      others.delete(id);
    }
  }

  // balls
  const bSeen = new Set();
  for (const b of msg.balls) {
    bSeen.add(b.id);
    let mesh = ballMeshes.get(b.id);
    if (!mesh) {
      if (b.kind === 'frisbee') {
        const g = new THREE.CylinderGeometry(0.32, 0.32, 0.06, 24);
        mesh = new THREE.Mesh(g, new THREE.MeshLambertMaterial({color:0xff5252}));
      } else {
        mesh = new THREE.Mesh(new THREE.SphereGeometry(0.18, 16, 12), new THREE.MeshLambertMaterial({color:0xfaca10}));
      }
      mesh.castShadow = true;
      ballMeshes.set(b.id, mesh); scene.add(mesh);
    }
    mesh.position.set(b.x, b.y, b.z);
    if (b.kind === 'frisbee') mesh.rotation.y += 0.4;
  }
  for (const [bid, mesh] of ballMeshes) {
    if (!bSeen.has(bid)) { scene.remove(mesh); ballMeshes.delete(bid); }
  }

  // counter
  document.getElementById('counter').textContent =
    `👤 ${msg.users.length} owner${msg.users.length===1?'':'s'} · 🐕 ${msg.users.length} dog${msg.users.length===1?'':'s'} · 🎾 ${msg.balls.length} toys`;
}

// ── Local control ────────────────────────────────────────────────────────
const keys = new Set();
// Map arrow keys onto WASD slots so the existing movement code stays single-
// path. Anything that isn't an arrow falls through unchanged so 'shift', 'c',
// 'p', etc. still work.
const _aliasKey = (k) => ({
  arrowup: 'w', arrowdown: 's', arrowleft: 'a', arrowright: 'd',
}[k] || k);
// "Auto" mode — chooses a random target inside the park and wanders toward it,
// re-picking when close or after ~6s. Any human keypress cancels Auto so users
// can grab the controls back instantly. Toggled via the HUD button or 'T' key.
let autoMode = false;
let _autoTarget = null;
let _autoUntil = 0;
function setAutoMode(on) {
  autoMode = !!on;
  const btn = document.getElementById('autoBtn');
  if (btn) {
    btn.textContent = autoMode ? '⏸ Stop auto' : '▶ Auto walk';
    btn.classList.toggle('on', autoMode);
  }
  if (!autoMode) _autoTarget = null;
}
window.addEventListener('keydown', (e) => {
  if (e.repeat) return;
  // Don't capture keys when typing in the chat or join inputs.
  const tag = (e.target?.tagName || '').toLowerCase();
  if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
  const k = e.key.toLowerCase();
  keys.add(_aliasKey(k));
  // Movement input from a human cancels auto-walk immediately.
  if (autoMode && ['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright'].includes(k)) {
    setAutoMode(false);
  }
  if (k === 'c') {
    if (ws?.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ t:'call' }));
      addLog('🗣 Called dog');
    }
  } else if (k === 'p') {
    cameraMode = (cameraMode + 1) % 2;
  } else if (k === 't') {
    setAutoMode(!autoMode);
  }
});
window.addEventListener('keyup', (e) => keys.delete(_aliasKey(e.key.toLowerCase())));

renderer.domElement.addEventListener('click', (e) => {
  // Throw a ball/frisbee in the direction the camera faces, with mouse-y for arc
  if (!myId) return;
  const me = others.get(myId);
  if (!me) return;
  const dir = new THREE.Vector3();
  camera.getWorldDirection(dir); dir.y = 0; dir.normalize();
  const arc = e.shiftKey ? 5 : 8;
  const horiz = e.shiftKey ? 14 : 16;
  ws.send(JSON.stringify({
    t:'throw',
    x: me.ownerGroup.position.x + dir.x * 0.5,
    y: 1.5,
    z: me.ownerGroup.position.z + dir.z * 0.5,
    vx: dir.x * horiz,
    vy: arc,
    vz: dir.z * horiz,
    kind: e.shiftKey ? 'frisbee' : 'ball',
  }));
  addLog(e.shiftKey ? '🥏 Threw frisbee' : '🎾 Threw ball');
});

let cameraMode = 0; // 0 = third-person, 1 = top-down
let myX = 0, myZ = 0, myRy = 0;
let lastSent = 0;
function step(dt) {
  // Local owner movement (we send x/z to server; server owns dog)
  const me = others.get(myId);
  if (me) {
    const speed = (keys.has('shift') ? 9 : 5) * dt;
    let mx = 0, mz = 0;
    if (keys.has('w')) mz -= 1;
    if (keys.has('s')) mz += 1;
    if (keys.has('a')) mx -= 1;
    if (keys.has('d')) mx += 1;
    // Auto-walk: pick a random point inside the park and stride toward it.
    // Re-pick when within 1.2u or after the 6s deadline. Human input above
    // already wins because setAutoMode(false) fires on keydown.
    if (autoMode && !mx && !mz) {
      const nowMs = performance.now();
      const dxT = (_autoTarget?.x ?? myX) - myX;
      const dzT = (_autoTarget?.z ?? myZ) - myZ;
      const distT = Math.hypot(dxT, dzT);
      if (!_autoTarget || distT < 1.2 || nowMs > _autoUntil) {
        _autoTarget = {
          x: (Math.random() - 0.5) * (PARK_HALF * 1.6),
          z: (Math.random() - 0.5) * (PARK_HALF * 1.6),
        };
        _autoUntil = nowMs + 6000;
      } else {
        mx = dxT / distT;
        mz = dzT / distT;
      }
    }
    if (mx || mz) {
      const len = Math.hypot(mx, mz);
      mx /= len; mz /= len;
      myX = THREE.MathUtils.clamp(myX + mx * speed, -PARK_HALF, PARK_HALF);
      myZ = THREE.MathUtils.clamp(myZ + mz * speed, -PARK_HALF, PARK_HALF);
      myRy = Math.atan2(mx, mz);
    } else {
      // initialize from server position once
      if (!me._initialized) {
        myX = me.ownerGroup.position.x;
        myZ = me.ownerGroup.position.z;
        me._initialized = true;
      }
    }
    // overwrite local owner position so it feels responsive
    me.ownerGroup.position.x = myX; me.ownerGroup.position.z = myZ;
    me.ownerGroup.rotation.y = myRy;

    // throttle send to 20Hz
    const now = performance.now();
    if (now - lastSent > 50 && ws?.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ t:'move', x: myX, y:0, z: myZ, ry: myRy }));
      lastSent = now;
    }

    // Camera follow
    if (cameraMode === 0) {
      const camDist = 8, camHt = 5;
      camera.position.x = myX - Math.sin(myRy) * camDist;
      camera.position.z = myZ - Math.cos(myRy) * camDist;
      camera.position.y = camHt;
      camera.lookAt(myX, 1.0, myZ);
    } else {
      camera.position.set(myX, 35, myZ + 0.01);
      camera.lookAt(myX, 0, myZ);
    }
  }
}

let prev = performance.now();
function loop(now) {
  const dt = Math.min(0.1, (now - prev) / 1000);
  prev = now;
  step(dt);
  animateProps();
  // Fountain water shimmer
  if (_fountain.userData.water) _fountain.userData.water.position.y = 0.86 + Math.sin(now / 500) * 0.02;
  composer.render();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

function addLog(line) {
  const el = document.getElementById('log');
  const div = document.createElement('div');
  div.textContent = line; el.appendChild(div); el.scrollTop = el.scrollHeight;
  while (el.children.length > 8) el.removeChild(el.firstChild);
}