← 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 & 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>