← back to Animals

public/dogpark/demo.html

605 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dog Park · Demo · AnimalsDirectory</title>
<style>
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{width:100%;height:100%;overflow:hidden;background:#9bd2e7;font-family:-apple-system,system-ui,sans-serif;color:#fff}
  canvas{display:block}
  #title{position:fixed;top:24px;left:50%;transform:translateX(-50%);z-index:50;background:rgba(14,14,16,.65);padding:14px 28px;border-radius:8px;font-size:18px;letter-spacing:.06em;text-align:center;backdrop-filter:blur(8px);font-weight:300;border:1px solid rgba(212,160,74,.4)}
  #title em{color:#d4a04a;font-style:normal}
  #title small{display:block;font-size:11px;letter-spacing:.18em;color:#d4a04a;text-transform:uppercase;margin-bottom:4px}
</style>
</head>
<body>
<div id="title"><small>AnimalsDirectory</small>The dog park · <em>Steve, Natalia &amp; Dylan</em></div>
<canvas id="dp-canvas"></canvas>

<script type="importmap">{ "imports": {
  "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
  "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
} }</script>

<script type="module">
import * as THREE from 'three';
import { buildHuman, buildDog, animateHuman, animateDog, CAST_PALETTES } 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';

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

const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.1, 200);
const renderer = new THREE.WebGLRenderer({ antialias: true, canvas: document.getElementById('dp-canvas') });
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 = 1.05;

// HDRI-style environment for image-based lighting (gives PBR materials reflections + soft fill)
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(renderer), 0.04).texture;

// Post-processing: bloom for warm-glow highlights + ACES tone mapping output
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.45, 0.7, 0.85);
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 sun, cool sky fill, magenta-warm rim
const sun = new THREE.DirectionalLight(0xfff0d4, 2.0);
sun.position.set(22, 32, 18); sun.castShadow = true;
sun.shadow.mapSize.set(4096, 4096);
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);
const skyFill = new THREE.HemisphereLight(0xa5c8e8, 0.6);
scene.add(skyFill);
const rim = new THREE.DirectionalLight(0xffcfa8, 0.7);
rim.position.set(-18, 14, -20);
scene.add(rim);

// Ground — PBR grass with subtle procedural texture
function makeGrassTexture() {
  const c = document.createElement('canvas'); c.width = c.height = 512;
  const ctx = c.getContext('2d');
  // Base
  ctx.fillStyle = '#7fbe60'; ctx.fillRect(0, 0, 512, 512);
  // Speckle for grass blade variation
  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 grassTex = makeGrassTexture();
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(PARK_HALF*2, PARK_HALF*2, 1, 1),
  new THREE.MeshStandardMaterial({ map: grassTex, roughness: 0.95, metalness: 0.0 })
);
ground.rotation.x = -Math.PI/2; ground.receiveShadow = true; scene.add(ground);

// Path (dirt strip down the middle) — slight gravel texture
function makeDirtTexture() {
  const c = document.createElement('canvas'); c.width = c.height = 256;
  const ctx = c.getContext('2d');
  ctx.fillStyle = '#b89167'; ctx.fillRect(0, 0, 256, 256);
  for (let i = 0; i < 1500; i++) {
    const v = Math.random();
    ctx.fillStyle = v < 0.5 ? `rgba(140,108,76,${0.3 + Math.random() * 0.3})`
                            : `rgba(200,170,130,${0.2 + Math.random() * 0.3})`;
    ctx.fillRect(Math.random()*256, Math.random()*256, 1+Math.random()*2, 1+Math.random()*2);
  }
  const t = new THREE.CanvasTexture(c);
  t.wrapS = t.wrapT = THREE.RepeatWrapping;
  t.repeat.set(1, 8);
  t.colorSpace = THREE.SRGBColorSpace;
  return t;
}
const path = new THREE.Mesh(
  new THREE.PlaneGeometry(2.2, PARK_HALF*1.8),
  new THREE.MeshStandardMaterial({ map: makeDirtTexture(), roughness: 0.9 })
);
path.rotation.x = -Math.PI/2; path.position.y = 0.01; path.receiveShadow = true; scene.add(path);

