返回 CodeWhale
pet.ts
根目录 / pet / src / ui / pet.ts
1 import { PetWorld, PET_MAX_SECONDS, type PetInteraction, type PetWorldCheckpoint } from '../core/pet-world.js';
2 import { compilePetTelemetry, decodePetJSONL, PetLiveTape, type PetBucket } from '../core/pet-telemetry.js';
3 import { petDemoEvents } from '../core/pet-demo.js';
4 import { importTrace } from '../core/ingest.js';
5 import { layout } from '../core/pet-sim.js';
6 import { renderPetPCM, type PetVoice } from '../core/pet-audio.js';
7 import { TraceLibrary, type SavedHabitat } from './storage.js';
8
9 const get = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
10 const canvas = get<HTMLCanvasElement>('tank'), ctx = canvas.getContext('2d')!;
11 const mode = get<HTMLSelectElement>('mode'), seek = get<HTMLInputElement>('seek'), motion = get<HTMLInputElement>('motion');
12 const message = get('message'), source = get('source');
13 const media = matchMedia('(prefers-reduced-motion: reduce)'); motion.checked = media.matches;
14 media.addEventListener('change', () => { motion.checked = media.matches; if (world) rebuild(seconds, undefined, world.recording(false).start); });
15 let points: [number, number][] = [], tape: readonly PetBucket[] = [], interactions: PetInteraction[] = [];
16 let expressionVersion: 1 | 2 = 2;
17 let worldMode = 'wild';
18 let world: PetWorld, seconds = 0, last = 0, accumulator = 0, paused = false;
19 let restoring = false, generation = 0, liveGeneration = 0, liveTimer = 0;
20 let imported: { world: PetWorld; name: string } | undefined;
21 const library = new TraceLibrary();
22 const liveTape = new PetLiveTape();
23 let liveObservation = 0;
24 let savedRevision: number | undefined, saving: Promise<boolean> | undefined, persistenceReady = false, persistenceFailed = false;
25 let audio: AudioContext | undefined, anchor = 0, sound = false;
26 const playing = new Set<AudioBufferSourceNode>();
27 const duration = () => Number(seek.max);
28 const timeLabel = (n: number) => {
29 const minutes = Math.floor(n / 60), seconds = String(Math.floor(n % 60)).padStart(2, '0');
30 return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${seconds}`;
31 };
32
33 function silence() { for (const node of playing) { try { node.stop(); } catch { /* Already ended. */ } } playing.clear(); }
34 function play(voices: readonly PetVoice[]) {
35 if (!audio || !sound || paused) return;
36 for (const voice of voices) {
37 const start = Math.floor(voice.start * audio.sampleRate), length = Math.ceil(voice.duration * audio.sampleRate) + 2;
38 const pcm = renderPetPCM([voice], start, length, audio.sampleRate);
39 const buffer = audio.createBuffer(2, length, audio.sampleRate);
40 buffer.copyToChannel(pcm.left, 0); buffer.copyToChannel(pcm.right, 1);
41 const node = audio.createBufferSource(); node.buffer = buffer; node.connect(audio.destination);
42 const at = anchor + start / audio.sampleRate;
43 if (at + buffer.duration < audio.currentTime) continue;
44 playing.add(node); node.onended = () => { playing.delete(node); node.disconnect(); };
45 node.start(Math.max(audio.currentTime, at), Math.max(0, audio.currentTime - at));
46 }
47 }
48 async function rebuild(to = 0, checkpoint?: PetWorldCheckpoint, start?: PetWorldCheckpoint) {
49 const ticket = ++generation;
50 const next = start ? PetWorld.fromRecording(points, { petReplayVersion: 2, expressionVersion, tape, interactions, start, checkpoint })
51 : checkpoint ? PetWorld.restore(points, tape, interactions, checkpoint) : new PetWorld(points, tape, interactions, expressionVersion, true);
52 const ticks = Math.round(Math.max(next.startTimeMs / 1000, Math.min(PET_MAX_SECONDS, to)) * 30);
53 if (checkpoint && checkpoint.tick !== ticks) throw new Error('Saved pet clock does not match its checkpoint.');
54 silence(); restoring = true;
55 canvas.setAttribute('aria-busy', 'true');
56 for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = true;
57 for (let i = Math.round(next.frame.timeMs * 30 / 1000); i < ticks; i++) {
58 next.step(1 / 30, { motion: !motion.checked, sensitivity: 1 });
59 if (i > 0 && i % 600 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); if (ticket !== generation) return; }
60 }
61 if (ticket !== generation) return;
62 adoptWorld(next);
63 }
64 function adoptWorld(next: PetWorld) {
65 world = next; worldMode = mode.value; tape = world.tape; interactions = [...world.interactions];
66 seek.min = String(next.startTimeMs / 1000); expressionVersion = next.sim.expressionVersion; restoring = false;
67 if (mode.value === 'replay') imported = { world: next, name: imported?.name ?? source.textContent ?? 'Imported replay' };
68 canvas.setAttribute('aria-busy', 'false');
69 for (const id of ['save', 'attention', 'feed']) get<HTMLButtonElement>(id).disabled = false;
70 seconds = next.frame.timeMs / 1000; accumulator = 0; last = 0;
71 if (audio) anchor = audio.currentTime - seconds + .08;
72 draw();
73 }
74 function stopFollowing() { liveGeneration++; clearTimeout(liveTimer); liveTape.reset(); get<HTMLButtonElement>('pause').disabled = false; seek.disabled = false; }
75 async function refreshArchives() {
76 const list = get<HTMLSelectElement>('archive');
77 const entries = await library.petArchives();
78 list.replaceChildren(new Option('Earlier recordings…', ''));
79 for (const entry of entries) list.add(new Option(`${entry.name} · ${timeLabel(entry.startSeconds)}–${timeLabel(entry.seconds)}`, String(entry.key)));
80 }
81 async function persist(archiveCurrent = false): Promise<boolean> {
82 if (saving) {
83 if (!archiveCurrent) return saving;
84 await saving;
85 return persist(true);
86 }
87 if (!world || restoring || !persistenceReady || persistenceFailed) return false;
88 const task = saveCurrent(archiveCurrent); saving = task;
89 try { return await task; }
90 finally { if (saving === task) saving = undefined; }
91 }
92 async function saveCurrent(archiveCurrent: boolean): Promise<boolean> {
93 try {
94 const owner = world;
95 const snapshot: SavedHabitat = { petPersistenceVersion: 1, seconds: owner.frame.timeMs / 1000,
96 source: worldMode === 'live' ? 'replay' : worldMode as SavedHabitat['source'],
97 sourceName: worldMode === 'live' ? 'Saved live recording' : source.textContent ?? '', still: motion.checked,
98 ...owner.recording() };
99 const segment = archiveCurrent || owner.needsSegment ? owner.prepareSegment() : undefined;
100 savedRevision = await library.saveHabitat(segment ? { ...snapshot, ...segment.recording } : snapshot, savedRevision,
101 archiveCurrent ? snapshot : segment ? { ...snapshot, ...segment.archive } : undefined);
102 if (segment) {
103 segment.commit();
104 if (world === owner) { tape = owner.tape; interactions = [...owner.interactions]; seek.min = String(owner.startTimeMs / 1000); }
105 void refreshArchives().catch(() => {});
106 }
107 get('persistence').textContent = 'Habitat saved on this device. Earlier recordings remain available.';
108 return true;
109 }
110 catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Unable to save the habitat. Save a replay file to keep it.'; return false; }
111 }
112 async function mayLeave(): Promise<boolean> {
113 return await persist(true) || window.confirm('This visit could not be saved. Cancel to keep it and export a replay, or leave without saving its latest progress.');
114 }
115 function adoptImported(next: PetWorld, name: string) {
116 stopFollowing(); mode.value = 'replay';
117 imported = { world: next, name }; source.textContent = name;
118 mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false;
119 seek.max = String(Math.max(90, next.endTimeMs / 1000));
120 ++generation; silence(); adoptWorld(next);
121 }
122 get<HTMLSelectElement>('archive').onchange = async event => {
123 const picker = event.target as HTMLSelectElement, key = Number(picker.value); picker.value = '';
124 if (!key) return;
125 try {
126 const saved = await library.petArchive(key);
127 const next = PetWorld.fromRecording(points, { ...saved, petReplayVersion: saved.petReplayVersion ?? 1 });
128 if (!await mayLeave()) return;
129 adoptImported(next, saved.sourceName); message.textContent = 'Earlier recording opened. Its original remains in local storage.';
130 } catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to open this recording.'; }
131 };
132
133 function draw() {
134 if (!world) return;
135 const w = canvas.clientWidth, h = canvas.clientHeight, dpr = Math.min(2, devicePixelRatio || 1);
136 if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); }
137 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h);
138 const frame = world.frame, state = frame.state, sim = world.sim, l = layout(w, h - 65, state);
139 const t = motion.checked ? 0 : seconds;
140 ctx.lineWidth = 1;
141 for (let i = 0; i < 6; i++) {
142 ctx.strokeStyle = `rgba(98,169,190,${.025 + .016 * frame.caustic})`; ctx.beginPath();
143 for (let x = 0; x <= w; x += 8) { const y = h * .82 + Math.sin(x / 110 + i + t * .18) * 8 + i * 6; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke();
144 }
145 ctx.strokeStyle = '#24424f'; ctx.beginPath(); ctx.moveTo(24, h * (.08 + frame.surface * .008)); ctx.lineTo(w - 24, h * (.08 + frame.surface * .008)); ctx.stroke();
146 const f = sim.frame;
147 ctx.fillStyle = ctx.strokeStyle = `rgba(${Math.round(f.r)},${Math.round(f.g)},${Math.round(f.b)},${f.alpha})`;
148 for (const q of sim.p) {
149 ctx.beginPath(); ctx.arc(l.ox + q.x * l.scale * l.flipX, l.oy + q.y * l.scale, Math.max(.7, l.dot * .31), 0, Math.PI * 2);
150 if (state.observed < .92) ctx.stroke(); else ctx.fill();
151 }
152 if (frame.food) { ctx.fillStyle = `rgba(210,198,146,${frame.food.life})`; ctx.beginPath(); ctx.arc(w * (.5 + frame.food.x * .3), h * (.5 + frame.food.y * .3), 3, 0, Math.PI * 2); ctx.fill(); }
153 get('channel').textContent = `${f.channel} · ${f.arch}${state.observed < .92 ? ' · unobserved' : ''}`;
154 get('behaviour').textContent = `${frame.behaviour}${frame.needs !== 'none' ? ' · awaiting input' : ''}${frame.pod.length >= 3 ? ` · ${frame.pod.filter(p => p.present).length} peers present` : ''}`;
155 canvas.setAttribute('aria-label', `Dot whale: ${f.channel}, ${f.arch}, ${frame.behaviour}${state.observed < .92 ? ', telemetry unobserved' : ''}.`);
156 seek.value = String(seconds); get('clock').textContent = timeLabel(seconds); get('end').textContent = timeLabel(duration());
157 }
158 function interact(kind: PetInteraction['kind'], x = .2, y = -.15) {
159 if (restoring) return;
160 world.interact(kind, x, y); interactions = [...world.interactions];
161 }
162 canvas.addEventListener('pointerdown', event => { const r = canvas.getBoundingClientRect(); interact('attention', Math.max(-1, Math.min(1, (event.clientX - r.left) / r.width * 2 - 1)), Math.max(-1, Math.min(1, (event.clientY - r.top) / r.height * 2 - 1))); });
163 get('attention').onclick = () => interact('attention'); get('feed').onclick = () => interact('food');
164 get('pause').onclick = () => { paused = !paused; get('pause').textContent = paused ? 'Play' : 'Pause'; get('pause').setAttribute('aria-pressed', String(paused)); silence(); if (audio) anchor = audio.currentTime - seconds + .08; last = 0; };
165 get('sound').onclick = async () => {
166 try { if (!audio) audio = new AudioContext(); await audio.resume(); sound = !sound; silence(); anchor = audio.currentTime - seconds + .08;
167 get('sound').textContent = sound ? 'Sound on' : 'Sound off'; get('sound').setAttribute('aria-pressed', String(sound));
168 } catch { message.textContent = 'Audio is unavailable in this browser. The visual replay remains available.'; }
169 };
170 motion.onchange = () => rebuild(seconds, undefined, world.recording(false).start); seek.oninput = () => rebuild(Number(seek.value), undefined, world.recording(false).start);
171 mode.onchange = async () => {
172 if (!await mayLeave()) { mode.value = worldMode; return; }
173 stopFollowing();
174 if (mode.value === 'replay' && imported) {
175 tape = imported.world.tape; interactions = [...imported.world.interactions]; source.textContent = imported.name;
176 seek.max = String(Math.max(90, imported.world.endTimeMs / 1000));
177 ++generation; silence(); adoptWorld(imported.world);
178 message.textContent = 'Returned to the imported world at its current pose.'; return;
179 }
180 expressionVersion = 2;
181 tape = mode.value === 'demo' ? compilePetTelemetry(petDemoEvents(), 80_000) : [];
182 interactions = []; seek.max = mode.value === 'demo' ? '80' : '120';
183 source.textContent = mode.value === 'demo' ? 'Event demo · synthetic telemetry' : 'Wild · simulated creature'; rebuild();
184 message.textContent = mode.value === 'demo' ? 'Synthetic event-v1 telemetry uses the same derivation as imported traces.' : 'Wild mode is a simulated creature. Import event-v1, OTLP, or Codewhale telemetry to see work.';
185 };
186 get<HTMLInputElement>('file').onchange = async event => {
187 const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file) return;
188 try {
189 if (file.size > 64 * 1024 * 1024) throw new Error('Pet import exceeds 64 MiB.');
190 const text = await file.text(); let replay: unknown;
191 try { replay = JSON.parse(text); } catch { /* JSONL and trace imports follow below. */ }
192 let next: PetWorld;
193 if (replay && typeof replay === 'object' && 'petReplayVersion' in replay) next = PetWorld.fromRecording(points, replay);
194 else {
195 let nextTape: readonly PetBucket[];
196 const first = replay ?? JSON.parse(text.split(/\r?\n/).find(line => line.trim()) || '{}');
197 if (first && typeof first === 'object' && 'version' in first && first.version === 1 && 'simTimeMs' in first) nextTape = decodePetJSONL(text);
198 else {
199 const traces = importTrace(text, file.name, { privacy: 'metadata' });
200 if (traces.length !== 1) throw new Error('Choose one recording to import.');
201 nextTape = compilePetTelemetry(traces[0].events, traces[0].duration);
202 }
203 next = new PetWorld(points, nextTape, [], 2, true);
204 }
205 if (!await mayLeave()) return;
206 adoptImported(next, `Local replay · ${file.name}`);
207 message.textContent = 'Recording loaded locally, including its current pose, interactions and score.';
208 } catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to import this file.'; }
209 finally { input.value = ''; }
210 };
211 get('save').onclick = () => {
212 try {
213 const chunks: string[] = []; let bytes = 0;
214 for (let index = 0; ; index++) {
215 const chunk = world.recordingChunk(index); if (chunk === null) break;
216 bytes += new TextEncoder().encode(chunk).length;
217 if (bytes > 64 * 1024 * 1024) throw new Error('Recording exceeds the 64 MiB export limit. The current world was kept.');
218 chunks.push(chunk);
219 }
220 const blob = new Blob(chunks, { type: 'application/json' });
221 const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = 'codewhale-pet-replay.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
222 } catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to export the recording. The current world was kept.'; }
223 };
224 get('follow').onclick = async () => {
225 // A browser-granted read handle is the same local JSONL seam native hosts use.
226 // It is never persisted: reloading restores the accepted recording, not access.
227 const picker = (window as Window & { showOpenFilePicker?: () => Promise<{ getFile(): Promise<File> }[]> }).showOpenFilePicker;
228 if (!picker) { message.textContent = 'This browser cannot follow local files. Import a tape to replay it, or use the native pet for live telemetry.'; return; }
229 try {
230 const [handle] = await picker.call(window); if (!handle) return;
231 const file = await handle.getFile();
232 if (!await mayLeave()) return;
233 stopFollowing(); const ticket = liveGeneration;
234 mode.value = 'live'; paused = false; get('pause').textContent = 'Pause'; get('pause').setAttribute('aria-pressed', 'false');
235 get<HTMLButtonElement>('pause').disabled = true; seek.disabled = true;
236 expressionVersion = 2; tape = compilePetTelemetry([]); interactions = []; seek.max = '120';
237 source.textContent = `Live local tape · ${file.name}`; await rebuild();
238 const poll = async () => {
239 if (ticket !== liveGeneration) return;
240 if (document.hidden) { liveTimer = window.setTimeout(poll, 400); return; }
241 const observation = liveObservation;
242 try {
243 const current = await handle.getFile();
244 const tail = await current.slice(Math.max(0, current.size - 262_144)).text();
245 if (ticket !== liveGeneration) return;
246 if (restoring || document.hidden || observation !== liveObservation) { liveTimer = window.setTimeout(poll, 400); return; }
247 const packet = liveTape.readTail(tail);
248 if (packet) { world.acceptTelemetry(packet); tape = world.tape; }
249 message.textContent = 'Following local telemetry. Existing or unchanged input stays unobserved until new packets arrive.';
250 } catch { if (ticket === liveGeneration && observation === liveObservation) { liveTape.reset(); message.textContent = 'Local tape unavailable or invalid · unobserved. Select the file again if it was replaced.'; } }
251 if (ticket === liveGeneration) liveTimer = window.setTimeout(poll, 400);
252 };
253 await poll();
254 } catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) message.textContent = 'Unable to open this local tape.'; }
255 };
256 document.addEventListener('visibilitychange', () => {
257 last = 0; silence();
258 if (worldMode === 'live') {
259 liveObservation++;
260 liveTape.reset();
261 if (!document.hidden && world && !restoring) { world.resumeObservation(); seconds = world.frame.timeMs / 1000; }
262 }
263 void persist(); if (audio) anchor = audio.currentTime - seconds + .08;
264 });
265 window.addEventListener('pagehide', () => { void persist(); });
266 function animate(now: number) {
267 if (world && !restoring && !paused && !document.hidden) {
268 accumulator += last ? Math.min(.1, (now - last) / 1000) : 0;
269 while (accumulator >= 1 / 30 && (mode.value === 'live' || !tape.length || seconds < duration())) {
270 world.step(1 / 30, { motion: !motion.checked, sensitivity: 1 }); seconds = world.frame.timeMs / 1000; play(world.voices); accumulator -= 1 / 30;
271 }
272 if ((mode.value === 'live' || !tape.length) && seconds >= duration()) seek.max = String(Math.ceil(seconds / 30) * 30 + 30);
273 draw();
274 }
275 last = now; requestAnimationFrame(animate);
276 }
277 try {
278 const response = await fetch('./whale-points.tsv'); if (!response.ok) throw new Error('Whale point asset is unavailable.');
279 points = (await response.text()).trim().split('\n').map(row => row.trim().split(/\s+/).map(Number) as [number, number]);
280 if (points.length !== 980 || points.some(p => p.length !== 2 || !p.every(Number.isFinite))) throw new Error('Invalid whale point asset.');
281 try {
282 const saved = await library.getHabitat();
283 if (saved) {
284 savedRevision = saved.revision; const h = saved.habitat;
285 if (h.petPersistenceVersion !== 1 || !Number.isFinite(h.seconds) || h.seconds < 0 || h.seconds > PET_MAX_SECONDS
286 || !['wild', 'demo', 'replay'].includes(h.source) || typeof h.still !== 'boolean' || typeof h.sourceName !== 'string') throw new Error('Saved habitat is invalid. Save a replay file before replacing it.');
287 expressionVersion = h.expressionVersion === undefined ? 1 : h.expressionVersion;
288 if (![1, 2].includes(expressionVersion) || h.checkpoint && (h.checkpoint.sim?.expressionVersion ?? 1) !== expressionVersion) throw new Error('Saved expression version does not match its checkpoint.');
289 const savedWorld = PetWorld.fromRecording(points, { ...h, petReplayVersion: h.petReplayVersion === undefined ? 1 : h.petReplayVersion });
290 if (h.checkpoint && savedWorld.frame.timeMs / 1000 !== h.seconds) throw new Error('Saved pet clock does not match its checkpoint.');
291 tape = h.tape; interactions = [...h.interactions]; mode.value = h.source; motion.checked = h.still || media.matches;
292 source.textContent = h.sourceName;
293 seek.max = String(Math.max(h.source === 'demo' ? 80 : h.source === 'replay' ? 90 : 120, savedWorld.endTimeMs / 1000));
294 if (h.source === 'replay') {
295 mode.querySelector<HTMLOptionElement>('[value="replay"]')!.disabled = false;
296 }
297 message.textContent = 'Restoring the saved habitat…';
298 if (h.checkpoint && h.still === motion.checked) adoptWorld(savedWorld);
299 else await rebuild(h.seconds, undefined, savedWorld.recording(false).start);
300 message.textContent = 'Habitat restored locally. Sound starts only when you enable it.';
301 } else await rebuild();
302 persistenceReady = true; void refreshArchives().catch(() => {});
303 } catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Local persistence is unavailable.'; await rebuild(); }
304 void refreshArchives().catch(() => {});
305 setInterval(() => { void persist(); }, 5000); requestAnimationFrame(animate);
306 } catch (error) { message.textContent = error instanceof Error ? error.message : 'Unable to start the habitat.'; }
307
308 // Joining is a view switch; existing IndexedDB recordings are retained.
309 get<HTMLInputElement>('join-shared').onchange = async event => {
310 const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file) return;
311 try {
312 if (file.size > 4096) throw new Error('Invalid local connection file.');
313 const d = JSON.parse(await file.text());
314 if (d.version !== 1 || !Number.isSafeInteger(d.port) || d.port < 1 || d.port > 65535 || !/^[a-f0-9]{64}$/i.test(d.token)) throw new Error('Invalid local connection file.');
315 if (!await mayLeave()) return;
316 location.assign(`http://127.0.0.1:${d.port}/#${d.token}`);
317 } catch (e) { message.textContent = e instanceof Error ? e.message : 'Unable to attach.'; }
318 finally { input.value = ''; }
319 };
320
320 lines TYPESCRIPT