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