// Fence
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
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);
}
[[-25, 22],[25, 22],[-25, -22],[25, -22],[-15, -3],[15, -3]].forEach(([x,z]) => tree(x, z));

// Hydrants
const hydrantMat = new THREE.MeshLambertMaterial({ color: 0xc0392b });
for (const h of [{x:-10,z:-10},{x:10,z:-10},{x:0,z:12},{x:-18,z:18},{x:18,z:18}]) {
  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);
}

// ─── Café ──────────────────────────────────────────────────────────────────
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 ctx = sc.getContext('2d');
  ctx.fillStyle = '#c8543a'; ctx.fillRect(0,0,256,96);
  ctx.fillStyle = '#fff8eb'; ctx.font = 'bold 56px serif'; ctx.textAlign = 'center'; ctx.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);

// ─── Fountain ──────────────────────────────────────────────────────────────
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; 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);

// ─── Bowls + frisbee rack + speaker (light props) ──────────────────────────
function makeBowl(x, z) {
  const g = new THREE.Group();
  const m = new THREE.MeshStandardMaterial({ color: 0xc7c4bd, metalness: 0.7, roughness: 0.3 });
  const wm = new THREE.MeshStandardMaterial({ color: 0x4d9fc6, transparent: true, opacity: 0.85 });
  const b = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.32, 0.18), m); b.position.y = 0.09; b.castShadow = true; g.add(b);
  const w = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.04), wm); 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));

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);

// ─── Dog houses with random "guard" dogs that bark on proximity ──────────
function makeDogHouse(x, z, color = 0xa37146) {
  const g = new THREE.Group();
  const wood = new THREE.MeshLambertMaterial({ color });
  const roofMat = new THREE.MeshLambertMaterial({ color: 0x6b3a2a });
  // Body box
  const body = new THREE.Mesh(new THREE.BoxGeometry(1.6, 1.2, 1.4), wood);
  body.position.y = 0.6; body.castShadow = true; body.receiveShadow = true; g.add(body);
  // Roof (two slanted boxes — pyramid feel)
  const roof = new THREE.Mesh(new THREE.ConeGeometry(1.3, 0.8, 4), roofMat);
  roof.rotation.y = Math.PI / 4; roof.position.y = 1.6; roof.castShadow = true; g.add(roof);
  // Doorway (dark hole on front face)
  const door = new THREE.Mesh(new THREE.PlaneGeometry(0.55, 0.8), new THREE.MeshBasicMaterial({color: 0x111111}));
  door.position.set(0, 0.5, 0.71); g.add(door);
  // Tiny "name" plaque
  g.position.set(x, 0, z);
  scene.add(g);
  return g;
}

// 4 dog houses with 4 different guard breeds at the park edges
const HOUSES = [
  { x: -24, z:  4, color: 0xa37146, breed:'husky',     name:'Rex',    coat: 0xb8c4cf, snarl:'Grrr!' },
  { x:  24, z:  -8, color: 0x8b5a2b, breed:'bulldog',   name:'Tank',   coat: 0xc1a986, snarl:'Ruff!' },
  { x: -24, z: -16, color: 0x9b6a40, breed:'lab',       name:'Duke',   coat: 0x3a2a1a, snarl:'Woof!' },
  { x:  24, z:  16, color: 0xa37146, breed:'chihuahua', name:'Pepper', coat: 0xd1a86a, snarl:'Yip yip!' },
];
const guardDogs = [];
for (const h of HOUSES) {
  makeDogHouse(h.x, h.z, h.color);
  // Guard dog stands just in front of its house
  const dog = buildDog({ name: h.name, palette: { coat: h.coat }, breedShape: h.breed });
  // Position relative to house: in front of doorway
  const dz = h.z > 0 ? -1.4 : 1.4; // face inward toward park center
  dog.position.set(h.x, 0, h.z + dz);
  dog.rotation.y = h.z > 0 ? Math.PI : 0; // face park center
  scene.add(dog);
  // Speech-bubble sprite (initially hidden)
  const bubble = makeSpeechBubble(h.snarl);
  bubble.position.set(0, 1.4, 0);
  bubble.visible = false;
  dog.add(bubble);
  guardDogs.push({ rig: dog, home: { x: h.x, z: h.z + dz }, snarl: h.snarl, bubble, lastBark: 0, alertness: 0 });
}

