算力台灣
<!DOCTYPE html> <html lang="zh-Hant"> <head> <meta charset="UTF-8"> <title>算力台灣 AITaiwan · 自動駕駛巴士車內</title> <style> *, *::before, *::after { box-sizing: border-box !important; } html { background: #000000 !important; color-scheme: dark !important; } html, body { margin: 0 !important; padding: 0 !important; width: 100% !important; height: 100% !important; min-height: 100% !important; overflow: hidden !important; background: #000000 !important; border: 0 !important; outline: 0 !important; box-shadow: none !important; } body { position: fixed !important; inset: 0 !important; } #canvas-container { position: fixed !important; inset: 0 !important; width: 100vw !important; height: 100vh !important; height: 100dvh !important; display: block !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; border: 0 !important; outline: 0 !important; box-shadow: none !important; background: #000000 !important; touch-action: none !important; -webkit-user-select: none !important; user-select: none !important; -webkit-touch-callout: none !important; } canvas { display: block !important; width: 100% !important; height: 100% !important; margin: 0 !important; padding: 0 !important; border: 0 !important; outline: 0 !important; box-shadow: none !important; background: #000000 !important; vertical-align: top !important; touch-action: none !important; } #canvas-container::after { content: ""; position: absolute; inset: 0; z-index: 55; pointer-events: none; background: radial-gradient(circle at 50% 46%, rgba(52,234,255,0.045) 0%, rgba(0,0,0,0.00) 40%, rgba(0,3,9,0.36) 100%), linear-gradient(to bottom, rgba(3,16,29,0.34) 0%, rgba(0,0,0,0.00) 15%, rgba(0,0,0,0.00) 84%, rgba(1,8,18,0.38) 100%); } #flash { position: fixed; inset: 0; z-index: 60; pointer-events: none; opacity: 0; visibility: hidden; display: none; background: radial-gradient(circle at 50% 48%, #ffffff 0%, #f2fff7 26%, #b9ffd9 48%, #e8fff2 72%, #ffffff 100%); } #arrival-dialog[hidden] { display: none !important; } #arrival-dialog { position: fixed; inset: 0; z-index: 72; display: grid; place-items: center; padding: max(18px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(18px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left)); pointer-events: auto; background: radial-gradient(circle at 50% 54%, rgba(45,238,255,0.12), transparent 54%); opacity: 0; transform: translateY(12px) scale(0.975); transition: opacity .22s ease, transform .32s cubic-bezier(.2,.85,.25,1); } #arrival-dialog.is-visible { opacity: 1; transform: translateY(0) scale(1); } #arrival-dialog.is-closing { pointer-events: none; } .arrival-card { position: relative; width: min(760px, 94vw); min-height: 174px; display: grid; grid-template-columns: 126px 1fr; align-items: center; gap: 24px; padding: 26px 34px 26px 38px; border: 1px solid rgba(154,247,255,0.58); border-radius: 28px; overflow: hidden; pointer-events: auto; color: #f5feff; background: linear-gradient(135deg, rgba(12,31,50,0.78), rgba(3,12,26,0.62)), radial-gradient(circle at 18% 20%, rgba(64,231,255,0.18), transparent 34%); box-shadow: 0 0 0 1px rgba(255,255,255,0.08) inset, 0 0 34px rgba(36,227,255,0.34), 0 18px 70px rgba(0,0,0,0.58); backdrop-filter: blur(18px) saturate(145%); -webkit-backdrop-filter: blur(18px) saturate(145%); } .arrival-card::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(115deg, transparent 18%, rgba(255,255,255,0.10) 42%, transparent 64%); transform: translateX(-100%); animation: arrivalSheen 2.8s ease-in-out infinite; } @keyframes arrivalSheen { 52%,100% { transform: translateX(100%); } } .arrival-close { position: absolute; left: 12px; top: 12px; z-index: 2; width: 32px; height: 32px; display: grid; place-items: center; padding: 0; border: 1px solid rgba(178,247,255,0.46); border-radius: 50%; color: #dffcff; background: rgba(1,12,25,0.54); font: 300 23px/1 Arial, sans-serif; cursor: pointer; box-shadow: 0 0 16px rgba(54,228,255,0.20); transition: background .18s ease, transform .18s ease; } .arrival-close:hover, .arrival-close:focus-visible { background: rgba(38,192,218,0.38); transform: scale(1.08); outline: none; } .arrival-avatar-wrap { width: 116px; height: 116px; padding: 4px; border-radius: 50%; background: conic-gradient(from 180deg, #80f7ff, #8a7dff, #ff8de8, #ffe27a, #80f7ff); box-shadow: 0 0 28px rgba(72,232,255,0.46); } .arrival-avatar { width: 100%; height: 100%; display: block; border: 3px solid rgba(4,15,29,0.88); border-radius: 50%; object-fit: cover; background: #071323; } .arrival-avatar-wrap.image-failed { display: grid; place-items: center; color: #eaffff; font: 900 30px/1 system-ui,sans-serif; } .arrival-avatar-wrap.image-failed::after { content: "AI"; } .arrival-avatar-wrap.image-failed .arrival-avatar { display: none; } .arrival-copy { min-width: 0; font-family: "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; } .arrival-kicker { margin: 0 0 8px; color: #85f4ff; font-size: 12px; font-weight: 800; letter-spacing: .22em; text-transform: uppercase; } .arrival-text { margin: 0; min-height: 4.9em; color: #f7feff; font-size: clamp(16px, 2.2vw, 22px); font-weight: 700; line-height: 1.65; letter-spacing: .035em; text-shadow: 0 0 16px rgba(142,247,255,0.30); } .arrival-cursor { display: inline-block; width: 2px; height: 1.15em; margin-left: 3px; vertical-align: -.18em; background: #a7fbff; box-shadow: 0 0 10px #53edff; animation: arrivalBlink .55s steps(1,end) infinite; } @keyframes arrivalBlink { 50% { opacity: 0; } } .sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; } #runtime-status[hidden] { display: none !important; } #runtime-status { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: rgba(1,7,14,.88); color: #f3feff; font-family: "PingFang TC","Microsoft JhengHei",system-ui,sans-serif; text-align: center; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } .runtime-card { width: min(92vw,520px); padding: 28px; border: 1px solid rgba(130,238,255,.5); border-radius: 22px; background: rgba(6,20,34,.94); box-shadow: 0 0 40px rgba(45,221,255,.18); } #runtime-message { margin: 0 0 18px; font-weight: 700; line-height: 1.7; } #runtime-retry { min-height: 44px; padding: 10px 20px; border: 1px solid rgba(170,246,255,.72); border-radius: 999px; color: #ecfeff; background: rgba(28,139,166,.48); font: 800 15px/1.4 inherit; cursor: pointer; } #runtime-retry:focus-visible { outline: 3px solid #fff; outline-offset: 3px; } @media (max-width: 620px) { #arrival-dialog { padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); } .arrival-card { grid-template-columns: 82px 1fr; gap: 15px; min-height: 150px; padding: 24px 18px 22px 28px; border-radius: 22px; } .arrival-avatar-wrap { width: 78px; height: 78px; } .arrival-close { left: 8px; top: 8px; width: 29px; height: 29px; } .arrival-kicker { margin-bottom: 5px; font-size: 10px; } .arrival-text { font-size: clamp(14px, 4vw, 18px); line-height: 1.55; } } @media (prefers-reduced-motion: reduce) { .arrival-card::before, .arrival-cursor { animation: none; } #arrival-dialog { transition-duration: .01ms; } } </style> <script type="importmap"> { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.175.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.175.0/examples/jsm/" } } </script> <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/three@0.175.0/build/three.module.js" crossorigin fetchpriority="high"> <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/three@0.175.0/examples/jsm/geometries/RoundedBoxGeometry.js" crossorigin> <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/three@0.175.0/examples/jsm/utils/BufferGeometryUtils.js" crossorigin> </head> <body> <div id="canvas-container"></div> <div id="flash"></div> <div id="runtime-status" hidden role="alertdialog" aria-modal="true" aria-labelledby="runtime-message"> <div class="runtime-card"><p id="runtime-message"></p><button id="runtime-retry" type="button">重新載入場景</button></div> </div> <section id="arrival-dialog" hidden role="dialog" aria-modal="true" aria-label="AI BUS 到站通知" aria-describedby="arrival-message-a11y"> <div class="arrival-card"> <button id="arrival-close" class="arrival-close" type="button" aria-label="關閉到站通知">×</button> <div class="arrival-avatar-wrap"> <img class="arrival-avatar" src="https://custom-images.strikinglycdn.com/res/hrscywv4p/image/upload/c_fill,fl_lossy,h_240,w_240,f_auto,q_auto/238199/877057_194815.jpeg" srcset="https://custom-images.strikinglycdn.com/res/hrscywv4p/image/upload/c_fill,fl_lossy,h_160,w_160,f_auto,q_auto/238199/877057_194815.jpeg 160w, https://custom-images.strikinglycdn.com/res/hrscywv4p/image/upload/c_fill,fl_lossy,h_240,w_240,f_auto,q_auto/238199/877057_194815.jpeg 240w" sizes="(max-width:620px) 78px,116px" width="120" height="120" alt="AI BUS 服務機器人頭像" decoding="async" referrerpolicy="no-referrer"> </div> <div class="arrival-copy"> <p class="arrival-kicker">AI BUS · ARRIVAL MESSAGE</p> <p id="arrival-message-a11y" class="sr-only">感謝搭乘 AI BUS 您已抵達城市商圈站 下車後如需再次搭乘 請按呼叫鍵即可</p> <p class="arrival-text" aria-hidden="true"><span id="arrival-typewriter"></span><span class="arrival-cursor" aria-hidden="true"></span></p> </div> </div> </section> <script type="module"> import * as THREE from 'three'; import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; const TOUCH_POINTS = navigator.maxTouchPoints || 0; const IS_COARSE_POINTER = Boolean(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); const IS_TOUCH_DEVICE = TOUCH_POINTS > 0 || IS_COARSE_POINTER || /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent); const DEVICE_MEMORY_GB = navigator.deviceMemory || 4; const CPU_CORES = navigator.hardwareConcurrency || 4; const SAVE_DATA = Boolean(navigator.connection && navigator.connection.saveData); const LOW_POWER_DEVICE = SAVE_DATA || DEVICE_MEMORY_GB <= 2 || CPU_CORES <= 4; const QUALITY_MODE = LOW_POWER_DEVICE ? 'low' : (IS_TOUCH_DEVICE ? 'balanced' : 'high'); const MAX_PIXEL_RATIO = QUALITY_MODE === 'low' ? 1 : (QUALITY_MODE === 'balanced' ? 1.12 : 1.35); const MAX_RENDER_PIXELS = QUALITY_MODE === 'low' ? 2100000 : (QUALITY_MODE === 'balanced' ? 3700000 : 8300000); const TARGET_FPS = QUALITY_MODE === 'high' ? 45 : (QUALITY_MODE === 'balanced' ? 30 : 24); const PREFERS_REDUCED_MOTION = Boolean(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); const randomValue = Math.random; const AITW_SESSION_NONCE = '__AITW_SESSION_NONCE__'; const AITW_BUILD_VERSION = '__AITW_BUILD_VERSION__'; function postToParent(payload) { try { window.parent.postMessage({ ...payload, aitwNonce: AITW_SESSION_NONCE }, '*'); return true; } catch { return false; } } const gradientMap = (() => { const c = document.createElement('canvas'); c.width = 5; c.height = 1; const x = c.getContext('2d'); x.fillStyle = '#3c4046'; x.fillRect(0,0,1,1); x.fillStyle = '#74787e'; x.fillRect(1,0,1,1); x.fillStyle = '#a8acb0'; x.fillRect(2,0,1,1); x.fillStyle = '#d8dadc'; x.fillRect(3,0,1,1); x.fillStyle = '#ffffff'; x.fillRect(4,0,1,1); const t = new THREE.CanvasTexture(c); t.minFilter = THREE.NearestFilter; t.magFilter = THREE.NearestFilter; t.generateMipmaps = false; return t; })(); const toon = (color, extra = {}) => new THREE.MeshToonMaterial({ color, gradientMap, ...extra }); const glow = (color) => new THREE.MeshBasicMaterial({ color }); function disposeMaterialDeep(material) { if (!material) return; const textureKeys = ['map','alphaMap','aoMap','bumpMap','normalMap','displacementMap','emissiveMap','metalnessMap','roughnessMap','lightMap','specularMap']; const seenTextures = new Set(); textureKeys.forEach(key => { const tex = material[key]; if (tex && typeof tex.dispose === 'function' && !seenTextures.has(tex)) { seenTextures.add(tex); try { tex.dispose(); } catch { /* best-effort GPU cleanup */ } } }); try { material.dispose(); } catch { /* best-effort GPU cleanup */ } } const WIN_GEO = new THREE.PlaneGeometry(0.6, 0.85); const WIN_MAT_A = new THREE.MeshBasicMaterial({ color: 0xffd98a }); const WIN_MAT_B = new THREE.MeshBasicMaterial({ color: 0x9fe0ff }); const fabricTexture = (() => { const c = document.createElement('canvas'); c.width = 32; c.height = 32; const x = c.getContext('2d'); x.fillStyle = '#ffffff'; x.fillRect(0,0,32,32); for (let i=0;i<32;i++) for (let j=0;j<32;j++){ const v = (i+j)%4; if (v===0){ x.fillStyle='rgba(0,0,0,0.20)'; x.fillRect(i,j,1,1); } else if (v===2){ x.fillStyle='rgba(255,255,255,0.18)'; x.fillRect(i,j,1,1); } } const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(3,3); t.minFilter = THREE.NearestFilter; t.magFilter = THREE.NearestFilter; t.generateMipmaps = false; t.colorSpace = THREE.SRGBColorSpace; return t; })(); const floorTexture = (() => { const c = document.createElement('canvas'); c.width = 64; c.height = 64; const x = c.getContext('2d'); x.fillStyle = '#ffffff'; x.fillRect(0,0,64,64); x.fillStyle = 'rgba(0,0,0,0.22)'; for (let i=0;i<64;i+=8) x.fillRect(i,0,2,64); x.fillStyle = 'rgba(255,255,255,0.25)'; for (let k=0;k<90;k++) x.fillRect((k*37)%64, (k*53)%64, 1, 1); const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(3,16); t.minFilter = THREE.NearestFilter; t.magFilter = THREE.NearestFilter; t.generateMipmaps = false; t.colorSpace = THREE.SRGBColorSpace; return t; })(); function makeSign(bg, fg, label) { const c = document.createElement('canvas'); c.width = 128; c.height = 64; const x = c.getContext('2d'); x.fillStyle = bg; x.fillRect(0,0,128,64); x.fillStyle = fg; x.font = 'bold 22px "Courier New", monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(label, 64, 32); x.fillStyle = 'rgba(0,0,0,0.35)'; for (let i=0;i<64;i+=3) x.fillRect(0,i,128,1); const t = new THREE.CanvasTexture(c); t.minFilter = THREE.NearestFilter; t.magFilter = THREE.NearestFilter; t.generateMipmaps = false; t.colorSpace = THREE.SRGBColorSpace; return t; } function makeArrivalFacadeTexture(seed, base, accent) { const c = document.createElement('canvas'); c.width = 256; c.height = 512; const x = c.getContext('2d'); const grad = x.createLinearGradient(0, 0, 0, 512); grad.addColorStop(0, base); grad.addColorStop(1, '#05080d'); x.fillStyle = grad; x.fillRect(0, 0, 256, 512); for (let row = 0; row < 16; row++) { for (let col = 0; col < 5; col++) { const px = 18 + col * 47; const py = 24 + row * 29; const lit = ((row * 7 + col * 11 + seed * 13) % 6) < 3; x.fillStyle = lit ? (col % 3 === 0 ? '#9fe0ff' : '#ffd98a') : '#101923'; x.fillRect(px, py, 27, 14); } } x.fillStyle = accent; x.fillRect(0, 0, 256, 8); x.fillRect(0, 250, 256, 4); const t = new THREE.CanvasTexture(c); t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = false; t.colorSpace = THREE.SRGBColorSpace; return t; } function makeDoorSign() { const c = document.createElement('canvas'); c.width = 256; c.height = 64; const x = c.getContext('2d'); const t = new THREE.CanvasTexture(c); t.minFilter = THREE.NearestFilter; t.magFilter = THREE.NearestFilter; t.generateMipmaps = false; t.colorSpace = THREE.SRGBColorSpace; let curLabel = '直達'; let curOn = true; const render = () => { x.fillStyle = '#062b16'; x.fillRect(0,0,256,64); x.strokeStyle = '#39ff8a'; x.lineWidth = 4; x.strokeRect(4,4,248,56); if (curOn) { const isCJK = Array.from(curLabel).some(ch => ch.codePointAt(0) > 0x7f); x.fillStyle = '#ffffff'; x.font = isCJK ? ('900 34px ' + SEAT_FONT_CJK) : 'bold 34px "Courier New", monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(curLabel, 128, 34); } t.needsUpdate = true; }; const draw = (textOn) => { curOn = textOn; render(); }; const setLabel = (label) => { curLabel = label; render(); }; render(); return { texture: t, draw, setLabel }; } function makeSpeakerTexture() { const c = document.createElement('canvas'); c.width = 128; c.height = 128; const x = c.getContext('2d'); x.clearRect(0, 0, 128, 128); x.beginPath(); x.arc(64, 64, 60, 0, Math.PI*2); x.fillStyle = '#04141d'; x.fill(); x.lineWidth = 5; x.strokeStyle = '#34eaff'; x.stroke(); x.beginPath(); x.arc(64, 64, 48, 0, Math.PI*2); x.lineWidth = 2; x.strokeStyle = 'rgba(58,226,255,0.5)'; x.stroke(); x.save(); x.beginPath(); x.arc(64, 64, 46, 0, Math.PI*2); x.clip(); x.fillStyle = '#2bd6ff'; for (let gy = 20; gy <= 108; gy += 11) { for (let gx = 20; gx <= 108; gx += 11) { x.beginPath(); x.arc(gx, gy, 2.3, 0, Math.PI*2); x.fill(); } } x.restore(); x.beginPath(); x.arc(64, 64, 12, 0, Math.PI*2); x.fillStyle = '#0a2a38'; x.fill(); x.lineWidth = 2; x.strokeStyle = '#9bf0ff'; x.stroke(); const t = new THREE.CanvasTexture(c); t.minFilter = THREE.LinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = false; t.colorSpace = THREE.SRGBColorSpace; return t; } function createTechTexture(width, height) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = false; texture.colorSpace = THREE.SRGBColorSpace; return { ctx, texture, width, height }; } function lineText(ctx, text, x, y, color = '#bafcff', size = 22, weight = '700') { ctx.fillStyle = color; ctx.font = `${weight} ${size}px "Courier New", monospace`; ctx.fillText(text, x, y); } const SEAT_FONT_CJK = '"PingFang TC","Microsoft JhengHei","Noto Sans CJK TC","Heiti TC",sans-serif'; const SERVICE_NO = '886-' + String(1000 + Math.floor(Math.random() * 9000)); const SPEED_MULTIPLIER = 3.375; const TRAVEL_TIME_SCALE = 1 / SPEED_MULTIPLIER; const CRUISE_SPEED = 187.2 * SPEED_MULTIPLIER; const DISPLAY_CRUISE_KMH = 117; const FULL_STOP_TIME = 5.0; const ROUTE = Object.freeze(['算力航廈站', '城市商圈站']); const ROUTE_DEST_IDX = 1; const DEST = ROUTE[ROUTE_DEST_IDX]; const npcGeo = { torso: new THREE.CylinderGeometry(0.24, 0.3, 0.82, 10), chest: new THREE.SphereGeometry(0.3, 12, 8), neck: new THREE.CylinderGeometry(0.075, 0.09, 0.16, 8), head: new THREE.SphereGeometry(0.2, 16, 12), hair: new THREE.SphereGeometry(0.218, 18, 12, 0, 2*Math.PI, 0, 0.54*Math.PI), eyeWhite: new THREE.SphereGeometry(0.04, 8, 8), iris: new THREE.SphereGeometry(0.021, 6, 6), limb: new THREE.CylinderGeometry(0.06, 0.065, 0.5, 8), slimLimb: new THREE.CylinderGeometry(0.052, 0.058, 0.46, 8), leg: new THREE.CylinderGeometry(0.073, 0.08, 0.54, 8), hand: new THREE.SphereGeometry(0.07, 10, 8), shoe: new THREE.BoxGeometry(0.16, 0.09, 0.28), }; function npcMaterials(idx) { const SKIN = [15251615,14131588,13209460,15780017,12157544,14594970,13279370]; const SHIRT = [2450411,1013358,8141549,12456508,223649,5078031,9647082,11817737]; const PANTS = [1976635,3359061,988970,1516884,2565930]; const HAIR = [1511693,2364945,3481621,4860696,6044196,7031343,3092275,9071183]; const shoeCol = new THREE.Color().setHSL((0.61803398875*idx)%1, 0.62, 0.44).getHex(); const M = (color, roughness, metalness) => new THREE.MeshStandardMaterial({ color, roughness, metalness }); return { skin: M(SKIN[idx%SKIN.length], 0.88, 0), shirt: M(SHIRT[idx%SHIRT.length], 0.74, 0.03), pants: M(PANTS[idx%PANTS.length], 0.8, 0.01), shoes: M(shoeCol, 0.72, 0.04), hair: M(HAIR[idx%HAIR.length], 0.92, 0), eyeWhite: M(16777215, 0.55, 0), iris: M(988970, 0.66, 0), }; } const npcPart = (geo, mat, opts = {}) => { const m = new THREE.Mesh(geo, mat); if (opts.position) m.position.copy(opts.position); if (opts.scale) m.scale.copy(opts.scale); return m; }; const npcEye = (side, m) => { const g = new THREE.Group(); g.position.set(0.072*side, 0.035, 0.175); g.add(npcPart(npcGeo.eyeWhite, m.eyeWhite, { scale: new THREE.Vector3(1.14, 0.72, 0.38) }), npcPart(npcGeo.iris, m.iris, { position: new THREE.Vector3(0,0,0.022), scale: new THREE.Vector3(0.82,0.82,0.34) })); return g; }; const npcArm = (side, m) => { const g = new THREE.Group(); g.position.set(0.38*side, 1.92, 0); const lower = new THREE.Group(); lower.position.y = -0.52; lower.add(npcPart(npcGeo.slimLimb, m.skin, { position: new THREE.Vector3(0,-0.23,0) }), npcPart(npcGeo.hand, m.skin, { position: new THREE.Vector3(0,-0.5,0) })); g.add(npcPart(npcGeo.limb, m.shirt, { position: new THREE.Vector3(0,-0.25,0) }), lower); return g; }; const npcLeg = (side, m) => { const g = new THREE.Group(); g.position.set(0.15*side, 1.03, 0); const lower = new THREE.Group(); lower.position.y = -0.56; lower.add(npcPart(npcGeo.leg, m.pants, { position: new THREE.Vector3(0,-0.28,0) }), npcPart(npcGeo.shoe, m.shoes, { position: new THREE.Vector3(0,-0.58,0.08) })); g.add(npcPart(npcGeo.leg, m.pants, { position: new THREE.Vector3(0,-0.28,0) }), lower); g.userData.lower = lower; return g; }; function mergeNpcMeshes(container, excludedChild = null) { container.updateWorldMatrix(true, true); const inverseContainer = container.matrixWorld.clone().invert(); const geometriesByMaterial = new Map(); const collect = object => { for (const child of object.children) { if (child === excludedChild) continue; if (child.isMesh && child.geometry && child.material && !Array.isArray(child.material)) { child.updateWorldMatrix(true, false); const transformed = child.geometry.clone(); transformed.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inverseContainer, child.matrixWorld)); const list = geometriesByMaterial.get(child.material) || []; list.push(transformed); geometriesByMaterial.set(child.material, list); } collect(child); } }; collect(container); const mergedMeshes = []; for (const [material, geometries] of geometriesByMaterial) { const geometry = geometries.length === 1 ? geometries[0] : mergeGeometries(geometries, false); if (geometries.length > 1) geometries.forEach(item => item.dispose()); if (!geometry) continue; geometry.computeBoundingSphere(); const mesh = new THREE.Mesh(geometry, material); mesh.userData.npcMergedGeometry = true; mergedMeshes.push(mesh); } container.clear(); if (excludedChild) container.add(excludedChild); mergedMeshes.forEach(mesh => container.add(mesh)); } function buildNpc(idx) { const root = new THREE.Group(); const m = npcMaterials(idx); const body = new THREE.Group(); body.position.y = 0.155; const head = new THREE.Group(); head.position.y = 2.32; const hair = npcPart(npcGeo.hair, m.hair, { position: new THREE.Vector3(0,0.11,0.008) }); hair.rotation.x = -0.04; hair.scale.set(1.02 + idx%3*0.02, 0.9 + idx%4*0.03, 1.01); head.add(npcPart(npcGeo.head, m.skin), hair, npcEye(-1, m), npcEye(1, m)); const legL = npcLeg(-1, m), legR = npcLeg(1, m); const armL = npcArm(-1, m), armR = npcArm(1, m); body.add( npcPart(npcGeo.torso, m.shirt, { position: new THREE.Vector3(0,1.5,0) }), npcPart(npcGeo.chest, m.shirt, { position: new THREE.Vector3(0,1.83,0) }), npcPart(npcGeo.neck, m.skin, { position: new THREE.Vector3(0,2.06,0) }), head, armL, armR, legL, legR); root.add(body); legL.rotation.x = -1.45; legR.rotation.x = -1.45; legL.userData.lower.rotation.x = 1.45; legR.userData.lower.rotation.x = 1.45; armL.rotation.x = -0.42 - (idx%3)*0.06; armR.rotation.x = -0.42 - ((idx+1)%3)*0.06; armL.rotation.z = 0.08; armR.rotation.z = -0.08; mergeNpcMeshes(head); mergeNpcMeshes(body, head); root.userData.head = head; root.userData.body = body; return root; } class Bus { constructor(scene) { this.group = new THREE.Group(); scene.add(this.group); this.width = 2.8; this.length = 15; this.deckHeight = 2.5; this.realLights = []; this.routeProg = 0.08; this.roundedGeometries = new Map(); this.panelRateReduced = false; this.displayUpdateInterval = QUALITY_MODE === 'high' ? 0.18 : 0.30; this.build(); } roundedGeometry(w,h,d,r){ const segments = QUALITY_MODE === 'high' ? 4 : (QUALITY_MODE === 'balanced' ? 3 : 2); const key = `${w}|${h}|${d}|${r}|${segments}`; let geometry = this.roundedGeometries.get(key); if (!geometry) { geometry = new RoundedBoxGeometry(w,h,d,segments,r); this.roundedGeometries.set(key, geometry); } return geometry; } pillow(w,h,d,r,mat){ return new THREE.Mesh(this.roundedGeometry(w,h,d,r), mat); } composeMatrix(x, y, z, rx = 0, ry = 0, rz = 0) { const position = new THREE.Vector3(x, y, z); const rotation = new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)); return new THREE.Matrix4().compose(position, rotation, new THREE.Vector3(1, 1, 1)); } mergeGeometryCopies(baseGeometry, matrices) { if (!baseGeometry || !matrices || matrices.length === 0) return null; const copies = matrices.map(matrix => { const geometry = baseGeometry.clone(); geometry.applyMatrix4(matrix); return geometry; }); const merged = mergeGeometries(copies, false); copies.forEach(geometry => geometry.dispose()); return merged; } addMergedMesh(baseGeometry, material, matrices, renderOrder = 0) { const geometry = this.mergeGeometryCopies(baseGeometry, matrices); if (!geometry) return null; const mesh = new THREE.Mesh(geometry, material); mesh.renderOrder = renderOrder; this.group.add(mesh); return mesh; } build() { // Bright, neutral cabin palette: warm greys replace the previous blue-heavy shell. const wallMat = toon(0xb8b7b1, { emissive: 0x302f2c, emissiveIntensity: 0.19 }); const floorMat = toon(0x595a57, { map: floorTexture, emissive: 0x20211f, emissiveIntensity: 0.13 }); const roofMat = toon(0xdeddd7, { emissive: 0x3d3c38, emissiveIntensity: 0.16 }); const dashMat = toon(0x292b2e, { emissive: 0x252526, emissiveIntensity: 0.22 }); const seatMat = toon(0x48494d, { map: fabricTexture, emissive: 0x1d1e21, emissiveIntensity: 0.15 }); this.seatAssets = { trimTopGeo: new THREE.BoxGeometry(0.85*0.82, 0.026, 0.03), trimFrontGeo: new THREE.BoxGeometry(0.85*0.82, 0.02, 0.025), legGeo: new THREE.CylinderGeometry(0.045, 0.07, 0.32, 10), footGeo: new THREE.CylinderGeometry(0.12, 0.12, 0.025, 12), trimTopMat: glow(0xd8cfbc), trimFrontMat: glow(0xf7f6f1), legMat: toon(0x60615f), footMat: glow(0xb8ad96) }; const halfW = this.width/2, halfL = this.length/2, H = this.deckHeight; const DOOR_Z = -4.3, DOOR_HALF = 0.72; const floor = this.pillow(this.width-0.1, 0.2, this.length-0.1, 0.05, floorMat); floor.position.y = 0.0; this.group.add(floor); const roof = this.pillow(this.width-0.1, 0.2, this.length-0.1, 0.05, roofMat); roof.position.y = H; this.group.add(roof); const sill = 1.0, header = 2.05; [-1,1].forEach(s => { if (s === 1) { const aLen = (DOOR_Z - DOOR_HALF) - (-halfL); const a = this.pillow(0.16, sill, aLen, 0.05, wallMat); a.position.set(s*(halfW-0.08), sill/2, -halfL + aLen/2); this.group.add(a); const bLen = halfL - (DOOR_Z + DOOR_HALF); const bb = this.pillow(0.16, sill, bLen, 0.05, wallMat); bb.position.set(s*(halfW-0.08), sill/2, halfL - bLen/2); this.group.add(bb); } else { const lower = this.pillow(0.16, sill, this.length, 0.05, wallMat); lower.position.set(s*(halfW-0.08), sill/2, 0); this.group.add(lower); } const top = this.pillow(0.16, H-header, this.length, 0.05, wallMat); top.position.set(s*(halfW-0.08), (header+H)/2, 0); this.group.add(top); }); for (let z=-halfL+1.6; z<=halfL-1.0; z+=1.5) { [-1,1].forEach(s => { if (s === 1 && Math.abs(z - DOOR_Z) < DOOR_HALF + 0.12) return; const strut = this.pillow(0.12, header-sill-0.04, 0.12, 0.02, wallMat); strut.position.set(s*(halfW-0.1), (sill+header)/2, z); this.group.add(strut); }); } const glassFaint = new THREE.MeshBasicMaterial({ color: 0xd8e6eb, transparent: true, opacity: 0.04, depthWrite: false, side: THREE.DoubleSide }); const addPane = (s, z0, z1) => { const pane = new THREE.Mesh(new THREE.PlaneGeometry(z1 - z0, header-sill-0.04), glassFaint); pane.rotation.y = Math.PI/2; pane.position.set(s*(halfW-0.13), (sill+header)/2, (z0+z1)/2); this.group.add(pane); }; addPane(-1, -halfL+0.25, halfL-0.25); addPane( 1, -halfL+0.25, DOOR_Z - DOOR_HALF); addPane( 1, DOOR_Z + DOOR_HALF, halfL-0.25); const back = this.pillow(this.width, H, 0.2, 0.05, wallMat); back.position.set(0, H/2, halfL-0.1); this.group.add(back); const frontZ = -halfL+0.15; const frontHeader = this.pillow(this.width, 0.5, 0.3, 0.05, wallMat); frontHeader.position.set(0, H-0.25, frontZ); this.group.add(frontHeader); const frontSill = this.pillow(this.width, 0.4, 0.3, 0.05, wallMat); frontSill.position.set(0, 0.6, frontZ); this.group.add(frontSill); [-1,1].forEach(s => { const p = this.pillow(0.18, H, 0.18, 0.03, wallMat); p.position.set(s*(halfW-0.1), H/2, frontZ); this.group.add(p); }); this.lastDisplayT = -999; this.lastDisplaySpeed = -1; this.lastDisplayPhase = ''; const dash = this.pillow(this.width-0.45, 0.42, 0.86, 0.1, dashMat); dash.position.set(0, 0.92, frontZ+0.55); this.group.add(dash); const statusDisplay = createTechTexture(512, 256); this.statusDisplay = statusDisplay; const statusPanel = new THREE.Mesh( new THREE.PlaneGeometry(1.55, 0.58), new THREE.MeshBasicMaterial({ map: statusDisplay.texture, transparent: true, opacity: 0.95, side: THREE.FrontSide }) ); statusPanel.position.set(0, 1.29, frontZ+0.68); statusPanel.rotation.x = -0.72; statusPanel.renderOrder = 8; this.group.add(statusPanel); const speakerTex = makeSpeakerTexture(); const speaker = new THREE.Mesh( new THREE.CircleGeometry(0.11, 32), new THREE.MeshBasicMaterial({ map: speakerTex, transparent: true, side: THREE.FrontSide }) ); speaker.position.set(0.98, 1.22, frontZ+0.70); speaker.rotation.x = -0.72; speaker.renderOrder = 9; this.group.add(speaker); const windscreenGlass = new THREE.Mesh( new THREE.PlaneGeometry(this.width-0.55, 1.16), new THREE.MeshBasicMaterial({ color: 0xe8edf0, transparent: true, opacity: 0.035, depthWrite: false, side: THREE.FrontSide }) ); windscreenGlass.position.set(0, 1.55, frontZ+0.045); windscreenGlass.renderOrder = 4; this.group.add(windscreenGlass); const dashLight = new THREE.PointLight(0xf5f4ee, 0.38, 3.5, 2); dashLight.position.set(0, 1.35, frontZ+0.75); this.group.add(dashLight); this.updateTechDisplays(0, CRUISE_SPEED, 'riding'); this.passengerSeats = []; for (let z=-halfL+4.5; z<=halfL-1.2; z+=1.25) { this.passengerSeats.push({ x:-0.8, z }, { x: 0.8, z }); } this.buildMergedSeats(seatMat); // Warm-white ceiling lamps keep the cabin bright without a cyan cast. const lampMat = toon(0xffffff, { emissive: 0xf7f6f0, emissiveIntensity: 0.96 }); const lampHousingGeo = new THREE.CylinderGeometry(0.15,0.15,0.04,20); const lampHousingMat = toon(0x555653); const lampGeo = new THREE.CylinderGeometry(0.12,0.12,0.02,20); for (let z=-halfL+2; z<=halfL-1; z+=2.0) { [-0.7,0.7].forEach(lx=>{ const housing = new THREE.Mesh(lampHousingGeo, lampHousingMat); housing.position.set(lx, H-0.12, z); this.group.add(housing); const lamp = new THREE.Mesh(lampGeo, lampMat); lamp.position.set(lx, H-0.14, z); this.group.add(lamp); }); } for (let z=-halfL+3; z<=halfL-2; z+=4) { const pl = new THREE.PointLight(0xf7f6f0, 1.75, 12, 2); pl.position.set(0, H-0.4, z); this.group.add(pl); this.realLights.push(pl); } this.makeDoor(DOOR_Z); this.addTechCabin(); this.addPilotRobot(frontZ); } addTechCabin() { const halfW = this.width/2, halfL = this.length/2, H = this.deckHeight; const DOOR_Z = this.doorZ; const cabinAccent = 0xe8dfca, cabinAccentDeep = 0xa69d89; this.aisleSegs = []; const AISLE_Y = 0xffd84a, AISLE_YD = 0xd99500; const aisleSideGeo = new THREE.BoxGeometry(0.05, 0.02, 0.64); const aisleMidGeo = new THREE.BoxGeometry(0.06, 0.02, 0.32); if (QUALITY_MODE === 'low' || PREFERS_REDUCED_MOTION) { const sideMatrices = [], midMatrices = []; for (let z=-halfL+0.6; z<=halfL-0.6; z+=0.7) { sideMatrices.push(this.composeMatrix(-0.5, 0.11, z), this.composeMatrix(0.5, 0.11, z)); midMatrices.push(this.composeMatrix(0, 0.11, z)); } this.addMergedMesh(aisleSideGeo, glow(AISLE_Y), sideMatrices); this.addMergedMesh(aisleMidGeo, glow(AISLE_YD), midMatrices); aisleSideGeo.dispose(); aisleMidGeo.dispose(); } else { for (let z=-halfL+0.6; z<=halfL-0.6; z+=0.7){ const rowAz = Math.abs(z); const sideMat = new THREE.MeshBasicMaterial({ color: AISLE_Y, transparent: true }); const midMat = new THREE.MeshBasicMaterial({ color: AISLE_YD, transparent: true }); [-0.5, 0, 0.5].forEach(lx => { const isMid = lx === 0; const segMat = isMid ? midMat : sideMat; const seg = new THREE.Mesh(isMid ? aisleMidGeo : aisleSideGeo, segMat); seg.position.set(lx, 0.11, z); this.group.add(seg); }); this.aisleSegs.push({ m: sideMat, az: rowAz }, { m: midMat, az: rowAz }); } } this.aisleMaxZ = halfL - 0.6; const sill = 1.0; const DOOR_HALF = 0.72, DOOR_GAP = DOOR_HALF + 0.2; const addWallStrip = (s, y, h, col, z0, z1) => { const len = z1 - z0; if (len <= 0.06) return; const m = new THREE.Mesh(new THREE.BoxGeometry(0.03, h, len), glow(col)); m.position.set(s*(halfW-0.16), y, (z0+z1)/2); this.group.add(m); }; [-1, 1].forEach(s => { if (s === 1) { addWallStrip(s, sill+0.02, 0.04, cabinAccent, -halfL+0.2, DOOR_Z - DOOR_GAP); addWallStrip(s, sill+0.02, 0.04, cabinAccent, DOOR_Z + DOOR_GAP, halfL-0.2); addWallStrip(s, 0.18, 0.03, cabinAccentDeep, -halfL+0.2, DOOR_Z - DOOR_GAP); addWallStrip(s, 0.18, 0.03, cabinAccentDeep, DOOR_Z + DOOR_GAP, halfL-0.2); } else { addWallStrip(s, sill+0.02, 0.04, cabinAccent, -halfL+0.2, halfL-0.2); addWallStrip(s, 0.18, 0.03, cabinAccentDeep, -halfL+0.2, halfL-0.2); } }); const ceilingBar = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.03, this.length-1.2), glow(0x35e6bd)); ceilingBar.position.set(0, H-0.18, 0); this.group.add(ceilingBar); } // ── 駕駛艙自動領航機器人 · NEURAL PILOT UNIT ────────────────────────────── // 靜態站姿(無手部動作),全身依材質合併,僅約 11 個 draw call。 // 平時面向車頭;主角起身準備下車時轉身 180° 面向主角,直到離站。 addPilotRobot(frontZ) { const root = new THREE.Group(); this.pilotBaseY = 0.10; this.pilotYaw = Math.PI; this.pilotT = 0; this.pilotTurnT = 0; root.position.set(-0.80, this.pilotBaseY, frontZ + 1.42); root.rotation.y = this.pilotYaw; root.scale.setScalar(1.5); this.group.add(root); this.pilotRobot = root; const shellMat = toon(0xf7fafd, { emissive: 0x4c6d7d, emissiveIntensity: 0.58 }); const jointMat = toon(0x10161d, { emissive: 0x0e2a19, emissiveIntensity: 0.45 }); const visorMat = toon(0x03060b, { emissive: 0x07210f, emissiveIntensity: 0.55 }); const trimMat = glow(0x74ff3d); const eyeMat = glow(0x9bfa45); const bakeMatrix = new THREE.Matrix4(); const bakeQuat = new THREE.Quaternion(); const bakeEuler = new THREE.Euler(); const bakePos = new THREE.Vector3(); const bakeScale = new THREE.Vector3(1, 1, 1); const bake = (geometry, x, y, z, rx = 0, ry = 0, rz = 0) => { let g = geometry.clone(); if (!geometry.userData.aitwShared) geometry.dispose(); if (g.index) { const flat = g.toNonIndexed(); g.dispose(); g = flat; } bakeEuler.set(rx, ry, rz); bakeQuat.setFromEuler(bakeEuler); bakePos.set(x, y, z); g.applyMatrix4(bakeMatrix.compose(bakePos, bakeQuat, bakeScale)); return g; }; const box = (w, h, d, r) => { const g = this.roundedGeometry(w, h, d, r); g.userData.aitwShared = true; return g; }; const weld = (parts, material, parent, renderOrder = 0) => { if (!parts.length) return null; const merged = mergeGeometries(parts, false); parts.forEach(geometry => geometry.dispose()); if (!merged) return null; const mesh = new THREE.Mesh(merged, material); if (renderOrder) mesh.renderOrder = renderOrder; parent.add(mesh); return mesh; }; const temporarySharedGeometries = []; const retainUntilRobotBuilt = geometry => { geometry.userData.aitwShared = true; temporarySharedGeometries.push(geometry); return geometry; }; // 造型:纖細圓潤白殼 + 黑色關節環帶 + 螢光綠光件,雙腿站姿。 const armGeo = box(0.072, 0.165, 0.078, 0.034); const legGeo = box(0.092, 0.175, 0.100, 0.044); const handGeo = box(0.082, 0.082, 0.084, 0.038); const footGeo = box(0.125, 0.092, 0.205, 0.044); const shoulderGeo = retainUntilRobotBuilt(new THREE.SphereGeometry(0.056, 14, 10)); const elbowGeo = retainUntilRobotBuilt(new THREE.CylinderGeometry(0.046, 0.046, 0.088, 12)); const kneeGeo = retainUntilRobotBuilt(new THREE.CylinderGeometry(0.058, 0.058, 0.104, 12)); const ankleGeo = retainUntilRobotBuilt(new THREE.CylinderGeometry(0.052, 0.052, 0.104, 12)); const ringGeo = retainUntilRobotBuilt(new THREE.TorusGeometry(0.050, 0.008, 6, 16)); const earRimGeo = retainUntilRobotBuilt(new THREE.CylinderGeometry(0.080, 0.080, 0.032, 16)); const earDiscGeo = retainUntilRobotBuilt(new THREE.CylinderGeometry(0.066, 0.066, 0.058, 18)); const earCoreGeo = retainUntilRobotBuilt(new THREE.CylinderGeometry(0.022, 0.022, 0.022, 12)); // 珍珠白外殼:纖細軀幹(腹部保持全空白)、細手臂、雙腿與腳掌 const shell = [ bake(box(0.300, 0.330, 0.250, 0.115), 0, 0.645, 0), bake(box(0.250, 0.110, 0.210, 0.050), 0, 0.470, 0) ]; [-1, 1].forEach(s => { shell.push(bake(armGeo, s * 0.194, 0.660, 0.008, 0, 0, -s * 0.09)); shell.push(bake(armGeo, s * 0.210, 0.492, 0.018, 0, 0, -s * 0.03)); shell.push(bake(handGeo, s * 0.218, 0.376, 0.024)); shell.push(bake(legGeo, s * 0.085, 0.362, 0)); shell.push(bake(legGeo, s * 0.085, 0.180, 0)); shell.push(bake(footGeo, s * 0.085, 0.052, 0.028)); }); weld(shell, shellMat, root); // 黑色關節環帶:肩、肘、腕、膝、踝 const joint = []; [-1, 1].forEach(s => { joint.push(bake(shoulderGeo, s * 0.176, 0.742, 0.006)); joint.push(bake(elbowGeo, s * 0.204, 0.578, 0.012, 0, 0, Math.PI / 2)); joint.push(bake(elbowGeo, s * 0.214, 0.424, 0.020, 0, 0, Math.PI / 2)); joint.push(bake(kneeGeo, s * 0.085, 0.272, 0, 0, 0, Math.PI / 2)); joint.push(bake(ankleGeo, s * 0.085, 0.115, 0, 0, 0, Math.PI / 2)); }); weld(joint, jointMat, root); // 螢光綠光環:肩與膝 const trim = []; [-1, 1].forEach(s => { trim.push(bake(ringGeo, s * 0.202, 0.742, 0.006, 0, Math.PI / 2, 0)); trim.push(bake(ringGeo, s * 0.139, 0.272, 0, 0, Math.PI / 2, 0)); }); weld(trim, trimMat, root); const dockGlow = new THREE.Mesh( new THREE.RingGeometry(0.08, 0.190, 26), new THREE.MeshBasicMaterial({ color: 0x74ff3d, transparent: true, opacity: 0.13, depthWrite: false }) ); dockGlow.rotation.x = -Math.PI / 2; dockGlow.position.y = 0.006; dockGlow.renderOrder = 3; root.add(dockGlow); // 頭部:大圓頭 + 環繞式黑面罩 + 螢光綠耳盤(可微幅擺動) const head = new THREE.Group(); head.position.set(0, 0.965, 0); root.add(head); this.pilotHead = head; weld([bake(box(0.400, 0.365, 0.360, 0.158), 0, 0, 0)], shellMat, head); const visor = new THREE.Mesh(this.roundedGeometry(0.330, 0.300, 0.290, 0.108), visorMat); visor.position.set(0, 0.010, 0.052); head.add(visor); // 耳部:深色外圈襯底 + 明顯的螢光綠圓盤 + 深色中心點 const earJoint = []; const earTrim = []; [-1, 1].forEach(s => { earJoint.push(bake(earRimGeo, s * 0.192, 0.005, -0.015, 0, 0, Math.PI / 2)); earJoint.push(bake(earCoreGeo, s * 0.238, 0.005, -0.015, 0, 0, Math.PI / 2)); earTrim.push(bake(earDiscGeo, s * 0.204, 0.005, -0.015, 0, 0, Math.PI / 2)); }); weld(earJoint, jointMat, head); weld(earTrim, trimMat, head); // 螢光綠圓眼:左右對稱兩顆 const eyeGeo = retainUntilRobotBuilt(new THREE.CylinderGeometry(0.050, 0.050, 0.060, 20)); weld([ bake(eyeGeo, -0.078, 0.018, 0.175, Math.PI / 2, 0, 0), bake(eyeGeo, 0.078, 0.018, 0.175, Math.PI / 2, 0, 0) ], eyeMat, head); // Reusable temporary primitives are released only after every baked copy exists. temporarySharedGeometries.forEach(geometry => geometry.dispose()); } resetPilotRobot() { this.pilotYaw = Math.PI; this.pilotT = 0; this.pilotTurnT = 0; if (this.pilotRobot) { this.pilotRobot.rotation.y = this.pilotYaw; this.pilotRobot.position.y = this.pilotBaseY; } if (this.pilotHead) this.pilotHead.rotation.set(0, 0, 0); } updatePilotRobot(dt, phase, camPos, turnDuration = 2.1) { const root = this.pilotRobot; if (!root) return; const damp = PREFERS_REDUCED_MOTION ? 0.22 : 1; this.pilotT += dt; // 主角起身 → 走到走道的這段時間內,剛好完成 180° 轉身面向主角,之後持續朝向主角直到下車 const greeting = phase === 'standing' || phase === 'walking' || phase === 'advancing' || phase === 'arrivalmessage' || phase === 'turning' || phase === 'dooropen' || phase === 'exiting'; const span = Math.max(0.35, turnDuration); this.pilotTurnT = greeting ? Math.min(span, this.pilotTurnT + dt) : 0; if (this.pilotTurnT <= 0) { this.pilotYaw = Math.PI; } else if (this.pilotTurnT < span) { const k = this.pilotTurnT / span; const e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; this.pilotYaw = Math.PI * (1 - e); } else if (camPos) { const targetYaw = Math.atan2(camPos.x - root.position.x, camPos.z - root.position.z); let diff = targetYaw - this.pilotYaw; while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; this.pilotYaw += diff * (1 - Math.exp(-dt * 3.0)); } root.rotation.y = this.pilotYaw; // 待機呼吸與頭部微擺 root.position.y = this.pilotBaseY + Math.sin(this.pilotT * 1.5) * 0.007 * damp; if (this.pilotHead) { this.pilotHead.rotation.y = Math.sin(this.pilotT * 0.52) * 0.075 * damp; this.pilotHead.rotation.x = Math.sin(this.pilotT * 0.83) * 0.032 * damp; } } drawLidarSweep(ctx, cx, cy, r, t) { ctx.strokeStyle = 'rgba(58,226,255,0.55)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.stroke(); ctx.strokeStyle = 'rgba(58,226,255,0.28)'; ctx.beginPath(); ctx.arc(cx, cy, r*0.55, 0, Math.PI*2); ctx.stroke(); const ang = (t * 2.4) % (Math.PI*2); ctx.fillStyle = 'rgba(58,226,255,0.16)'; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, ang-0.9, ang); ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#7dfcff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(ang)*r, cy + Math.sin(ang)*r); ctx.stroke(); ctx.fillStyle = '#82ffbd'; [[0.42,1.1],[0.72,3.9],[0.55,5.2]].forEach(p => { ctx.beginPath(); ctx.arc(cx + Math.cos(p[1])*r*p[0], cy + Math.sin(p[1])*r*p[0], 2.4, 0, Math.PI*2); ctx.fill(); }); } drawPanelBackground(display) { const { ctx, width, height } = display; ctx.clearRect(0, 0, width, height); ctx.fillStyle = 'rgba(0, 12, 18, 0.92)'; ctx.fillRect(0, 0, width, height); ctx.strokeStyle = 'rgba(62, 239, 255, 0.82)'; ctx.lineWidth = 3; ctx.strokeRect(8, 8, width - 16, height - 16); ctx.strokeStyle = 'rgba(62, 239, 255, 0.14)'; ctx.lineWidth = 1; for (let y = 54; y < height; y += 54) { ctx.beginPath(); ctx.moveTo(18, y); ctx.lineTo(width - 18, y); ctx.stroke(); } } updateTechDisplays(t, speed = 0, phase = 'riding') { if (!this.statusDisplay) return; const speedKmh = Math.max(0, Math.round((speed / CRUISE_SPEED) * DISPLAY_CRUISE_KMH)); const phaseKey = ['stopping','standing','walking','advancing','arrivalmessage','turning','dooropen','exiting'].includes(phase) ? 'ARRIVAL' : 'CRUISE'; const prog = this.routeProg || 0; const progMoved = Math.abs(prog - (this._lastConsoleProg || 0)) > 0.002; if (!progMoved && speedKmh === this.lastDisplaySpeed && phaseKey === this.lastDisplayPhase && (t - this.lastDisplayT) < this.displayUpdateInterval) return; this.lastDisplayT = t; this.lastDisplaySpeed = speedKmh; this.lastDisplayPhase = phaseKey; this._lastConsoleProg = prog; const d = this.statusDisplay, ctx = d.ctx, cjk = SEAT_FONT_CJK; const blink = Math.sin(t * 6) > -0.2; this.drawPanelBackground(d); lineText(ctx, 'AITAIWAN DIRECT SERVICE', 26, 38, '#eaffff', 19, '900'); this.drawRouteStrip(ctx, 58, 454, 104, t, 128, 15, cjk, blink); lineText(ctx, `SPEED ${String(speedKmh).padStart(3, '0')} KM/H`, 26, 178, '#ffffff', 26, '900'); lineText(ctx, `DIRECT ${DEST}`, 26, 212, '#82ffbd', 19, '800'); lineText(ctx, 'SENSOR FUSION · V2X LINK · PATH LOCK', 26, 240, '#a9f7ff', 16, '800'); this.drawLidarSweep(ctx, 452, 200, 34, t); ctx.fillStyle = phaseKey === 'ARRIVAL' ? '#ffeaa0' : '#39ff8a'; ctx.fillRect(434, 18, 38, 38); ctx.strokeStyle = 'rgba(255,255,255,0.52)'; ctx.lineWidth = 3; ctx.strokeRect(430, 14, 46, 46); d.texture.needsUpdate = true; } makeDoor(z) { this.doorZ = z; const g = new THREE.Group(); const xWall = this.width/2 - 0.08; const frameMat = toon(0x4d5358, { emissive: 0x151719, emissiveIntensity: 0.16 }); const leafMat = toon(0x30363b, { emissive: 0x0b1014, emissiveIntensity: 0.28 }); const glassMat = toon(0x1d3540, { emissive: 0x0d2028, emissiveIntensity: 0.34 }); const W = 1.18, Ht = 2.0; [-1,1].forEach(s=>{ const jamb = this.pillow(0.12, Ht, 0.1, 0.03, frameMat); jamb.position.set(xWall-0.02, Ht/2+0.08, z + s*W/2); g.add(jamb); }); const lintel = this.pillow(0.12, 0.14, W+0.12, 0.03, frameMat); lintel.position.set(xWall-0.02, Ht+0.05, z); g.add(lintel); const threshold = this.pillow(0.16, 0.06, W+0.12, 0.02, frameMat); threshold.position.set(xWall-0.03, 0.12, z); g.add(threshold); this.doorLeaves = []; [-1,1].forEach(s=>{ const pivot = new THREE.Group(); pivot.position.set(xWall-0.06, 0, z + s*(W/2)); pivot.userData.side = s; const leaf = this.pillow(0.06, Ht-0.2, W/2-0.04, 0.02, leafMat); leaf.position.set(0, Ht/2, -s*(W/4)); const glassPane = this.pillow(0.03, Ht-0.7, W/2-0.16, 0.02, glassMat); glassPane.position.set(-0.03, Ht/2+0.1, -s*(W/4)); const edge = this.pillow(0.07, Ht-0.2, 0.05, 0.02, frameMat); edge.position.set(0, Ht/2, -s*(W/2-0.03)); pivot.add(leaf, glassPane, edge); g.add(pivot); this.doorLeaves.push(pivot); }); const doorSign = makeDoorSign(); this.signDraw = doorSign.draw; this.signSetLabel = doorSign.setLabel; this.signTextOn = true; const sign = new THREE.Mesh(new THREE.PlaneGeometry(1.0, 0.25), new THREE.MeshBasicMaterial({ map: doorSign.texture })); sign.position.set(xWall-0.12, Ht+0.24, z); sign.rotation.y = -Math.PI/2; g.add(sign); const glowBar = new THREE.Mesh(new THREE.BoxGeometry(0.02,0.04,W), glow(0xffffff)); glowBar.position.set(xWall-0.12, Ht+0.02, z); g.add(glowBar); this.exitLight = new THREE.PointLight(0xffffff, 0.42, 3.2, 2); this.exitLight.position.set(xWall-0.4, Ht-0.2, z); g.add(this.exitLight); const gc = document.createElement('canvas'); gc.width = gc.height = 128; const gx = gc.getContext('2d'); const grad = gx.createRadialGradient(64,64,0, 64,64,64); grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(0.35,'rgba(255,255,255,0.82)'); grad.addColorStop(0.7, 'rgba(255,255,255,0.20)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); gx.fillStyle = grad; gx.fillRect(0,0,128,128); const glowTex = new THREE.CanvasTexture(gc); this.doorGlow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, fog: false })); this.doorGlow.position.set(xWall+0.8, 1.45, z); this.doorGlow.scale.setScalar(0.1); g.add(this.doorGlow); this.group.add(g); } setSignText(on) { if (this.signTextOn === on) return; this.signTextOn = on; if (this.signDraw) this.signDraw(on); } setDoorDest(label) { if (this.signSetLabel) this.signSetLabel(label); } setDoorOpen(k) { this.doorLeaves.forEach(p => { p.rotation.y = -p.userData.side * k * 1.62; }); if (this.exitLight) this.exitLight.intensity = 0.6 + k * 1.6; } updateAisleFlash(t, active) { if (!this.aisleSegs || this.aisleSegs.length === 0) return; if (!active && this._aisleActive === false) return; if (active && (t - (this._lastAisleUpdateT || 0)) < 0.05) return; this._aisleActive = active; this._lastAisleUpdateT = t; const frequency = PREFERS_REDUCED_MOTION ? 2.0 : 5.0; const minOpacity = PREFERS_REDUCED_MOTION ? 0.42 : 0.16; const amplitude = PREFERS_REDUCED_MOTION ? 0.30 : 0.84; for (const s of this.aisleSegs) { s.m.opacity = active ? minOpacity + amplitude * (0.5 + 0.5 * Math.sin(t * frequency - (this.aisleMaxZ - s.az) * 1.35)) : 1; } } buildMergedSeats(mat) { const bw = 0.85; const bh = 0.86; const y = 0.5; const a = this.seatAssets; const matrices = { base: [], back: [], head: [], trimTop: [], trimFront: [], leg: [], foot: [] }; for (const seat of this.passengerSeats) { const { x, z } = seat; matrices.base.push(this.composeMatrix(x, y, z)); matrices.back.push(this.composeMatrix(x, y + 0.43, z + 0.35, -0.1)); matrices.head.push(this.composeMatrix(x, y + 0.93, z + 0.30, -0.1)); matrices.trimTop.push(this.composeMatrix(x, y + 0.84, z + 0.27, -0.1)); matrices.trimFront.push(this.composeMatrix(x, y + 0.045, z - bw * 0.5 + 0.05)); matrices.leg.push(this.composeMatrix(x, y - 0.25, z)); matrices.foot.push(this.composeMatrix(x, y - 0.41, z)); } this.addMergedMesh(this.roundedGeometry(bw, 0.16, bw, 0.05), mat, matrices.base); this.addMergedMesh(this.roundedGeometry(bw, bh, 0.16, 0.05), mat, matrices.back); this.addMergedMesh(this.roundedGeometry(bw * 0.52, 0.2, 0.14, 0.05), mat, matrices.head); this.addMergedMesh(a.trimTopGeo, a.trimTopMat, matrices.trimTop); this.addMergedMesh(a.trimFrontGeo, a.trimFrontMat, matrices.trimFront); this.addMergedMesh(a.legGeo, a.legMat, matrices.leg); this.addMergedMesh(a.footGeo, a.footMat, matrices.foot); } buildSeatScreens() { this.seatScreenTex = createTechTexture(512, 336); this.lastPanelT = -1; this._lastPanelPhase = ''; this._lastPanelSecond = -1; this._lastPanelJourneyBucket = -1; const rows = this.passengerSeats || []; this.seatScreenAssets = { frameMat: toon(0x050c13, { emissive: 0x03151f, emissiveIntensity: 0.45 }), bezelTopMat: glow(0xffd84a), // yellow upper rail above seat-back displays bezelBottomMat: glow(0x1f9fcf), panelMat: new THREE.MeshBasicMaterial({ map: this.seatScreenTex.texture, transparent: true, opacity: 0.99, side: THREE.FrontSide }), bezelTopGeo: new THREE.BoxGeometry(0.52, 0.018, 0.018), bezelBottomGeo: new THREE.BoxGeometry(0.52, 0.014, 0.018), panelGeo: new THREE.PlaneGeometry(0.5, 0.33), postGeo: new THREE.CylinderGeometry(0.022, 0.03, 1.0, 8), postMat: toon(0x13212c) }; const matrices = { frame: [], top: [], bottom: [], panel: [], post: [] }; const frontZBySide = new Map(); rows.forEach(seat => frontZBySide.set(seat.x, Math.min(frontZBySide.get(seat.x) ?? Infinity, seat.z))); const addScreenPart = (target, groupMatrix, x, y, z) => { const localMatrix = this.composeMatrix(x, y, z); target.push(new THREE.Matrix4().multiplyMatrices(groupMatrix, localMatrix)); }; rows.forEach(seat => { const screenZ = seat.z - 0.78; const groupMatrix = this.composeMatrix(seat.x, 1.04, screenZ, -0.12); addScreenPart(matrices.frame, groupMatrix, 0, 0, -0.018); addScreenPart(matrices.top, groupMatrix, 0, 0.205, 0.004); addScreenPart(matrices.bottom, groupMatrix, 0, -0.205, 0.004); addScreenPart(matrices.panel, groupMatrix, 0, 0, 0.006); if (Math.abs(seat.z - frontZBySide.get(seat.x)) < 0.01) { addScreenPart(matrices.post, groupMatrix, 0, -0.55, -0.02); } }); const a = this.seatScreenAssets; this.addMergedMesh(this.roundedGeometry(0.58, 0.42, 0.04, 0.02), a.frameMat, matrices.frame); this.addMergedMesh(a.bezelTopGeo, a.bezelTopMat, matrices.top); this.addMergedMesh(a.bezelBottomGeo, a.bezelBottomMat, matrices.bottom); this.addMergedMesh(a.panelGeo, a.panelMat, matrices.panel, 7); this.addMergedMesh(a.postGeo, a.postMat, matrices.post); } setReducedPanelRate(reduced) { this.panelRateReduced = Boolean(reduced); this.displayUpdateInterval = this.panelRateReduced ? 0.50 : (QUALITY_MODE === 'high' ? 0.18 : 0.30); this.lastDisplayT = -999; this.lastPanelT = -1; } updateSeatScreens(t, phase, journey) { if (!this.seatScreenTex) return; const target = Math.min(1, Math.max(0, journey)); const moving = phase === 'riding' || phase === 'stopping'; const minInterval = moving ? (this.panelRateReduced ? 0.40 : (QUALITY_MODE === 'high' ? 0.12 : 0.20)) : (this.panelRateReduced ? 1.0 : 0.50); const second = Math.floor(Date.now() / 1000); const journeyBucket = Math.round(target * 100); const unchangedStaticPanel = !moving && phase === this._lastPanelPhase && second === this._lastPanelSecond && journeyBucket === this._lastPanelJourneyBucket; if (unchangedStaticPanel) return; if (this.lastPanelT >= 0 && (t - this.lastPanelT) < minInterval) return; this.lastPanelT = t; this._lastPanelPhase = phase; this._lastPanelSecond = second; this._lastPanelJourneyBucket = journeyBucket; this.routeProg = target; this.drawRoutePanel(t, phase); } drawRouteStrip(ctx, x0, x1, ry, t, labelY, fontPx, cjk, blink) { const n = ROUTE.length; const destIdx = ROUTE_DEST_IDX; const destFrac = (n > 1) ? destIdx/(n-1) : 1; const prog = Math.min(1, Math.max(0, this.routeProg)); const markerX = x0 + (x1-x0) * destFrac * prog; ctx.lineCap = 'round'; ctx.strokeStyle = 'rgba(120,150,170,0.45)'; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(x0, ry); ctx.lineTo(x1, ry); ctx.stroke(); ctx.strokeStyle = '#34eaff'; ctx.lineWidth = 6; ctx.shadowColor = '#34eaff'; ctx.shadowBlur = 12; ctx.beginPath(); ctx.moveTo(x0, ry); ctx.lineTo(markerX, ry); ctx.stroke(); ctx.shadowBlur = 0; const flow = (t * 0.5) % 1, fx = x0 + (markerX - x0) * flow; ctx.fillStyle = '#eaffff'; ctx.beginPath(); ctx.arc(fx, ry, 4, 0, Math.PI*2); ctx.fill(); ctx.textAlign = 'center'; for (let i = 0; i < n; i++){ const nx = x0 + (x1-x0) * (i/(n-1)); const isDest = (i === destIdx); const passed = nx <= markerX + 2; ctx.beginPath(); ctx.arc(nx, ry, isDest?9:6, 0, Math.PI*2); ctx.fillStyle = isDest ? '#82ffbd' : (passed ? '#34eaff' : '#46606e'); ctx.fill(); if (isDest){ ctx.strokeStyle = 'rgba(130,255,189,' + (blink?0.95:0.4) + ')'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(nx, ry, 14, 0, Math.PI*2); ctx.stroke(); } ctx.fillStyle = isDest ? '#aeffd6' : (passed ? '#bafcff' : '#7f97a4'); ctx.font = (isDest?'800':'600') + ' ' + fontPx + 'px ' + cjk; ctx.fillText(ROUTE[i], nx, labelY); } const pr = 8 + Math.sin(t*5) * 2; ctx.strokeStyle = '#ffd76a'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(markerX, ry, pr, 0, Math.PI*2); ctx.stroke(); ctx.fillStyle = '#ffd76a'; ctx.beginPath(); ctx.moveTo(markerX, ry-pr-4); ctx.lineTo(markerX-5, ry-pr-12); ctx.lineTo(markerX+5, ry-pr-12); ctx.closePath(); ctx.fill(); ctx.textAlign = 'left'; } drawRoutePanel(t, phase) { const d = this.seatScreenTex, ctx = d.ctx, W = d.width, H = d.height, cjk = SEAT_FONT_CJK; const now = new Date(); const p2 = n => String(n).padStart(2, '0'); const dateStr = now.getFullYear() + '/' + p2(now.getMonth()+1) + '/' + p2(now.getDate()); const timeStr = p2(now.getHours()) + ':' + p2(now.getMinutes()) + ':' + p2(now.getSeconds()); const blink = Math.sin(t * 6) > -0.2; const bg = ctx.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, '#04141d'); bg.addColorStop(1, '#020a10'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = 'rgba(58,226,255,0.07)'; ctx.lineWidth = 1; for (let gx = 0; gx < W; gx += 32){ ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke(); } for (let gy = 0; gy < H; gy += 32){ ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); ctx.stroke(); } ctx.strokeStyle = 'rgba(58,226,255,0.85)'; ctx.lineWidth = 4; ctx.strokeRect(7, 7, W-14, H-14); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; ctx.fillStyle = '#9bf0ff'; ctx.font = '800 24px ' + cjk; ctx.fillText('AITaiwan', 24, 44); ctx.fillStyle = '#ffd76a'; ctx.font = '700 18px ' + cjk; ctx.fillText('車次 ' + SERVICE_NO, 160, 44); ctx.textAlign = 'right'; ctx.fillStyle = '#bafcff'; ctx.font = '700 22px "Courier New", monospace'; ctx.fillText(timeStr, W-24, 42); ctx.fillStyle = '#7fc8dd'; ctx.font = '600 15px "Courier New", monospace'; ctx.fillText(dateStr, W-24, 62); ctx.textAlign = 'left'; ctx.strokeStyle = 'rgba(58,226,255,0.35)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(20, 76); ctx.lineTo(W-20, 76); ctx.stroke(); this.drawRouteStrip(ctx, 56, W-56, 134, t, 134 + 30, 15, cjk, blink); const by = 196, bh = 56; const hasArrived = !['riding', 'stopping'].includes(phase); ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(20,80,45,0.55)'; ctx.fillRect(20, by, W-40, bh); ctx.strokeStyle = '#82ffbd'; ctx.lineWidth = 2; ctx.strokeRect(20, by, W-40, bh); ctx.fillStyle = '#aeffd6'; ctx.font = '900 27px ' + cjk; ctx.fillText(hasArrived ? DEST : '下一站 ' + DEST, W/2, by+37); ctx.textAlign = 'left'; ctx.fillStyle = '#82ffbd'; ctx.font = '800 24px ' + cjk; ctx.fillText(hasArrived ? DEST : '下一站 ' + DEST, 24, H-26); d.texture.needsUpdate = true; } resetSeatScreens() { this.routeProg = 0.08; this.lastPanelT = -1; this._lastPanelPhase = ''; this._lastPanelSecond = -1; this._lastPanelJourneyBucket = -1; } } let game = null; let fatalReported = false; function readableError(error) { const value = error && (error.message || error.reason || error); return String(value || 'Unknown error').slice(0, 300); } function showRuntimeStatus(message, canRetry = true, retryLabel = '重新載入場景') { const panel = document.getElementById('runtime-status'); const text = document.getElementById('runtime-message'); const retry = document.getElementById('runtime-retry'); if (text) text.textContent = message; if (retry) { retry.hidden = !canRetry; retry.textContent = retryLabel; } if (panel) panel.hidden = false; if (canRetry && retry) requestAnimationFrame(() => retry.focus()); } function hideRuntimeStatus() { const panel = document.getElementById('runtime-status'); if (panel) panel.hidden = true; } function reportFatal(code, error, stage) { if (fatalReported) return; fatalReported = true; if (game) { game._fatal = true; game.stopAnimationLoop(); } const message = readableError(error); try { console.error(`[AITaiwan Bus ${AITW_BUILD_VERSION}] ${code} at ${stage}:`, error); } catch { /* diagnostic only */ } showRuntimeStatus('3D 場景暫時無法繼續,請按下方按鈕重新載入。'); postToParent({ aitwBusError: true, code, stage, message, buildVersion: AITW_BUILD_VERSION }); } function requestSceneReload() { if (game && game.phase === 'complete' && !game._fatal) { game.retryNavigation(); return; } postToParent({ aitwReloadRequest: true, buildVersion: AITW_BUILD_VERSION }); } const runtimeRetry = document.getElementById('runtime-retry'); if (runtimeRetry) runtimeRetry.addEventListener('click', requestSceneReload); addEventListener('error', event => reportFatal('UNCAUGHT_RUNTIME_ERROR', event.error || event.message, 'window.error')); addEventListener('unhandledrejection', event => reportFatal('UNHANDLED_PROMISE_REJECTION', event.reason, 'unhandledrejection')); class Game { constructor() { this.container = document.getElementById('canvas-container'); this.clock = new THREE.Clock(); this.dayTime = 0; this.cruiseSpeed = CRUISE_SPEED; this.busSpeed = 0; this.phase = 'riding'; this.phaseT = 0; this.totalT = 0; this.DECEL_TIME = 2.0 * TRAVEL_TIME_SCALE; this.RIDE_DELAY = Math.max(0.1, FULL_STOP_TIME - this.DECEL_TIME); this.STAND_TIME = 0.95; this.WALK_TIME = 1.15; this.ADVANCE_TIME = 2.7; this.TURN_TIME = 0.95; this.DOOR_TIME = 1.05; this.ARRIVAL_MESSAGE_TIME = 7.0; this.EXIT_TIME = 2.1; this.seatPos = new THREE.Vector3(0.8, 1.4, 5.75); this.standY = 1.62; this.flashing = false; this.flashOpacity = 0; this.navigationAttempted = false; this.signBlinking = false; this.signT = 0; this.rideElapsed = 0; this._navigationStartTimer = 0; this._navigationRecoveryTimer = 0; this.arrivalMessage = '感謝搭乘 AI BUS 您已抵達城市商圈站 下車後如需再次搭乘 請按呼叫鍵即可'; this._arrivalTypedCount = -1; this._arrivalDialogRaf = 0; this._arrivalHideTimer = 0; this._previousFocus = null; this.arrivalDistrict = null; this._arrivalBuildHandle = 0; this._arrivalBuildUsesIdleCallback = false; this.arrivalApproachDistance = 0; this.lookYaw = 0; this.lookPitch = 0; this.lookTargetYaw = 0; this.lookTargetPitch = 0; this._frameInterval = 1000 / TARGET_FPS; this._pixelRatioCap = MAX_PIXEL_RATIO; this._slowFrameCount = 0; this._lastFrame = 0; this._paused = false; this._disposed = false; this._adaptiveExhausted = false; this._dprDowngradedOnce = false; this._passengerLoadReduced = false; this._passengerHeadAnimationEnabled = true; this._panelRateReduced = false; this._dayLightingAccumulator = 1; this._passengerAnimationAccumulator = 0; this.motionScale = PREFERS_REDUCED_MOTION ? 0.2 : 1; this._raf = this.animate.bind(this); this._rafId = 0; this._firstFrameRendered = false; this._lastHeartbeatAt = 0; this._renderCount = 0; this._fatal = false; this._contextRestoreTimer = 0; try { this.init(); this.buildLighting(); this.bus = new Bus(this.scene); this.pickPlayerSeat(); this.buildCity(); this.buildPassengers(); this.bus.buildSeatScreens(); this.setupLook(); this.prepareDirectRide(); this.freezeStaticMeshes(this.scene); this.bindEvents(); } catch (error) { try { this.dispose(); } catch { /* partial startup cleanup */ } throw error; } } init() { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x000000); this.scene.fog = new THREE.FogExp2(0x000000, 0.008); this.camera = new THREE.PerspectiveCamera(70, Math.max(1, innerWidth)/Math.max(1, innerHeight), 0.1, 1000); this.camera.rotation.order = 'YXZ'; this.camera.position.copy(this.seatPos); this.camera.rotation.set(0, 0, 0); this.renderer = new THREE.WebGLRenderer({ alpha: false, antialias: QUALITY_MODE === 'high', depth: true, stencil: false, preserveDrawingBuffer: false, precision: QUALITY_MODE === 'high' ? 'highp' : 'mediump', powerPreference: QUALITY_MODE === 'high' ? 'high-performance' : 'low-power' }); this.renderer.setClearColor(0x000000, 1); if (THREE.SRGBColorSpace) this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.toneMapping = THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure = 1.38; this.container.appendChild(this.renderer.domElement); this._contextLost = false; const glCanvas = this.renderer.domElement; this._onContextLost = (event) => { event.preventDefault(); if (this._disposed || this._contextLost) return; this._contextLost = true; this.stopAnimationLoop(); showRuntimeStatus('顯示晶片正在恢復 3D 畫面,請稍候…', false); postToParent({ aitwContextStatus: 'lost', buildVersion: AITW_BUILD_VERSION }); if (this._contextRestoreTimer) clearTimeout(this._contextRestoreTimer); this._contextRestoreTimer = window.setTimeout(() => { this._contextRestoreTimer = 0; if (this._contextLost && !this._disposed) reportFatal('WEBGL_CONTEXT_RESTORE_TIMEOUT', 'WebGL context did not recover in time', 'webglcontextlost'); }, 10000); }; this._onContextRestored = () => { if (this._disposed) return; if (this._contextRestoreTimer) clearTimeout(this._contextRestoreTimer); this._contextRestoreTimer = 0; this._contextLost = false; if (!this._fatal) hideRuntimeStatus(); try { if (this.renderer) this.renderer.resetState(); } catch (error) { reportFatal('WEBGL_CONTEXT_RESTORE_FAILED', error, 'webglcontextrestored'); return; } this._lastFrame = performance.now(); postToParent({ aitwContextStatus: 'restored', buildVersion: AITW_BUILD_VERSION }); if (!this._paused) this.startAnimationLoop(); }; glCanvas.addEventListener('webglcontextlost', this._onContextLost, false); glCanvas.addEventListener('webglcontextrestored', this._onContextRestored, { passive: true }); this.resizeRenderer(); this.flashEl = document.getElementById('flash'); this.hideFlashOverlay(); this.arrivalDialogEl = document.getElementById('arrival-dialog'); this.arrivalTypewriterEl = document.getElementById('arrival-typewriter'); this.arrivalCloseEl = document.getElementById('arrival-close'); this.arrivalAvatarEl = document.querySelector('.arrival-avatar'); this._onArrivalClose = () => this.closeArrivalDialog(); this._onArrivalKeyDown = event => { if (event.key === 'Escape') { event.preventDefault(); this.closeArrivalDialog(); } else if (event.key === 'Tab') { event.preventDefault(); if (this.arrivalCloseEl) this.arrivalCloseEl.focus(); } }; this._onAvatarError = () => { const wrap = this.arrivalAvatarEl && this.arrivalAvatarEl.parentElement; if (wrap) wrap.classList.add('image-failed'); }; if (this.arrivalCloseEl) this.arrivalCloseEl.addEventListener('click', this._onArrivalClose); if (this.arrivalDialogEl) this.arrivalDialogEl.addEventListener('keydown', this._onArrivalKeyDown); if (this.arrivalAvatarEl) this.arrivalAvatarEl.addEventListener('error', this._onAvatarError, { once: true }); this.hideArrivalDialog(true); } showArrivalDialog() { if (!this.arrivalDialogEl) return; if (this._arrivalDialogRaf) cancelAnimationFrame(this._arrivalDialogRaf); if (this._arrivalHideTimer) clearTimeout(this._arrivalHideTimer); this._arrivalHideTimer = 0; this._previousFocus = document.activeElement; this._arrivalTypedCount = -1; if (this.arrivalTypewriterEl) this.arrivalTypewriterEl.textContent = ''; this.arrivalDialogEl.classList.remove('is-closing'); this.arrivalDialogEl.hidden = false; if (this.container) this.container.inert = true; this._arrivalDialogRaf = requestAnimationFrame(() => { this._arrivalDialogRaf = 0; if (this.arrivalDialogEl) { this.arrivalDialogEl.classList.add('is-visible'); if (this.arrivalCloseEl) this.arrivalCloseEl.focus(); } }); } hideArrivalDialog(immediate = false) { if (!this.arrivalDialogEl) return; if (this._arrivalDialogRaf) cancelAnimationFrame(this._arrivalDialogRaf); if (this._arrivalHideTimer) clearTimeout(this._arrivalHideTimer); this._arrivalDialogRaf = 0; this._arrivalHideTimer = 0; this.arrivalDialogEl.classList.remove('is-visible'); this.arrivalDialogEl.classList.toggle('is-closing', !immediate); if (this.container) this.container.inert = false; const finish = () => { this._arrivalHideTimer = 0; if (!this.arrivalDialogEl) return; this.arrivalDialogEl.hidden = true; this.arrivalDialogEl.classList.remove('is-closing'); if (this._previousFocus && typeof this._previousFocus.focus === 'function') this._previousFocus.focus(); this._previousFocus = null; }; if (immediate || PREFERS_REDUCED_MOTION) finish(); else this._arrivalHideTimer = window.setTimeout(finish, 340); } beginArrivalMessage() { this.phase = 'arrivalmessage'; this.phaseT = 0; this.showArrivalDialog(); } closeArrivalDialog() { this.hideArrivalDialog(); if (this.phase === 'arrivalmessage') { this.phase = 'turning'; this.phaseT = 0; } } updateArrivalTypewriter() { if (!this.arrivalTypewriterEl) return; const count = Math.min(this.arrivalMessage.length, Math.floor(this.phaseT * 34)); if (count === this._arrivalTypedCount) return; this._arrivalTypedCount = count; this.arrivalTypewriterEl.textContent = this.arrivalMessage.slice(0, count); } showFlashOverlay() { if (!this.flashEl || this._flashVisible) return; this.flashEl.style.display = 'block'; this.flashEl.style.visibility = 'visible'; this._flashVisible = true; } hideFlashOverlay() { if (!this.flashEl || this._flashVisible === false) return; this.flashEl.style.opacity = '0'; this.flashEl.style.visibility = 'hidden'; this.flashEl.style.display = 'none'; this._flashVisible = false; } finishExperience(userRetry = false) { if (this.navigationAttempted || this._disposed) return; this.navigationAttempted = true; this.phase = 'complete'; this.phaseT = 0; this.flashing = false; this.flashOpacity = 1; this.showFlashOverlay(); if (this.flashEl) this.flashEl.style.opacity = '1'; if (this._navigationStartTimer) clearTimeout(this._navigationStartTimer); this._navigationStartTimer = window.setTimeout(() => { this._navigationStartTimer = 0; postToParent({ aitwNavigate: true, userRetry, buildVersion: AITW_BUILD_VERSION }); if (this._navigationRecoveryTimer) clearTimeout(this._navigationRecoveryTimer); this._navigationRecoveryTimer = window.setTimeout(() => { this._navigationRecoveryTimer = 0; if (this._disposed || this.phase !== 'complete') return; this.flashOpacity = 0; this.hideFlashOverlay(); showRuntimeStatus('未能自動前往目的頁面,請按下方按鈕重試。', true, '再次前往'); }, 4000); }, 80); } retryNavigation() { if (this._disposed || this.phase !== 'complete') return; if (this._navigationRecoveryTimer) clearTimeout(this._navigationRecoveryTimer); this._navigationRecoveryTimer = 0; hideRuntimeStatus(); this.navigationAttempted = false; this.finishExperience(true); } freezeStaticMeshes(root) { if (!root) return; root.traverse(object => { if (!object.isMesh) return; object.updateMatrix(); object.matrixAutoUpdate = false; }); } cancelArrivalDistrictBuild() { if (!this._arrivalBuildHandle) return; if (this._arrivalBuildUsesIdleCallback && typeof cancelIdleCallback === 'function') { cancelIdleCallback(this._arrivalBuildHandle); } else { clearTimeout(this._arrivalBuildHandle); } this._arrivalBuildHandle = 0; this._arrivalBuildUsesIdleCallback = false; } scheduleArrivalDistrictBuild() { if (this._disposed || this.arrivalDistrict || this._arrivalBuildHandle) return; const buildWhenIdle = () => { this._arrivalBuildHandle = 0; this._arrivalBuildUsesIdleCallback = false; if (!this._disposed && !this.arrivalDistrict) this.buildArrivalDistrict(); }; if (typeof requestIdleCallback === 'function') { this._arrivalBuildUsesIdleCallback = true; this._arrivalBuildHandle = requestIdleCallback(buildWhenIdle, { timeout: 1200 }); } else { this._arrivalBuildHandle = setTimeout(buildWhenIdle, 350); } } buildLighting() { this.ambient = new THREE.AmbientLight(0xf7f6f1, 0.96); this.scene.add(this.ambient); this.hemi = new THREE.HemisphereLight(0xfafaf7, 0x35363a, 0.72); this.scene.add(this.hemi); this.dir = new THREE.DirectionalLight(0xf5f4ef, 0.68); this.dir.position.set(5,10,5); this.scene.add(this.dir); this.rim = new THREE.DirectionalLight(0xdedbd3, 0.22); this.rim.position.set(-6,6,-10); this.scene.add(this.rim); const moon = new THREE.Mesh(new THREE.CircleGeometry(8,32), glow(0xd9f6ff)); moon.position.set(-70,60,-280); moon.material.fog = false; this.scene.add(moon); } buildPassengers() { if (this.passengers) { const usedMaterials = new Set(); const usedGeometries = new Set(); this.passengers.forEach(npc => { this.scene.remove(npc); npc.traverse(o => { if (o.geometry && o.userData.npcMergedGeometry) usedGeometries.add(o.geometry); if (!o.material) return; const mats = Array.isArray(o.material) ? o.material : [o.material]; mats.forEach(m => { if (m) usedMaterials.add(m); }); }); }); usedGeometries.forEach(geometry => geometry.dispose()); usedMaterials.forEach(disposeMaterialDeep); } this.passengers = []; const frontRowZ = this.seatPos.z - 1.25; const seats = this.bus.passengerSeats.filter(s => Math.abs(s.z - this.seatPos.z) >= 0.01 && Math.abs(s.z - frontRowZ) >= 0.01); const baseCount = QUALITY_MODE === 'low' ? 3 : (QUALITY_MODE === 'balanced' ? 4 : 5); const variance = QUALITY_MODE === 'high' ? 4 : (QUALITY_MODE === 'balanced' ? 3 : 2); let count = baseCount + Math.floor(randomValue() * variance); count = Math.max(3, Math.min(seats.length, count)); for (let i = seats.length - 1; i > 0; i--) { const j = Math.floor(randomValue() * (i + 1)); [seats[i], seats[j]] = [seats[j], seats[i]]; } seats.slice(0, count).forEach((s, i) => { const npc = buildNpc(i + 11); npc.scale.multiplyScalar(0.8); npc.rotation.y = Math.PI; npc.position.set(s.x, -0.34, s.z); if (npc.userData.head) npc.userData.head.rotation.y = ((i * 0.61803) % 1 - 0.5) * 0.7; npc.userData.animPhase = randomValue() * Math.PI * 2; npc.userData.headHomeY = npc.userData.head ? npc.userData.head.rotation.y : 0; npc.userData.headTargetY = npc.userData.headHomeY; npc.userData.nextHeadTurnAt = 2 + randomValue() * 5; this.freezeStaticMeshes(npc); this.scene.add(npc); this.passengers.push(npc); }); } reducePassengerLoad() { if (this._passengerLoadReduced || !this.passengers || this.passengers.length <= 3) return false; const keepCount = Math.max(3, Math.ceil(this.passengers.length * 0.55)); const removed = this.passengers.splice(keepCount); const materials = new Set(); const geometries = new Set(); for (const npc of removed) { this.scene.remove(npc); npc.traverse(object => { if (object.geometry && object.userData.npcMergedGeometry) geometries.add(object.geometry); const list = Array.isArray(object.material) ? object.material : [object.material]; list.forEach(material => { if (material) materials.add(material); }); }); } geometries.forEach(geometry => geometry.dispose()); materials.forEach(disposeMaterialDeep); this._passengerLoadReduced = true; return removed.length > 0; } buildCity() { this.ROAD_LEN = 600; this.ROAD_HALF_W = 11; this.scroll = new THREE.Group(); this.scene.add(this.scroll); const asphalt = new THREE.Mesh(new THREE.PlaneGeometry(this.ROAD_HALF_W*2, this.ROAD_LEN*2), toon(0x303338)); asphalt.rotation.x = -Math.PI/2; asphalt.position.set(0,-0.3,-this.ROAD_LEN/2); this.scene.add(asphalt); [-1,1].forEach(s=>{ const g = new THREE.Mesh(new THREE.PlaneGeometry(160, this.ROAD_LEN*2), toon(0x15171a)); g.rotation.x = -Math.PI/2; g.position.set(s*(this.ROAD_HALF_W+80),-0.32,-this.ROAD_LEN/2); this.scene.add(g); }); [-1,1].forEach(s=>{ const e = new THREE.Mesh(new THREE.PlaneGeometry(0.25,this.ROAD_LEN*2), toon(0xd0b96f)); e.rotation.x=-Math.PI/2; e.position.set(s*(this.ROAD_HALF_W-0.5),-0.29,-this.ROAD_LEN/2); this.scene.add(e); }); this.dashInstances = []; this.dashMesh = null; this._dashMatrix = new THREE.Matrix4(); this._dashPosition = new THREE.Vector3(); this._dashQuaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0)); this._dashScale = new THREE.Vector3(1, 1, 1); this.lights = []; this.buildings = []; this.streetlightAssets = { poleGeo: new THREE.CylinderGeometry(0.12,0.16,7,8), armGeo: new THREE.BoxGeometry(2.2,0.12,0.12), lampGeo: new THREE.BoxGeometry(0.7,0.25,0.4), metalMat: toon(0x3d5262), lampMat: glow(0xb9f6ff) }; this.signMaterials = new Map(); this.signGeometry = new THREE.PlaneGeometry(4,2); const dashMat = toon(0xe7dfc7); const dashGeo = new THREE.PlaneGeometry(0.22,3.2); const dashSpacing = QUALITY_MODE === 'high' ? 14 : (QUALITY_MODE === 'balanced' ? 18 : 22); [-this.ROAD_HALF_W/2,0,this.ROAD_HALF_W/2].forEach(lx=>{ for (let z=0; z>-this.ROAD_LEN; z-=dashSpacing){ this.dashInstances.push({ x: lx, z }); } }); this.dashMesh = new THREE.InstancedMesh(dashGeo, dashMat, this.dashInstances.length); this.dashMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); this.dashMesh.frustumCulled = false; this.scroll.add(this.dashMesh); this.updateDashInstanceMatrices(true); const streetlightSpacing = QUALITY_MODE === 'high' ? 55 : (QUALITY_MODE === 'balanced' ? 72 : 88); for (let z=0,i=0; z>-this.ROAD_LEN; z-=streetlightSpacing,i++) this.lights.push(this.makeStreetlight(i%2?1:-1, z)); const signs = [ makeSign('#120015','#ff3da6','HOTEL'), makeSign('#151200','#ffd23d','BANK'), makeSign('#100010','#9d3dff','NEON'), makeSign('#001008','#3dff7a','EXIT'), makeSign('#150005','#ff6a3d','BAR'), ]; let si = 0; const cityRows = QUALITY_MODE === 'high' ? 2 : 1; [-1,1].forEach(side=>{ for (let row=0; row<cityRows; row++){ const setback = this.ROAD_HALF_W + 8 + row*18; const spacing = row === 0 ? 84 : 116; for (let z=-row*6; z>-this.ROAD_LEN; z-=spacing){ const tall = row===0 ? 1 : 1.6; const hue = (si * 0.61803398875) % 1; const col = new THREE.Color().setHSL(hue, 0.5, 0.34).getHex(); this.buildings.push(this.makeBuilding( side*(setback + (randomValue()*4-2)), z, col, signs[si%signs.length], tall, row)); si++; } } }); [[-50, 17.5], [-59, 21.5], [-67, 18.0], [-74, 23.0]].forEach(([z, setback], i) => { const hue = ((si + i) * 0.61803398875) % 1; const col = new THREE.Color().setHSL(hue, 0.5, 0.36).getHex(); this.buildings.push(this.makeBuilding(setback, z, col, signs[(si + i) % signs.length], 1, 0)); }); } updateDashInstanceMatrices(forceAll = false) { if (!this.dashMesh) return; const matrix = this._dashMatrix; const position = this._dashPosition; let changed = forceAll; for (let i = 0; i < this.dashInstances.length; i++) { const dash = this.dashInstances[i]; if (forceAll || dash._matrixDirty) { position.set(dash.x, -0.28, dash.z); matrix.compose(position, this._dashQuaternion, this._dashScale); this.dashMesh.setMatrixAt(i, matrix); dash._matrixDirty = false; changed = true; } } if (changed) this.dashMesh.instanceMatrix.needsUpdate = true; } makeStreetlight(side, z) { const g = new THREE.Group(); const a = this.streetlightAssets; const pole = new THREE.Mesh(a.poleGeo, a.metalMat); pole.position.y=3.2; g.add(pole); const arm = new THREE.Mesh(a.armGeo, a.metalMat); arm.position.set(-side*1.0,6.6,0); g.add(arm); const lamp = new THREE.Mesh(a.lampGeo, a.lampMat); lamp.position.set(-side*2.0,6.5,0); g.add(lamp); g.position.set(side*(this.ROAD_HALF_W+1.5),-0.3,z); this.scroll.add(g); return g; } makeBuilding(x, z, color, signTex, tallMul, row) { const g = new THREE.Group(); const w = 6+randomValue()*5, d = 6+randomValue()*5; const h = (8 + randomValue()*22) * tallMul; const body = new THREE.Mesh(new THREE.BoxGeometry(w,h,d), toon(color)); body.position.y = h/2; g.add(body); const rows = Math.max(3, Math.floor(h/5.0)); const geoA = [], geoB = []; const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _p = new THREE.Vector3(), _s = new THREE.Vector3(1,1,1), _e = new THREE.Euler(); const addGrid = (sign) => { const cols = Math.max(2, Math.floor(d/3.2)); for (let c=0;c<cols;c++) for (let r=0;r<rows;r++){ if (randomValue()<0.68) continue; const hpos = -d/2 + 0.8 + c*(d-1.6)/Math.max(1,cols-1); const vpos = 1.2 + r*(h-2.4)/Math.max(1,rows-1); _p.set(sign*(w/2+0.02), vpos, hpos); _e.set(0, sign>0 ? Math.PI/2 : -Math.PI/2, 0); _q.setFromEuler(_e); _m.compose(_p, _q, _s); const wg = WIN_GEO.clone(); wg.applyMatrix4(_m); (randomValue()<0.3 ? geoB : geoA).push(wg); } }; addGrid(x > 0 ? -1 : 1); if (geoA.length) { const mg = mergeGeometries(geoA, false); if (mg) g.add(new THREE.Mesh(mg, WIN_MAT_A)); geoA.forEach(o=>o.dispose()); } if (geoB.length) { const mg = mergeGeometries(geoB, false); if (mg) g.add(new THREE.Mesh(mg, WIN_MAT_B)); geoB.forEach(o=>o.dispose()); } if (row===0){ let signMat = this.signMaterials.get(signTex); if (!signMat) { signMat = new THREE.MeshBasicMaterial({map:signTex}); this.signMaterials.set(signTex, signMat); } const sign = new THREE.Mesh(this.signGeometry, signMat); sign.position.set(0,h+1.4,0); sign.rotation.y = x>0 ? -Math.PI/2 : Math.PI/2; g.add(sign); } g.position.set(x,-0.3,z); this.scroll.add(g); return g; } buildArrivalDistrict() { if (this.arrivalDistrict || this._disposed) return; const district = new THREE.Group(); district.visible = false; this.arrivalDistrict = district; this.scene.add(district); const facadeTextures = [ makeArrivalFacadeTexture(0, '#14243a', '#34eaff'), makeArrivalFacadeTexture(1, '#2d183c', '#ff7ae6'), makeArrivalFacadeTexture(2, '#302713', '#ffd76a') ]; const towerZ = QUALITY_MODE === 'high' ? [-46, -29, -12, 7, 26, 45] : [-46, -12, 26, 45]; const panelCount = QUALITY_MODE === 'high' ? 11 : 7; const panelStep = 100 / (panelCount - 1); const arrivalPanelGeometry = new THREE.PlaneGeometry(7.7, 2.65); const arrivalPanelMaterials = facadeTextures.map(texture => new THREE.MeshBasicMaterial({ map: texture })); [-1, 1].forEach(side => { const sidewalk = new THREE.Mesh( new THREE.BoxGeometry(11, 0.3, 118), toon(0x4a4d56) ); sidewalk.position.set(side * (this.ROAD_HALF_W + 5.8), -0.13, 0); district.add(sidewalk); const podium = new THREE.Mesh( new THREE.BoxGeometry(9.5, 4.8, 112), toon(side > 0 ? 0x24283a : 0x292238, { emissive: 0x090b14, emissiveIntensity: 0.18 }) ); podium.position.set(side * (this.ROAD_HALF_W + 10.3), 2.1, 0); district.add(podium); for (let i = 0; i < panelCount; i++) { const materialIndex = (i + (side > 0 ? 1 : 0)) % arrivalPanelMaterials.length; const panel = new THREE.Mesh(arrivalPanelGeometry, arrivalPanelMaterials[materialIndex]); panel.rotation.y = side > 0 ? -Math.PI / 2 : Math.PI / 2; panel.position.set(side * (this.ROAD_HALF_W + 5.53), 2.05, -50 + i * panelStep); district.add(panel); } towerZ.forEach((z, i) => { const width = 8.2 + (i % 3) * 1.3; const depth = 11.5 + (i % 2) * 2.5; const height = 18 + ((i * 7 + (side > 0 ? 3 : 0)) % 23); const x = side * (this.ROAD_HALF_W + 10.7 + (i % 2) * 3.1); district.add(this.makeArrivalTower( side, x, z + (side > 0 ? 2 : -2), width, height, depth, facadeTextures[(i + 1) % facadeTextures.length], i )); }); }); this.freezeStaticMeshes(district); } makeArrivalTower(side, x, z, width, height, depth, facadeTexture, index) { const g = new THREE.Group(); const hue = ((index + (side > 0 ? 2 : 0)) * 0.173) % 1; const color = new THREE.Color().setHSL(hue, 0.42, 0.28).getHex(); const body = new THREE.Mesh( new THREE.BoxGeometry(width, height, depth), toon(color, { emissive: new THREE.Color(color).multiplyScalar(0.16).getHex(), emissiveIntensity: 0.22 }) ); body.position.y = height / 2; g.add(body); const facade = new THREE.Mesh( new THREE.PlaneGeometry(depth - 0.7, height - 1.5), new THREE.MeshBasicMaterial({ map: facadeTexture }) ); facade.rotation.y = side > 0 ? -Math.PI / 2 : Math.PI / 2; facade.position.set(side > 0 ? -width / 2 - 0.015 : width / 2 + 0.015, height / 2, 0); g.add(facade); const roof = new THREE.Mesh( new THREE.BoxGeometry(0.16, 0.2, depth + 0.5), glow(index % 2 ? 0xff7ae6 : 0x34eaff) ); roof.position.set(side > 0 ? -width / 2 - 0.07 : width / 2 + 0.07, height + 0.05, 0); g.add(roof); g.position.set(x, -0.3, z); return g; } prepareArrivalDistrict(speed) { if (!this.arrivalDistrict) { this.cancelArrivalDistrictBuild(); this.buildArrivalDistrict(); } if (!this.arrivalDistrict) return; this.arrivalApproachDistance = Math.max(1, speed * this.DECEL_TIME * 0.25); this.arrivalDistrict.position.set(0, 0, -this.arrivalApproachDistance); this.arrivalDistrict.visible = true; } updateArrivalDistrict(progress) { if (!this.arrivalDistrict || !this.arrivalDistrict.visible) return; const remaining = this.arrivalApproachDistance * Math.pow(1 - progress, 4); this.arrivalDistrict.position.z = -remaining; } resetArrivalDistrict() { if (!this.arrivalDistrict) return; this.arrivalApproachDistance = 0; this.arrivalDistrict.visible = false; this.arrivalDistrict.position.set(0, 0, 0); } setupLook() { const MAX_YAW = 1.4, MAX_PITCH = 0.55; const onMove = (cx, cy) => { const nx = (cx / Math.max(1, innerWidth)) * 2 - 1; const ny = (cy / Math.max(1, innerHeight)) * 2 - 1; this.lookTargetYaw = -nx * MAX_YAW; this.lookTargetPitch = -ny * MAX_PITCH; }; const surface = this.renderer.domElement; this._lookBindings = []; const bind = (target, type, handler, options = { passive: true }) => { target.addEventListener(type, handler, options); this._lookBindings.push({ target, type, handler }); }; if (IS_TOUCH_DEVICE) { this._activeLookPointer = null; bind(surface, 'pointerdown', event => { this._activeLookPointer = event.pointerId; onMove(event.clientX, event.clientY); }); bind(surface, 'pointermove', event => { if (event.pointerId === this._activeLookPointer) onMove(event.clientX, event.clientY); }); const release = event => { if (event.pointerId === this._activeLookPointer) this._activeLookPointer = null; }; bind(surface, 'pointerup', release); bind(surface, 'pointercancel', release); } else { bind(surface, 'pointermove', event => onMove(event.clientX, event.clientY)); } } applyLook(dt) { const s = Math.min(dt * 8, 1); this.lookYaw += (this.lookTargetYaw - this.lookYaw) * s; this.lookPitch += (this.lookTargetPitch - this.lookPitch) * s; this.camera.rotation.order = 'YXZ'; this.camera.rotation.y = this.lookYaw; this.camera.rotation.x = this.lookPitch; this.camera.rotation.z = 0; } resizeRenderer() { if (this._disposed || !this.renderer) return; const w = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1); const h = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1); const areaRatioCap = Math.sqrt(MAX_RENDER_PIXELS / Math.max(1, w * h)); const dimensionRatioCap = Math.min(4096 / w, 4096 / h); const pixelRatio = Math.max(0.7, Math.min(window.devicePixelRatio || 1, this._pixelRatioCap, areaRatioCap, dimensionRatioCap)); if (w === this._renderW && h === this._renderH && pixelRatio === this._renderPixelRatio) return; this._renderW = w; this._renderH = h; this._renderPixelRatio = pixelRatio; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setPixelRatio(pixelRatio); this.renderer.setSize(w, h, false); } startAnimationLoop() { if (this._disposed || this._rafId || this._paused || this._contextLost) return; this._rafId = requestAnimationFrame(this._raf); } stopAnimationLoop() { if (!this._rafId) return; cancelAnimationFrame(this._rafId); this._rafId = 0; } bindEvents() { this._onResize = () => { if (this._disposed || this._resizeRaf) return; this._resizeRaf = requestAnimationFrame(() => { this._resizeRaf = 0; this.resizeRenderer(); }); }; this._onVisibilityChange = () => { if (this._disposed) return; this._paused = document.hidden; if (document.hidden) { this.stopAnimationLoop(); } else { this.clock.getDelta(); this._lastFrame = performance.now(); this.resizeRenderer(); this.startAnimationLoop(); } }; this._onPageHide = event => { this._paused = true; this.stopAnimationLoop(); if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf); this._resizeRaf = 0; if (!event.persisted) this.dispose(); }; addEventListener('resize', this._onResize, { passive: true }); addEventListener('orientationchange', this._onResize, { passive: true }); addEventListener('pagehide', this._onPageHide, { passive: true }); if (window.visualViewport) window.visualViewport.addEventListener('resize', this._onResize, { passive: true }); document.addEventListener('visibilitychange', this._onVisibilityChange); } easeInOut(k){ return k < 0.5 ? 2*k*k : 1 - Math.pow(-2*k + 2, 2)/2; } pickPlayerSeat() { const seats = (this.bus && this.bus.passengerSeats) || []; if (!seats.length) return; const s = seats[Math.floor(randomValue() * seats.length)]; this.seatPos.set(s.x, 1.4, s.z); } prepareDirectRide() { this.resetArrivalDistrict(); this.phase = 'riding'; this.phaseT = 0; this.totalT = 0; this.busSpeed = this.cruiseSpeed; this.flashing = false; this.flashOpacity = 0; this.navigationAttempted = false; this.signBlinking = false; this.signT = 0; this.hideArrivalDialog(true); this.rideElapsed = 0; if (this._navigationRecoveryTimer) { clearTimeout(this._navigationRecoveryTimer); this._navigationRecoveryTimer = 0; } this.bus.setDoorDest('直達'); this.bus.setSignText(true); this.bus.setDoorOpen(0); if (this.bus.doorGlow) { this.bus.doorGlow.material.opacity = 0; this.bus.doorGlow.scale.setScalar(0.1); } this.camera.rotation.order = 'YXZ'; this.camera.position.copy(this.seatPos); this.camera.rotation.set(0, 0, 0); this.lookYaw = this.lookPitch = 0; this.lookTargetYaw = this.lookTargetPitch = 0; this.bus.resetSeatScreens(); this.bus.resetPilotRobot(); } beginStanding() { this.startYaw = this.lookYaw; this.startPitch = this.lookPitch; this.phase = 'standing'; this.phaseT = 0; } updateSequence(dt) { this.phaseT += dt; const cam = this.camera; const inTravelPhase = this.phase === 'riding' || this.phase === 'stopping'; if (inTravelPhase) this.rideElapsed = Math.min(FULL_STOP_TIME, this.rideElapsed + dt); const rideElapsed = this.rideElapsed; if (inTravelPhase) this.totalT = Math.min(FULL_STOP_TIME, rideElapsed); else this.totalT += dt; if (inTravelPhase && rideElapsed >= FULL_STOP_TIME) { if (this.phase === 'riding') { this.decelFrom = this.busSpeed; this.prepareArrivalDistrict(this.decelFrom); } this.updateArrivalDistrict(1); this.busSpeed = 0; this.bus.routeProg = 1; this.signBlinking = true; this.signT = 0; this.bus.setDoorDest(DEST); this.beginStanding(); } if (this.phase === 'riding') { cam.position.copy(this.seatPos); cam.position.y += (Math.sin(this.totalT * 8.3) * 0.005 + Math.sin(this.totalT * 21.7) * 0.0025) * this.motionScale; this.applyLook(dt); this.updateWorld(dt); if (rideElapsed >= this.RIDE_DELAY) { this.phase = 'stopping'; this.phaseT = Math.min(this.DECEL_TIME, Math.max(0, rideElapsed - this.RIDE_DELAY)); this.decelFrom = this.busSpeed; this.prepareArrivalDistrict(this.decelFrom); } } if (this.phase === 'stopping') { cam.position.copy(this.seatPos); this.applyLook(dt); this.phaseT = Math.min(this.DECEL_TIME, Math.max(0, rideElapsed - this.RIDE_DELAY)); const k = Math.min(this.phaseT / this.DECEL_TIME, 1); const e = 1 - Math.pow(1 - k, 3); this.busSpeed = this.decelFrom * (1 - e); const shake = this.decelFrom > 0 ? this.busSpeed / this.decelFrom : 0; cam.position.y += (Math.sin(this.totalT * 8.3) * 0.005 + Math.sin(this.totalT * 21.7) * 0.0025) * shake * this.motionScale; this.updateWorld(dt); this.updateArrivalDistrict(k); } else if (this.phase === 'standing') { const e = this.easeInOut(Math.min(this.phaseT / this.STAND_TIME, 1)); cam.position.set(this.seatPos.x, THREE.MathUtils.lerp(this.seatPos.y, this.standY, e), this.seatPos.z); cam.rotation.order = 'YXZ'; cam.rotation.y = THREE.MathUtils.lerp(this.startYaw, 0, e); cam.rotation.x = THREE.MathUtils.lerp(this.startPitch, 0, e); cam.rotation.z = 0; if (this.phaseT >= this.STAND_TIME) { this.phase = 'walking'; this.phaseT = 0; this.walkFrom = cam.position.clone(); } } else if (this.phase === 'walking') { const e = this.easeInOut(Math.min(this.phaseT / this.WALK_TIME, 1)); cam.position.x = THREE.MathUtils.lerp(this.walkFrom.x, 0, e); cam.position.z = this.walkFrom.z; cam.position.y = this.standY + Math.sin(this.phaseT * 7) * 0.025 * this.motionScale; cam.rotation.set(0, 0, 0); if (this.phaseT >= this.WALK_TIME) { this.phase = 'advancing'; this.phaseT = 0; this.advFrom = cam.position.clone(); } } else if (this.phase === 'advancing') { const e = this.easeInOut(Math.min(this.phaseT / this.ADVANCE_TIME, 1)); cam.position.x = 0; cam.position.z = THREE.MathUtils.lerp(this.advFrom.z, this.bus.doorZ, e); cam.position.y = this.standY + Math.sin(this.phaseT * 7) * 0.025 * this.motionScale; cam.rotation.set(0, 0, 0); if (this.phaseT >= this.ADVANCE_TIME) this.beginArrivalMessage(); } else if (this.phase === 'arrivalmessage') { cam.position.set(0, this.standY, this.bus.doorZ); cam.rotation.order = 'YXZ'; cam.rotation.set(0, 0, 0); this.updateArrivalTypewriter(); if (this.phaseT >= this.ARRIVAL_MESSAGE_TIME) this.closeArrivalDialog(); } else if (this.phase === 'turning') { const e = this.easeInOut(Math.min(this.phaseT / this.TURN_TIME, 1)); cam.position.set(0, this.standY, this.bus.doorZ); cam.rotation.order = 'YXZ'; cam.rotation.y = THREE.MathUtils.lerp(0, -Math.PI/2, e); cam.rotation.x = 0; cam.rotation.z = 0; if (this.phaseT >= this.TURN_TIME) { this.phase = 'dooropen'; this.phaseT = 0; } } else if (this.phase === 'dooropen') { cam.position.set(0, this.standY, this.bus.doorZ); cam.rotation.order = 'YXZ'; cam.rotation.set(0, -Math.PI/2, 0); const e = this.easeInOut(Math.min(this.phaseT / this.DOOR_TIME, 1)); this.bus.setDoorOpen(e); if (this.bus.doorGlow) { this.bus.doorGlow.material.opacity = e * 0.18; this.bus.doorGlow.scale.setScalar(0.3 + e * 0.9); } if (this.phaseT >= this.DOOR_TIME) { this.phase = 'exiting'; this.phaseT = 0; } } else if (this.phase === 'exiting') { const e = this.easeInOut(Math.min(this.phaseT / this.EXIT_TIME, 1)); cam.position.set(THREE.MathUtils.lerp(0, this.bus.width/2 + 0.7, e), this.standY + Math.sin(this.phaseT * 7) * 0.02 * this.motionScale, this.bus.doorZ); cam.rotation.order = 'YXZ'; cam.rotation.set(0, -Math.PI/2, 0); if (this.phaseT >= this.EXIT_TIME && !this.flashing && !this.navigationAttempted) { this.flashing = true; this.showFlashOverlay(); } } if (this.flashing) { this.showFlashOverlay(); const flashDuration = PREFERS_REDUCED_MOTION ? 0.18 : 0.55; this.flashOpacity = Math.min(1, this.flashOpacity + dt / flashDuration); if (this.flashEl) this.flashEl.style.opacity = this.flashOpacity.toFixed(3); if (this.bus.doorGlow) { this.bus.doorGlow.material.opacity = Math.min(1, 0.18 + this.flashOpacity); this.bus.doorGlow.scale.setScalar(1.2 + this.flashOpacity * 10); } if (this.flashOpacity >= 1 && !this.navigationAttempted) { this.finishExperience(); } } } updatePassengers(t, dt) { if (!this.passengers) return; this._passengerAnimationAccumulator += dt; const interval = QUALITY_MODE === 'high' ? 1 / 30 : (QUALITY_MODE === 'balanced' ? 1 / 20 : 1 / 15); if (this._passengerAnimationAccumulator < interval) return; const animationDt = this._passengerAnimationAccumulator; this._passengerAnimationAccumulator = 0; const follow = Math.min(1, animationDt * 2.4); for (const npc of this.passengers) { const u = npc.userData; const body = u.body; if (body) { body.position.y = 0.155 + Math.sin(t * 1.7 + u.animPhase) * 0.010; body.rotation.z = Math.sin(t * 0.8 + u.animPhase) * 0.018; } const head = u.head; if (head && this._passengerHeadAnimationEnabled) { if (t >= u.nextHeadTurnAt) { u.nextHeadTurnAt = t + 3 + randomValue() * 6; u.headTargetY = randomValue() < 0.45 ? u.headHomeY : u.headHomeY + (randomValue() - 0.5) * 0.6; } head.rotation.y += (u.headTargetY - head.rotation.y) * follow; head.rotation.x = Math.sin(t * 1.1 + u.animPhase) * 0.02; } } } updateWorld(dt) { const dz = this.busSpeed*dt; this.scroll.position.z += dz; const gz = this.scroll.position.z; let dashChanged = false; for (const dash of this.dashInstances) { if (dash.z + gz > 14) { dash.z -= this.ROAD_LEN; dash._matrixDirty = true; dashChanged = true; } } if (dashChanged) this.updateDashInstanceMatrices(); for (const object of this.lights) { if (object.position.z + gz > 14) object.position.z -= this.ROAD_LEN; } for (const object of this.buildings) { if (object.position.z + gz > 14) object.position.z -= this.ROAD_LEN; } } updateDayNight(dt) { this.dayTime += dt*0.15; this._dayLightingAccumulator += dt; if (this._dayLightingAccumulator < 0.1) return; this._dayLightingAccumulator = 0; const cycle = (Math.sin(this.dayTime)+1)/2; this.ambient.intensity = THREE.MathUtils.lerp(0.92,1.22,cycle); this.hemi.intensity = THREE.MathUtils.lerp(0.66,0.92,cycle); this.dir.intensity = THREE.MathUtils.lerp(0.52,0.78,cycle); const lit = 1.0-cycle; this.rim.intensity = 0.16 + lit*0.18; for (const light of this.bus.realLights) light.intensity = 1.45 + lit*0.72; } applyPerformanceDowngrade() { if (this._adaptiveExhausted || this._disposed) return false; if (!this._dprDowngradedOnce && this._pixelRatioCap > 0.85 + 0.001) { this._pixelRatioCap = Math.max(0.85, this._pixelRatioCap - 0.25); this._dprDowngradedOnce = true; this._renderPixelRatio = -1; this.resizeRenderer(); return true; } if (this.reducePassengerLoad()) return true; if (this._pixelRatioCap > 0.85 + 0.001) { this._pixelRatioCap = Math.max(0.85, this._pixelRatioCap - 0.25); this._renderPixelRatio = -1; this.resizeRenderer(); return true; } if (this._passengerHeadAnimationEnabled) { this._passengerHeadAnimationEnabled = false; for (const npc of this.passengers || []) { const head = npc.userData && npc.userData.head; if (!head) continue; npc.userData.headTargetY = npc.userData.headHomeY; head.rotation.x = 0; } return true; } if (!this._panelRateReduced) { this._panelRateReduced = true; if (this.bus) this.bus.setReducedPanelRate(true); return true; } this._adaptiveExhausted = true; return false; } dispose() { if (this._disposed) return; this._disposed = true; this._paused = true; this.stopAnimationLoop(); this.hideArrivalDialog(true); if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf); this._resizeRaf = 0; this.cancelArrivalDistrictBuild(); if (this._navigationStartTimer) clearTimeout(this._navigationStartTimer); this._navigationStartTimer = 0; if (this._navigationRecoveryTimer) clearTimeout(this._navigationRecoveryTimer); this._navigationRecoveryTimer = 0; if (this._contextRestoreTimer) clearTimeout(this._contextRestoreTimer); this._contextRestoreTimer = 0; if (this._arrivalHideTimer) clearTimeout(this._arrivalHideTimer); this._arrivalHideTimer = 0; if (this._onResize) { removeEventListener('resize', this._onResize); removeEventListener('orientationchange', this._onResize); if (window.visualViewport) window.visualViewport.removeEventListener('resize', this._onResize); } if (this._onPageHide) removeEventListener('pagehide', this._onPageHide); if (this._onVisibilityChange) document.removeEventListener('visibilitychange', this._onVisibilityChange); if (this.arrivalCloseEl && this._onArrivalClose) this.arrivalCloseEl.removeEventListener('click', this._onArrivalClose); if (this.arrivalDialogEl && this._onArrivalKeyDown) this.arrivalDialogEl.removeEventListener('keydown', this._onArrivalKeyDown); if (this.arrivalAvatarEl && this._onAvatarError) this.arrivalAvatarEl.removeEventListener('error', this._onAvatarError); for (const binding of this._lookBindings || []) { binding.target.removeEventListener(binding.type, binding.handler); } this._lookBindings = []; const canvas = this.renderer && this.renderer.domElement; if (canvas) { if (this._onContextLost) canvas.removeEventListener('webglcontextlost', this._onContextLost); if (this._onContextRestored) canvas.removeEventListener('webglcontextrestored', this._onContextRestored); } const geometries = new Set(); const materials = new Set(); const textures = new Set(); const textureKeys = ['map','alphaMap','aoMap','bumpMap','normalMap','displacementMap','emissiveMap','metalnessMap','roughnessMap','lightMap','specularMap','gradientMap']; const collectMaterial = material => { if (!material || materials.has(material)) return; materials.add(material); for (const key of textureKeys) { const texture = material[key]; if (texture && texture.isTexture) textures.add(texture); } }; const collectObject = object => { if (object.geometry && object.geometry.isBufferGeometry) geometries.add(object.geometry); if (Array.isArray(object.material)) object.material.forEach(collectMaterial); else collectMaterial(object.material); }; if (this.scene) this.scene.traverse(collectObject); const collectAssetObject = assets => { if (!assets) return; for (const asset of Object.values(assets)) { if (asset && asset.isBufferGeometry) geometries.add(asset); else if (asset && asset.isMaterial) collectMaterial(asset); else if (asset && asset.isTexture) textures.add(asset); } }; collectAssetObject(npcGeo); geometries.add(WIN_GEO); collectMaterial(WIN_MAT_A); collectMaterial(WIN_MAT_B); textures.add(gradientMap); textures.add(fabricTexture); textures.add(floorTexture); if (this.bus) { for (const geometry of this.bus.roundedGeometries.values()) geometries.add(geometry); collectAssetObject(this.bus.seatAssets); collectAssetObject(this.bus.seatScreenAssets); if (this.bus.statusDisplay && this.bus.statusDisplay.texture) textures.add(this.bus.statusDisplay.texture); if (this.bus.seatScreenTex && this.bus.seatScreenTex.texture) textures.add(this.bus.seatScreenTex.texture); this.bus.roundedGeometries.clear(); } collectAssetObject(this.streetlightAssets); if (this.signGeometry) geometries.add(this.signGeometry); if (this.signMaterials) this.signMaterials.forEach(collectMaterial); geometries.forEach(geometry => { try { geometry.dispose(); } catch { /* best effort */ } }); materials.forEach(material => { try { material.dispose(); } catch { /* best effort */ } }); textures.forEach(texture => { try { texture.dispose(); } catch { /* best effort */ } }); if (this.renderer) { try { if (this.renderer.renderLists) this.renderer.renderLists.dispose(); } catch { /* best effort */ } try { this.renderer.dispose(); } catch { /* best effort */ } try { this.renderer.forceContextLoss(); } catch { /* optional */ } } if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas); if (this.scene) this.scene.clear(); this.renderer = null; this.scene = null; this.camera = null; this.bus = null; this.passengers = []; } markFirstFrameReady(now) { if (this._firstFrameRendered || this._renderCount < 2) return; const renderInfo = this.renderer && this.renderer.info && this.renderer.info.render; if (!renderInfo || renderInfo.calls < 1 || renderInfo.triangles < 1) return; this._firstFrameRendered = true; this._lastHeartbeatAt = now; this.scheduleArrivalDistrictBuild(); postToParent({ aitwBusReady: true, buildVersion: AITW_BUILD_VERSION, qualityMode: QUALITY_MODE, renderCount: this._renderCount }); } animate() { this._rafId = 0; if (this._disposed || this._paused || this._contextLost || this._fatal) return; let shouldContinue = true; try { const now = performance.now(); const frameElapsed = now - this._lastFrame; if (frameElapsed >= this._frameInterval - 0.5) { this._lastFrame = frameElapsed > this._frameInterval * 2 ? now : this._lastFrame + this._frameInterval; if (frameElapsed > this._frameInterval * 1.7 && frameElapsed < 250) { this._slowFrameCount++; if (this._slowFrameCount >= 20) { this.applyPerformanceDowngrade(); this._slowFrameCount = 0; } } else { this._slowFrameCount = Math.max(0, this._slowFrameCount - 1); } const dt = Math.min(this.clock.getDelta(), 0.1); this.updateDayNight(dt); this.updateSequence(dt); const tNow = now * 0.001; this.updatePassengers(tNow, dt); this.bus.updateTechDisplays(tNow, this.busSpeed, this.phase); const ph = this.phase; let journey; if (ph === 'riding') journey = 0.12 + 0.66 * Math.min(this.totalT / this.RIDE_DELAY, 1); else if (ph === 'stopping') journey = 0.80 + 0.18 * Math.min(this.phaseT / this.DECEL_TIME, 1); else journey = 1.0; this.bus.updateSeatScreens(tNow, this.phase, journey); this.bus.updateAisleFlash(tNow, this.phase !== 'riding' && this.phase !== 'stopping'); this.bus.updatePilotRobot(dt, this.phase, this.camera.position, this.STAND_TIME + this.WALK_TIME); if (this.signBlinking) { this.signT += dt; this.bus.setSignText((this.signT % 1.0) < 0.55); } else { this.bus.setSignText(true); } this.renderer.render(this.scene, this.camera); this._renderCount++; this.markFirstFrameReady(now); if (now - this._lastHeartbeatAt >= 3000) { this._lastHeartbeatAt = now; postToParent({ aitwHeartbeat: true, buildVersion: AITW_BUILD_VERSION, phase: this.phase, renderCount: this._renderCount }); } } } catch (error) { shouldContinue = false; reportFatal('ANIMATION_FRAME_FAILED', error, this.phase || 'animate'); } finally { if (shouldContinue && !this._disposed && !this._paused && !this._contextLost && !this._fatal) this.startAnimationLoop(); } } } function bootGame() { try { game = new Game(); game.renderer.compile(game.scene, game.camera); game.startAnimationLoop(); } catch (error) { reportFatal('SCENE_STARTUP_FAILED', error, 'bootGame'); } } bootGame(); function onParentMessage(e) { if (!e || e.source !== window.parent || !e.data) return; if (e.data.aitwNonce !== AITW_SESSION_NONCE) return; if (e.data.aitwDispose === true) { removeEventListener('message', onParentMessage); removeEventListener('pageshow', onInnerPageShow); if (game && typeof game.dispose === 'function') game.dispose(); game = null; return; } if (e.data.aitwReadyProbe === true && game && game._firstFrameRendered && !game._fatal) { postToParent({ aitwBusReady: true, buildVersion: AITW_BUILD_VERSION, qualityMode: QUALITY_MODE, renderCount: game._renderCount }); } if (e.data.aitwNavigationFailed === true && game && game.phase === 'complete') { if (game._navigationRecoveryTimer) clearTimeout(game._navigationRecoveryTimer); game._navigationRecoveryTimer = 0; game.flashOpacity = 0; game.hideFlashOverlay(); showRuntimeStatus('未能自動前往目的頁面,請按下方按鈕重試。', true, '再次前往'); } } function onInnerPageShow(e) { if (!game || game._disposed) return; game._paused = false; game.resizeRenderer(); if (game.clock) game.clock.getDelta(); game._lastFrame = performance.now(); game.startAnimationLoop(); } addEventListener('message', onParentMessage, { passive: true }); addEventListener('pageshow', onInnerPageShow, { passive: true }); </script> </body> </html>
Cookie的使用
我們使用cookies來確保流暢的瀏覽體驗。若繼續,我們會認爲你接受使用cookies。
確認
了解更多