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.