function makeSpeechBubble(text) {
  const c = document.createElement('canvas');
  c.width = 256; c.height = 100;
  const ctx = c.getContext('2d');
  // Pill bg
  ctx.fillStyle = 'rgba(255,255,255,.95)';
  ctx.beginPath();
  ctx.roundRect ? ctx.roundRect(8, 8, 240, 70, 16) : ctx.rect(8, 8, 240, 70);
  ctx.fill();
  ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 3;
  ctx.beginPath();
  ctx.roundRect ? ctx.roundRect(8, 8, 240, 70, 16) : ctx.rect(8, 8, 240, 70);
  ctx.stroke();
  // Tail
  ctx.beginPath();
  ctx.moveTo(120, 78); ctx.lineTo(130, 95); ctx.lineTo(140, 78); ctx.closePath();
  ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.fill();
  ctx.strokeStyle = '#c0392b'; ctx.stroke();
  // Text
  ctx.fillStyle = '#c0392b';
  ctx.font = 'bold 40px Arial Black, Impact, sans-serif';
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  ctx.fillText(text, 128, 42);
  const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), transparent: true, depthTest: false }));
  sp.scale.set(1.5, 0.6, 1);
  return sp;
}

// ─── Cast: 3 humans + 3 dogs ──────────────────────────────────────────────
const HUMANS = [
  { name: 'Steve',   palette: { ...CAST_PALETTES.steve,   portraitUrl: '/static/uploads/avatars/people/steve.png' },   hasGoatee: true,  longHair: false },
  { name: 'Natalia', palette: { ...CAST_PALETTES.natalia, portraitUrl: '/static/uploads/avatars/people/natalia.png' }, hasGoatee: false, longHair: true  },
  { name: 'Dylan',   palette: { ...CAST_PALETTES.dylan,   portraitUrl: '/static/uploads/avatars/people/dylan.png' },   hasGoatee: false, longHair: true  },
];
const DOGS = [
  { name: 'Bowie',    palette: { ...CAST_PALETTES.bowie,    portraitUrl: '/static/uploads/avatars/test-bowie/hero_pbr.png'    }, breedShape: 'lab' },
  { name: 'Madison',  palette: { ...CAST_PALETTES.madison,  portraitUrl: '/static/uploads/avatars/test-madison/hero_pbr.png'  }, breedShape: 'lab' },
  { name: 'Humphrey', palette: { ...CAST_PALETTES.humphrey, portraitUrl: '/static/uploads/avatars/test-humphrey/hero_pbr.png' }, breedShape: 'lab' },
];

const actors = [];
HUMANS.forEach((h, i) => {
  const rig = buildHuman(h);
  rig.position.set(-8 + i * 8, 0, 0);
  scene.add(rig);
  actors.push({ kind: 'human', name: h.name, rig, target: { x: -8 + i * 8, z: 0 }, speed: 2.6 });
});
DOGS.forEach((d, i) => {
  const rig = buildDog(d);
  rig.position.set(-6 + i * 6, 0, 3);
  scene.add(rig);
  actors.push({ kind: 'dog', name: d.name, rig, target: { x: -6 + i * 6, z: 3 }, speed: 4.0 });
});
const STEVE=0, NATALIA=1, DYLAN=2, BOWIE=3, MADISON=4, HUMPHREY=5;

// Projectile (ball / frisbee in flight)
let projectile = null;
function throwProjectile(from, to, kind) {
  const color = kind === 'frisbee' ? 0xfdca40 : 0xfaca10;
  const geom = kind === 'frisbee' ? new THREE.CylinderGeometry(0.32, 0.32, 0.04, 24) : new THREE.SphereGeometry(0.18, 16, 12);
  const mesh = new THREE.Mesh(geom, new THREE.MeshLambertMaterial({color}));
  mesh.castShadow = true; scene.add(mesh);
  if (kind === 'frisbee') mesh.rotation.z = Math.PI/2;
  projectile = { mesh, from:{...from}, to:{...to}, t:0, dur:1.6, kind, spin: kind === 'frisbee' ? 8 : 0 };
}

