PageSourceSearch

https://assmagic2026.github.io/ass-magic/experiments/realism/whole…?v=story-audio-recovery-1

js assmagic2026.github.io collected 2026-10-03 08:40:14 UTC 155,492 bytes, 3,884 lines download raw bytes

1import * as THREE from "../../three.module.js";
2import { playlist } from "../../playlist.js?v=music-services-5";
3import { supabaseConfig } from "../../supabase-config.js";
4import {
5  EXPERIENCE_MODE,
6  EXPERIENCE_MODE_SELECTED_EVENT,
7} from "./experience-mode.js?v=mobile-fullscreen-1";
8
9const CHILL_AUDIO_URL = "./assets/audio/chill-mode.mp3";
10const BOOK_CACHE_KEY = "ass-magic-book-messages-v1";
11const BOOK_PLAYER_KEY = "ass-magic-book-player-v1";
12const BOOK_FETCH_PAGE_SIZE = 200;
13const BOOK_FETCH_MAX_ROWS = 5000;
14const HIDDEN_BOOK_AUTHOR = "__return_history__";
15const EXCLUDED_BOOK_NAMES = new Set([
16  "サーモンユッケ伯爵",
17  "揚げパン大王",
18  "道夫",
19  "トリケラトプさん",
20]);
21const CONTACTS = [
22  { id: "blackSphere", radius: 24 },
23  { id: "whiteSphere", radius: 24 },
24  { id: "recordPlayer", radius: 31 },
25  { id: "book", radius: 64 },
26  { id: "compass", radius: 17 },
27  // The sanctuary is a tall, radial structure: its upper rings and outer
28  // towers extend well beyond the group's surface anchor. Use a sphere that
29  // covers every physical part plus a small approach margin.
30  { id: "sanctuary", radius: 88 },
31  { id: "blackBox", radius: 8 },
32];
33const SPACE_RETURN_ALTITUDE = 150;
34const SPACE_RETURN_AXIS_DISTANCE = 260;
35const SPACE_RETURN_BEAM_RADIUS = 180;
36const EARTH_REVEAL_AXIS_DISTANCE = 680;
37const EARTH_CONTACT_DISTANCE = 135;
38const EARTH_BASE_SIZE = 170;
39const EARTH_GLOW_SIZE = 310;
40const SPACE_TRANSITION_RATE = 1.45;
41const LOCAL_Y_AXIS = new THREE.Vector3(0, 1, 0);
42const RETURN_HISTORY_PREFIX = "__return_history__:";
43const RETURN_HISTORY_STORAGE_KEY = "ass-magic-return-histories-v1";
44const ENDING_ROLL_DURATION = 46;
45const ENDING_WHITEOUT_DURATION = 1050;
46const ENDING_BLACK_DELAY = 650;
47const ENDING_TRUE_MESSAGE_DURATION = 2200;
48const ENDING_ROLL_AUDIO_DELAY = 2000;
49const CAT_ROUTE_JOIN_DURATION = 1.75;
50const CAT_ROUTE_ROTATION_RESPONSE = 7.2;
51const CAT_ROUTE_COMPANION_SCALE = 0.1512;
52const CAT_ROUTE_MOUNT_SIDE = 0.46;
53const CAT_ROUTE_MOUNT_HEIGHT = 0.13;
54const CAT_ROUTE_MOUNT_FORWARD = 0.62;
55const DEVIL_SPAWN_DELAY = 5;
56const DEVIL_ENTRY_DURATION = 1.2;
57const DEVIL_ENTRY_FORWARD = 20;
58const DEVIL_ENTRY_SIDE = 34;
59const DEVIL_SPAWN_DISTANCE = 50;
60const DEVIL_SPAWN_SIDE = 8;
61const DEVIL_SPAWN_HEIGHT = 3.1;
62const DEVIL_NOTICE_RADIUS = 30;
63// The full-planet experiment flies faster than the production globe, so cap fleeing
64// relative to that speed instead of making the encounter effectively unwinnable.
65const DEVIL_FLEE_FAST_GAP = -14;
66const DEVIL_FLEE_SLOW_GAP = 6;
67const DEVIL_FLEE_SPEED_PULSE = 1.15;
68const DEVIL_FLEE_ACCEL_SHARPNESS = 4.4;
69const DEVIL_FLEE_MIN_SPEED = 12;
70const DEVIL_FLEE_MAX_SPEED = 56;
71const DEVIL_FLEE_TURN_RESPONSE = 1.82;
72const DEVIL_FLEE_WEAVE_SPEED = 1.35;
73const DEVIL_FLEE_WEAVE_AMOUNT = 0.1;
74const DEVIL_TERRAIN_RESPONSE = 4.2;
75const CHILL_DEVIL_DELAY_MIN = 35;
76const CHILL_DEVIL_DELAY_MAX = 75;
77const CHILL_DEVIL_ROAM_SPEED = 9;
78const CHILL_DEVIL_ROAM_SPEED_VARIATION = 2.4;
79const CHILL_DEVIL_ROAM_TURN = 0.14;
80// Realistic terrain adds vertical variation, so use a more forgiving encounter radius.
81const DEVIL_CONTACT_RADIUS = 22;
82const DEVIL_ASSIST_CONTACT_DELAY = 5;
83const DEVIL_APPROACH_RESPONSE = 2.4;
84const MONOCHROME_CHALLENGE_DURATION = 30;
85const DEVIL_ROUTE_SPEED = 84;
86const DEVIL_ROUTE_MIN_DURATION = 6.5;
87const DEVIL_ROUTE_MAX_DURATION = 24;
88const DEVIL_ROUTE_EASE_PEAK = 1.875;
89const DEVIL_ROUTE_CLEARANCE = 18;
90const DEVIL_ROUTE_LEAD_DISTANCE = 48;
91const DEVIL_BLACK_BOX_ALTITUDE = 20;
92const DEVIL_BLACK_BOX_ROUTE_CLEARANCE = 26;
93const DEVIL_BLACK_BOX_ARRIVAL_LEAD = 3.2;
94const DEVIL_BLACK_BOX_WAIT_TIMEOUT = 16;
95const DEVIL_BLACK_BOX_CONTACT_RADIUS = 24;
96const DEVIL_ROUTE_PLANNER = Object.freeze({
97  low: { samples: 18, offsets: [0, -0.32, 0.32, -0.56, 0.56] },
98  standard: { samples: 26, offsets: [0, -0.2, 0.2, -0.4, 0.4, -0.64, 0.64] },
99  high: { samples: 36, offsets: [0, -0.16, 0.16, -0.32, 0.32, -0.52, 0.52, -0.74, 0.74] },
100});
101const DEVIL_DESTINATIONS = [
102  { id: "recordPlayer", label: "レコードプレイヤー", stopDistance: 31, endAltitude: 5, focusHeight: 5 },
103  { id: "book", label: "巨大な本", stopDistance: 48, endAltitude: 22, focusHeight: 34 },
104  { id: "whiteSphere", label: "白い球体", stopDistance: 30, endAltitude: 40, focusHeight: 0 },
105  { id: "blackSphere", label: "黒い球体", stopDistance: 30, endAltitude: 40, focusHeight: 0 },
106  { id: "compass", label: "羅針盤", stopDistance: 15, endAltitude: 24, focusHeight: 0 },
107  { id: "sanctuary", label: "太陽光式集光遠達装置", stopDistance: 72, endAltitude: 12, focusHeight: 18 },
108  { id: "blackBox", label: "高速移動する黒い箱", stopDistance: 15, endAltitude: DEVIL_BLACK_BOX_ALTITUDE, focusHeight: 2, special: "blackBox" },
109];
110const DEVIL_ESCAPE_COPY = "昼のエリアに黒い球、夜のエリアに白い球がある。\n\nどちらかの球に触れた後、30秒以内にもう片方の球に触れることで昼夜が逆転する。\n\nその後、白い球の近くの巨大な装置を起動させれば脱出の道標が現れる。";
111const DEVIL_DESTINATION_TRANSLATION_KEYS = Object.freeze({
112  recordPlayer: "runtime.destination.recordPlayer",
113  book: "runtime.destination.book",
114  whiteSphere: "runtime.destination.whiteSphere",
115  blackSphere: "runtime.destination.blackSphere",
116  compass: "runtime.destination.compass",
117  sanctuary: "runtime.destination.sanctuary",
118  blackBox: "runtime.destination.blackBox",
119});
120
121function createDevilModel() {
122  const devil = new THREE.Group();
123  devil.name = "DevilGuide";
124  const bodyMaterial = new THREE.MeshLambertMaterial({
125    color: 0x17191b,
126    emissive: 0xffffff,
127    emissiveIntensity: 0.038,
128    flatShading: true,
129  });
130  const hornMaterial = new THREE.MeshLambertMaterial({
131    color: 0x101112,
132    emissive: 0xffffff,
133    emissiveIntensity: 0.034,
134    flatShading: true,
135  });
136  const wingMaterial = new THREE.MeshLambertMaterial({
137    color: 0x131517,
138    emissive: 0xffffff,
139    emissiveIntensity: 0.036,
140    flatShading: true,
141    side: THREE.DoubleSide,
142  });
143  const eyeMaterial = new THREE.MeshLambertMaterial({
144    color: 0xffffff,
145    emissive: 0xffffff,
146    emissiveIntensity: 0.025,
147  });
148  const addSegment = (start, end, radius, material = bodyMaterial) => {
149    const direction = end.clone().sub(start);
150    const mesh = new THREE.Mesh(
151      new THREE.CapsuleGeometry(radius, Math.max(0.02, direction.length() - radius * 2), 3, 5),
152      material,
153    );
154    mesh.position.copy(start).add(end).multiplyScalar(0.5);
155    mesh.quaternion.setFromUnitVectors(LOCAL_Y_AXIS, direction.normalize());
156    devil.add(mesh);
157    return mesh;
158  };
159
160  const spine = [
161    new THREE.Vector3(0, -0.38, 0),
162    new THREE.Vector3(0, 0, 0.04),
163    new THREE.Vector3(0, 0.34, 0.24),
164    new THREE.Vector3(0, 0.62, 0.62),
165  ];
166  addSegment(spine[0], spine[1], 0.19);
167  addSegment(spine[1], spine[2], 0.205);
168  addSegment(spine[2], spine[3], 0.22);
169  const shoulders = new THREE.Mesh(new THREE.SphereGeometry(0.38, 7, 5), bodyMaterial);
170  shoulders.scale.set(1.38, 0.42, 0.62);
171  shoulders.position.set(0, 0.62, 0.62);
172  devil.add(shoulders);
173  const hips = new THREE.Mesh(new THREE.SphereGeometry(0.3, 7, 5), bodyMaterial);
174  hips.scale.set(0.9, 0.56, 0.58);
175  hips.position.y = -0.43;
176  devil.add(hips);
177  const tatteredWaist = new THREE.Mesh(new THREE.ConeGeometry(0.4, 0.86, 5, 1, true), bodyMaterial);
178  tatteredWaist.position.y = -0.72;
179  devil.add(tatteredWaist);
180  addSegment(new THREE.Vector3(0, 0.64, 0.64), new THREE.Vector3(0, 0.78, 0.9), 0.105);
181  const head = new THREE.Mesh(new THREE.SphereGeometry(0.29, 7, 6), bodyMaterial);
182  head.scale.set(0.78, 1.08, 0.82);
183  head.position.set(0, 0.88, 1.05);
184  head.rotation.x = 0.52;
185  devil.add(head);
186
187  const wingShape = new THREE.Shape();
188  wingShape.moveTo(0, 0.08);
189  wingShape.lineTo(0.72, 1.12);
190  wingShape.lineTo(1.42, 1.54);
191  wingShape.lineTo(2.25, 1.48);
192  wingShape.lineTo(1.82, 0.92);
193  wingShape.lineTo(2.5, 0.42);
194  wingShape.lineTo(1.72, 0.2);
195  wingShape.lineTo(2.18, -0.4);
196  wingShape.lineTo(1.28, -0.18);
197  wingShape.lineTo(1.02, -0.92);
198  wingShape.lineTo(0.56, -0.34);
199  wingShape.lineTo(0.14, -0.62);
200  wingShape.closePath();
201  const wingGeometry = new THREE.ShapeGeometry(wingShape, 1);
202  const wingRoots = [];
203
204  for (const side of [-1, 1]) {
205    const hornBase = new THREE.Mesh(new THREE.ConeGeometry(0.11, 0.52, 5), hornMaterial);
206    hornBase.position.set(side * 0.2, 1.07, 0.96);
207    hornBase.rotation.x = 0.24;
208    hornBase.rotation.z = side * -0.68;
209    devil.add(hornBase);
210    const hornTip = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.42, 5), hornMaterial);
211    hornTip.position.set(side * 0.42, 1.19, 0.97);
212    hornTip.rotation.x = 0.24;
213    hornTip.rotation.z = side * -1.16;
214    devil.add(hornTip);
215    const ear = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.3, 4), bodyMaterial);
216    ear.position.set(side * 0.31, 0.9, 1.02);
217    ear.rotation.x = 0.24;
218    ear.rotation.z = side * -1.32;
219    devil.add(ear);
220    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.032, 5, 3), eyeMaterial);
221    eye.scale.set(1.7, 0.48, 0.48);
222    eye.position.set(side * 0.095, 0.84, 1.3);
223    devil.add(eye);
224    const elbow = new THREE.Vector3(side * 0.7, 0.08, 0.72);
225    addSegment(new THREE.Vector3(side * 0.4, 0.58, 0.62), elbow, 0.075);
226    addSegment(elbow, new THREE.Vector3(side * 0.54, -0.4, 1), 0.058);
227    const elbowJoint = new THREE.Mesh(new THREE.SphereGeometry(0.085, 6, 4), bodyMaterial);
228    elbowJoint.position.copy(elbow);
229    devil.add(elbowJoint);
230    for (let clawIndex = -1; clawIndex <= 1; clawIndex += 1) {
231      const claw = new THREE.Mesh(new THREE.ConeGeometry(0.025, 0.27, 4), hornMaterial);
232      claw.position.set(
233        side * (0.54 + clawIndex * 0.035),
234        -0.53,
235        1.06 + clawIndex * 0.035,
236      );
237      claw.rotation.x = -0.45;
238      claw.rotation.z = side * 0.1;
239      devil.add(claw);
240    }
241    const knee = new THREE.Vector3(side * 0.34, -1.1, 0.34);
242    addSegment(new THREE.Vector3(side * 0.18, -0.42, 0.02), knee, 0.1);
243    addSegment(knee, new THREE.Vector3(side * 0.25, -1.68, -0.06), 0.072);
244    const kneeJoint = new THREE.Mesh(new THREE.SphereGeometry(0.11, 6, 4), bodyMaterial);
245    kneeJoint.position.copy(knee);
246    devil.add(kneeJoint);
247    const talon = new THREE.Mesh(new THREE.ConeGeometry(0.09, 0.4, 5), hornMaterial);
248    talon.position.set(side * 0.27, -1.91, 0.13);
249    talon.rotation.x = Math.PI * 0.5;
250    devil.add(talon);
251    const wingRoot = new THREE.Group();
252    wingRoot.position.set(side * 0.28, 0.58, 0.48);
253    wingRoot.rotation.x = 0.48;
254    const wing = new THREE.Mesh(wingGeometry, wingMaterial);
255    wing.scale.x = side;
256    wingRoot.add(wing);
257    wingRoots.push({ root: wingRoot, side });
258    devil.add(wingRoot);
259  }
260
261  const tailCurve = new THREE.CatmullRomCurve3([
262    new THREE.Vector3(0.06, -0.52, -0.2),
263    new THREE.Vector3(0.72, -0.82, -0.42),
264    new THREE.Vector3(1.05, -0.28, -0.62),
265    new THREE.Vector3(0.78, 0.22, -0.72),
266  ]);
267  devil.add(new THREE.Mesh(new THREE.TubeGeometry(tailCurve, 12, 0.035, 4, false), bodyMaterial));
268  const tailTip = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.3, 3), hornMaterial);
269  tailTip.position.set(0.79, 0.3, -0.73);
270  tailTip.rotation.z = -0.42;
271  devil.add(tailTip);
272  const visibilityGlow = new THREE.Sprite(new THREE.SpriteMaterial({
273    map: createGlowTexture(),
274    color: 0xffffff,
275    transparent: true,
276    opacity: 0.016,
277    blending: THREE.AdditiveBlending,
278    depthWrite: false,
279    depthTest: true,
280    toneMapped: false,
281    fog: false,
282  }));
283  visibilityGlow.position.set(0, 0.18, 0.28);
284  visibilityGlow.scale.setScalar(7.4);
285  visibilityGlow.renderOrder = 3;
286  const visibilityLight = new THREE.PointLight(0xffffff, 26, 28, 1.9);
287  visibilityLight.position.set(0, 0.24, 0.42);
288  visibilityLight.castShadow = false;
289  devil.add(visibilityGlow, visibilityLight);
290  devil.userData.wingRoots = wingRoots;
291  devil.userData.visibilityGlow = visibilityGlow;
292  devil.userData.visibilityLight = visibilityLight;
293  devil.userData.emissiveMaterials = [
294    { material: bodyMaterial, nightIntensity: 0.038 },
295    { material: hornMaterial, nightIntensity: 0.034 },
296    { material: wingMaterial, nightIntensity: 0.036 },
297    { material: eyeMaterial, nightIntensity: 0.025 },
298  ];
299  devil.scale.setScalar(0.62);
300  devil.visible = false;
301  return devil;
302}
303
304function resolveRootAsset(path) {
305  if (!path) return "";
306  const resolvedUrl = new URL(path, new URL("../../", import.meta.url)).href;
307  return window.assSiteProfile?.resolveAssetUrl?.(resolvedUrl) ?? resolvedUrl;
308}
309
310// Dynamic experience UI must resolve copy while it is created. The helper is
311// deliberately display-only: it never changes routes, state, or event flow.
312function t(key, fallback) {
313  try {
314    return window.assI18n?.text?.(key, fallback) ?? fallback;
315  } catch (error) {
316    return fallback;
317  }
318}
319
320function setLocalizedText(element, key, fallback) {
321  if (!element) return;
322  element.dataset.assI18n = key;
323  element.textContent = t(key, fallback);
324}
325
326function setLocalizedAriaLabel(element, key, fallback) {
327  if (!element) return;
328  element.dataset.assI18nAriaLabel = key;
329  element.setAttribute("aria-label", t(key, fallback));
330}
331
332function getDevilDestinationLabel(destination, blackBoxOpened = false) {
333  if (destination?.id === "blackBox" && blackBoxOpened) {
334    return t("runtime.destination.blackBoxOpened", "黒い箱");
335  }
336  return t(DEVIL_DESTINATION_TRANSLATION_KEYS[destination?.id], destination?.label || "");
337}
338
339function createButton(label, className = "") {
340  const button = document.createElement("button");
341  button.type = "button";
342  button.className = className;
343  button.textContent = label;
344  return button;
345}
346
347function normalizeBookEntry(entry) {
348  if (!entry || typeof entry.message !== "string") return null;
349  const name = typeof entry.name === "string" && entry.name.trim()
350    ? entry.name.trim()
351    : "anonymous";
352  if (name === HIDDEN_BOOK_AUTHOR || EXCLUDED_BOOK_NAMES.has(name)) return null;
353  return {
354    id: String(entry.id ?? ""),
355    name,
356    message: entry.message,
357    createdAt: String(entry.created_at ?? entry.createdAt ?? ""),
358  };
359}
360
361function formatBookDate(value) {
362  const date = new Date(value);
363  if (Number.isNaN(date.getTime())) return "";
364  const locale = window.assI18n?.getLanguage?.() === "en" ? "en-US" : "ja-JP";
365  return date.toLocaleDateString(locale, {
366    month: "numeric",
367    day: "numeric",
368    hour: "2-digit",
369    minute: "2-digit",
370  });
371}
372
373function createEarthTexture() {
374  const size = 512;
375  const canvas = document.createElement("canvas");
376  canvas.width = size;
377  canvas.height = size;
378  const context = canvas.getContext("2d");
379  const fallback = context.createRadialGradient(196, 174, 20, 256, 256, 248);
380  fallback.addColorStop(0, "#a9ddff");
381  fallback.addColorStop(0.46, "#287db4");
382  fallback.addColorStop(0.86, "#0a2c61");
383  fallback.addColorStop(1, "rgba(4,16,45,0)");
384  context.fillStyle = fallback;
385  context.fillRect(0, 0, size, size);
386  const texture = new THREE.CanvasTexture(canvas);
387  texture.colorSpace = THREE.SRGBColorSpace;
388  const image = new Image();
389  image.decoding = "async";
390  image.onload = () => {
391    const crop = Math.min(image.width, image.height) * 0.92;
392    const sourceX = (image.width - crop) * 0.5;
393    const sourceY = (image.height - crop) * 0.49;
394    context.clearRect(0, 0, size, size);
395    context.save();
396    context.beginPath();
397    context.arc(size * 0.5, size * 0.5, size * 0.492, 0, Math.PI * 2);
398    context.clip();
399    context.drawImage(image, sourceX, sourceY, crop, crop, 0, 0, size, size);
400    context.restore();
401    context.globalCompositeOperation = "destination-in";
402    const mask = context.createRadialGradient(256, 256, 218, 256, 256, 256);
403    mask.addColorStop(0, "rgba(255,255,255,1)");
404    mask.addColorStop(0.94, "rgba(255,255,255,1)");
405    mask.addColorStop(1, "rgba(255,255,255,0)");
406    context.fillStyle = mask;
407    context.fillRect(0, 0, size, size);
408    context.globalCompositeOperation = "source-over";
409    texture.needsUpdate = true;
410  };
411  image.src = resolveRootAsset("./earth.jpg");
412  return texture;
413}
414
415function createGlowTexture() {
416  const canvas = document.createElement("canvas");
417  canvas.width = 128;
418  canvas.height = 128;
419  const context = canvas.getContext("2d");
420  const gradient = context.createRadialGradient(64, 64, 8, 64, 64, 62);
421  gradient.addColorStop(0, "rgba(190,225,255,0.65)");
422  gradient.addColorStop(0.42, "rgba(92,164,255,0.22)");
423  gradient.addColorStop(1, "rgba(50,120,255,0)");
424  context.fillStyle = gradient;
425  context.fillRect(0, 0, 128, 128);
426  return new THREE.CanvasTexture(canvas);
427}
428
429function createEarthReturnVisual() {
430  const earth = new THREE.Sprite(new THREE.SpriteMaterial({
431    map: createEarthTexture(),
432    transparent: true,
433    opacity: 0,
434    depthTest: true,
435    depthWrite: false,
436    fog: false,
437    toneMapped: false,
438  }));
439  const glow = new THREE.Sprite(new THREE.SpriteMaterial({
440    map: createGlowTexture(),
441    color: 0xb9dcff,
442    transparent: true,
443    opacity: 0,
444    blending: THREE.AdditiveBlending,
445    depthTest: true,
446    depthWrite: false,
447    fog: false,
448    toneMapped: false,
449  }));
450  earth.scale.setScalar(EARTH_BASE_SIZE);
451  glow.scale.setScalar(EARTH_GLOW_SIZE);
452  earth.visible = false;
453  glow.visible = false;
454  earth.frustumCulled = false;
455  glow.frustumCulled = false;
456  earth.renderOrder = 1;
457  glow.renderOrder = 0;
458  return { earth, glow };
459}
460
461function createSpaceStars() {
462  const count = 72;
463  const positions = new Float32Array(count * 3);
464  let seed = 43891;
465  const random = () => {
466    seed = Math.imul(seed ^ (seed >>> 15), 1 | seed);
467    seed ^= seed + Math.imul(seed ^ (seed >>> 7), 61 | seed);
468    return ((seed ^ (seed >>> 14)) >>> 0) / 4294967296;
469  };
470  for (let index = 0; index < count; index += 1) {
471    const angle = random() * Math.PI * 2;
472    const radius = 90 + random() * 380;
473    positions[index * 3] = Math.cos(angle) * radius;
474    positions[index * 3 + 1] = 240 + random() * 3800;
475    positions[index * 3 + 2] = Math.sin(angle) * radius;
476  }
477  const geometry = new THREE.BufferGeometry();
478  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
479  const material = new THREE.PointsMaterial({
480    color: 0xf7fbff,
481    size: 3.2,
482    transparent: true,
483    opacity: 0,
484    depthWrite: false,
485    fog: false,
486    sizeAttenuation: true,
487  });
488  const stars = new THREE.Points(geometry, material);
489  stars.visible = false;
490  stars.frustumCulled = false;
491  return stars;
492}
493
494// This is the same rear-view companion silhouette used by the production flight.
495// It stays lightweight because it is attached to the moving player every frame.
496function createCatCompanionVisual() {
497  const group = new THREE.Group();
498  group.name = "CatRouteCompanion";
499  const bodyMaterial = new THREE.MeshStandardMaterial({ color: 0xf7f4ed, roughness: 0.96 });
500  const shadeMaterial = new THREE.MeshStandardMaterial({ color: 0xded8cf, roughness: 1 });
501  const brownMaterial = new THREE.MeshStandardMaterial({ color: 0x684432, roughness: 0.98 });
502  const darkBrownMaterial = new THREE.MeshStandardMaterial({ color: 0x3a251e, roughness: 0.98 });
503  const innerEarMaterial = new THREE.MeshStandardMaterial({ color: 0xe8bdb8, roughness: 0.92 });
504  const eyeMaterial = new THREE.MeshStandardMaterial({
505    color: 0xb8c96c,
506    emissive: 0x33451b,
507    emissiveIntensity: 0.24,
508    roughness: 0.4,
509  });
510  const pupilMaterial = new THREE.MeshBasicMaterial({ color: 0x090806, toneMapped: false });
511  const noseMaterial = new THREE.MeshStandardMaterial({ color: 0x5a302f, roughness: 0.72 });
512
513  const body = new THREE.Mesh(new THREE.SphereGeometry(1.05, 22, 16), bodyMaterial);
514  body.scale.set(0.92, 0.66, 1.78);
515  body.position.set(0, 0.94, -0.08);
516  group.add(body);
517
518  const shoulders = new THREE.Mesh(new THREE.SphereGeometry(0.86, 20, 14), shadeMaterial);
519  shoulders.scale.set(0.86, 0.62, 0.96);
520  shoulders.position.set(0, 1.05, 0.86);
521  group.add(shoulders);
522
523  for (const side of [-1, 1]) {
524    const haunch = new THREE.Mesh(new THREE.SphereGeometry(0.6, 16, 12), shadeMaterial);
525    haunch.scale.set(0.78, 0.72, 1.08);
526    haunch.position.set(side * 0.38, 0.88, -1.14);
527    group.add(haunch);
528  }
529
530  const backPatch = new THREE.Mesh(new THREE.SphereGeometry(0.62, 16, 12), brownMaterial);
531  backPatch.scale.set(0.94, 0.28, 1.36);
532  backPatch.position.set(0.12, 1.58, -0.52);
533  backPatch.rotation.z = -0.08;
534  group.add(backPatch);
535
536  const head = new THREE.Mesh(new THREE.SphereGeometry(0.68, 22, 16), bodyMaterial);
537  head.scale.set(0.86, 0.74, 0.8);
538  head.position.set(0, 1.13, 1.72);
539  group.add(head);
540
541  for (const side of [-1, 1]) {
542    const ear = new THREE.Mesh(new THREE.ConeGeometry(0.24, 0.48, 10), brownMaterial);
543    ear.position.set(side * 0.29, 1.55, 1.86);
544    ear.rotation.set(-0.1, 0, side * -0.2);
545    group.add(ear);
546    const earInner = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.29, 10), innerEarMaterial);
547    earInner.position.set(side * 0.29, 1.51, 1.91);
548    earInner.rotation.copy(ear.rotation);
549    group.add(earInner);
550
551    const hindLeg = new THREE.Mesh(new THREE.CapsuleGeometry(0.16, 0.74, 5, 10), shadeMaterial);
552    hindLeg.position.set(side * 0.34, 0.72, -1.62);
553    hindLeg.rotation.x = Math.PI * 0.5;
554    hindLeg.rotation.z = side * -0.16;
555    group.add(hindLeg);
556
557    const frontLeg = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 0.9, 5, 10), bodyMaterial);
558    frontLeg.position.set(side * 0.28, 0.82, 2.04);
559    frontLeg.rotation.x = Math.PI * 0.5;
560    frontLeg.rotation.z = side * -0.08;
561    group.add(frontLeg);
562
563    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.105, 12, 8), eyeMaterial);
564    eye.scale.set(1, 0.82, 0.42);
565    eye.position.set(side * 0.21, 1.21, 2.23);
566    group.add(eye);
567    const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.052, 10, 7), pupilMaterial);
568    pupil.scale.set(0.55, 1.15, 0.36);
569    pupil.position.set(side * 0.21, 1.21, 2.275);
570    group.add(pupil);
571
572    const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 8), bodyMaterial);
573    muzzle.scale.set(1.08, 0.72, 0.68);
574    muzzle.position.set(side * 0.11, 1.02, 2.27);
575    group.add(muzzle);
576  }
577
578  const nose = new THREE.Mesh(new THREE.SphereGeometry(0.085, 10, 8), noseMaterial);
579  nose.scale.set(1.15, 0.72, 0.62);
580  nose.position.set(0, 1.04, 2.39);
581  group.add(nose);
582
583  const tailRoot = new THREE.Group();
584  tailRoot.position.set(0.38, 1.05, -1.62);
585  tailRoot.rotation.set(-1.02, 0.08, -0.38);
586  group.add(tailRoot);
587  const tailJoints = [];
588  let tailParent = tailRoot;
589  const tailLengths = [0.58, 0.58, 0.56, 0.52, 0.46, 0.4];
590  for (let index = 0; index < tailLengths.length; index += 1) {
591    const length = tailLengths[index];
592    const radius = THREE.MathUtils.lerp(0.25, 0.14, index / (tailLengths.length - 1));
593    const joint = new THREE.Group();
594    tailParent.add(joint);
595    const segment = new THREE.Mesh(
596      new THREE.CapsuleGeometry(radius, length, 6, 12),
597      index % 3 === 2 ? darkBrownMaterial : brownMaterial,
598    );
599    segment.position.y = length * 0.5;
600    joint.add(segment);
601    const fluff = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.08, 12, 8), segment.material);
602    fluff.scale.set(1.06, 1.28, 1.06);
603    fluff.position.y = length;
604    joint.add(fluff);
605    tailJoints.push(joint);
606    const next = new THREE.Group();
607    next.position.y = length;
608    joint.add(next);
609    tailParent = next;
610  }
611  group.userData.tailRoot = tailRoot;
612  group.userData.tailJoints = tailJoints;
613  group.scale.setScalar(CAT_ROUTE_COMPANION_SCALE);
614  group.visible = false;
615  group.frustumCulled = false;
616  return group;
617}
618
619export function createWholePlanetExperience({
620  canvas,
621  scene,
622  camera,
623  flight,
624  playerObject = null,
625  landmarks,
626  getAltitude,
627  getSurfaceRadius,
628  isEnvironmentPhasing = null,
629  isSkating = () => false,
630  getExperienceMode = () => EXPERIENCE_MODE.MAIN,
631  quality = "standard",
632  onGuideSpeedChange,
633  onWorldInversion,
634}) {
635  const musicRoot = document.querySelector("#experience-music");
636  const artLink = document.querySelector("#experience-art-link");
637  const art = document.querySelector("#experience-art");
638  const title = document.querySelector("#experience-track-title");
639  const playButton = document.querySelector("#experience-play");
640  const nextButton = document.querySelector("#experience-next");
641  const lyricsButton = document.querySelector("#experience-lyrics-toggle");
642  const lyricsPanel = document.querySelector("#experience-lyrics");
643  const lyricsText = document.querySelector("#experience-lyrics-text");
644  const lyricsClose = document.querySelector("#experience-lyrics-close");
645  const overlay = document.querySelector("#experience-overlay");
646  const overlayTitle = document.querySelector("#experience-modal-title");
647  const overlayBody = document.querySelector("#experience-modal-body");
648  const overlayClose = document.querySelector("#experience-modal-close");
649  const toast = document.querySelector("#experience-toast");
650  const audio = document.querySelector("#experience-audio");
651  const phaseAudioEngine = window.__realismPhaseAudioEngine || null;
652  const isMainExperience = () => getExperienceMode() === EXPERIENCE_MODE.MAIN;
653  const isChillExperience = () => getExperienceMode() === EXPERIENCE_MODE.CHILL;
654  const bookOverlay = document.querySelector("#book-overlay");
655  const bookBackdrop = document.querySelector("#book-backdrop");
656  const bookClose = document.querySelector("#book-close");
657  const bookViews = [...document.querySelectorAll(".book-view")];
658  const bookViewButtons = [...document.querySelectorAll(".book-mode-btn")];
659  const bookMessagePage = document.querySelector("#book-message-page");
660  const bookNextPage = document.querySelector("#book-next-page");
661  const bookForm = document.querySelector("#book-form");
662  const bookName = document.querySelector("#book-name");
663  const bookMessageInput = document.querySelector("#book-message-input");
664  const bookSubmit = document.querySelector("#book-submit");
665  const bookStatus = document.querySelector("#book-status");
666  const blackBoxOverlay = document.querySelector("#black-box-overlay");
667  const blackBoxBackdrop = document.querySelector("#black-box-backdrop");
668  const blackBoxClose = document.querySelector("#black-box-close");
669  const blackBoxOpen = document.querySelector("#black-box-open");
670  const blackBoxIgnore = document.querySelector("#black-box-ignore");
671  const blackBoxTitle = document.querySelector("#black-box-title");
672  const blackBoxViews = [...document.querySelectorAll(".black-box-view")];
673  const musicSelectorOverlay = document.querySelector("#music-selector-overlay");
674  const musicSelectorBackdrop = document.querySelector("#music-selector-backdrop");
675  const musicSelectorClose = document.querySelector("#music-selector-close");
676  const musicSelectorNowPlaying = document.querySelector("#music-selector-now-playing");
677  const musicSelectorList = document.querySelector("#music-selector-list");
678  const endingWhiteout = document.querySelector("#ending-whiteout");
679  const endingOverlay = document.querySelector("#ending-overlay");
680  const endingRollTrack = document.querySelector("#ending-roll-track");
681  const endingRestart = document.querySelector("#ending-restart-trigger");
682  const endingReturnees = document.querySelector("#ending-returnees-list");
683  const endingTrueReturnees = document.querySelector("#ending-true-returnees-list");
684  const endingLastCredit = endingTrueReturnees?.closest(".ending-roll-credit") || null;
685  // Safari grants autoplay permission per media element. Keep every story
686  // transition on one persistent element and switch only its source, rather
687  // than asking the browser to authorize four independent Audio instances.
688  const clockAudio = Object.freeze({
689    name: "monochrome",
690    src: resolveRootAsset("./振り子時計(エコー入り).mp3"),
691    loop: true,
692    volume: 0.48,
693  });
694  const spaceReturnAudio = Object.freeze({
695    name: "space-return",
696    src: resolveRootAsset("./死後の世界.mp3"),
697    loop: true,
698    volume: 0.78,
699  });
700  const earthArrivalAudio = Object.freeze({
701    name: "earth-arrival",
702    src: resolveRootAsset("./過去を思い出す.mp3"),
703    loop: false,
704    volume: 0.9,
705  });
706  const endingAudio = Object.freeze({
707    name: "ending-roll",
708    src: resolveRootAsset("./森の羊水 piano 1 2.m4a"),
709    loop: false,
710    volume: 0.72,
711  });
712  const storyAudio = new Audio();
713  storyAudio.preload = "auto";
714  storyAudio.playsInline = true;
715  storyAudio.crossOrigin = "anonymous";
716  const chillAudio = new Audio(new URL(CHILL_AUDIO_URL, import.meta.url).href);
717  chillAudio.loop = true;
718  chillAudio.preload = "auto";
719  chillAudio.volume = 0.82;
720  chillAudio.playsInline = true;
721  chillAudio.crossOrigin = "anonymous";
722  canvas.dataset.chillAudioLoop = String(chillAudio.loop);
723  chillAudio.addEventListener("loadedmetadata", () => {
724    canvas.dataset.chillAudioDuration = Number.isFinite(chillAudio.duration)
725      ? chillAudio.duration.toFixed(2)
726      : "unknown";
727  });
728  const challengeFlash = document.querySelector("#experience-theme-flash");
729  const challengeTimer = document.querySelector("#experience-challenge-timer");
730
731  const tracks = playlist
732    .filter((track) => !track.disabled)
733    .map((track) => ({
734      ...track,
735      lyricsPath: track.lyricsPath ?? (typeof track.src === "string"
736        ? track.src.replace(/\/[^/]+\.(?:mp3|wav|m4a)$/i, "/lyrics.txt")
737        : null),
738    }));
739  let currentTrackIndex = Math.max(0, tracks.findIndex((track) => track.initial));
740  let randomTrackQueue = [];
741  let audioUnlocked = false;
742  let musicGesturePending = false;
743  let lyricsRequest = 0;
744  let toastTimer = 0;
745  let closeAction = null;
746  let activeNativeOverlay = null;
747  let bookPageIndex = 0;
748  let bookPages = [];
749  let endingRollStart = 0;
750  let endingStartedAt = 0;
751  let endingAudioTimer = 0;
752  let endingWhiteoutTimer = 0;
753  let endingBlackTimer = 0;
754  let challengeMusicWasPlaying = false;
755  let activeStoryAudio = null;
756  let pendingStoryAudio = null;
757  let compassAssist = 0;
758  let compassAssistTarget = null;
759  const contactState = new Map();
760  const worldPosition = new THREE.Vector3();
761  const targetPosition = new THREE.Vector3();
762  const targetDirection = new THREE.Vector3();
763  const playerUp = new THREE.Vector3();
764  const targetTangent = new THREE.Vector3();
765  const beamOffset = new THREE.Vector3();
766  const beamClosestPoint = new THREE.Vector3();
767  const radialUp = new THREE.Vector3();
768  const projectedUp = new THREE.Vector3();
769  const projectedForward = new THREE.Vector3();
770  const devilUp = new THREE.Vector3();
771  const devilRight = new THREE.Vector3();
772  const devilForward = new THREE.Vector3();
773  const devilTarget = new THREE.Vector3();
774  const devilNdc = new THREE.Vector3();
775  const devilBasis = new THREE.Matrix4();
776  const devilAway = new THREE.Vector3();
777  const devilDesired = new THREE.Vector3();
778  const devilTravelAxis = new THREE.Vector3();
779  const devilEvadeRight = new THREE.Vector3();
780  const devilEntryInward = new THREE.Vector3();
781  const devilDayDirection = landmarks.directions.day.clone().normalize();
782  const devilRouteDirection = new THREE.Vector3();
783  const devilRouteForward = new THREE.Vector3();
784  const devilRouteTowardStart = new THREE.Vector3();
785  const devilBlackBoxPreviousPosition = new THREE.Vector3();
786  const devilBlackBoxSegment = new THREE.Vector3();
787  const devilBlackBoxOffset = new THREE.Vector3();
788  const devilBlackBoxClosestPoint = new THREE.Vector3();
789  const devilPreviousAnchor = new THREE.Vector3();
790  const devilRelativeStart = new THREE.Vector3();
791  const devilRelativeEnd = new THREE.Vector3();
792  const devilRelativeDelta = new THREE.Vector3();
793  const devilClosestApproach = new THREE.Vector3();
794  const catSourceUp = new THREE.Vector3();
795  const catMountUp = new THREE.Vector3();
796  const catMountForward = new THREE.Vector3();
797  const catMountRight = new THREE.Vector3();
798  const catDesiredPosition = new THREE.Vector3();
799  const catTargetQuaternion = new THREE.Quaternion();
800  const catBasis = new THREE.Matrix4();
801  const earthVisual = createEarthReturnVisual();
802  const spaceStars = createSpaceStars();
803  const devilModel = createDevilModel();
804  const catCompanion = createCatCompanionVisual();
805  scene.add(earthVisual.glow, earthVisual.earth, spaceStars, devilModel, catCompanion);
806  const state = {
807    phase: "idle",
808    challengeStart: null,
809    challengeTimeRemaining: 0,
810    worldInverted: false,
811    blackBoxOpened: false,
812    catFound: false,
813    catRouteAvailable: false,
814    catJoinPhase: "idle",
815    catJoinElapsed: 0,
816    catFollowing: false,
817    catPendingJoin: false,
818    catTailTime: 0,
819    catJoinStart: new THREE.Vector3(),
820    catPosition: new THREE.Vector3(),
821    reachedEarthWithCat: false,
822    returnRecorded: false,
823    modalOpen: false,
824    beamOrigin: new THREE.Vector3(),
825    beamDirection: new THREE.Vector3(),
826    earthPosition: new THREE.Vector3(),
827    spaceUp: new THREE.Vector3(),
828    spaceFlightActive: false,
829    spaceTransition: 0,
830    sanctuaryStartAltitude: 0,
831    earthApproachStartDistance: 0,
832    ending: false,
833    debugForceSpace: false,
834    devil: {
835      phase: "delay",
836      timer: new URLSearchParams(window.location.search).get("devildebug") === "1" ? 0.4 : DEVIL_SPAWN_DELAY,
837      fleeTime: 0,
838      encounterTime: 0,
839      outOfViewTime: 0,
840      hasBeenVisible: false,
841      lastSide: -1,
842      route: null,
843      entryTime: 0,
844      noticeGrace: 0,
845      flightRadius: 0,
846      flightForward: new THREE.Vector3(),
847      anchorPosition: new THREE.Vector3(),
848      anchorUp: new THREE.Vector3(0, 1, 0),
849      lastPlayerPosition: new THREE.Vector3(),
850      skateSuppressed: false,
851      bobTime: 0,
852      evadeWave: 0,
853      chillRoamInitialized: false,
854      chillRoamDebug: false,
855      chillRoamTime: 0,
856      chillRoamSeed: 0,
857      navigation: {
858        destination: null,
859        elapsed: 0,
860        duration: 0,
861        timeout: 0,
862        startAltitude: 0,
863        endAltitude: 0,
864        arcAngle: 0,
865        waitElapsed: 0,
866        interceptSeconds: 0,
867        savedSpeedSelection: 40,
868        savedSpeed: 40,
869        startDirection: new THREE.Vector3(),
870        endDirection: new THREE.Vector3(),
871        routeAxis: new THREE.Vector3(),
872        focusPoint: new THREE.Vector3(),
873        pathDirections: [],
874        pathRadii: [],
875        pathCumulative: [],
876        pathLength: 0,
877        previousPathDistance: 0,
878        routeRisk: 0,
879        plannedSpeed: DEVIL_ROUTE_SPEED,
880      },
881    },
882  };
883
884  const stopUiPropagation = (event) => event.stopPropagation();
885  for (const element of [
886    musicRoot,
887    lyricsPanel,
888    overlay,
889    bookOverlay,
890    blackBoxOverlay,
891    musicSelectorOverlay,
892    endingOverlay,
893  ]) {
894    element?.addEventListener("pointerdown", stopUiPropagation);
895    element?.addEventListener("click", stopUiPropagation);
896  }
897
898  function getTrack() {
899    return tracks[currentTrackIndex] || tracks[0];
900  }
901
902  const musicServices = [
903    {
904      id: "appleMusic",
905      urlKey: "appleMusicUrl",
906      label: "Apple Musicで聴く",
907    },
908    {
909      id: "spotify",
910      urlKey: "spotifyUrl",
911      label: "Spotifyで聴く",
912    },
913  ];
914  const MUSIC_ARTIST_NAME = "ASS MAGIC";
915
916  function getAvailableMusicServices(track = getTrack()) {
917    return musicServices.filter((service) => (
918      typeof track?.[service.urlKey] === "string"
919      && track[service.urlKey].trim().length > 0
920    ));
921  }
922
923  function openMusicService(url, service) {
924    if (!isMainExperience() || typeof url !== "string" || !url.trim()) return false;
925    window.open(url, "_blank", "noopener,noreferrer");
926    canvas.dataset.musicServiceLastOpen = service;
927    canvas.dataset.musicServiceLastUrl = url;
928    return true;
929  }
930
931  function syncMusicServiceUi(track = getTrack()) {
932    if (!track) return;
933    const available = getAvailableMusicServices(track);
934    const enabled = isMainExperience() && available.length > 0;
935    if (artLink) {
936      artLink.disabled = !enabled;
937      artLink.setAttribute("aria-label", `${t("runtime.continueListening", "好きなサービスで続きを聴く")}: ${track.title}`);
938      artLink.classList.toggle("is-service-enabled", enabled);
939    }
940    canvas.dataset.musicServiceTrack = track.title;
941    canvas.dataset.musicServiceAvailable = available.map((service) => service.id).join(",") || "none";
942
943    if (!overlay?.classList.contains("is-music-service")) return;
944    if (!enabled) {
945      closeModal();
946      return;
947    }
948    const modalArt = overlayBody?.querySelector("[data-music-service-art]");
949    const modalTrackTitle = overlayBody?.querySelector("[data-music-service-title]");
950    const modalArtist = overlayBody?.querySelector("[data-music-service-artist]");
951    if (overlayTitle) setLocalizedText(overlayTitle, "runtime.servicePicker", "配信先を選ぶ");
952    if (modalArt instanceof HTMLImageElement) {
953      modalArt.src = resolveRootAsset(track.art);
954      modalArt.alt = t("music.artAlt", "再生中のジャケット");
955    }
956    if (modalTrackTitle) modalTrackTitle.textContent = track.title;
957    if (modalArtist) modalArtist.textContent = MUSIC_ARTIST_NAME;
958    for (const button of overlayBody?.querySelectorAll("[data-music-service]") || []) {
959      const service = musicServices.find((entry) => entry.id === button.dataset.musicService);
960      const visible = Boolean(service && track[service.urlKey]);
961      button.hidden = !visible;
962      button.disabled = !visible;
963    }
964  }
965
966  function openMusicServiceModal() {
967    const track = getTrack();
968    if (!isMainExperience() || !track || !getAvailableMusicServices(track).length) return;
969    overlay?.classList.add("is-music-service");
970    openModal(t("runtime.servicePicker", "配信先を選ぶ"), (container) => {
971      const release = document.createElement("div");
972      release.className = "music-service-release";
973
974      const artworkFrame = document.createElement("div");
975      artworkFrame.className = "music-service-artwork-frame";
976      const artwork = document.createElement("img");
977      artwork.className = "music-service-artwork";
978      artwork.dataset.musicServiceArt = "";
979      artwork.src = resolveRootAsset(track.art);
980      artwork.alt = t("music.artAlt", "再生中のジャケット");
981      artwork.decoding = "async";
982      artworkFrame.append(artwork);
983
984      const releaseCopy = document.createElement("div");
985      releaseCopy.className = "music-service-release-copy";
986      const nowPlaying = document.createElement("div");
987      nowPlaying.className = "music-service-kicker";
988      nowPlaying.textContent = "NOW PLAYING";
989      const trackTitle = document.createElement("div");
990      trackTitle.className = "music-service-track-title";
991      trackTitle.dataset.musicServiceTitle = "";
992      trackTitle.textContent = track.title;
993      const artist = document.createElement("div");
994      artist.className = "music-service-artist";
995      artist.dataset.musicServiceArtist = "";
996      artist.textContent = MUSIC_ARTIST_NAME;
997      const prompt = document.createElement("p");
998      prompt.className = "music-service-prompt";
999      setLocalizedText(prompt, "runtime.continueListening", "好きなサービスで続きを聴く");
1000      releaseCopy.append(nowPlaying, trackTitle, artist, prompt);
1001
1002      const actions = document.createElement("div");
1003      actions.className = "music-service-actions";
1004      for (const service of musicServices) {
1005        const button = createButton("", `music-service-button is-${service.id}`);
1006        button.dataset.musicService = service.id;
1007        const serviceName = document.createElement("span");
1008        serviceName.className = "music-service-button-name";
1009        serviceName.textContent = service.label.replace("で聴く", "");
1010        const serviceAction = document.createElement("span");
1011        serviceAction.className = "music-service-button-action";
1012        setLocalizedText(serviceAction, "runtime.open", "開く ↗");
1013        button.append(serviceName, serviceAction);
1014        button.addEventListener("click", () => {
1015          const currentTrack = getTrack();
1016          const currentUrl = currentTrack?.[service.urlKey];
1017          if (!openMusicService(currentUrl, service.id)) return;
1018          closeModal();
1019        });
1020        actions.append(button);
1021      }
1022      releaseCopy.append(actions);
1023      release.append(artworkFrame, releaseCopy);
1024      container.append(release);
1025      syncMusicServiceUi(track);
1026      actions.querySelector("button:not([hidden])")?.focus({ preventScroll: true });
1027    }, () => {
1028      overlay?.classList.remove("is-music-service");
1029      if (isMainExperience() && !artLink?.disabled) {
1030        artLink?.focus({ preventScroll: true });
1031      }
1032    });
1033  }
1034
1035  function syncMusicUi() {
1036    const track = getTrack();
1037    if (!track) return;
1038    if (art) art.src = resolveRootAsset(track.art);
1039    if (title) title.textContent = track.title;
1040    syncMusicServiceUi(track);
1041    if (playButton) {
1042      playButton.classList.toggle("is-playing", !audio.paused);
1043      setLocalizedAriaLabel(playButton, audio.paused ? "music.play" : "music.pause", audio.paused ? "再生" : "停止");
1044    }
1045    musicRoot?.classList.toggle("is-playing", !audio.paused);
1046    if (landmarks.objects.recordPlayer) {
1047      landmarks.objects.recordPlayer.userData.playing = !audio.paused;
1048    }
1049    refreshMusicSelector();
1050  }
1051
1052  async function loadLyrics(track) {
1053    const requestId = ++lyricsRequest;
1054    if (!lyricsText) return;
1055    setLocalizedText(lyricsText, "runtime.lyricsLoading", "歌詞を読み込んでいます。");
1056    if (Array.isArray(track?.lyrics)) {
1057      lyricsText.removeAttribute("data-ass-i18n");
1058      lyricsText.textContent = track.lyrics.map((line) => line.text).join("\n");
1059      return;
1060    }
1061    if (!track?.lyricsPath) {
1062      setLocalizedText(lyricsText, "runtime.lyricsMissing", "この曲の歌詞はまだありません。");
1063      return;
1064    }
1065    try {
1066      const response = await fetch(resolveRootAsset(track.lyricsPath), { cache: "force-cache" });
1067      if (!response.ok) throw new Error(`lyrics-${response.status}`);
1068      const text = await response.text();
1069      if (requestId === lyricsRequest) {
1070        lyricsText.removeAttribute("data-ass-i18n");
1071        lyricsText.textContent = text.trim();
1072      }
1073    } catch (error) {
1074      console.warn("Lyrics could not be loaded.", error);
1075      if (requestId === lyricsRequest) setLocalizedText(lyricsText, "runtime.lyricsUnavailable", "歌詞を読み込めませんでした。");
1076    }
1077  }
1078
1079  function loadTrack(index, shouldPlay = false) {
1080    if (!tracks.length) return;
1081    currentTrackIndex = (index + tracks.length) % tracks.length;
1082    const track = getTrack();
1083    audio.src = resolveRootAsset(track.src);
1084    audio.load();
1085    void loadLyrics(track);
1086    syncMusicUi();
1087    if (shouldPlay) void playMusic();
1088  }
1089
1090  function shuffleTrackIndices(indices) {
1091    for (let index = indices.length - 1; index > 0; index -= 1) {
1092      const swapIndex = Math.floor(Math.random() * (index + 1));
1093      [indices[index], indices[swapIndex]] = [indices[swapIndex], indices[index]];
1094    }
1095    return indices;
1096  }
1097
1098  // Match the original site's playback: the initial track stays fixed, then
1099  // every following selection comes from a shuffled queue without immediately
1100  // repeating the song that just played.
1101  function getNextRandomTrackIndex() {
1102    if (tracks.length <= 1) return 0;
1103    if (!randomTrackQueue.length) {
1104      const candidates = tracks
1105        .map((_, index) => index)
1106        .filter((index) => index !== currentTrackIndex);
1107      randomTrackQueue = shuffleTrackIndices(candidates);
1108    }
1109    return randomTrackQueue.shift() ?? currentTrackIndex;
1110  }
1111
1112  async function playMusic() {
1113    if (!isMainExperience()) return;
1114    if (!audio.src) loadTrack(currentTrackIndex, false);
1115    try {
1116      await audio.play();
1117      audioUnlocked = true;
1118      // Once HTML music owns the iOS audio session, retry the effects context.
1119      // This does not reroute or process the music, so its pitch is untouched.
1120      const effectsReady = phaseAudioEngine?.ensurePlayback?.();
1121      if (effectsReady) {
1122        void Promise.resolve(effectsReady).then((playable) => {
1123          canvas.dataset.phaseAudioMusicSync = playable ? "running" : "waiting-for-gesture";
1124        });
1125      }
1126    } catch (error) {
1127      console.warn("Music playback is waiting for a user gesture.", error);
1128    }
1129    syncMusicUi();
1130  }
1131
1132  function pauseMusic() {
1133    audio.pause();
1134    syncMusicUi();
1135  }
1136
1137  function setStoryAudioStatus(cue, status) {
1138    storyAudio.dataset.assPlaybackStatus = status;
1139    if (activeStoryAudio === cue || pendingStoryAudio === cue) {
1140      canvas.dataset.storyAudioCue = cue?.name || "unknown";
1141      canvas.dataset.storyAudioStatus = status;
1142    }
1143  }
1144
1145  function configureStoryAudio(cue) {
1146    if (!cue) return false;
1147    const sourceChanged = storyAudio.src !== cue.src;
1148    if (sourceChanged) {
1149      activeStoryAudio = null;
1150      pendingStoryAudio = null;
1151      storyAudio.pause();
1152      storyAudio.src = cue.src;
1153      storyAudio.load();
1154    }
1155    storyAudio.loop = cue.loop;
1156    storyAudio.volume = cue.volume;
1157    return sourceChanged;
1158  }
1159
1160  function playEffectAudio(cue, restart = false) {
1161    if (!cue || !isMainExperience()) return;
1162    const sourceChanged = configureStoryAudio(cue);
1163    activeStoryAudio = cue;
1164    pendingStoryAudio = cue;
1165    storyAudio.dataset.assPrimePending = "false";
1166    if (restart) {
1167      try {
1168        storyAudio.currentTime = 0;
1169      } catch (error) {
1170        console.warn("Effect audio rewind failed.", error);
1171      }
1172    }
1173    storyAudio.muted = false;
1174    setStoryAudioStatus(cue, sourceChanged ? "loading" : "starting");
1175    const result = storyAudio.play();
1176    if (!result) {
1177      pendingStoryAudio = null;
1178      storyAudio.dataset.assPrimed = "true";
1179      canvas.dataset.storyAudioPrimed = "true";
1180      setStoryAudioStatus(cue, "playing");
1181      return result;
1182    }
1183    void result.then(() => {
1184      storyAudio.dataset.assPrimed = "true";
1185      canvas.dataset.storyAudioPrimed = "true";
1186      if (activeStoryAudio !== cue) return;
1187      pendingStoryAudio = null;
1188      setStoryAudioStatus(cue, "playing");
1189    }).catch((error) => {
1190      if (activeStoryAudio !== cue) return;
1191      pendingStoryAudio = cue;
1192      setStoryAudioStatus(cue, "waiting-for-gesture");
1193      if (error?.name !== "AbortError" && error?.name !== "NotAllowedError") {
1194        console.warn("Effect audio playback failed.", error);
1195      }
1196    });
1197    return result;
1198  }
1199
1200  function stopEffectAudio(cue = null, reset = false) {
1201    if (
1202      cue
1203      && activeStoryAudio
1204      && activeStoryAudio !== cue
1205      && pendingStoryAudio !== cue
1206    ) return;
1207    activeStoryAudio = null;
1208    pendingStoryAudio = null;
1209    storyAudio.dataset.assPrimePending = "false";
1210    storyAudio.pause();
1211    if (reset) {
1212      try {
1213        storyAudio.currentTime = 0;
1214      } catch (error) {
1215        console.warn("Effect audio reset failed.", error);
1216      }
1217    }
1218    storyAudio.dataset.assPlaybackStatus = "stopped";
1219    canvas.dataset.storyAudioCue = "none";
1220    canvas.dataset.storyAudioStatus = "stopped";
1221  }
1222
1223  function primeStoryAudio() {
1224    if (
1225      activeStoryAudio
1226      || storyAudio.dataset.assPrimed === "true"
1227      || storyAudio.dataset.assPrimePending === "true"
1228    ) return;
1229    configureStoryAudio(clockAudio);
1230    storyAudio.dataset.assPrimePending = "true";
1231    canvas.dataset.storyAudioPrimed = "pending";
1232    storyAudio.muted = true;
1233    const result = storyAudio.play();
1234    if (!result) {
1235      storyAudio.pause();
1236      storyAudio.currentTime = 0;
1237      storyAudio.muted = false;
1238      storyAudio.dataset.assPrimePending = "false";
1239      storyAudio.dataset.assPrimed = "true";
1240      canvas.dataset.storyAudioPrimed = "true";
1241      return;
1242    }
1243    void result.then(() => {
1244      if (storyAudio.dataset.assPrimePending !== "true") return;
1245      storyAudio.pause();
1246      storyAudio.currentTime = 0;
1247      storyAudio.muted = false;
1248      storyAudio.dataset.assPrimePending = "false";
1249      storyAudio.dataset.assPrimed = "true";
1250      canvas.dataset.storyAudioPrimed = "true";
1251    }).catch((error) => {
1252      storyAudio.muted = false;
1253      storyAudio.dataset.assPrimePending = "false";
1254      canvas.dataset.storyAudioPrimed = "false";
1255      if (error?.name !== "AbortError" && error?.name !== "NotAllowedError") {
1256        console.warn("Effect audio priming failed.", error);
1257      }
1258    });
1259  }
1260
1261  function retryPendingStoryAudio() {
1262    if (!pendingStoryAudio || pendingStoryAudio !== activeStoryAudio) return false;
1263    playEffectAudio(pendingStoryAudio, false);
1264    return true;
1265  }
1266
1267  function logEndingDebug(label) {
1268    if (new URLSearchParams(window.location.search).get("endingdebug") !== "1") return;
1269    const elapsed = endingStartedAt > 0 ? performance.now() - endingStartedAt : 0;
1270    console.info(`[ending +${Math.round(elapsed)}ms] ${label}`);
1271  }
1272
1273  function unlockMusic(event) {
1274    if (event?.defaultPrevented) return;
1275    if (
1276      event?.target instanceof Element
1277      && event.target.closest("#experience-art-link, .experience-overlay.is-music-service")
1278    ) return;
1279    if (isChillExperience()) {
1280      playChillAudio();
1281      return;
1282    }
1283    if (!isMainExperience()) return;
1284    // The mode-selection click reaches the global capture listeners before
1285    // MAIN is selected. Prime the story element again from the trusted button
1286    // handler below, and keep retrying it on later gestures even
1287    // after the playlist itself is already unlocked.
1288    retryPendingStoryAudio();
1289    primeStoryAudio();
1290    void phaseAudioEngine?.unlock?.(`music-after-${event?.type || "gesture"}`);
1291    if (
1292      audioUnlocked
1293      || musicGesturePending
1294      || state.phase === "challenge"
1295      || state.spaceFlightActive
1296      || state.ending
1297      || state.modalOpen
1298    ) return;
1299    musicGesturePending = true;
1300    const musicAttempt = playMusic();
1301    // playMusic() invokes audio.play() synchronously before its first await.
1302    // Retry Web Audio immediately afterwards in the same trusted event so the
1303    // music and effects join the same active iOS audio session.
1304    void musicAttempt.finally(() => {
1305      musicGesturePending = false;
1306    });
1307  }
1308
1309  function stopStoryAudioForChill() {
1310    pauseMusic();
1311    stopEffectAudio(null, true);
1312    // The main playlist may already have been preloaded while the choice screen
1313    // was open. Releasing it keeps CHILL from owning an unnecessary media
1314    // request or accidentally resuming main-mode music later.
1315    audio.removeAttribute("src");
1316    audio.load();
1317    audioUnlocked = false;
1318  }
1319
1320  function playChillAudio() {
1321    if (!CHILL_AUDIO_URL) {
1322      canvas.dataset.chillAudio = "silent-no-source";
1323      return;
1324    }
1325    if (!chillAudio.paused) {
1326      canvas.dataset.chillAudio = "playing";
1327      return;
1328    }
1329    canvas.dataset.chillAudio = "starting";
1330    const result = chillAudio.play();
1331    result?.then(() => {
1332      canvas.dataset.chillAudio = "playing";
1333    }).catch((error) => {
1334      canvas.dataset.chillAudio = "waiting-for-gesture";
1335      if (error?.name !== "AbortError" && error?.name !== "NotAllowedError") {
1336        console.warn("CHILL audio playback failed.", error);
1337      }
1338    });
1339  }
1340
1341  function mountChillCat() {
1342    state.catFound = true;
1343    state.catRouteAvailable = false;
1344    state.catJoinPhase = "complete";
1345    state.catJoinElapsed = CAT_ROUTE_JOIN_DURATION;
1346    state.catFollowing = true;
1347    state.catPendingJoin = false;
1348    state.catTailTime = 0;
1349    if (playerObject) {
1350      playerObject.add(catCompanion);
1351      catCompanion.position.set(
1352        CAT_ROUTE_MOUNT_SIDE,
1353        CAT_ROUTE_MOUNT_HEIGHT,
1354        CAT_ROUTE_MOUNT_FORWARD,
1355      );
1356      catCompanion.quaternion.identity();
1357    }
1358    catCompanion.visible = true;
1359    refreshCatRouteAvailability();
1360  }
1361
1362  function applyExperienceMode(mode, sourceEvent = null) {
1363    canvas.dataset.experienceMode = mode;
1364    canvas.dataset.storyEvents = mode === EXPERIENCE_MODE.MAIN ? "enabled" : "disabled";
1365    syncMusicUi();
1366
1367    if (mode === EXPERIENCE_MODE.CHILL) {
1368      stopStoryAudioForChill();
1369      mountChillCat();
1370      scheduleChillDevil();
1371      void phaseAudioEngine?.unlock?.(`chill-selection-${sourceEvent?.type || "choice"}`);
1372      playChillAudio();
1373      return;
1374    }
1375
1376    if (mode === EXPERIENCE_MODE.MAIN) {
1377      chillAudio.pause();
1378      canvas.dataset.chillAudio = "inactive";
1379      if (!audio.src) loadTrack(currentTrackIndex, false);
1380      // This runs synchronously inside the trusted Main button click.  The
1381      // window capture handlers have already seen that click while the mode
1382      // was still PENDING, so this is the reliable iOS/Safari unlock point for
1383      // challenge, space-return and ending audio.
1384      primeStoryAudio();
1385      const musicAttempt = playMusic();
1386      void phaseAudioEngine?.unlock?.(`main-selection-${sourceEvent?.type || "choice"}`);
1387      void musicAttempt;
1388    }
1389  }
1390
1391  function showToast(message, duration = 3200) {
1392    if (!toast || !isMainExperience()) return;
1393    window.clearTimeout(toastTimer);
1394    toast.textContent = message;
1395    toast.classList.add("is-visible");
1396    toastTimer = window.setTimeout(() => toast.classList.remove("is-visible"), duration);
1397  }
1398
1399  function openModal(nextTitle, render, onClose = null) {
1400    if (!isMainExperience() || !overlay || !overlayBody || !overlayTitle) return;
1401    closeAction = onClose;
1402    state.modalOpen = true;
1403    overlayTitle.textContent = nextTitle;
1404    overlayBody.textContent = "";
1405    render(overlayBody);
1406    overlay.classList.add("is-open");
1407    overlay.setAttribute("aria-hidden", "false");
1408    flight.stickOffset?.set(0, 0);
1409    flight.stickSmooth?.set(0, 0);
1410    flight.keySmooth?.set(0, 0);
1411    flight.accelPointers?.clear();
1412  }
1413
1414  function closeModal() {
1415    if (!state.modalOpen) return;
1416    state.modalOpen = false;
1417    overlay?.classList.remove("is-open");
1418    overlay?.setAttribute("aria-hidden", "true");
1419    if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
1420    const action = closeAction;
1421    closeAction = null;
1422    action?.();
1423  }
1424
1425  function clearFlightInput() {
1426    flight.stickOffset?.set(0, 0);
1427    flight.stickSmooth?.set(0, 0);
1428    flight.keySmooth?.set(0, 0);
1429    flight.accelPointers?.clear();
1430    flight.keys?.clear();
1431    flight.holdAccel = 0;
1432    flight.directTurnX = 0;
1433    flight.directTurnY = 0;
1434  }
1435
1436  function openNativeOverlay(element) {
1437    if (!isMainExperience() || !element) return;
1438    if (activeNativeOverlay && activeNativeOverlay !== element) closeNativeOverlay();
1439    activeNativeOverlay = element;
1440    state.modalOpen = true;
1441    clearFlightInput();
1442    element.classList.add("is-open");
1443    element.setAttribute("aria-hidden", "false");
1444  }
1445
1446  function closeNativeOverlay() {
1447    if (!activeNativeOverlay) return;
1448    const shouldStartCatJoin = activeNativeOverlay === blackBoxOverlay
1449      && state.catPendingJoin
1450      && state.catJoinPhase === "dialogue";
1451    activeNativeOverlay.classList.remove("is-open");
1452    activeNativeOverlay.setAttribute("aria-hidden", "true");
1453    activeNativeOverlay = null;
1454    state.modalOpen = false;
1455    if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
1456    if (shouldStartCatJoin) startCatJoin();
1457  }
1458
1459  function refreshCatRouteAvailability() {
1460    state.catRouteAvailable = state.blackBoxOpened
1461      && state.catFound
1462      && state.phase === "sanctuary"
1463      && state.catJoinPhase === "idle"
1464      && !state.catFollowing
1465      && !state.ending;
1466    canvas.dataset.catRoute = state.catFollowing
1467      ? "following"
1468      : state.catJoinPhase === "joining" ? "joining" : state.catRouteAvailable ? "ready" : "inactive";
1469  }
1470
1471  function getCatMountTransform() {
1472    catMountUp.copy(flight.position).normalize();
1473    catMountForward.copy(flight.forward)
1474      .addScaledVector(catMountUp, -flight.forward.dot(catMountUp));
1475    if (catMountForward.lengthSq() < 0.0001) catMountForward.set(0, 0, 1);
1476    catMountForward.normalize();
1477    catMountRight.crossVectors(catMountUp, catMountForward).normalize();
1478    catDesiredPosition.copy(flight.position)
1479      .addScaledVector(catMountRight, CAT_ROUTE_MOUNT_SIDE)
1480      .addScaledVector(catMountUp, CAT_ROUTE_MOUNT_HEIGHT)
1481      .addScaledVector(catMountForward, CAT_ROUTE_MOUNT_FORWARD);
1482    catBasis.makeBasis(catMountRight, catMountUp, catMountForward);
1483    catTargetQuaternion.setFromRotationMatrix(catBasis);
1484  }
1485
1486  function startCatJoin() {
1487    if (state.catJoinPhase !== "dialogue" || state.catFollowing || state.ending) return;
1488    const blackBox = landmarks.objects.blackBox;
1489    blackBox.getWorldPosition(state.catJoinStart);
1490    catSourceUp.copy(state.catJoinStart).normalize();
1491    state.catJoinStart.addScaledVector(catSourceUp, 1.3);
1492    state.catPosition.copy(state.catJoinStart);
1493    state.catJoinElapsed = 0;
1494    state.catJoinPhase = "joining";
1495    state.catPendingJoin = false;
1496    catCompanion.position.copy(state.catJoinStart);
1497    getCatMountTransform();
1498    catCompanion.quaternion.copy(catTargetQuaternion);
1499    catCompanion.visible = true;
1500    refreshCatRouteAvailability();
1501  }
1502
1503  function updateCatCompanion(delta) {
1504    if (state.catJoinPhase !== "joining" && !state.catFollowing) return;
1505    state.catTailTime += delta;
1506    const tailRoot = catCompanion.userData.tailRoot;
1507    const tailJoints = catCompanion.userData.tailJoints || [];
1508    if (tailRoot) {
1509      tailRoot.rotation.x = -1.02 + Math.sin(state.catTailTime * 1.35) * 0.09;
1510      tailRoot.rotation.z = -0.38 + Math.sin(state.catTailTime * 2.15) * 0.2;
1511      tailJoints.forEach((joint, index) => {
1512        const wave = state.catTailTime * 2.5 - index * 0.72;
1513        joint.rotation.z = Math.sin(wave) * (0.1 + index * 0.018);
1514        joint.rotation.x = Math.cos(wave * 0.72) * (0.045 + index * 0.008);
1515      });
1516    }
1517    if (state.catFollowing && catCompanion.parent === playerObject) {
1518      // Once the jump has completed, make the cat part of the player rig. This
1519      // keeps it in the same pivot, bob, roll, and descent frame as the body.
1520      catCompanion.position.set(
1521        CAT_ROUTE_MOUNT_SIDE,
1522        CAT_ROUTE_MOUNT_HEIGHT,
1523        CAT_ROUTE_MOUNT_FORWARD,
1524      );
1525      catCompanion.quaternion.identity();
1526      return;
1527    }
1528    getCatMountTransform();
1529    if (state.catJoinPhase === "joining") {
1530      state.catJoinElapsed += delta;
1531      const progress = THREE.MathUtils.clamp(state.catJoinElapsed / CAT_ROUTE_JOIN_DURATION, 0, 1);
1532      const eased = 1 - Math.pow(1 - progress, 3);
1533      state.catPosition.lerpVectors(state.catJoinStart, catDesiredPosition, eased);
1534      // A single broad arc makes the jump readable without a sudden snap.
1535      state.catPosition.addScaledVector(catMountUp, Math.sin(progress * Math.PI) * 1.15);
1536      catCompanion.position.copy(state.catPosition);
1537      catCompanion.quaternion.slerp(
1538        catTargetQuaternion,
1539        1 - Math.exp(-CAT_ROUTE_ROTATION_RESPONSE * delta),
1540      );
1541      if (progress >= 1) {
1542        state.catJoinPhase = "complete";
1543        state.catFollowing = true;
1544        state.catPosition.copy(catDesiredPosition);
1545        catCompanion.position.copy(catDesiredPosition);
1546        catCompanion.quaternion.copy(catTargetQuaternion);
1547        if (playerObject) {
1548          playerObject.add(catCompanion);
1549          catCompanion.position.set(
1550            CAT_ROUTE_MOUNT_SIDE,
1551            CAT_ROUTE_MOUNT_HEIGHT,
1552            CAT_ROUTE_MOUNT_FORWARD,
1553          );
1554          catCompanion.quaternion.identity();
1555        }
1556        refreshCatRouteAvailability();
1557        showToast(t("toast.catMounted", "猫が肩に飛び乗った。"), 3000);
1558      }
1559      return;
1560    }
1561    state.catPosition.copy(catDesiredPosition);
1562    catCompanion.position.copy(catDesiredPosition);
1563    catCompanion.quaternion.copy(catTargetQuaternion);
1564  }
1565
1566  function refreshMusicSelector() {
1567    const track = getTrack();
1568    if (musicSelectorNowPlaying && track) {
1569      musicSelectorNowPlaying.replaceChildren();
1570      const artImage = document.createElement("img");
1571      artImage.className = "music-selector-now-playing-art";
1572      artImage.src = resolveRootAsset(track.art);
1573      artImage.alt = `${track.title} jacket`;
1574      const copy = document.createElement("div");
1575      copy.className = "music-selector-now-playing-copy";
1576      const label = document.createElement("div");
1577      label.className = "music-selector-now-playing-label";
1578      setLocalizedText(label, "music.nowPlaying", "再生中");
1579      const trackTitle = document.createElement("div");
1580      trackTitle.className = "music-selector-now-playing-title";
1581      trackTitle.textContent = track.title;
1582      const meta = document.createElement("div");
1583      meta.className = "music-selector-now-playing-meta";
1584      meta.textContent = `TRACK ${String(currentTrackIndex + 1).padStart(2, "0")}`;
1585      copy.append(label, trackTitle, meta);
1586      musicSelectorNowPlaying.append(artImage, copy);
1587    }
1588    for (const item of musicSelectorList?.querySelectorAll(".music-selector-item") || []) {
1589      const active = Number(item.dataset.trackIndex) === currentTrackIndex;
1590      item.classList.toggle("is-current", active);
1591      const itemState = item.querySelector(".music-selector-item-state");
1592      if (itemState) setLocalizedText(
1593        itemState,
1594        active ? (audio.paused ? "music.ready" : "music.nowPlaying") : "music.playTrack",
1595        active ? (audio.paused ? "READY" : "NOW PLAYING") : "PLAY",
1596      );
1597    }
1598  }
1599
1600  function ensureMusicSelector() {
1601    if (!musicSelectorList || musicSelectorList.childElementCount) return;
1602    tracks.forEach((track, index) => {
1603      const button = createButton("", "music-selector-item ui-control");
1604      button.dataset.trackIndex = String(index);
1605      const itemArt = document.createElement("img");
1606      itemArt.className = "music-selector-item-art";
1607      itemArt.loading = "lazy";
1608      itemArt.src = resolveRootAsset(track.art);
1609      itemArt.alt = `${track.title} jacket`;
1610      const main = document.createElement("div");
1611      main.className = "music-selector-item-main";
1612      const itemTitle = document.createElement("div");
1613      itemTitle.className = "music-selector-item-title";
1614      itemTitle.textContent = track.title;
1615      const meta = document.createElement("div");
1616      meta.className = "music-selector-item-meta";
1617      meta.textContent = `TRACK ${String(index + 1).padStart(2, "0")}`;
1618      const itemState = document.createElement("div");
1619      itemState.className = "music-selector-item-state";
1620      setLocalizedText(itemState, "music.playTrack", "PLAY");
1621      main.append(itemTitle, meta);
1622      button.append(itemArt, main, itemState);
1623      button.addEventListener("click", () => {
1624        loadTrack(index, true);
1625        refreshMusicSelector();
1626      });
1627      musicSelectorList.append(button);
1628    });
1629  }
1630
1631  function openMusicSelector() {
1632    ensureMusicSelector();
1633    refreshMusicSelector();
1634    openNativeOverlay(musicSelectorOverlay);
1635  }
1636
1637  function createBookCard(entry) {
1638    const card = document.createElement("article");
1639    card.className = "book-message-card";
1640    const meta = document.createElement("div");
1641    meta.className = "book-message-meta";
1642    const author = document.createElement("div");
1643    author.className = "book-message-author";
1644    const name = document.createElement("div");
1645    name.className = "book-message-name";
1646    name.textContent = entry.name;
1647    const date = document.createElement("div");
1648    date.className = "book-message-date";
1649    date.textContent = formatBookDate(entry.createdAt);
1650    const message = document.createElement("div");
1651    message.className = "book-message-body";
1652    message.textContent = entry.message;
1653    author.append(name);
1654    meta.append(author, date);
1655    card.append(meta, message);
1656    return card;
1657  }
1658
1659  function estimateBookMessageRows(entry) {
1660    const text = `${entry.name || ""}\n${entry.message || ""}`;
1661    const characterWidth = window.matchMedia("(max-width: 720px)").matches ? 24 : 42;
1662    const bodyRows = text.split("\n").reduce(
1663      (rows, line) => rows + Math.max(1, Math.ceil(Array.from(line).length / characterWidth)),
1664      0,
1665    );
1666    // Name/date, the meta spacing, card padding and the page counter account
1667    // for about four extra text rows in the actual book layout.
1668    return bodyRows + 4;
1669  }
1670
1671  function paginateBookEntries(entries) {
1672    const rowBudget = window.matchMedia("(max-width: 720px)").matches ? 16 : 22;
1673    const pages = [];
1674    let page = [];
1675    let usedRows = 0;
1676    for (const entry of entries) {
1677      const rows = estimateBookMessageRows(entry);
1678      if (page.length && usedRows + rows > rowBudget) {
1679        pages.push(page);
1680        page = [];
1681        usedRows = 0;
1682      }
1683      page.push(entry);
1684      usedRows += rows;
1685    }
1686    if (page.length) pages.push(page);
1687    return pages;
1688  }
1689
1690  function renderProductionBook(entries, stopped = false) {
1691    if (!bookMessagePage) return;
1692    bookMessagePage.textContent = "";
1693    bookPages = paginateBookEntries(entries);
1694    if (!bookPages.length) {
1695      const card = document.createElement("div");
1696      card.className = "book-message-card";
1697      setLocalizedText(
1698        card,
1699        stopped ? "book.stopped" : "book.empty",
1700        stopped ? "停止中" : "まだ何も書かれていません。最初のひとことを残せます。",
1701      );
1702      bookMessagePage.append(card);
1703      if (bookNextPage) bookNextPage.disabled = true;
1704      return;
1705    }
1706    bookPageIndex %= bookPages.length;
1707    for (const entry of bookPages[bookPageIndex]) bookMessagePage.append(createBookCard(entry));
1708    const page = document.createElement("div");
1709    page.className = "book-message-index";
1710    page.textContent = `${bookPageIndex + 1} / ${bookPages.length}`;
1711    bookMessagePage.append(page);
1712    if (bookNextPage) bookNextPage.disabled = bookPages.length <= 1;
1713  }
1714
1715  function setBookView(view) {
1716    for (const button of bookViewButtons) {
1717      const active = button.dataset.bookView === view;
1718      button.classList.toggle("is-active", active);
1719      button.setAttribute("aria-selected", active ? "true" : "false");
1720    }
1721    for (const page of bookViews) page.classList.toggle("is-active", page.dataset.bookView === view);
1722  }
1723
1724  async function openProductionBook() {
1725    bookPageIndex = 0;
1726    setBookView("write");
1727    if (bookStatus) setLocalizedText(bookStatus, "book.loading", "本の記録を読み込んでいます。");
1728    openNativeOverlay(bookOverlay);
1729    bookMessageInput?.focus({ preventScroll: true });
1730    try {
1731      const entries = await fetchBookMessages();
1732      if (bookStatus) setLocalizedText(bookStatus, "book.shared", "この本に書いたことばは、ほかの人にも共有されます。");
1733      renderProductionBook(entries);
1734    } catch (error) {
1735      console.warn("Supabase book is unavailable.", error);
1736      if (bookStatus) setLocalizedText(bookStatus, "book.stopped", "停止中");
1737      renderProductionBook(readBookCache(), true);
1738    }
1739  }
1740
1741  function setBlackBoxView(view) {
1742    for (const item of blackBoxViews) item.classList.toggle("is-active", item.id === `black-box-view-${view}`);
1743  }
1744
1745  function openProductionBlackBox() {
1746    const blackBox = landmarks.objects.blackBox;
1747    blackBox.userData.grounded = true;
1748    refreshCatRouteAvailability();
1749    if (state.catFollowing || state.catJoinPhase === "joining" || state.catJoinPhase === "complete") return;
1750    const view = state.catRouteAvailable ? "cat-route" : "intro";
1751    state.catPendingJoin = view === "cat-route";
1752    if (view === "cat-route") state.catJoinPhase = "dialogue";
1753    setBlackBoxView(view);
1754    if (blackBoxTitle) blackBoxTitle.style.visibility = state.blackBoxOpe
1754ned || view === "cat-route" ? "hidden" : "visible";
1755    if (blackBoxOpen) setLocalizedText(
1756      blackBoxOpen,
1757      state.blackBoxOpened ? "blackBox.openAgain" : "blackBox.open",
1758      state.blackBoxOpened ? "また開けちゃう" : "開けてみる",
1759    );
1760    openNativeOverlay(blackBoxOverlay);
1761  }
1762
1763  function getBookPlayerName() {
1764    try {
1765      const stored = JSON.parse(localStorage.getItem(BOOK_PLAYER_KEY) || "null");
1766      return stored?.hasWrittenNameInBook && typeof stored.bookPlayerName === "string"
1767        ? stored.bookPlayerName.trim()
1768        : "";
1769    } catch (error) {
1770      return "";
1771    }
1772  }
1773
1774  async function recordReturnHistory(isTrueReturn) {
1775    if (state.returnRecorded) return;
1776    state.returnRecorded = true;
1777    const playerName = getBookPlayerName();
1778    if (!playerName) return;
1779    const createdAt = new Date().toISOString();
1780    const historyEntry = {
1781      playerName,
1782      isTrueReturn: Boolean(isTrueReturn),
1783      createdAt,
1784    };
1785    try {
1786      const response = await fetch(
1787        getReturnHistoryUrl("?select=id,player_name,is_true_return,created_at"),
1788        {
1789          method: "POST",
1790          headers: getBookHeaders("return=representation"),
1791          body: JSON.stringify([{
1792            player_name: playerName,
1793            is_true_return: Boolean(isTrueReturn),
1794          }]),
1795        },
1796      );
1797      if (!response.ok) throw new Error(`return-history-table-save-${response.status}`);
1798      return;
1799    } catch (tableError) {
1800      console.warn("Return history table is unavailable; using shared-book fallback.", tableError);
1801    }
1802    const message = `${RETURN_HISTORY_PREFIX}${JSON.stringify(historyEntry)}`;
1803    try {
1804      const response = await fetch(getBookUrl("?select=id"), {
1805        method: "POST",
1806        headers: getBookHeaders("return=minimal"),
1807        body: JSON.stringify([{ name: HIDDEN_BOOK_AUTHOR, message }]),
1808      });
1809      if (!response.ok) throw new Error(`return-history-save-${response.status}`);
1810    } catch (error) {
1811      console.warn("Return history could not be saved.", error);
1812      try {
1813        const stored = JSON.parse(localStorage.getItem(RETURN_HISTORY_STORAGE_KEY) || "[]");
1814        const entries = Array.isArray(stored) ? stored : [];
1815        entries.unshift(historyEntry);
1816        localStorage.setItem(RETURN_HISTORY_STORAGE_KEY, JSON.stringify(entries.slice(0, 80)));
1817      } catch (storageError) {
1818        console.warn("Local return history could not be saved.", storageError);
1819      }
1820    }
1821  }
1822
1823  function renderEndingHistories(histories) {
1824    const isAllowed = (name) => name && !EXCLUDED_BOOK_NAMES.has(name);
1825    const normal = histories.filter((entry) => !entry.isTrue && isAllowed(entry.name)).map((entry) => entry.name);
1826    const truth = histories.filter((entry) => entry.isTrue && isAllowed(entry.name)).map((entry) => entry.name);
1827    if (endingReturnees) {
1828      if (normal.length) {
1829        endingReturnees.removeAttribute("data-ass-i18n");
1830        endingReturnees.textContent = normal.join("\n");
1831      } else setLocalizedText(endingReturnees, "ending.unconfirmed", "未確認");
1832    }
1833    if (endingTrueReturnees) {
1834      if (truth.length) {
1835        endingTrueReturnees.removeAttribute("data-ass-i18n");
1836        endingTrueReturnees.textContent = truth.join("\n");
1837      } else setLocalizedText(endingTrueReturnees, "ending.unconfirmed", "未確認");
1838    }
1839  }
1840
1841  function readLocalReturnHistories() {
1842    try {
1843      const stored = JSON.parse(localStorage.getItem(RETURN_HISTORY_STORAGE_KEY) || "[]");
1844      return (Array.isArray(stored) ? stored : []).map((entry) => ({
1845        name: typeof entry?.playerName === "string" ? entry.playerName.trim() : "",
1846        isTrue: Boolean(entry?.isTrueReturn),
1847      }));
1848    } catch (error) {
1849      return [];
1850    }
1851  }
1852
1853  async function loadEndingNames() {
1854    try {
1855      const response = await fetch(
1856        getReturnHistoryUrl("?select=id,player_name,is_true_return,created_at&order=created_at.asc&limit=80"),
1857        { headers: getBookHeaders(), cache: "no-store" },
1858      );
1859      if (!response.ok) throw new Error(`return-history-table-${response.status}`);
1860      const rows = await response.json();
1861      const histories = (Array.isArray(rows) ? rows : []).map((row) => ({
1862        name: typeof row.player_name === "string" ? row.player_name.trim() : "",
1863        isTrue: row.is_true_return === true
1864          || row.is_true_return === "true"
1865          || row.is_true_return === 1
1866          || row.is_true_return === "1",
1867      }));
1868      renderEndingHistories(histories);
1869      return;
1870    } catch (tableError) {
1871      console.warn("Return history table could not be loaded; using shared-book fallback.", tableError);
1872    }
1873    try {
1874      const response = await fetch(
1875        getBookUrl(`?select=id,name,message,created_at&name=eq.${encodeURIComponent(HIDDEN_BOOK_AUTHOR)}&order=created_at.asc&limit=120`),
1876        { headers: getBookHeaders(), cache: "no-store" },
1877      );
1878      if (!response.ok) throw new Error(`return-history-${response.status}`);
1879      const rows = await response.json();
1880      const histories = (Array.isArray(rows) ? rows : []).map((row) => {
1881        if (typeof row.message !== "string" || !row.message.startsWith(RETURN_HISTORY_PREFIX)) return null;
1882        try {
1883          const parsed = JSON.parse(row.message.slice(RETURN_HISTORY_PREFIX.length));
1884          return {
1885            name: typeof parsed.playerName === "string" ? parsed.playerName.trim() : "",
1886            isTrue: Boolean(parsed.isTrueReturn),
1887          };
1888        } catch (error) {
1889          return null;
1890        }
1891      }).filter(Boolean);
1892      renderEndingHistories(histories);
1893    } catch (error) {
1894      console.warn("Return history could not be loaded for credits.", error);
1895      renderEndingHistories(readLocalReturnHistories());
1896    }
1897  }
1898
1899  function openEndingRoll() {
1900    endingWhiteout?.classList.remove("is-active");
1901    endingOverlay?.classList.remove("is-transitioning");
1902    endingOverlay?.classList.add("is-open");
1903    endingOverlay?.setAttribute("aria-hidden", "false");
1904    if (endingRollTrack) {
1905      endingRollTrack.style.animation = "none";
1906      endingRollTrack.offsetHeight;
1907      endingRollTrack.style.animation = `ending-roll ${ENDING_ROLL_DURATION}s linear forwards`;
1908    }
1909    endingRollStart = performance.now();
1910    logEndingDebug("credits started");
1911    canvas.dataset.returnBgm = "ending-roll";
1912    window.clearTimeout(endingAudioTimer);
1913    endingAudioTimer = window.setTimeout(() => {
1914      logEndingDebug("ending music requested");
1915      playEffectAudio(endingAudio, true);
1916    }, ENDING_ROLL_AUDIO_DELAY);
1917  }
1918
1919  function startProductionEnding(isTrueReturn) {
1920    window.clearTimeout(endingWhiteoutTimer);
1921    window.clearTimeout(endingBlackTimer);
1922    endingWhiteout?.classList.add("is-active");
1923    endingWhiteout?.classList.remove("is-true-message");
1924    endingWhiteout?.setAttribute("aria-hidden", "false");
1925    endingRestart?.classList.remove("is-visible");
1926    void loadEndingNames();
1927    endingWhiteoutTimer = window.setTimeout(() => {
1928      if (isTrueReturn) endingWhiteout?.classList.add("is-true-message");
1929      const beginBlackTransition = () => {
1930        endingWhiteout?.classList.remove("is-true-message");
1931        endingOverlay?.classList.add("is-transitioning");
1932        endingOverlay?.setAttribute("aria-hidden", "false");
1933        endingBlackTimer = window.setTimeout(openEndingRoll, ENDING_BLACK_DELAY);
1934      };
1935      if (isTrueReturn) endingBlackTimer = window.setTimeout(beginBlackTransition, ENDING_TRUE_MESSAGE_DURATION);
1936      else beginBlackTransition();
1937    }, ENDING_WHITEOUT_DURATION);
1938  }
1939
1940  function createDevilUi() {
1941    const devilOverlay = document.createElement("div");
1942    devilOverlay.id = "devil-guide-overlay";
1943    devilOverlay.className = "ui-control";
1944    devilOverlay.setAttribute("aria-hidden", "true");
1945    const backdrop = document.createElement("div");
1946    backdrop.id = "devil-guide-backdrop";
1947    const panel = document.createElement("div");
1948    panel.id = "devil-guide-panel";
1949    panel.setAttribute("role", "dialog");
1950    panel.setAttribute("aria-modal", "true");
1951    const kicker = document.createElement("div");
1952    kicker.className = "devil-guide-kicker";
1953    setLocalizedText(kicker, "runtime.devilEncounter", "悪魔に遭遇");
1954    const message = document.createElement("div");
1955    message.id = "devil-guide-message";
1956    const actions = document.createElement("div");
1957    actions.id = "devil-guide-actions";
1958    panel.append(kicker, message, actions);
1959    devilOverlay.append(backdrop, panel);
1960    const summon = createButton(t("runtime.summonDevil", "悪魔を呼ぶ"), "ui-control");
1961    summon.dataset.assI18n = "runtime.summonDevil";
1962    summon.id = "devil-guide-summon";
1963    summon.setAttribute("aria-hidden", "true");
1964    const navigation = document.createElement("div");
1965    navigation.id = "devil-guide-navigation";
1966    navigation.className = "ui-control";
1967    navigation.setAttribute("aria-hidden", "true");
1968    const navigationText = document.createElement("div");
1969    navigationText.className = "devil-guide-navigation-text";
1970    const navigationKicker = document.createElement("span");
1971    setLocalizedText(navigationKicker, "runtime.devilGuide", "悪魔の案内");
1972    const navigationDestination = document.createElement("strong");
1973    navigationText.append(navigationKicker, navigationDestination);
1974    const navigationCancel = createButton(t("runtime.cancel", "中止"));
1975    navigationCancel.dataset.assI18n = "runtime.cancel";
1976    navigation.append(navigationText, navigationCancel);
1977    document.body.append(devilOverlay, summon, navigation);
1978    for (const element of [devilOverlay, summon, navigation]) {
1979      element.addEventListener("pointerdown", (event) => event.stopPropagation());
1980      element.addEventListener("click", (event) => event.stopPropagation());
1981    }
1982    return {
1983      overlay: devilOverlay,
1984      kicker,
1985      message,
1986      actions,
1987      summon,
1988      navigation,
1989      navigationKicker,
1990      navigationDestination,
1991      navigationCancel,
1992    };
1993  }
1994
1995  const devilUi = createDevilUi();
1996
1997  function makeDevilButton(label, action, className = "") {
1998    const button = createButton(label, className ? `devil-guide-choice ${className}` : "devil-guide-choice");
1999    button.addEventListener("click", action);
2000    return button;
2001  }
2002
2003  function closeDevilDialog() {
2004    devilUi.overlay.classList.remove("is-open");
2005    devilUi.overlay.setAttribute("aria-hidden", "true");
2006    state.modalOpen = false;
2007    state.devil.phase = "dormant";
2008    devilModel.visible = false;
2009    devilUi.summon.classList.add("is-visible");
2010    devilUi.summon.setAttribute("aria-hidden", "false");
2011  }
2012
2013  function renderDevilQuestion(view = "question") {
2014    devilUi.view = view;
2015    devilUi.actions.replaceChildren();
2016    if (view === "hint") {
2017      setLocalizedText(devilUi.message, "runtime.escapeHint", DEVIL_ESCAPE_COPY);
2018      devilUi.actions.append(
2019        makeDevilButton(t("runtime.gotIt", "わかった"), closeDevilDialog),
2020        makeDevilButton(t("runtime.back", "戻る"), () => renderDevilQuestion(), "secondary"),
2021      );
2022      return;
2023    }
2024    if (view === "creator") {
2025      setLocalizedText(
2026        devilUi.message,
2027        "runtime.creatorCopy",
2028        "創造主は、ASS MAGICという謎の音楽ユニットだ。\nそれ以上のことはわからない。\n\n本人たちさえ、一体何を作っているのかよくわかっていない。",
2029      );
2030      devilUi.actions.append(
2031        makeDevilButton(t("runtime.oh", "へぇ"), closeDevilDialog),
2032        makeDevilButton(t("runtime.back", "戻る"), () => renderDevilQuestion(), "secondary"),
2033      );
2034      return;
2035    }
2036    if (view === "destinations") {
2037      setLocalizedText(devilUi.message, "runtime.whereTo", "どこへ行きたい?");
2038      for (const destination of DEVIL_DESTINATIONS) {
2039        const label = getDevilDestinationLabel(destination, state.blackBoxOpened);
2040        devilUi.actions.append(makeDevilButton(label, () => startDevilRoute(destination)));
2041      }
2042      devilUi.actions.append(makeDevilButton(t("runtime.back", "戻る"), () => renderDevilQuestion(), "secondary"));
2043      return;
2044    }
2045    setLocalizedText(devilUi.message, "runtime.whatDoYouWant", "何が望みだ?");
2046    const choices = [
2047      makeDevilButton(t("runtime.explore", "自由に探索したい"), closeDevilDialog),
2048      makeDevilButton(t("runtime.needHint", "ヒントがほしい"), () => renderDevilQuestion("hint")),
2049      makeDevilButton(t("runtime.needGuide", "案内してほしい"), () => renderDevilQuestion("destinations")),
2050    ];
2051    if (window.assSiteProfile?.showCreatorStory !== false) {
2052      choices.push(makeDevilButton(t("runtime.aboutCreator", "創造主について"), () => renderDevilQuestion("creator")));
2053    }
2054    devilUi.actions.append(...choices);
2055  }
2056
2057  function refreshDevilLocalization() {
2058    setLocalizedText(devilUi.kicker, "runtime.devilEncounter", "悪魔に遭遇");
2059    setLocalizedText(devilUi.summon, "runtime.summonDevil", "悪魔を呼ぶ");
2060    setLocalizedText(devilUi.navigationKicker, "runtime.devilGuide", "悪魔の案内");
2061    setLocalizedText(devilUi.navigationCancel, "runtime.cancel", "中止");
2062    if (devilUi.overlay.classList.contains("is-open")) {
2063      renderDevilQuestion(devilUi.view || "question");
2064    }
2065    if (state.devil.route) {
2066      devilUi.navigationDestination.textContent = getDevilDestinationLabel(state.devil.route, state.blackBoxOpened);
2067    }
2068  }
2069
2070  function openDevilDialog() {
2071    if (!isMainExperience()) return;
2072    state.devil.phase = "dialog";
2073    state.modalOpen = true;
2074    clearFlightInput();
2075    devilModel.visible = true;
2076    devilModel.position.copy(state.devil.anchorPosition);
2077    orientDevil(devilTarget.copy(flight.position).sub(devilModel.position));
2078    renderDevilQuestion();
2079    devilUi.overlay.classList.add("is-open");
2080    devilUi.overlay.setAttribute("aria-hidden", "false");
2081  }
2082
2083  function stopDevilRoute(completed = false) {
2084    const navigation = state.devil.navigation;
2085    state.devil.route = null;
2086    state.devil.phase = "dormant";
2087    devilModel.visible = false;
2088    devilUi.navigation.classList.remove("is-visible");
2089    devilUi.navigation.setAttribute("aria-hidden", "true");
2090    devilUi.summon.classList.add("is-visible");
2091    devilUi.summon.setAttribute("aria-hidden", "false");
2092    document.body.classList.remove("devil-guide-navigating");
2093    navigation.destination = null;
2094    navigation.elapsed = 0;
2095    navigation.waitElapsed = 0;
2096    flight.radialSpeed = 0;
2097    if (completed) {
2098      onGuideSpeedChange?.(12);
2099      flight.speed = 12;
2100    } else {
2101      onGuideSpeedChange?.(navigation.savedSpeedSelection);
2102      flight.speed = navigation.savedSpeed;
2103    }
2104  }
2105
2106  function alignFlightAtGuideDestination(navigation) {
2107    const endRadius = getSurfaceRadius(navigation.endDirection) + 0.9 + navigation.endAltitude;
2108    flight.position.copy(navigation.endDirection).multiplyScalar(endRadius);
2109    devilRouteForward.copy(navigation.focusPoint).sub(flight.position)
2110      .addScaledVector(
2111        navigation.endDirection,
2112        -devilRouteForward.dot(navigation.endDirection),
2113      );
2114    if (devilRouteForward.lengthSq() < 0.0001) {
2115      const lastIndex = navigation.pathDirections.length - 1;
2116      const previousIndex = Math.max(0, lastIndex - 1);
2117      devilRouteForward.copy(navigation.pathDirections[lastIndex])
2118        .sub(navigation.pathDirections[previousIndex])
2119        .addScaledVector(
2120          navigation.endDirection,
2121          -devilRouteForward.dot(navigation.endDirection),
2122        );
2123    }
2124    if (devilRouteForward.lengthSq() > 0.0001) flight.forward.copy(devilRouteForward).normalize();
2125    flight.radialSpeed = 0;
2126    flight.bodyPitch = 0;
2127    flight.roll = 0;
2128    flight.onGround = false;
2129  }
2130
2131  function didBlackBoxCrossGuideTarget(start, end, radius) {
2132    devilBlackBoxSegment.subVectors(end, start);
2133    const lengthSq = devilBlackBoxSegment.lengthSq();
2134    if (lengthSq < 0.000001) return start.distanceToSquared(flight.position) <= radius * radius;
2135    devilBlackBoxOffset.subVectors(flight.position, start);
2136    const ratio = THREE.MathUtils.clamp(
2137      devilBlackBoxOffset.dot(devilBlackBoxSegment) / lengthSq,
2138      0,
2139      1,
2140    );
2141    devilBlackBoxClosestPoint.copy(start).addScaledVector(devilBlackBoxSegment, ratio);
2142    return devilBlackBoxClosestPoint.distanceToSquared(flight.position) <= radius * radius;
2143  }
2144
2145  function buildDevilRoutePlan(navigation) {
2146    const planner = DEVIL_ROUTE_PLANNER[quality] || DEVIL_ROUTE_PLANNER.standard;
2147    const isBlackBoxRoute = navigation.destination?.special === "blackBox";
2148    const sampleCount = isBlackBoxRoute ? planner.samples * 2 : planner.samples;
2149    const start = navigation.startDirection;
2150    const end = navigation.endDirection;
2151    const routeAxis = new THREE.Vector3().crossVectors(start, end);
2152    if (routeAxis.lengthSq() < 0.000001) {
2153      routeAxis.crossVectors(
2154        start,
2155        Math.abs(start.y) < 0.92 ? LOCAL_Y_AXIS : new THREE.Vector3(1, 0, 0),
2156      );
2157    }
2158    routeAxis.normalize();
2159    const directMidpoint = start.clone().add(end);
2160    if (directMidpoint.lengthSq() < 0.000001) directMidpoint.copy(routeAxis).cross(start);
2161    directMidpoint.normalize();
2162    const baseSurface = Math.min(getSurfaceRadius(start), getSurfaceRadius(end));
2163    const startRadius = flight.position.length();
2164    let best = null;
2165
2166    for (const sideOffset of planner.offsets) {
2167      const control = directMidpoint.clone().addScaledVector(routeAxis, sideOffset).normalize();
2168      const directions = [];
2169      const surfaces = [];
2170      let terrainCost = 0;
2171      let maximumRise = 0;
2172      let totalSlope = 0;
2173      let angularLength = 0;
2174      let previousSurface = getSurfaceRadius(start);
2175      let previousDirection = start;
2176
2177      for (let index = 0; index <= sampleCount; index += 1) {
2178        const t = index / sampleCount;
2179        const oneMinusT = 1 - t;
2180        const direction = start.clone().multiplyScalar(oneMinusT * oneMinusT)
2181          .addScaledVector(control, 2 * oneMinusT * t)
2182          .addScaledVector(end, t * t)
2183          .normalize();
2184        const surface = getSurfaceRadius(direction);
2185        const rise = Math.max(0, surface - baseSurface);
2186        const slope = index > 0 ? Math.abs(surface - previousSurface) : 0;
2187        if (index > 0) {
2188          angularLength += Math.acos(THREE.MathUtils.clamp(previousDirection.dot(direction), -1, 1));
2189        }
2190        maximumRise = Math.max(maximumRise, rise);
2191        totalSlope += slope;
2192        // Prefer a broad side-route before raising the guide high enough to
2193        // leave the player's view near steep terrain.
2194        terrainCost += rise * rise * 0.32 + slope * 12.5;
2195        directions.push(direction);
2196        surfaces.push(surface);
2197        previousSurface = surface;
2198        previousDirection = direction;
2199      }
2200
2201      const routeDistance = angularLength * Math.max(baseSurface, 1);
2202      const cost = routeDistance + terrainCost + Math.abs(sideOffset) * routeDistance * 0.08;
2203      if (!best || cost < best.cost) {
2204        best = {
2205          cost,
2206          directions,
2207          surfaces,
2208          routeDistance,
2209          risk: maximumRise + totalSlope / Math.max(1, sampleCount) * 2.2,
2210          sideOffset,
2211        };
2212      }
2213    }
2214
2215    const radii = [];
2216    const cumulative = [0];
2217    let pathLength = 0;
2218    const endRadius = best.surfaces[best.surfaces.length - 1] + 0.9 + navigation.endAltitude;
2219    const routeClearance = isBlackBoxRoute
2220      ? DEVIL_BLACK_BOX_ROUTE_CLEARANCE
2221      : DEVIL_ROUTE_CLEARANCE;
2222    if (isBlackBoxRoute) {
2223      // For the fast target, build a terrain envelope rather than one tall
2224      // global parabola. Future peaks propagate backward with a limited climb
2225      // grade, so avoidance begins far away without sending the player far
2226      // above the scene. The forward pass makes the descent even shallower.
2227      const lastIndex = best.directions.length - 1;
2228      for (let index = 0; index <= lastIndex; index += 1) {
2229        radii[index] = index === 0
2230          ? startRadius
2231          : best.surfaces[index] + 0.9 + routeClearance;
2232      }
2233      radii[lastIndex] = Math.max(endRadius, radii[lastIndex]);
2234      for (let index = lastIndex - 1; index >= 1; index -= 1) {
2235        const angle = Math.acos(THREE.MathUtils.clamp(
2236          best.directions[index].dot(best.directions[index + 1]),
2237          -1,
2238          1,
2239        ));
2240        const segmentDistance = angle * Math.max(
2241          1,
2242          (best.surfaces[index] + best.surfaces[index + 1]) * 0.5,
2243        );
2244        radii[index] = Math.max(radii[index], radii[index + 1] - segmentDistance * 0.18);
2245      }
2246      for (let index = 1; index <= lastIndex; index += 1) {
2247        const angle = Math.acos(THREE.MathUtils.clamp(
2248          best.directions[index - 1].dot(best.directions[index]),
2249          -1,
2250          1,
2251        ));
2252        const segmentDistance = angle * Math.max(
2253          1,
2254          (best.surfaces[index - 1] + best.surfaces[index]) * 0.5,
2255        );
2256        radii[index] = Math.max(radii[index], radii[index - 1] - segmentDistance * 0.1);
2257      }
2258      // The old route kept the generic 26-unit clearance at the final sample,
2259      // then lowered the player to the box only after waiting began. Ease onto
2260      // the predicted interception radius during the route itself instead.
2261      const arrivalStart = Math.max(1, lastIndex - Math.max(4, Math.round(lastIndex * 0.22)));
2262      const arrivalStartRadius = radii[arrivalStart];
2263      for (let index = arrivalStart + 1; index <= lastIndex; index += 1) {
2264        const rawMix = (index - arrivalStart) / Math.max(1, lastIndex - arrivalStart);
2265        const arrivalMix = rawMix * rawMix * (3 - 2 * rawMix);
2266        const arrivalRadius = THREE.MathUtils.lerp(arrivalStartRadius, endRa
2266dius, arrivalMix);
2267        const minimumRadius = best.surfaces[index] + 0.9 + DEVIL_BLACK_BOX_ALTITUDE;
2268        radii[index] = Math.max(minimumRadius, arrivalRadius);
2269      }
2270      radii[lastIndex] = endRadius;
2271    } else {
2272      let arcLift = 0;
2273      for (let index = 1; index < best.directions.length - 1; index += 1) {
2274        const t = index / Math.max(1, best.directions.length - 1);
2275        const baseline = THREE.MathUtils.lerp(startRadius, endRadius, t);
2276        const parabolaWeight = 4 * t * (1 - t);
2277        const requiredRadius = best.surfaces[index] + 0.9 + routeClearance;
2278        arcLift = Math.max(arcLift, (requiredRadius - baseline) / Math.max(0.0001, parabolaWeight));
2279      }
2280      // A single low parabola clears every sampled point on the already-detoured
2281      // route, keeping the devil framed instead of pinning the whole trip high.
2282      arcLift = Math.max(0, arcLift) + 2.5;
2283      for (let index = 0; index < best.directions.length; index += 1) {
2284        const t = index / Math.max(1, best.directions.length - 1);
2285        const baseline = THREE.MathUtils.lerp(startRadius, endRadius, t);
2286        const parabolicRadius = baseline + 4 * t * (1 - t) * arcLift;
2287        radii[index] = index === 0
2288          ? startRadius
2289          : index === best.directions.length - 1
2290            ? endRadius
2291            : parabolicRadius;
2292      }
2293    }
2294    for (let index = 0; index < best.directions.length; index += 1) {
2295      const radius = radii[index];
2296      if (index > 0) {
2297        const angle = Math.acos(THREE.MathUtils.clamp(
2298          best.directions[index - 1].dot(best.directions[index]),
2299          -1,
2300          1,
2301        ));
2302        const tangential = angle * (radii[index - 1] + radius) * 0.5;
2303        const radial = radius - radii[index - 1];
2304        pathLength += Math.hypot(tangential, radial);
2305        cumulative.push(pathLength);
2306      }
2307    }
2308
2309    navigation.pathDirections = best.directions;
2310    navigation.pathRadii = radii;
2311    navigation.pathCumulative = cumulative;
2312    navigation.pathLength = pathLength;
2313    navigation.routeRisk = best.risk;
2314    navigation.plannedSpeed = DEVIL_ROUTE_SPEED;
2315    navigation.routeAxis.copy(routeAxis);
2316    navigation.arcAngle = Math.acos(THREE.MathUtils.clamp(start.dot(end), -1, 1));
2317    canvas.dataset.guideRouteSide = best.sideOffset.toFixed(2);
2318    canvas.dataset.guideRouteRisk = best.risk.toFixed(1);
2319    canvas.dataset.guideRouteSamples = String(best.directions.length);
2320  }
2321
2322  function startDevilRoute(destination) {
2323    if (!isMainExperience()) return;
2324    const target = landmarks.objects[destination.id];
2325    if (!target || !target.parent || !target.visible) return;
2326    const navigation = state.devil.navigation;
2327    target.getWorldPosition(targetPosition);
2328    navigation.destination = destination;
2329    navigation.startDirection.copy(flight.position).normalize();
2330    navigation.startAltitude = Math.max(0, getAltitude?.(flight.position) || 0);
2331    navigation.endAltitude = destination.endAltitude;
2332    navigation.savedSpeedSelection = flight.speedSelection;
2333    navigation.savedSpeed = flight.speed;
2334
2335    if (destination.special === "blackBox" && landmarks.isBlackBoxMoving?.()) {
2336      let estimatedDuration = 5;
2337      let predictedSeconds = estimatedDuration + DEVIL_BLACK_BOX_ARRIVAL_LEAD;
2338      for (let attempt = 0; attempt < 3; attempt += 1) {
2339        predictedSeconds = estimatedDuration + DEVIL_BLACK_BOX_ARRIVAL_LEAD;
2340        if (landmarks.predictBlackBoxDayIntercept) {
2341          predictedSeconds = landmarks.predictBlackBoxDayIntercept(
2342            predictedSeconds,
2343            targetPosition,
2344          );
2345          navigation.endDirection.copy(targetPosition).normalize();
2346        } else {
2347          landmarks.predictBlackBoxDirection(predictedSeconds, navigation.endDirection);
2348        }
2349        const routeAngle = Math.acos(THREE.MathUtils.clamp(
2350          navigation.startDirection.dot(navigation.endDirection),
2351          -1,
2352          1,
2353        ));
2354        estimatedDuration = THREE.MathUtils.clamp(
2355          routeAngle * flight.position.length() * DEVIL_ROUTE_EASE_PEAK / DEVIL_ROUTE_SPEED,
2356          DEVIL_ROUTE_MIN_DURATION,
2357          DEVIL_ROUTE_MAX_DURATION,
2358        );
2359      }
2360      if (landmarks.predictBlackBoxDayIntercept) {
2361        predictedSeconds = landmarks.predictBlackBoxDayIntercept(
2362          estimatedDuration + DEVIL_BLACK_BOX_ARRIVAL_LEAD,
2363          targetPosition,
2364        );
2365        navigation.endDirection.copy(targetPosition).normalize();
2366        navigation.endAltitude = Math.max(
2367          DEVIL_BLACK_BOX_ALTITUDE,
2368          targetPosition.length() - getSurfaceRadius(navigation.endDirection) - 0.9,
2369        );
2370        navigation.focusPoint.copy(targetPosition);
2371        canvas.dataset.guideBlackBoxDaylight = navigation.endDirection
2372          .dot(landmarks.directions.day)
2373          .toFixed(3);
2374        canvas.dataset.guideBlackBoxInterceptSeconds = predictedSeconds.toFixed(2);
2375        navigation.interceptSeconds = predictedSeconds;
2376      } else if (landmarks.predictBlackBoxPosition) {
2377        landmarks.predictBlackBoxPosition(predictedSeconds, targetPosition);
2378        navigation.endDirection.copy(targetPosition).normalize();
2379        navigation.endAltitude = Math.max(
2380          DEVIL_BLACK_BOX_ALTITUDE,
2381          targetPosition.length() - getSurfaceRadius(navigation.endDirection) - 0.9,
2382        );
2383        navigation.focusPoint.copy(targetPosition);
2384      } else {
2385        navigation.focusPoint.copy(navigation.endDirection)
2386          .multiplyScalar(getSurfaceRadius(navigation.endDirection) + DEVIL_BLACK_BOX_ALTITUDE);
2387      }
2388    } else if (destination.special === "blackBox") {
2389      // Once caught, the box stays at its exact last flight position. Arrive
2390      // at that position instead of applying the generic stand-off angle.
2391      navigation.endDirection.copy(targetPosition).normalize();
2392      navigation.endAltitude = Math.max(
2393        0,
2394        targetPosition.length() - getSurfaceRadius(navigation.endDirection) - 0.9,
2395      );
2396      navigation.focusPoint.copy(targetPosition);
2397      navigation.interceptSeconds = 0;
2398    } else {
2399      navigation.interceptSeconds = 0;
2400      targetDirection.copy(targetPosition).normalize();
2401      devilRouteTowardStart.copy(navigation.startDirection)
2402        .addScaledVector(targetDirection, -navigation.startDirection.dot(targetDirection));
2403      if (devilRouteTowardStart.lengthSq() < 0.0001) {
2404        devilRouteTowardStart.copy(flight.forward)
2405          .addScaledVector(targetDirection, -flight.forward.dot(targetDirection));
2406      }
2407      if (devilRouteTowardStart.lengthSq() < 0.0001) {
2408        devilRouteTowardStart.crossVectors(
2409          targetDirection,
2410          Math.abs(targetDirection.y) < 0.92 ? LOCAL_Y_AXIS : new THREE.Vector3(1, 0, 0),
2411        );
2412      }
2413      devilRouteTowardStart.normalize();
2414      const stopAngle = destination.stopDistance / Math.max(1, getSurfaceRadius(targetDirection));
2415      navigation.endDirection.copy(targetDirection)
2416        .multiplyScalar(Math.cos(stopAngle))
2417        .addScaledVector(devilRouteTowardStart, Math.sin(stopAngle))
2418        .normalize();
2419      navigation.focusPoint.copy(targetPosition)
2420        .addScaledVector(targetDirection, destination.focusHeight);
2421    }
2422
2423    buildDevilRoutePlan(navigation);
2424    navigation.duration = THREE.MathUtils.clamp(
2425      navigation.pathLength * DEVIL_ROUTE_EASE_PEAK / navigation.plannedSpeed,
2426      DEVIL_ROUTE_MIN_DURATION,
2427      DEVIL_ROUTE_MAX_DURATION,
2428    );
2429    navigation.timeout = navigation.duration + 6;
2430    navigation.elapsed = 0;
2431    navigation.previousPathDistance = 0;
2432    navigation.waitElapsed = 0;
2433    clearFlightInput();
2434    devilUi.overlay.classList.remove("is-open");
2435    devilUi.overlay.setAttribute("aria-hidden", "true");
2436    state.modalOpen = false;
2437    state.devil.route = destination;
2438    state.devil.phase = "route";
2439    devilUi.navigationDestination.textContent = getDevilDestinationLabel(destination, state.blackBoxOpened);
2440    devilUi.navigation.classList.add("is-visible");
2441    devilUi.navigation.setAttribute("aria-hidden", "false");
2442    devilUi.summon.classList.remove("is-visible");
2443    devilUi.summon.setAttribute("aria-hidden", "true");
2444    devilModel.visible = true;
2445    flight.radialSpeed = 0;
2446    flight.bodyPitch = 0;
2447    flight.roll = 0;
2448    flight.onGround = false;
2449    document.body.classList.add("devil-guide-navigating");
2450  }
2451
2452  function orientDevil(forward) {
2453    devilUp.copy(devilModel.position).normalize();
2454    devilForward.copy(forward).addScaledVector(devilUp, -forward.dot(devilUp));
2455    if (devilForward.lengthSq() < 0.0001) devilForward.copy(flight.forward);
2456    devilForward.normalize();
2457    devilRight.crossVectors(devilUp, devilForward).normalize();
2458    devilBasis.makeBasis(devilRight, devilUp, devilForward);
2459    devilModel.quaternion.setFromRotationMatrix(devilBasis);
2460  }
2461
2462  function placeDevilNearPlayer(forwardDistance, sideDistance) {
2463    playerUp.copy(flight.position).normalize();
2464    devilRight.crossVectors(playerUp, flight.forward).normalize();
2465    const radius = flight.position.length() + DEVIL_SPAWN_HEIGHT;
2466    devilTarget.copy(playerUp).multiplyScalar(radius)
2467      .addScaledVector(flight.forward, forwardDistance)
2468      .addScaledVector(devilRight, sideDistance)
2469      .normalize();
2470    state.devil.anchorUp.copy(devilTarget);
2471    state.devil.flightRadius = radius;
2472    state.devil.anchorPosition.copy(devilTarget).multiplyScalar(radius);
2473    devilModel.position.copy(state.devil.anchorPosition);
2474  }
2475
2476  function spawnDevil() {
2477    const side = state.devil.lastSide || -1;
2478    placeDevilNearPlayer(DEVIL_ENTRY_FORWARD, side * DEVIL_ENTRY_SIDE);
2479    state.devil.flightForward.copy(devilRight).multiplyScalar(-side).normalize();
2480    orientDevil(state.devil.flightForward);
2481    devilModel.visible = true;
2482    state.devil.phase = "entry";
2483    state.devil.entryTime = DEVIL_ENTRY_DURATION;
2484    state.devil.noticeGrace = 0;
2485    state.devil.outOfViewTime = 0;
2486    state.devil.hasBeenVisible = false;
2487    state.devil.encounterTime = 0;
2488    state.devil.lastPlayerPosition.copy(flight.position);
2489  }
2490
2491  function scheduleChillDevil() {
2492    const devil = state.devil;
2493    const chillDebug = new URLSearchParams(window.location.search).get("chilldevildebug") === "1";
2494    devil.chillRoamInitialized = true;
2495    devil.chillRoamDebug = chillDebug;
2496    devil.chillRoamTime = 0;
2497    devil.chillRoamSeed = Math.random() * Math.PI * 2;
2498    devil.phase = "chill-delay";
2499    devil.timer = chillDebug
2500      ? 1.5
2501      : THREE.MathUtils.lerp(CHILL_DEVIL_DELAY_MIN, CHILL_DEVIL_DELAY_MAX, Math.random());
2502    devil.route = null;
2503    devil.navigation.destination = null;
2504    devilModel.visible = false;
2505    canvas.dataset.devilRoam = "waiting";
2506    canvas.dataset.devilRoamDelay = devil.timer.toFixed(1);
2507    canvas.dataset.devilRoamFollowing = "false";
2508  }
2509
2510  function spawnChillDevil() {
2511    const devil = state.devil;
2512    const latitudeY = Math.random() * 2 - 1;
2513    const horizontal = Math.sqrt(Math.max(0, 1 - latitudeY * latitudeY));
2514    const longitude = Math.random() * Math.PI * 2;
2515    devilTarget.set(
2516      Math.cos(longitude) * horizontal,
2517      latitudeY,
2518      Math.sin(longitude) * horizontal,
2519    );
2520    // Start on a distant part of the globe. This is only an initial placement
2521    // check; roaming never steers toward, follows, or flees from the player.
2522    playerUp.copy(flight.position).normalize();
2523    if (devilTarget.dot(playerUp) > 0.24) devilTarget.multiplyScalar(-1);
2524    devil.anchorUp.copy(devilTarget).normalize();
2525    devilRight.set(0, 1, 0);
2526    if (Math.abs(devilRight.dot(devil.anchorUp)) > 0.92) devilRight.set(1, 0, 0);
2527    devil.flightForward.crossVectors(devilRight, devil.anchorUp).normalize();
2528    devil.flightForward.applyAxisAngle(devil.anchorUp, Math.random() * Math.PI * 2);
2529    devil.flightRadius = getSurfaceRadius(devil.anchorUp) + 0.9 + DEVIL_SPAWN_HEIGHT;
2530    devil.anchorPosition.copy(devil.anchorUp).multiplyScalar(devil.flightRadius);
2531    devilModel.position.copy(devil.anchorPosition);
2532    devilModel.visible = true;
2533    devil.phase = "chill-roam";
2534    devil.chillRoamTime = 0;
2535    devil.bobTime = 0;
2536    orientDevil(devil.flightForward);
2537    canvas.dataset.devilRoam = "wandering";
2538    canvas.dataset.devilRoamDelay = "0.0";
2539  }
2540
2541  function updateChillDevil(delta) {
2542    const devil = state.devil;
2543    if (!devil.chillRoamInitialized) scheduleChillDevil();
2544    if (devil.phase === "chill-delay") {
2545      devil.timer = Math.max(0, devil.timer - delta);
2546      canvas.dataset.devilRoamDelay = devil.timer.toFixed(1);
2547      if (devil.timer <= 0) spawnChillDevil();
2548      return;
2549    }
2550    if (devil.phase !== "chill-roam") {
2551      scheduleChillDevil();
2552      return;
2553    }
2554
2555    devil.chillRoamTime += delta;
2556    devil.bobTime += delta * 1.45;
2557    devilUp.copy(devil.anchorUp).normalize();
2558    const wanderTurn = (
2559      Math.sin(devil.chillRoamTime * 0.17 + devil.chillRoamSeed)
2560      + Math.sin(devil.chillRoamTime * 0.071 + devil.chillRoamSeed * 1.73) * 0.46
2561    ) * CHILL_DEVIL_ROAM_TURN;
2562    devil.flightForward.applyAxisAngle(devilUp, wanderTurn * delta)
2563      .addScaledVector(devilUp, -devil.flightForward.dot(devilUp))
2564      .normalize();
2565    const roamSpeed = CHILL_DEVIL_ROAM_SPEED
2566      + Math.sin(devil.chillRoamTime * 0.23 + devil.chillRoamSeed)
2567        * CHILL_DEVIL_ROAM_SPEED_VARIATION;
2568    devilTravelAxis.crossVectors(devilUp, devil.flightForward).normalize();
2569    const moveAngle = roamSpeed * delta / Math.max(devil.flightRadius, 1);
2570    devilUp.applyAxisAngle(devilTravelAxis, moveAngle).normalize();
2571    devil.flightForward.applyAxisAngle(devilTravelAxis, moveAngle)
2572      .addScaledVector(devilUp, -devil.flightForward.dot(devilUp))
2573      .normalize();
2574    devil.flightRadius = THREE.MathUtils.damp(
2575      devil.flightRadius,
2576      getSurfaceRadius(devilUp) + 0.9 + DEVIL_SPAWN_HEIGHT,
2577      DEVIL_TERRAIN_RESPONSE,
2578      delta,
2579    );
2580    devil.anchorUp.copy(devilUp);
2581    devil.anchorPosition.copy(devilUp).multiplyScalar(devil.flightRadius);
2582    devilModel.position.copy(devil.anchorPosition)
2583      .addScaledVector(devil.anchorUp, Math.sin(devil.bobTime) * 0.28);
2584    orientDevil(devil.flightForward);
2585    devilModel.rotateZ(Math.sin(devil.bobTime * 0.61) * 0.065);
2586    for (const wing of devilModel.userData.wingRoots || []) {
2587      wing.root.rotation.y = wing.side * (0.2 + Math.sin(devil.bobTime * 7.2) * 0.48);
2588    }
2589    if (devil.chillRoamDebug) {
2590      canvas.dataset.devilRoamDistance = devil.anchorPosition.distanceTo(flight.position).toFixed(1);
2591    }
2592  }
2593
2594  function summonDevil(openDialog = false) {
2595    if (!isMainExperience()) return;
2596    devilUi.summon.classList.remove("is-visible");
2597    devilUi.summon.setAttribute("aria-hidden", "true");
2598    state.devil.outOfViewTime = 0;
2599    if (openDialog) {
2600      placeDevilNearPlayer(DEVIL_SPAWN_DISTANCE, -DEVIL_SPAWN_SIDE);
2601      state.devil.flightForward.copy(flight.forward);
2602      devilModel.visible = true;
2603      openDevilDialog();
2604      return;
2605    }
2606    spawnDevil();
2607  }
2608
2609  function updateDevilRoute(delta) {
2610    const navigation = state.devil.navigation;
2611    const route = navigation.destination;
2612    if (!route) {
2613      stopDevilRoute();
2614      return;
2615    }
2616
2617    // The route clock pauses while the shared flight controller is passing
2618    // through terrain, so the guide cannot pull the player back into it.
2619    if (isEnvironmentPhasing?.()) {
2620      navigation.phaseInterrupted = true;
2621      flight.speed = Math.max(flight.speed, DEVIL_ROUTE_SPEED * 0.58);
2622      return;
2623    }
2624    if (navigation.phaseInterrupted) {
2625      const savedSpeedSelection = navigation.savedSpeedSelection;
2626      const savedSpeed = navigation.savedSpeed;
2627      navigation.phaseInterrupted = false;
2628      startDevilRoute(route);
2629      navigation.savedSpeedSelection = savedSpeedSelection;
2630      navigation.savedSpeed = savedSpeed;
2631      return;
2632    }
2633
2634    if (state.devil.phase === "route-wait") {
2635      navigation.waitElapsed += delta;
2636      const blackBox = landmarks.objects.blackBox;
2637      blackBox?.getWorldPosition(worldPosition);
2638      if (blackBox && landmarks.isBlackBoxMoving?.()) {
2639        const blackBoxRadius = worldPosition.length();
2640        const waitRadius = navigation.pathRadii.at(-1);
2641        flight.position.copy(navigation.endDirection).multiplyScalar(waitRadius);
2642        canvas.dataset.guideBlackBoxAltitudeGap = (blackBoxRadius - waitRadius).toFixed(2);
2643        canvas.dataset.guideBlackBoxWaitRadius = waitRadius.toFixed(2);
2644        devilRouteForward.copy(worldPosition).sub(flight.position)
2645          .addScaledVector(
2646            navigation.endDirection,
2647            -devilRouteForward.dot(navigation.endDirection),
2648          );
2649        if (devilRouteForward.lengthSq() > 0.0001) {
2650          flight.forward.lerp(devilRouteForward.normalize(), 1 - Math.exp(-2.8 * delta)).normalize();
2651        }
2652        if (didBlackBoxCrossGuideTarget(
2653          devilBlackBoxPreviousPosition,
2654          worldPosition,
2655          DEVIL_BLACK_BOX_CONTACT_RADIUS,
2656        )) {
2657          stopDevilRoute(true);
2658          openProductionBlackBox();
2659          return;
2660        }
2661        devilBlackBoxPreviousPosition.copy(worldPosition);
2662      } else {
2663        stopDevilRoute();
2664        return;
2665      }
2666      flight.speed = 0;
2667      flight.radialSpeed = 0;
2668      if (navigation.waitElapsed >= DEVIL_BLACK_BOX_WAIT_TIMEOUT) {
2669        const predictedWait = Math.max(
2670          DEVIL_BLACK_BOX_WAIT_TIMEOUT,
2671          navigation.interceptSeconds - navigation.duration + 6,
2672        );
2673        if (navigation.waitElapsed >= predictedWait) stopDevilRoute();
2674      }
2675      return;
2676    }
2677
2678    navigation.elapsed += delta;
2679    if (navigation.elapsed >= navigation.timeout) {
2680      stopDevilRoute();
2681      return;
2682    }
2683    const progress = THREE.MathUtils.clamp(navigation.elapsed / navigation.duration, 0, 1);
2684    const eased = progress * progress * progress * (progress * (progress * 6 - 15) + 10);
2685    const pathDistance = navigation.pathLength * eased;
2686    const routeFrameSpeed = Math.abs(pathDistance - navigation.previousPathDistance)
2687      / Math.max(delta, 1 / 240);
2688    navigation.previousPathDistance = pathDistance;
2689    let pathIndex = 1;
2690    while (
2691      pathIndex < navigation.pathCumulative.length - 1
2692      && navigation.pathCumulative[pathIndex] < pathDistance
2693    ) pathIndex += 1;
2694    const segmentStart = Math.max(0, pathIndex - 1);
2695    const segmentLength = Math.max(
2696      0.0001,
2697      navigation.pathCumulative[pathIndex] - navigation.pathCumulative[segmentStart],
2698    );
2699    const segmentMix = THREE.MathUtils.clamp(
2700      (pathDistance - navigation.pathCumulative[segmentStart]) / segmentLength,
2701      0,
2702      1,
2703    );
2704    devilRouteDirection.copy(navigation.pathDirections[segmentStart])
2705      .lerp(navigation.pathDirections[pathIndex], segmentMix)
2706      .normalize();
2707    const routeRadius = THREE.MathUtils.lerp(
2708      navigation.pathRadii[segmentStart],
2709      navigation.pathRadii[pathIndex],
2710      segmentMix,
2711    );
2712    flight.position.copy(devilRouteDirection).multiplyScalar(routeRadius);
2713    const forwardIndex = Math.min(navigation.pathDirections.length - 1, pathIndex + 1);
2714    devilRouteForward.copy(navigation.pathDirections[forwardIndex])
2715      .addScaledVector(
2716        devilRouteDirection,
2717        -navigation.pathDirections[forwardIndex].dot(devilRouteDirection),
2718      )
2719      .normalize();
2720    if (devilRouteForward.lengthSq() < 0.0001) devilRouteForward.copy(flight.forward);
2721    flight.forward.lerp(devilRouteForward, 1 - Math.exp(-3.2 * delta)).normalize();
2722    flight.speed = THREE.MathUtils.damp(
2723      flight.speed,
2724      Math.min(DEVIL_ROUTE_SPEED, routeFrameSpeed),
2725      4.2,
2726      delta,
2727    );
2728    flight.radialSpeed = 0;
2729    flight.bodyPitch = THREE.MathUtils.damp(flight.bodyPitch, 0, 6, delta);
2730    flight.roll = THREE.MathUtils.damp(flight.roll, 0, 6, delta);
2731
2732    const leadDistance = Math.min(
2733      navigation.pathLength,
2734      pathDistance + DEVIL_ROUTE_LEAD_DISTANCE,
2735    );
2736    let leadIndex = pathIndex;
2737    while (
2738      leadIndex < navigation.pathCumulative.length - 1
2739      && navigation.pathCumulative[leadIndex] < leadDistance
2740    ) leadIndex += 1;
2741    const leadStart = Math.max(0, leadIndex - 1);
2742    const leadSegmentLength = Math.max(
2743      0.0001,
2744      navigation.pathCumulative[leadIndex] - navigation.pathCumulative[leadStart],
2745    );
2746    const leadMix = THREE.MathUtils.clamp(
2747      (leadDistance - navigation.pathCumulative[leadStart]) / leadSegmentLength,
2748      0,
2749      1,
2750    );
2751    devilTarget.copy(navigation.pathDirections[leadStart])
2752      .lerp(navigation.pathDirections[leadIndex], leadMix)
2753      .normalize();
2754    const leadRadius = THREE.MathUtils.lerp(
2755      navigation.pathRadii[leadStart],
2756      navigation.pathRadii[leadIndex],
2757      leadMix,
2758    );
2759    devilDesired.copy(devilTarget).multiplyScalar(leadRadius + 2.8);
2760    const guideLeadResponse = THREE.MathUtils.lerp(
2761      1.45,
2762      4.2,
2763      THREE.MathUtils.smoothstep(progress, 0.04, 0.32),
2764    );
2765    state.devil.anchorPosition.lerp(
2766      devilDesired,
2767      1 - Math.exp(-guideLeadResponse * delta),
2768    );
2769    state.devil.anchorUp.copy(state.devil.anchorPosition).normalize();
2770    devilRelativeEnd.copy(state.devil.anchorUp).sub(devilRouteDirection);
2771    const devilLeadDistance = Math.acos(THREE.MathUtils.clamp(
2772      state.devil.anchorUp.dot(devilRouteDirection),
2773      -1,
2774      1,
2775    )) * routeRadius;
2776    canvas.dataset.guideDevilLead = (
2777      devilRelativeEnd.dot(devilRouteForward) >= 0 ? devilLeadDistance : -devilLeadDistance
2778    ).toFixed(2);
2779    state.devil.flightForward.copy(devilTarget)
2780      .addScaledVector(devilRouteDirection, -devilTarget.dot(devilRouteDirection))
2781      .normalize();
2782    if (state.devil.flightForward.lengthSq() < 0.0001) {
2783      state.devil.flightForward.copy(devilRouteForward);
2784    }
2785    devilModel.position.copy(state.devil.anchorPosition);
2786    orientDevil(state.devil.flightForward);
2787
2788    if (progress < 1) return;
2789    if (route.special === "blackBox" && landmarks.isBlackBoxMoving?.()) {
2790      const blackBox = landmarks.objects.blackBox;
2791      blackBox?.getWorldPosition(devilBlackBoxPreviousPosition);
2792      state.devil.phase = "route-wait";
2793      navigation.waitElapsed = 0;
2794      flight.speed = 0;
2795      return;
2796    }
2797    if (route.special === "blackBox") {
2798      alignFlightAtGuideDestination(navigation);
2799      stopDevilRoute(true);
2800      openProductionBlackBox();
2801      return;
2802    }
2803    alignFlightAtGuideDestination(navigation);
2804    stopDevilRoute(true);
2805  }
2806
2807  function updateDevil(delta) {
2808    const devil = state.devil;
2809    const interactionsEnabled = isMainExperience();
2810    if (!interactionsEnabled) {
2811      updateChillDevil(delta);
2812      return;
2813    }
2814    if (isSkating?.()) {
2815      // Skate is an intentionally separate play mode: no surprise encounter,
2816      // no active guide route, and no summon control while riding.
2817      if (!devil.skateSuppressed) {
2818        devil.skateSuppressed = true;
2819        if (devil.phase === "route" || devil.phase === "route-wait") stopDevilRoute(false);
2820        devil.phase = "dormant";
2821        devil.route = null;
2822        devil.navigation.destination = null;
2823        devilModel.visible = false;
2824        devilUi.overlay.classList.remove("is-open");
2825        devilUi.overlay.setAttribute("aria-hidden", "true");
2826        devilUi.navigation.classList.remove("is-visible");
2827        devilUi.navigation.setAttribute("aria-hidden", "true");
2828        document.body.classList.remove("devil-guide-navigating");
2829        state.modalOpen = false;
2830      }
2831      devilUi.summon.classList.remove("is-visible");
2832      devilUi.summon.setAttribute("aria-hidden", "true");
2833      return;
2834    }
2835    if (devil.skateSuppressed) {
2836      devil.skateSuppressed = false;
2837      devilUi.summon.classList.add("is-visible");
2838      devilUi.summon.setAttribute("aria-hidden", "false");
2839    }
2840    if (state.modalOpen && devil.phase !== "dialog") return;
2841    if (devil.phase === "route" || devil.phase === "route-wait") {
2842      updateDevilRoute(delta);
2843      return;
2844    }
2845    if (["dialog", "dormant"].includes(devil.phase) || state.ending) return;
2846    if (devil.phase === "delay") {
2847      devil.timer -= delta;
2848      if (devil.timer <= 0) spawnDevil();
2849      return;
2850    }
2851    devil.encounterTime += delta;
2852    devilPreviousAnchor.copy(devil.anchorPosition);
2853    if (devil.phase === "entry") {
2854      devil.entryTime = Math.max(0, devil.entryTime - delta);
2855      const progress = 1 - devil.entryTime / DEVIL_ENTRY_DURATION;
2856      const eased = progress * progress * (3 - 2 * progress);
2857      devilUp.copy(devil.anchorPosition).normalize();
2858      devilDesired.copy(flight.forward)
2859        .addScaledVector(devilUp, -flight.forward.dot(devilUp))
2860        .normalize();
2861      devilRight.crossVectors(devilUp, devilDesired).normalize();
2862      devilEntryInward.copy(devilRight).multiplyScalar(-devil.lastSide);
2863      const extraForward = (DEVIL_SPAWN_DISTANCE - DEVIL_ENTRY_FORWARD) / DEVIL_ENTRY_DURATION;
2864      const inward = (DEVIL_ENTRY_SIDE - DEVIL_SPAWN_SIDE) / DEVIL_ENTRY_DURATION;
2865      devilDesired.multiplyScalar(flight.speed + extraForward)
2866        .addScaledVector(devilEntryInward, inward);
2867      const entrySpeed = devilDesired.length();
2868      devilDesired.normalize();
2869      devilTravelAxis.crossVectors(devilUp, devilDesired).normalize();
2870      devilUp.applyAxisAngle(devilTravelAxis, entrySpeed * delta / Math.max(devil.flightRadius, 1)).normalize();
2871      devil.flightRadius = THREE.MathUtils.damp(
2872        devil.flightRadius,
2873        getSurfaceRadius(devilUp) + 0.9 + DEVIL_SPAWN_HEIGHT,
2874        DEVIL_TERRAIN_RESPONSE,
2875        delta,
2876      );
2877      devil.anchorUp.copy(devilUp);
2878      devil.anchorPosition.copy(devilUp).multiplyScalar(devil.flightRadius);
2879      devil.flightForward.copy(devilEntryInward).lerp(devilDesired, eased * 0.72).normalize();
2880      if (devil.entryTime <= 0) {
2881        devil.phase = "waiting";
2882      }
2883    }
2884    const distance = devil.anchorPosition.distanceTo(flight.position);
2885    if (devil.phase === "waiting") {
2886      if (distance < DEVIL_NOTICE_RADIUS) {
2887        devil.phase = "flee";
2888        devil.fleeTime = 0;
2889        devil.noticeGrace = 0.65;
2890      }
2891    }
2892    if (devil.phase === "flee") {
2893      devil.noticeGrace = Math.max(0, devil.noticeGrace - delta);
2894      devil.fleeTime += delta;
2895      devilUp.copy(devil.anchorPosition).normalize();
2896      devilAway.copy(devil.anchorPosition).sub(flight.position)
2897        .addScaledVector(devilUp, -devilUp.dot(devilAway));
2898      if (devilAway.lengthSq() < 0.0001) devilAway.copy(devil.flightForward);
2899      else devilAway.normalize();
2900      devilDesired.copy(flight.forward)
2901        .addScaledVector(devilUp, -flight.forward.dot(devilUp))
2902        .normalize();
2903      devilEvadeRight.crossVectors(devilUp, devilAway).normalize();
2904      const proximity = THREE.MathUtils.clamp(1 - distance / DEVIL_NOTICE_RADIUS, 0, 1);
2905      devil.evadeWave = Math.sin(devil.fleeTime * DEVIL_FLEE_WEAVE_SPEED);
2906      devilDesired.multiplyScalar(0.78)
2907        .addScaledVector(devilAway, 0.22)
2908        .addScaledVector(devilEvadeRight, devil.evadeWave * DEVIL_FLEE_WEAVE_AMOUNT * (0.7 + proximity * 0.3))
2909        .normalize();
2910      devil.flightForward.lerp(
2911        devilDesired,
2912        1 - Math.exp(-DEVIL_FLEE_TURN_RESPONSE * delta),
2913      ).addScaledVector(devilUp, -devil.flightForward.dot(devilUp)).normalize();
2914      const speedPulse = Math.pow(
2915        0.5 + Math.sin(devil.fleeTime * DEVIL_FLEE_SPEED_PULSE) * 0.5,
2916        DEVIL_FLEE_ACCEL_SHARPNESS,
2917      );
2918      const speedGap = THREE.MathUtils.lerp(DEVIL_FLEE_SLOW_GAP, DEVIL_FLEE_FAST_GAP, speedPulse);
2919      const fleeSpeed = THREE.MathUtils.clamp(
2920        flight.speed - speedGap,
2921        DEVIL_FLEE_MIN_SPEED,
2922        DEVIL_FLEE_MAX_SPEED,
2923      );
2924      devilTravelAxis.crossVectors(devilUp, devil.flightForward).normalize();
2925      const moveAngle = fleeSpeed * delta / Math.max(devil.flightRadius, 1);
2926      devilUp.applyAxisAngle(devilTravelAxis, moveAngle).normalize();
2927      devil.flightForward.applyAxisAngle(devilTravelAxis, moveAngle)
2928        .addScaledVector(devilUp, -devil.flightForward.dot(devilUp))
2929        .normalize();
2930      devil.flightRadius = THREE.MathUtils.damp(
2931        devil.flightRadius,
2932        getSurfaceRadius(devilUp) + 0.9 + DEVIL_SPAWN_HEIGHT,
2933        DEVIL_TERRAIN_RESPONSE,
2934        delta,
2935      );
2936      devil.anchorUp.copy(devilUp);
2937      devil.anchorPosition.copy(devilUp).multiplyScalar(devil.flightRadius);
2938      devilRelativeStart.copy(devil.lastPlayerPosition).sub(devilPreviousAnchor);
2939      devilRelativeEnd.copy(flight.position).sub(devil.anchorPosition);
2940      devilRelativeDelta.copy(devilRelativeEnd).sub(devilRelativeStart);
2941      const relativeLengthSquared = devilRelativeDelta.lengthSq();
2942      const closestTime = relativeLengthSquared > 0.000001
2943        ? THREE.MathUtils.clamp(
2944          -devilRelativeStart.dot(devilRelativeDelta) / relativeLengthSquared,
2945          0,
2946          1,
2947        )
2948        : 0;
2949      devilClosestApproach.copy(devilRelativeStart)
2950        .addScaledVector(devilRelativeDelta, closestTime);
2951      if (
2952        interactionsEnabled
2953        &&
2954        devil.noticeGrace <= 0
2955        && devilClosestApproach.lengthSq() <= DEVIL_CONTACT_RADIUS * DEVIL_CONTACT_RADIUS
2956      ) {
2957        openDevilDialog();
2958        return;
2959      }
2960    }
2961    if (
2962      interactionsEnabled
2963      &&
2964      devil.encounterTime >= DEVIL_ASSIST_CONTACT_DELAY
2965      && ["entry", "waiting", "flee"].includes(devil.phase)
2966    ) {
2967      devil.phase = "approach";
2968    }
2969    if (devil.phase === "approach") {
2970      playerUp.copy(flight.position).normalize();
2971      devilTarget.copy(flight.position)
2972        .addScaledVector(flight.forward, -3)
2973        .addScaledVector(playerUp, 1.6);
2974      devilDesired.copy(devilTarget).normalize();
2975      devil.anchorUp.lerp(
2976        devilDesired,
2977        1 - Math.exp(-DEVIL_APPROACH_RESPONSE * delta),
2978      ).normalize();
2979      const approachRadius = Math.max(
2980        devilTarget.length(),
2981        getSurfaceRadius(devil.anchorUp) + 0.9 + DEVIL_SPAWN_HEIGHT,
2982      );
2983      devil.flightRadius = THREE.MathUtils.damp(
2984        devil.flightRadius,
2985        approachRadius,
2986        DEVIL_APPROACH_RESPONSE,
2987        delta,
2988      );
2989      devil.anchorPosition.copy(devil.anchorUp).multiplyScalar(devil.flightRadius);
2990      devil.flightForward.copy(devilTarget).sub(devilPreviousAnchor)
2991        .addScaledVector(devil.anchorUp, -devil.flightForward.dot(devil.anchorUp));
2992      if (devil.flightForward.lengthSq() > 0.0001) devil.flightForward.normalize();
2993      if (
2994        interactionsEnabled
2995        && devil.anchorPosition.distanceTo(flight.position) <= DEVIL_CONTACT_RADIUS
2996      ) {
2997        openDevilDialog();
2998        return;
2999      }
3000    }
3001    devil.bobTime += delta * 1.7;
3002    devilModel.position.copy(devil.anchorPosition)
3003      .addScaledVector(devil.anchorUp, Math.sin(devil.bobTime) * 0.28);
3004    orientDevil(devil.phase === "dialog"
3005      ? devilTarget.copy(flight.position).sub(devilModel.position)
3006      : devil.flightForward);
3007    devilModel.rotateZ(
3008      Math.sin(devil.bobTime * 0.74) * 0.08
3009        + (devil.phase === "flee" ? devil.evadeWave * 0.2 : 0),
3010    );
3011    for (const wing of devilModel.userData.wingRoots || []) {
3012      wing.root.rotation.y = wing.side * (0.2 + Math.sin(devil.bobTime * 7.2) * 0.48);
3013    }
3014    devil.lastPlayerPosition.copy(flight.position);
3015    devilNdc.copy(devilModel.position).project(camera);
3016    const outOfView = devilNdc.z < -1 || devilNdc.z > 1 || Math.abs(devilNdc.x) > 1.18 || Math.abs(devilNdc.y) > 1.18;
3017    if (!outOfView) {
3018      devil.hasBeenVisible = true;
3019      devil.outOfViewTime = 0;
3020    } else if (devil.hasBeenVisible) {
3021      devil.outOfViewTime += delta;
3022    }
3023    if (devil.hasBeenVisible && devil.outOfViewTime > 1.25) {
3024      devil.lastSide = devilNdc.x < 0 ? -1 : 1;
3025      devilModel.visible = false;
3026      devil.phase = "delay";
3027      devil.timer = 5;
3028      devil.outOfViewTime = 0;
3029      devil.hasBeenVisible = false;
3030    }
3031  }
3032
3033  function updateDevilLighting() {
3034    if (!devilModel.visible) return;
3035    devilUp.copy(devilModel.position).normalize();
3036    const daylight = devilUp.dot(devilDayDirection) * (state.worldInverted ? -1 : 1);
3037    // Fade the guide's visibility aid through dusk, but make it exactly zero
3038    // across the bright hemisphere so it reads as a naturally lit black body.
3039    const nightMix = 1 - THREE.MathUtils.smoothstep(daylight, -0.08, 0.24);
3040    for (const entry of devilModel.userData.emissiveMaterials || []) {
3041      entry.material.emissiveIntensity = entry.nightIntensity * nightMix;
3042    }
3043    if (devilModel.userData.visibilityGlow) {
3044      devilModel.userData.visibilityGlow.material.opacity = 0.016 * nightMix;
3045    }
3046    if (devilModel.userData.visibilityLight) {
3047      devilModel.userData.visibilityLight.intensity = 26 * nightMix;
3048    }
3049    canvas.dataset.devilGlow = nightMix <= 0.001 ? "off" : nightMix.toFixed(3);
3050  }
3051
3052  function renderTrackSelector(container) {
3053    const intro = document.createElement("p");
3054    intro.className = "experience-modal-copy";
3055    setLocalizedText(intro, "music.chooseTrack", "この惑星で流す曲を選ぶ。");
3056    const list = document.createElement("div");
3057    list.className = "experience-track-list";
3058    tracks.forEach((track, index) => {
3059      const button = createButton(track.title, "experience-track-choice");
3060      if (index === currentTrackIndex) button.classList.add("is-current");
3061      button.addEventListener("click", () => {
3062        loadTrack(index, true);
3063        for (const item of list.children) item.classList.remove("is-current");
3064        button.classList.add("is-current");
3065      });
3066      list.append(button);
3067    });
3068    container.append(intro, list);
3069  }
3070
3071  function cacheBookMessages(entries) {
3072    try {
3073      localStorage.setItem(BOOK_CACHE_KEY, JSON.stringify(entries));
3074    } catch (error) {
3075      console.warn("Book cache could not be saved.", error);
3076    }
3077  }
3078
3079  function readBookCache() {
3080    try {
3081      const parsed = JSON.parse(localStorage.getItem(BOOK_CACHE_KEY) || "[]");
3082      return Array.isArray(parsed) ? parsed.map(normalizeBookEntry).filter(Boolean) : [];
3083    } catch (error) {
3084      return [];
3085    }
3086  }
3087
3088  function getBookUrl(query = "") {
3089    const base = String(supabaseConfig?.url || "").replace(/\/+$/, "");
3090    const table = encodeURIComponent(supabaseConfig?.table || "book_messages");
3091    return `${base}/rest/v1/${table}${query}`;
3092  }
3093
3094  function getReturnHistoryUrl(query = "") {
3095    const base = String(supabaseConfig?.url || "").replace(/\/+$/, "");
3096    const table = encodeURIComponent(supabaseConfig?.returnHistoryTable || "return_histories");
3097    return `${base}/rest/v1/${table}${query}`;
3098  }
3099
3100  function getBookHeaders(prefer = "") {
3101    const key = String(supabaseConfig?.anonKey || "");
3102    const headers = {
3103      apikey: key,
3104      Authorization: `Bearer ${key}`,
3105      "Content-Type": "application/json",
3106    };
3107    if (prefer) headers.Prefer = prefer;
3108    return headers;
3109  }
3110
3111  async function fetchBookMessages() {
3112    const entries = [];
3113    for (let offset = 0; offset < BOOK_FETCH_MAX_ROWS; offset += BOOK_FETCH_PAGE_SIZE) {
3114      const response = await fetch(
3115        getBookUrl(`?select=id,name,message,created_at&name=not.eq.${encodeURIComponent(HIDDEN_BOOK_AUTHOR)}&order=created_at.desc&limit=${BOOK_FETCH_PAGE_SIZE}&offset=${offset}`),
3116        { headers: getBookHeaders(), cache: "no-store" },
3117      );
3118      if (!response.ok) throw new Error(`book-load-${response.status}`);
3119      const payload = await response.json();
3120      const rows = Array.isArray(payload) ? payload : [];
3121      entries.push(...rows.map(normalizeBookEntry).filter(Boolean));
3122      if (rows.length < BOOK_FETCH_PAGE_SIZE) break;
3123    }
3124    cacheBookMessages(entries);
3125    return entries;
3126  }
3127
3128  function renderBookEntries(container, entries, stopped = false) {
3129    container.textContent = "";
3130    if (stopped) {
3131      const stoppedMessage = document.createElement("p");
3132      stoppedMessage.className = "experience-book-status is-stopped";
3133      setLocalizedText(stoppedMessage, "book.stopped", "停止中");
3134      container.append(stoppedMessage);
3135    }
3136    if (!entries.length) {
3137      if (!stopped) {
3138        const empty = document.createElement("p");
3139        empty.className = "experience-book-status";
3140        setLocalizedText(empty, "book.empty", "まだ何も書かれていません。");
3141        container.append(empty);
3142      }
3143      return;
3144    }
3145    for (const entry of entries) {
3146      const card = document.createElement("article");
3147      card.className = "experience-book-entry";
3148      const meta = document.createElement("div");
3149      meta.className = "experience-book-meta";
3150      meta.textContent = `${entry.name}  ${formatBookDate(entry.createdAt)}`.trim();
3151      const message = document.createElement("p");
3152      message.textContent = entry.message;
3153      card.append(meta, message);
3154      container.append(card);
3155    }
3156  }
3157
3158  function rememberBookName(name, entryId) {
3159    const trimmedName = name.trim();
3160    if (!trimmedName) return;
3161    try {
3162      localStorage.setItem(BOOK_PLAYER_KEY, JSON.stringify({
3163        hasWrittenNameInBook: true,
3164        bookPlayerName: trimmedName,
3165        latestBookMessageId: String(entryId || ""),
3166      }));
3167    } catch (error) {
3168      console.warn("Book player name could not be saved.", error);
3169    }
3170  }
3171
3172  async function saveBookMessage(name, message) {
3173    const response = await fetch(getBookUrl("?select=id,name,message,created_at"), {
3174      method: "POST",
3175      headers: getBookHeaders("return=representation"),
3176      body: JSON.stringify([{ name: name.trim() || "anonymous", message: message.trim() }]),
3177    });
3178    if (!response.ok) throw new Error(`book-save-${response.status}`);
3179    const payload = await response.json();
3180    const entry = normalizeBookEntry(Array.isArray(payload) ? payload[0] : null);
3181    if (!entry) throw new Error("book-save-empty");
3182    rememberBookName(name, entry.id);
3183    return entry;
3184  }
3185
3186  function renderBook(container) {
3187    const status = document.createElement("p");
3188    status.className = "experience-book-status";
3189    setLocalizedText(status, "book.loading", "本の記録を読み込んでいます。");
3190    const entries = document.createElement("div");
3191    entries.className = "experience-book-entries";
3192    const form = document.createElement("form");
3193    form.className = "experience-book-form";
3194    const name = document.createElement("input");
3195    name.type = "text";
3196    name.maxLength = 24;
3197    name.placeholder = t("book.namePlaceholder", "名前(任意)");
3198    const message = document.createElement("textarea");
3199    message.rows = 4;
3200    message.maxLength = 280;
3201    message.placeholder = t("book.messagePlaceholder", "何を書く?");
3202    const submit = createButton(t("book.submit", "記す"));
3203    submit.dataset.assI18n = "book.submit";
3204    submit.type = "submit";
3205    form.append(name, message, submit);
3206    container.append(status, entries, form);
3207
3208    fetchBookMessages().then((loaded) => {
3209      setLocalizedText(status, "book.sharedProduction", "この本のことばは、ほかの人にも共有されます。");
3210      renderBookEntries(entries, loaded);
3211    }).catch((error) => {
3212      console.warn("Supabase book is unavailable.", error);
3213      setLocalizedText(status, "book.stopped", "停止中");
3214      renderBookEntries(entries, readBookCache(), true);
3215      submit.disabled = true;
3216    });
3217
3218    form.addEventListener("submit", async (event) => {
3219      event.preventDefault();
3220      if (!message.value.trim()) return;
3221      submit.disabled = true;
3222      setLocalizedText(status, "book.writing", "記しています。");
3223      try {
3224        await saveBookMessage(name.value, message.value);
3225        message.value = "";
3226        const loaded = await fetchBookMessages();
3227        setLocalizedText(status, "book.saved", "記しました。");
3228        renderBookEntries(entries, loaded);
3229      } catch (error) {
3230        console.warn("Book message could not be saved.", error);
3231        setLocalizedText(status, "book.stopped", "停止中");
3232      } finally {
3233        submit.disabled = status.dataset.assI18n === "book.stopped";
3234      }
3235    });
3236  }
3237
3238  function openBlackBox() {
3239    const blackBox = landmarks.objects.blackBox;
3240    blackBox.userData.grounded = true;
3241    const titleKey = state.blackBoxOpened ? "runtime.destination.blackBoxOpened" : "runtime.destination.blackBox";
3242    const titleFallback = state.blackBoxOpened ? "黒い箱" : "高速移動する黒い箱";
3243    const actionKey = state.blackBoxOpened ? "blackBox.openAgain" : "blackBox.open";
3244    const actionFallback = state.blackBoxOpened ? "また開けちゃう" : "開けてみる";
3245    openModal(
3246      t(titleKey, titleFallback),
3247      (container) => {
3248      setLocalizedText(overlayTitle, titleKey, titleFallback);
3249      const action = createButton(t(actionKey, actionFallback));
3250      action.dataset.assI18n = actionKey;
3251      const reveal = () => {
3252        container.textContent = "";
3253        const image = document.createElement("img");
3254        image.className = "experience-cat-image";
3255        image.src = resolveRootAsset("./blackbox-cat.jpg");
3256        image.alt = t("blackBox.catAlt", "箱の中にいた猫");
3257        const caption = document.createElement("p");
3258        caption.className = "experience-modal-copy";
3259        setLocalizedText(caption, "blackBox.caption", "かわいいのがいた。");
3260        container.append(image, caption);
3261        state.blackBoxOpened = true;
3262        blackBox.userData.opened = true;
3263      };
3264      action.addEventListener("click", reveal);
3265      container.append(action);
3266      },
3267    );
3268  }
3269
3270  function endChallenge(success) {
3271    document.body.classList.remove("is-monochrome");
3272    stopEffectAudio(clockAudio, true);
3273    if (challengeMusicWasPlaying) void playMusic();
3274    challengeMusicWasPlaying = false;
3275    if (!success) {
3276      state.phase = "idle";
3277      state.challengeStart = null;
3278      state.challengeTimeRemaining = 0;
3279      landmarks.objects.compass.userData.targetWorld = null;
3280      triggerChallengeFlash("is-failure");
3281      return;
3282    }
3283    state.phase = "inverted";
3284    state.challengeStart = null;
3285    state.challengeTimeRemaining = 0;
3286    state.worldInverted = true;
3287    landmarks.objects.compass.userData.targetWorld = null;
3288    onWorldInversion?.(true);
3289    triggerChallengeFlash("is-success");
3290  }
3291
3292  function startChallenge(id) {
3293    state.phase = "challenge";
3294    state.challengeStart = id;
3295    state.challengeTimeRemaining = MONOCHROME_CHALLENGE_DURATION;
3296    const targetId = id === "blackSphere" ? "whiteSphere" : "blackSphere";
3297    const target = landmarks.objects[targetId];
3298    target.getWorldPosition(targetPosition);
3299    landmarks.objects.compass.userData.targetWorld = targetPosition.clone();
3300    document.body.classList.add("is-monochrome");
3301    challengeMusicWasPlaying = !audio.paused;
3302    pauseMusic();
3303    playEffectAudio(clockAudio, true);
3304    triggerChallengeFlash("is-start");
3305  }
3306
3307  function triggerChallengeFlash(kind) {
3308    if (!challengeFlash) return;
3309    challengeFlash.className = `experience-theme-flash ${kind}`;
3310    void challengeFlash.offsetWidth;
3311    challengeFlash.classList.add("is-active");
3312  }
3313
3314  function updateChallenge(delta) {
3315    if (state.phase !== "challenge") return;
3316    state.challengeTimeRemaining = Math.max(0, state.challengeTimeRemaining - delta);
3317    if (challengeTimer) {
3318      challengeTimer.textContent = `${state.challengeTimeRemaining.toFixed(1)} SEC`;
3319    }
3320    if (state.challengeTimeRemaining <= 0) endChallenge(false);
3321  }
3322
3323  function handleSphere(id) {
3324    if (state.phase === "challenge") {
3325      if (id !== state.challengeStart) endChallenge(true);
3326      return;
3327    }
3328    if (state.phase === "idle") startChallenge(id);
3329  }
3330
3331  function activateSanctuary() {
3332    if (state.phase !== "inverted") {
3333      return;
3334    }
3335    state.phase = "sanctuary";
3336    state.sanctuaryStartAltitude = Math.max(0, getAltitude?.(flight.position) || 0);
3337    state.earthApproachStartDistance = 0;
3338    landmarks.objects.sanctuary.userData.activationTarget = 1;
3339    refreshCatRouteAvailability();
3340    if (state.blackBoxOpened && landmarks.objects.blackBox.userData.grounded) {
3341      landmarks.objects.blackBox.getWorldPosition(targetPosition);
3342      landmarks.objects.compass.userData.targetWorld = targetPosition.clone();
3343    }
3344  }
3345
3346  function beginSpaceReturn() {
3347    if (state.spaceFlightActive || state.ending) return;
3348    radialUp.copy(flight.position).normalize();
3349    projectedUp.copy(radialUp)
3350      .addScaledVector(state.beamDirection, -radialUp.dot(state.beamDirection));
3351    if (projectedUp.lengthSq() < 0.0001) {
3352      projectedUp.crossVectors(
3353        Math.abs(state.beamDirection.y) < 0.92 ? LOCAL_Y_AXIS : new THREE.Vector3(1, 0, 0),
3354        state.beamDirection,
3355      );
3356    }
3357    state.spaceUp.copy(projectedUp).normalize();
3358    projectedForward.copy(state.earthPosition).sub(flight.position)
3359      .addScaledVector(state.spaceUp, -projectedForward.dot(state.spaceUp));
3360    if (projectedForward.lengthSq() < 0.0001) projectedForward.copy(state.beamDirection);
3361    flight.forward.lerp(projectedForward.normalize(), 0.92).normalize();
3362    flight.radialSpeed = 0;
3363    flight.bodyPitch = 0;
3364    state.spaceFlightActive = true;
3365    audio.pause();
3366    syncMusicUi();
3367    playEffectAudio(spaceReturnAudio, true);
3368    canvas.dataset.returnBgm = "space-return";
3369  }
3370
3371  function triggerReturnEnding() {
3372    if (state.ending) return;
3373    state.ending = true;
3374    endingStartedAt = performance.now();
3375    logEndingDebug("ending state entered");
3376    state.reachedEarthWithCat = state.catFollowing === true;
3377    earthVisual.earth.visible = false;
3378    earthVisual.glow.visible = false;
3379    spaceStars.visible = false;
3380    audio.pause();
3381    audio.volume = 0;
3382    syncMusicUi();
3383    stopEffectAudio(clockAudio, true);
3384    stopEffectAudio(spaceReturnAudio, true);
3385    playEffectAudio(earthArrivalAudio, true);
3386    canvas.dataset.returnBgm = "earth-arrival";
3387    void recordReturnHistory(state.reachedEarthWithCat).finally(() => loadEndingNames());
3388    startProductionEnding(state.reachedEarthWithCat);
3389    refreshCatRouteAvailability();
3390    canvas.dataset.returnPhase = "ending";
3391    canvas.dataset.returnEnding = state.reachedEarthWithCat ? "true" : "normal";
3392  }
3393
3394  function updateReturnRoute(delta) {
3395    if (state.phase !== "sanctuary" || state.ending) {
3396      if (!state.ending) {
3397        state.spaceFlightActive = false;
3398        state.spaceTransition = THREE.MathUtils.damp(
3399          state.spaceTransition,
3400          0,
3401          4.4,
3402          delta,
3403        );
3404        earthVisual.earth.visible = false;
3405        earthVisual.glow.visible = false;
3406        spaceStars.visible = false;
3407      }
3408      return;
3409    }
3410
3411    const beamLength = landmarks.getSanctuaryBeamRay(
3412      state.beamOrigin,
3413      state.beamDirection,
3414    );
3415    state.earthPosition.copy(state.beamOrigin)
3416      .addScaledVector(state.beamDirection, beamLength);
3417    earthVisual.earth.position.copy(state.earthPosition);
3418    earthVisual.glow.position.copy(state.earthPosition);
3419    spaceStars.position.copy(state.beamOrigin);
3420    spaceStars.quaternion.setFromUnitVectors(LOCAL_Y_AXIS, state.beamDirection);
3421
3422    const axisDistance = beamOffset.copy(flight.position)
3423      .sub(state.beamOrigin)
3424      .dot(state.beamDirection);
3425    beamClosestPoint.copy(state.beamOrigin)
3426      .addScaledVector(state.beamDirection, Math.max(0, axisDistance));
3427    const beamDistance = beamOffset.copy(flight.position).sub(beamClosestPoint).length();
3428    const altitude = Math.max(0, getAltitude?.(flight.position) || 0);
3429    canvas.dataset.beamAxis = axisDistance.toFixed(1);
3430    canvas.dataset.beamDistance = beamDistance.toFixed(1);
3431    if (
3432      !state.spaceFlightActive
3433      && (
3434        state.debugForceSpace
3435        || (
3436          altitude >= SPACE_RETURN_ALTITUDE
3437          && axisDistance >= SPACE_RETURN_AXIS_DISTANCE
3438          && beamDistance <= SPACE_RETURN_BEAM_RADIUS
3439        )
3440      )
3441    ) {
3442      beginSpaceReturn();
3443      state.debugForceSpace = false;
3444    }
3445
3446    state.spaceTransition = THREE.MathUtils.damp(
3447      state.spaceTransition,
3448      state.spaceFlightActive ? 1 : 0,
3449      state.spaceFlightActive ? SPACE_TRANSITION_RATE : 4.4,
3450      delta,
3451    );
3452    spaceStars.visible = state.spaceTransition > 0.02;
3453    spaceStars.material.opacity = THREE.MathUtils.smoothstep(state.spaceTransition, 0.06, 0.82) * 0.86;
3454
3455    const approachDistance = flight.position.distanceTo(state.earthPosition);
3456    canvas.dataset.returnPhase = state.spaceFlightActive ? "space" : "beam";
3457    canvas.dataset.earthDistance = approachDistance.toFixed(1);
3458    if (state.earthApproachStartDistance <= 0 && state.spaceFlightActive) {
3459      state.earthApproachStartDistance = approachDistance;
3460    }
3461    const reveal = state.spaceFlightActive
3462      ? THREE.MathUtils.smoothstep(axisDistance, EARTH_REVEAL_AXIS_DISTANCE, EARTH_REVEAL_AXIS_DISTANCE + 420)
3463      : 0;
3464    const approachStart = Math.max(1200, state.earthApproachStartDistance || approachDistance);
3465    const approachProgress = THREE.MathUtils.smoothstep(
3466      THREE.MathUtils.clamp(1 - approachDistance / approachStart, 0, 1),
3467      0,
3468      1,
3469    );
3470    earthVisual.earth.visible = reveal > 0.001;
3471    earthVisual.glow.visible = reveal > 0.001;
3472    earthVisual.earth.material.opacity = reveal;
3473    earthVisual.glow.material.opacity = reveal * (0.12 + approachProgress * 0.16);
3474    earthVisual.earth.scale.setScalar(THREE.MathUtils.lerp(
3475      EARTH_BASE_SIZE * 0.72,
3476      EARTH_BASE_SIZE * 1.8,
3477      approachProgress,
3478    ));
3479    earthVisual.glow.scale.setScalar(THREE.MathUtils.lerp(
3480      EARTH_GLOW_SIZE * 0.68,
3481      EARTH_GLOW_SIZE * 1.48,
3482      approachProgress,
3483    ));
3484    audio.volume = THREE.MathUtils.clamp(
3485      1 - THREE.MathUtils.smoothstep(axisDistance, 420, 1450),
3486      0,
3487      1,
3488    );
3489
3490    if (state.spaceFlightActive && approachDistance <= EARTH_CONTACT_DISTANCE) {
3491      triggerReturnEnding();
3492    }
3493  }
3494
3495  function startCompassAssist() {
3496    const target = landmarks.objects.compass.userData.targetWorld;
3497    if (!target) return;
3498    compassAssistTarget = target.clone();
3499    compassAssist = 1.1;
3500    showToast(t("toast.compassAssist", "羅針盤が進行方向を整えている。"), 2200);
3501  }
3502
3503  function handleContact(id) {
3504    if (!isMainExperience()) return;
3505    if (state.phase === "challenge" && id !== "blackSphere" && id !== "whiteSphere" && id !== "compass") {
3506      endChallenge(false);
3507    }
3508    switch (id) {
3509      case "blackSphere":
3510      case "whiteSphere":
3511        handleSphere(id);
3512        break;
3513      case "recordPlayer":
3514        openMusicSelector();
3515        break;
3516      case "book":
3517        void openProductionBook();
3518        break;
3519      case "compass":
3520        startCompassAssist();
3521        break;
3522      case "sanctuary":
3523        activateSanctuary();
3524        break;
3525      case "blackBox":
3526        if (state.devil.phase === "route-wait") stopDevilRoute(true);
3527        openProductionBlackBox();
3528        break;
3529      default:
3530        break;
3531    }
3532  }
3533
3534  function updateContacts() {
3535    // Environment phasing must not collect, open, start, or complete event
3536    // objects while the body is intentionally non-solid.
3537    if (
3538      !isMainExperience()
3539      || state.modalOpen
3540      || state.ending
3541      || isEnvironmentPhasing?.()
3542    ) return;
3543    if (state.devil.phase === "route" || state.devil.phase === "route-wait") return;
3544    for (const contact of CONTACTS) {
3545      const object = landmarks.objects[contact.id];
3546      if (!object) continue;
3547      object.getWorldPosition(worldPosition);
3548      const distance = flight.position.distanceTo(worldPosition);
3549      if (contact.id === "book") canvas.dataset.bookDistance = distance.toFixed(1);
3550      const wasInside = contactState.get(contact.id) === true;
3551      if (!wasInside && distance <= contact.radius) {
3552        contactState.set(contact.id, true);
3553        handleContact(contact.id);
3554        if (state.modalOpen) break;
3555      } else if (wasInside && distance > contact.radius + 4) {
3556        contactState.set(contact.id, false);
3557      }
3558    }
3559  }
3560
3561  function updateCompassAssist(delta) {
3562    if (compassAssist <= 0 || !compassAssistTarget) return;
3563    compassAssist = Math.max(0, compassAssist - delta);
3564    playerUp.copy(flight.position).normalize();
3565    targetDirection.copy(compassAssistTarget).normalize();
3566    targetTangent.copy(targetDirection)
3567      .addScaledVector(playerUp, -targetDirection.dot(playerUp));
3568    if (targetTangent.lengthSq() < 0.0001) return;
3569    targetTangent.normalize();
3570    flight.forward.lerp(targetTangent, 1 - Math.exp(-7.2 * delta));
3571    flight.forward.addScaledVector(playerUp, -flight.forward.dot(playerUp)).normalize();
3572  }
3573
3574  function refreshLocalizedExperienceUi() {
3575    // The locale event only redraws already-open copy. It deliberately does
3576    // not close overlays, restart events, or alter any navigation state.
3577    refreshDevilLocalization();
3578    syncMusicUi();
3579    refreshMusicSelector();
3580    if (bookOverlay?.classList.contains("is-open") && bookPages.length) {
3581      renderProductionBook(bookPages.flat());
3582    }
3583    if (blackBoxOverlay?.classList.contains("is-open") && blackBoxOpen) {
3584      setLocalizedText(
3585        blackBoxOpen,
3586        state.blackBoxOpened ? "blackBox.openAgain" : "blackBox.open",
3587        state.blackBoxOpened ? "また開けちゃう" : "開けてみる",
3588      );
3589    }
3590  }
3591
3592  window.addEventListener("assmagic:locale-changed", refreshLocalizedExperienceUi);
3593
3594  playButton?.addEventListener("click", () => {
3595    if (audio.paused) void playMusic();
3596    else pauseMusic();
3597  });
3598  artLink?.addEventListener("click", openMusicServiceModal);
3599  artLink?.addEventListener("keydown", (event) => {
3600    if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
3601    event.preventDefault();
3602    event.stopPropagation();
3603    openMusicServiceModal();
3604  });
3605  nextButton?.addEventListener("click", () => loadTrack(getNextRandomTrackIndex(), true));
3606  lyricsButton?.addEventListener("click", () => {
3607    const isOpen = lyricsPanel?.classList.toggle("is-open") === true;
3608    lyricsButton.classList.toggle("is-active", isOpen);
3609    lyricsButton.setAttribute("aria-pressed", isOpen ? "true" : "false");
3610  });
3611  lyricsClose?.addEventListener("click", () => {
3612    lyricsPanel?.classList.remove("is-open");
3613    lyricsButton?.classList.remove("is-active");
3614    lyricsButton?.setAttribute("aria-pressed", "false");
3615  });
3616  overlayClose?.addEventListener("click", closeModal);
3617  overlay?.querySelector(".experience-overlay-backdrop")?.addEventListener("click", closeModal);
3618  window.addEventListener("keydown", (event) => {
3619    if (event.key !== "Escape" || !overlay?.classList.contains("is-music-service")) return;
3620    event.preventDefault();
3621    event.stopPropagation();
3622    closeModal();
3623  });
3624  bookClose?.addEventListener("click", closeNativeOverlay);
3625  bookBackdrop?.addEventListener("click", closeNativeOverlay);
3626  for (const button of bookViewButtons) {
3627    button.addEventListener("click", () => setBookView(button.dataset.bookView));
3628  }
3629  bookNextPage?.addEventListener("click", () => {
3630    if (!bookPages.length) return;
3631    bookPageIndex = (bookPageIndex + 1) % bookPages.length;
3632    renderProductionBook(bookPages.flat());
3633  });
3634  bookForm?.addEventListener("submit", async (event) => {
3635    event.preventDefault();
3636    if (!bookMessageInput?.value.trim()) return;
3637    if (bookSubmit) bookSubmit.disabled = true;
3638    if (bookStatus) setLocalizedText(bookStatus, "book.writing", "記しています。");
3639    try {
3640      await saveBookMessage(bookName?.value || "", bookMessageInput.value);
3641      bookMessageInput.value = "";
3642      const entries = await fetchBookMessages();
3643      renderProductionBook(entries);
3644      setBookView("read");
3645      if (bookStatus) setLocalizedText(bookStatus, "book.saved", "記しました。");
3646    } catch (error) {
3647      console.warn("Book message could not be saved.", error);
3648      if (bookStatus) setLocalizedText(bookStatus, "book.stopped", "停止中");
3649    } finally {
3650      if (bookSubmit) bookSubmit.disabled = bookStatus?.dataset.assI18n === "book.stopped";
3651    }
3652  });
3653  blackBoxClose?.addEventListener("click", closeNativeOverlay);
3654  blackBoxBackdrop?.addEventListener("click", closeNativeOverlay);
3655  blackBoxIgnore?.addEventListener("click", closeNativeOverlay);
3656  blackBoxOpen?.addEventListener("click", () => {
3657    state.blackBoxOpened = true;
3658    state.catFound = true;
3659    landmarks.objects.blackBox.userData.opened = true;
3660    setBlackBoxView("reveal");
3661    refreshCatRouteAvailability();
3662  });
3663  musicSelectorClose?.addEventListener("click", closeNativeOverlay);
3664  musicSelectorBackdrop?.addEventListener("click", closeNativeOverlay);
3665  devilUi.summon.addEventListener("click", () => {
3666    if (isSkating?.()) return;
3667    summonDevil(true);
3668  });
3669  devilUi.navigationCancel.addEventListener("click", () => stopDevilRoute(false));
3670  storyAudio.addEventListener("playing", () => {
3671    if (activeStoryAudio === endingAudio) logEndingDebug("ending music started");
3672  });
3673  storyAudio.addEventListener("pause", () => {
3674    // Safari can suspend media when the page is backgrounded. Intentional
3675    // stops clear activeStoryAudio before pause(), so only an unexpected
3676    // suspension reaches this recovery path.
3677    if (!activeStoryAudio || storyAudio.ended) return;
3678    pendingStoryAudio = activeStoryAudio;
3679    setStoryAudioStatus(activeStoryAudio, "paused-retry-pending");
3680  });
3681  storyAudio.addEventListener("ended", () => {
3682    if (!activeStoryAudio) return;
3683    activeStoryAudio = null;
3684    pendingStoryAudio = null;
3685    canvas.dataset.storyAudioCue = "none";
3686    canvas.dataset.storyAudioStatus = "ended";
3687  });
3688  storyAudio.addEventListener("error", () => {
3689    if (!activeStoryAudio) return;
3690    pendingStoryAudio = activeStoryAudio;
3691    setStoryAudioStatus(activeStoryAudio, "media-error");
3692    console.warn(`Story audio could not be loaded: ${activeStoryAudio.name || "unknown"}`);
3693  });
3694  document.addEventListener("visibilitychange", () => {
3695    if (!document.hidden) retryPendingStoryAudio();
3696  });
3697  endingRollTrack?.addEventListener("animationend", () => {
3698    if (!endingRestart?.classList.contains("is-visible")) {
3699      logEndingDebug("END cue triggered (animation fallback)");
3700      endingRestart?.classList.add("is-visible");
3701      logEndingDebug("END became visible");
3702    }
3703  });
3704  endingRestart?.addEventListener("click", () => {
3705    stopEffectAudio(null, true);
3706    const url = new URL(window.location.href);
3707    url.searchParams.delete("route");
3708    url.searchParams.set("start", "day");
3709    window.location.assign(url);
3710  });
3711  audio.addEventListener("play", syncMusicUi);
3712  audio.addEventListener("pause", syncMusicUi);
3713  audio.addEventListener("ended", () => loadTrack(getNextRandomTrackIndex(), true));
3714  // Match the production page: start inside the first user gesture, before the
3715  // flight controls consume it. This is the path iOS Safari permits for audio.
3716  window.addEventListener("pointerdown", unlockMusic, { capture: true, passive: true });
3717  window.addEventListener("pointerup", unlockMusic, { capture: true, passive: true });
3718  window.addEventListener("touchstart", unlockMusic, { capture: true, passive: true });
3719  window.addEventListener("touchend", unlockMusic, { capture: true, passive: true });
3720  window.addEventListener("click", unlockMusic, { capture: true, passive: true });
3721  canvas.addEventListener("pointerdown", unlockMusic);
3722  window.addEventListener("keydown", unlockMusic);
3723  window.addEventListener(EXPERIENCE_MODE_SELECTED_EVENT, (event) => {
3724    applyExperienceMode(event.detail?.mode, event.detail?.sourceEvent);
3725  });
3726
3727  loadTrack(currentTrackIndex, false);
3728  canvas.dataset.experienceMode = getExperienceMode();
3729  canvas.dataset.storyEvents = isMainExperience() ? "enabled" : "disabled";
3730  canvas.dataset.chillAudio = "inactive";
3731  canvas.dataset.storyAudioCue = "none";
3732  canvas.dataset.storyAudioStatus = "idle";
3733  canvas.dataset.storyAudioPrimed = "false";
3734  // Begin the first track shortly after the flight appears.  Mobile browsers that
3735  // reject this attempt will use the existing first-touch retry path.
3736  window.setTimeout(() => {
3737    if (
3738      isMainExperience()
3739      && !audioUnlocked
3740      && !state.modalOpen
3741      && state.phase !== "challenge"
3742    ) void playMusic();
3743  }, 500);
3744
3745  return {
3746    state,
3747    isPaused: () => isMainExperience()
3748      && (
3749        state.modalOpen
3750        || state.ending
3751        || state.devil.phase === "route"
3752        || state.devil.phase === "route-wait"
3753      ),
3754    isGuideNavigating: () => isMainExperience()
3755      && (
3756        state.devil.phase === "route"
3757        || state.devil.phase === "route-wait"
3758      ),
3759    getReturnState: () => state,
3760    update(delta) {
3761      const storyEnabled = isMainExperience();
3762      if (storyEnabled) updateChallenge(delta);
3763      updateDevil(delta);
3764      updateDevilLighting();
3765      canvas.dataset.devilPhase = state.devil.phase;
3766      canvas.dataset.devilEncounterTime = state.devil.encounterTime.toFixed(2);
3767      canvas.dataset.devilVisible = String(devilModel.visible);
3768      if (storyEnabled) {
3769        updateContacts();
3770        updateCompassAssist(delta);
3771        updateReturnRoute(delta);
3772      }
3773      if (storyEnabled || state.catFollowing) updateCatCompanion(delta);
3774      if (
3775        storyEnabled
3776        && state.ending
3777        && endingOverlay?.classList.contains("is-open")
3778        && endingRollStart > 0
3779        && (
3780          (endingLastCredit && endingLastCredit.getBoundingClientRect().bottom <= 0)
3781          || performance.now() - endingRollStart >= ENDING_ROLL_DURATION * 1000
3782        )
3783        && !endingRestart?.classList.contains("is-visible")
3784      ) {
3785        logEndingDebug("END cue triggered");
3786        endingRestart?.classList.add("is-visible");
3787        logEndingDebug("END became visible");
3788      }
3789    },
3790    reset() {
3791      closeModal();
3792      closeNativeOverlay();
3793      document.body.classList.remove("is-monochrome");
3794      document.body.classList.remove("devil-guide-navigating");
3795      stopEffectAudio(null, true);
3796      window.clearTimeout(endingAudioTimer);
3797      window.clearTimeout(endingWhiteoutTimer);
3798      window.clearTimeout(endingBlackTimer);
3799      endingWhiteout?.classList.remove("is-active");
3800      endingWhiteout?.classList.remove("is-true-message");
3801      endingWhiteout?.setAttribute("aria-hidden", "true");
3802      endingOverlay?.classList.remove("is-open", "is-transitioning");
3803      endingOverlay?.setAttribute("aria-hidden", "true");
3804      endingRestart?.classList.remove("is-visible");
3805      endingRollStart = 0;
3806      endingStartedAt = 0;
3807      devilUi.overlay.classList.remove("is-open");
3808      devilUi.summon.classList.remove("is-visible");
3809      devilUi.navigation.classList.remove("is-visible");
3810      devilModel.visible = false;
3811      state.devil.phase = "delay";
3812      state.devil.timer = new URLSearchParams(window.location.search).get("devildebug") === "1" ? 0.4 : DEVIL_SPAWN_DELAY;
3813      state.devil.route = null;
3814      state.devil.hasBeenVisible = false;
3815      state.devil.noticeGrace = 0;
3816      state.devil.encounterTime = 0;
3817      state.devil.navigation.destination = null;
3818      state.devil.navigation.elapsed = 0;
3819      state.devil.navigation.previousPathDistance = 0;
3820      state.devil.navigation.waitElapsed = 0;
3821      if (challengeMusicWasPlaying) void playMusic();
3822      challengeMusicWasPlaying = false;
3823      const resetParams = new URLSearchParams(window.location.search);
3824      const routeMode = resetParams.get("route");
3825      const catDebugFollowing = resetParams.get("catdebug") === "1";
3826      const catStartsFollowing = catDebugFollowing || isChillExperience();
3827      const routeReady = routeMode === "ready" || routeMode === "beam" || routeMode === "space";
3828      state.phase = routeMode === "beam" || routeMode === "space"
3829        ? "sanctuary"
3830        : routeReady ? "inverted" : "idle";
3831      state.worldInverted = routeReady;
3832      state.blackBoxOpened = false;
3833      state.catFound = false;
3834      state.catRouteAvailable = false;
3835      state.catJoinPhase = catStartsFollowing ? "complete" : "idle";
3836      state.catJoinElapsed = 0;
3837      state.catTailTime = 0;
3838      state.catFollowing = catStartsFollowing;
3839      state.catPendingJoin = false;
3840      state.reachedEarthWithCat = false;
3841      state.returnRecorded = false;
3842      if (catCompanion.parent !== scene) scene.add(catCompanion);
3843      catCompanion.visible = catStartsFollowing;
3844      if (catStartsFollowing && playerObject) {
3845        playerObject.add(catCompanion);
3846        catCompanion.position.set(
3847          CAT_ROUTE_MOUNT_SIDE,
3848          CAT_ROUTE_MOUNT_HEIGHT,
3849          CAT_ROUTE_MOUNT_FORWARD,
3850        );
3851        catCompanion.quaternion.identity();
3852      }
3853      state.challengeStart = null;
3854      state.challengeTimeRemaining = 0;
3855      state.spaceFlightActive = false;
3856      state.spaceTransition = 0;
3857      state.spaceUp.set(0, 0, 0);
3858      state.beamOrigin.set(0, 0, 0);
3859      state.beamDirection.set(0, 0, 0);
3860      state.earthPosition.set(0, 0, 0);
3861      state.earthApproachStartDistance = 0;
3862      state.ending = false;
3863      state.debugForceSpace = routeMode === "space";
3864      audio.volume = 1;
3865      canvas.dataset.returnBgm = "playlist";
3866      canvas.dataset.returnEnding = "none";
3867      earthVisual.earth.visible = false;
3868      earthVisual.glow.visible = false;
3869      spaceStars.visible = false;
3870      landmarks.objects.sanctuary.userData.activationTarget = routeMode === "beam" || routeMode === "space" ? 1 : 0;
3871      landmarks.objects.blackBox.userData.opened = false;
3872      landmarks.objects.compass.userData.targetWorld = null;
3873      onWorldInversion?.(routeReady);
3874      refreshCatRouteAvailability();
3875      contactState.clear();
3876      if (resetParams.get("blackboxguide") === "1") {
3877        window.setTimeout(() => {
3878          const destination = DEVIL_DESTINATIONS.find((item) => item.id === "blackBox");
3879          if (destination && state.devil.phase === "delay") startDevilRoute(destination);
3880        }, 0);
3881      }
3882    },
3883  };
3884}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.