// ─── Choreography ──────────────────────────────────────────────────────────
const C = {
  cafe:        { x:-22, z:14 },
  fountain:    { x: 8,  z:-13 },
  rack:        { x:17,  z:-12 },
  bench1:      { x:-6,  z:17 },
  bench2:      { x:18,  z: 4 },
  bowlA:       { x:-3,  z:-8 },
  bowlB:       { x:14,  z: 6 },
  bowlC:       { x:-14, z:14 },
  hydrantNW:   { x:-18, z:18 },
  centerL:     { x:-7,  z: 0 },
  centerR:     { x: 7,  z: 0 },
};
const BEATS = [
  { t:0,  who:STEVE,    walk:C.centerL },
  { t:0,  who:NATALIA,  walk:{ x:-1, z:1 } },
  { t:0,  who:DYLAN,    walk:C.centerR },
  { t:0,  who:BOWIE,    walk:{ x:-5, z: 2 } },
  { t:0,  who:MADISON,  walk:{ x: 0, z: 2 } },
  { t:0,  who:HUMPHREY, walk:{ x: 5, z: 2 } },

  // Steve heads to cafe; Bowie wanders toward NW guard house (Duke barks)
  { t:4,  who:STEVE,    walk:C.cafe },
  { t:4,  who:BOWIE,    walk:{ x:-22, z:-12 } },

  // Natalia throws ball for Madison
  { t:8,  who:NATALIA,  walk:{ x:-2, z:-2 } },
  { t:9,  who:MADISON,  walk:{ x:-2, z:-1 } },
  { t:10, throw:{ from:{x:-2,z:-2,y:1.4}, to:{x:14,z:-15}, kind:'ball' } },
  { t:10, who:MADISON,  walk:{ x:14, z:-15 }, run:true },

  // Dylan heads to frisbee rack, throws to Humphrey (Humphrey runs near Tank's house = bark)
  { t:14, who:DYLAN,    walk:C.rack },
  { t:14, who:HUMPHREY, walk:{ x:18, z:-10 }, run:true },
  { t:18, throw:{ from:{x:17,z:-12,y:1.4}, to:{x:22,z:14}, kind:'frisbee' } },
  { t:18, who:HUMPHREY, walk:{ x:22, z:14 }, run:true },

  // Bowie back to Steve at cafe; sniffs Pepper's house en route
  { t:24, who:BOWIE,    walk:{ x:-18, z:14 } },

  // Natalia + Madison to fountain area
  { t:26, who:NATALIA,  walk:C.fountain },
  { t:30, who:MADISON,  walk:C.bowlB },

  // Mid-act break — drinks at fountain, group on benches
  { t:36, who:NATALIA,  walk:C.bench2 },
  { t:38, who:DYLAN,    walk:C.bench2 },
  { t:36, who:HUMPHREY, walk:C.bench2 },

  // Steve+Bowie come back toward center bench
  { t:40, who:STEVE,    walk:C.bench1 },
  { t:42, who:BOWIE,    walk:C.bench1 },

  // Group photo at center
  { t:50, who:STEVE,    walk:{ x:-3, z: 0 } },
  { t:50, who:NATALIA,  walk:{ x: 0, z: 0 } },
  { t:50, who:DYLAN,    walk:{ x: 3, z: 0 } },
  { t:50, who:BOWIE,    walk:{ x:-4, z: 2 } },
  { t:50, who:MADISON,  walk:{ x: 0, z: 2 } },
  { t:50, who:HUMPHREY, walk:{ x: 4, z: 2 } },
];

// Camera path — wide aerial → push-in → orbit → group portrait
const CAM = [
  { t:0,  pos:[ 0, 22, 32], look:[0, 0, 0] },
  { t:6,  pos:[-18, 12, 22], look:[-15, 1, 8] },
  { t:14, pos:[ 0, 10, 20], look:[0, 1, -4] },
  { t:24, pos:[ 18, 8, 4], look:[6, 1, -10] },
  { t:36, pos:[-2, 8, 14], look:[6, 1, 4] },
  { t:48, pos:[ 0, 5, 11], look:[0, 1.4, 0] },
  { t:58, pos:[ 0, 4.5, 9], look:[0, 1.4, 0] },
];

// ─── Music — Web Audio synth pad ───────────────────────────────────────────
let audioCtx = null;
function startMusic() {
  try {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    const masterGain = audioCtx.createGain();
    masterGain.gain.value = 0.10;
    masterGain.connect(audioCtx.destination);
    [220, 277, 330, 415].forEach((f, i) => {
      const osc = audioCtx.createOscillator();
      osc.type = 'sine'; osc.frequency.value = f;
      const g = audioCtx.createGain(); g.gain.value = 0;
      osc.connect(g); g.connect(masterGain);
      const lfo = audioCtx.createOscillator();
      const lfoG = audioCtx.createGain();
      lfo.type = 'sine'; lfo.frequency.value = 0.05 + i * 0.02;
      lfoG.gain.value = 0.18; lfo.connect(lfoG); lfoG.connect(g.gain);
      osc.start(); lfo.start();
      g.gain.linearRampToValueAtTime(0.25, audioCtx.currentTime + 2 + i * 0.6);
    });
    function bell(at, freq) {
      const o = audioCtx.createOscillator(); o.type = 'triangle'; o.frequency.value = freq;
      const g = audioCtx.createGain(); o.connect(g); g.connect(masterGain);
      g.gain.setValueAtTime(0.0001, at);
      g.gain.exponentialRampToValueAtTime(0.18, at + 0.02);
      g.gain.exponentialRampToValueAtTime(0.0001, at + 1.2);
      o.start(at); o.stop(at + 1.3);
    }
    const startT = audioCtx.currentTime;
    const notes = [880, 1100, 1320, 990];
    for (let i = 0; i < 30; i++) bell(startT + 4 + i * 2.5, notes[i % notes.length]);
  } catch (e) { console.warn('audio failed', e); }
}

// Bark sound — quick descending square-wave burst with envelope
function playBark(intensity = 1) {
  if (!audioCtx) return;
  const t = audioCtx.currentTime;
  const o = audioCtx.createOscillator();
  o.type = 'square';
  o.frequency.setValueAtTime(280, t);
  o.frequency.exponentialRampToValueAtTime(140, t + 0.18);
  const g = audioCtx.createGain();
  g.gain.setValueAtTime(0.001, t);
  g.gain.exponentialRampToValueAtTime(0.22 * intensity, t + 0.02);
  g.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
  // Touch of distortion via waveshaper for a rougher "ruff"
  const ws = audioCtx.createWaveShaper();
  const curve = new Float32Array(256);
  for (let i = 0; i < 256; i++) { const x = i/128 - 1; curve[i] = Math.tanh(x * 4); }
  ws.curve = curve;
  o.connect(ws); ws.connect(g); g.connect(audioCtx.destination);
  o.start(t); o.stop(t + 0.25);
}

// ─── Movement + animation loop ─────────────────────────────────────────────
function applyBeats(t) {
  for (const b of BEATS) {
    if (b.__fired || b.t > t) continue;
    if (b.walk && b.who !== undefined) {
      actors[b.who].target.x = b.walk.x;
      actors[b.who].target.z = b.walk.z;
      actors[b.who].run = !!b.run;
    }
    if (b.throw) throwProjectile(b.throw.from, b.throw.to, b.throw.kind);
    b.__fired = true;
  }
}
function lerp(a,b,t){return a+(b-a)*t}
function smoothstep(t){return t*t*(3-2*t)}
function camAt(t) {
  let i = 0; while (i < CAM.length - 1 && CAM[i+1].t <= t) i++;
  const a = CAM[i], b = CAM[Math.min(i+1, CAM.length-1)];
  const seg = b.t - a.t;
  const u = seg > 0 ? smoothstep(Math.min(1, Math.max(0, (t - a.t) / seg))) : 0;
  return {
    pos:  [lerp(a.pos[0],b.pos[0],u), lerp(a.pos[1],b.pos[1],u), lerp(a.pos[2],b.pos[2],u)],
    look: [lerp(a.look[0],b.look[0],u), lerp(a.look[1],b.look[1],u), lerp(a.look[2],b.look[2],u)],
  };
}

const t0 = performance.now();
let prev = t0;
function loop(now) {
  const t = (now - t0) / 1000;
  const dt = Math.min(0.1, (now - prev) / 1000);
  prev = now;

  applyBeats(t);

  // Move actors toward targets, animate rigs based on whether they're moving
  for (const a of actors) {
    const tg = a.target;
    const dx = tg.x - a.rig.position.x;
    const dz = tg.z - a.rig.position.z;
    const d  = Math.hypot(dx, dz);
    let stride = 0;
    let runMode = false;
    if (d > 0.05) {
      const sp = a.run ? a.speed * 1.7 : a.speed;
      const step = Math.min(d, sp * dt);
      a.rig.position.x += (dx / d) * step;
      a.rig.position.z += (dz / d) * step;
      // Face direction of motion (yaw)
      const yawTarget = Math.atan2(dx, dz);
      let dy = yawTarget - a.rig.rotation.y;
      while (dy > Math.PI) dy -= Math.PI * 2;
      while (dy < -Math.PI) dy += Math.PI * 2;
      a.rig.rotation.y += dy * Math.min(1, dt * 6);
      stride = Math.min(1, sp / a.speed);
      runMode = a.run === true;
    }
    if (a.kind === 'human') animateHuman(a.rig, t, stride, runMode);
    else animateDog(a.rig, t, stride, runMode);
  }

  // Projectile arc
  if (projectile) {
    projectile.t += dt / projectile.dur;
    if (projectile.t >= 1) {
      scene.remove(projectile.mesh);
      projectile = null;
    } else {
      const u = projectile.t;
      const arc = Math.sin(u * Math.PI) * (projectile.kind === 'frisbee' ? 3.0 : 4.0);
      projectile.mesh.position.set(
        lerp(projectile.from.x, projectile.to.x, u),
        (projectile.from.y || 1.4) + arc,
        lerp(projectile.from.z, projectile.to.z, u)
      );
      if (projectile.spin) projectile.mesh.rotation.y += projectile.spin * dt;
    }
  }

  // Guard-dog proximity barking — any actor within 6 units triggers a bark.
  // Bubble shows for 1.5s, lockout 1.2s between barks per dog.
  for (const gd of guardDogs) {
    let nearby = false;
    for (const a of actors) {
      const dx = a.rig.position.x - gd.home.x;
      const dz = a.rig.position.z - gd.home.z;
      if (dx*dx + dz*dz < 36) { nearby = true; break; }
    }
    gd.alertness = nearby ? Math.min(1, gd.alertness + dt * 3) : Math.max(0, gd.alertness - dt * 1.5);
    // Trigger bark + show bubble
    if (nearby && (t - gd.lastBark) > 1.2) {
      gd.lastBark = t;
      gd.bubble.visible = true;
      playBark(0.8);
      setTimeout(() => { gd.bubble.visible = false; }, 1500);
    }
    // Snarl idle: head tilts slightly when alert
    const tail = gd.rig.userData.bones.tail;
    if (tail) tail.rotation.y = Math.sin(t * (nearby ? 14 : 6)) * (0.3 + gd.alertness * 0.4);
    // Body shake when barking
    if (nearby) {
      gd.rig.position.y = Math.abs(Math.sin(t * 22)) * 0.04;
    } else {
      gd.rig.position.y = 0;
    }
  }

  // Fountain water shimmer
  if (fountain.userData.water) fountain.userData.water.position.y = 0.86 + Math.sin(t * 2) * 0.02;

  // Camera
  const c = camAt(t);
  camera.position.set(c.pos[0], c.pos[1], c.pos[2]);
  camera.lookAt(c.look[0], c.look[1], c.look[2]);

  composer.render();
  requestAnimationFrame(loop);
}

setTimeout(() => {
  startMusic();
  requestAnimationFrame(loop);
  window.__DEMO_START = performance.now();
}, 800);
window.__DEMO_DURATION = 60;
</script>
</body>
</html>