算力台灣
<!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%); } </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> <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 LOW_POWER_DEVICE = 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.25 : 1.5); const TARGET_FPS = QUALITY_MODE === 'high' ? 60 : 30; const PREFERS_REDUCED_MOTION = Boolean(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); const randomValue = Math.random; let AITW_PARENT_HREF = null; let AITW_DESTINATION_URL = null; const AITW_SESSION_NONCE = '__AITW_SESSION_NONCE__'; function postToParent(payload) { try { window.parent.postMessage({ ...payload, aitwNonce: AITW_SESSION_NONCE }, '*'); } catch { /* parent may already be gone */ } } 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 = 'AIT-2026'; 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 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; 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 = 0.15; this.build(); } roundedGeometry(w,h,d,r){ const key = `${w}|${h}|${d}|${r}`; let geometry = this.roundedGeometries.get(key); if (!geometry) { geometry = new RoundedBoxGeometry(w,h,d,4,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() { const wallMat = toon(0x41566a, { emissive: 0x07131d, emissiveIntensity: 0.2 }); const floorMat = toon(0x263445, { map: floorTexture, emissive: 0x03090f, emissiveIntensity: 0.12 }); const roofMat = toon(0x71879a, { emissive: 0x07131c, emissiveIntensity: 0.14 }); const dashMat = toon(0x08131c, { emissive: 0x062333, emissiveIntensity: 0.38 }); const seatMat = toon(0x111d2d, { map: fabricTexture, emissive: 0x030a13, emissiveIntensity: 0.16 }); 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(0x34eaff), trimFrontMat: glow(0x2bd6ff), legMat: toon(0x182532), footMat: glow(0x125e7a) }; 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: 0x6ceaff, transparent: true, opacity: 0.065, 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: 0x83dcff, transparent: true, opacity: 0.055, 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(0x35d8e8, 0.48, 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); // Interior ceiling lamps: neutral white to reduce cyan cast. const lampMat = toon(0xffffff, { emissive: 0xffffff, emissiveIntensity: 0.78 }); const lampHousingGeo = new THREE.CylinderGeometry(0.15,0.15,0.04,20); const lampHousingMat = toon(0x071018); 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(0xcfe6f2, 1.25, 11, 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 cyan = 0x34eaff, cyanDeep = 0x0f6f9a; 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); 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, cyan, -halfL+0.2, DOOR_Z - DOOR_GAP); addWallStrip(s, sill+0.02, 0.04, cyan, DOOR_Z + DOOR_GAP, halfL-0.2); addWallStrip(s, 0.18, 0.03, cyanDeep, -halfL+0.2, DOOR_Z - DOOR_GAP); addWallStrip(s, 0.18, 0.03, cyanDeep, DOOR_Z + DOOR_GAP, halfL-0.2); } else { addWallStrip(s, sill+0.02, 0.04, cyan, -halfL+0.2, halfL-0.2); addWallStrip(s, 0.18, 0.03, cyanDeep, -halfL+0.2, halfL-0.2); } }); const ceilingBar = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.03, this.length-1.2), glow(0xffffff)); ceilingBar.position.set(0, H-0.18, 0); this.group.add(ceilingBar); const poleMat = toon(0x172633); const poleGeo = new THREE.CylinderGeometry(0.034, 0.034, H-1.0, 10); const ringGeo = new THREE.TorusGeometry(0.06, 0.012, 6, 14); const ringMat = glow(cyan); for (let z=-halfL+5.0; z<=halfL-1.6; z+=2.5){ [-0.45, 0.45].forEach(px => { const pole = new THREE.Mesh(poleGeo, poleMat); pole.position.set(px, 1.0 + (H-1.0)/2, z); this.group.add(pole); const ring = new THREE.Mesh(ringGeo, ringMat); ring.position.set(px, 1.4, z); ring.rotation.x = Math.PI/2; this.group.add(ring); }); } } // ── 駕駛艙自動領航機器人 · 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(0xf4f8fb, { emissive: 0x53788e, emissiveIntensity: 0.55 }); const plateMat = toon(0xc9dbe8, { emissive: 0x3a5a6d, emissiveIntensity: 0.45 }); const jointMat = toon(0x1b2836, { emissive: 0x13303f, emissiveIntensity: 0.42 }); const visorMat = toon(0x040a12, { emissive: 0x08222f, emissiveIntensity: 0.62, side: THREE.DoubleSide }); const trimMat = glow(0x2bd6ff); const goldMat = glow(0xffd84a); const eyeMat = glow(0x9df8ff); 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 lowerBodyGeo = box(0.300, 0.430, 0.220, 0.095); const lowerBaseGeo = box(0.315, 0.070, 0.235, 0.032); const upperArmGeo = box(0.082, 0.17, 0.092, 0.035); const foreArmGeo = box(0.075, 0.15, 0.084, 0.031); const handGeo = box(0.082, 0.088, 0.086, 0.040); const elbowGeo = retainUntilRobotBuilt(new THREE.CylinderGeometry(0.042, 0.042, 0.082, 12)); const shoulderGeo = retainUntilRobotBuilt(new THREE.SphereGeometry(0.058, 14, 10)); // 外殼(珍珠白):軀幹與一體成型下半部 const shell = [ bake(box(0.34, 0.32, 0.235, 0.095), 0, 0.645, 0), bake(lowerBodyGeo, 0, 0.230, 0) ]; [-1, 1].forEach(s => { shell.push(bake(upperArmGeo, s * 0.212, 0.665, 0.004, 0, 0, -s * 0.09)); shell.push(bake(foreArmGeo, s * 0.235, 0.487, 0.026, -0.14, 0, 0)); }); weld(shell, shellMat, root); // 深色關節件:保留腰部、手臂關節與單一底座 const joint = [ bake(box(0.27, 0.10, 0.170, 0.045), 0, 0.445, 0), bake(lowerBaseGeo, 0, 0.038, 0.008), bake(new THREE.CylinderGeometry(0.05, 0.05, 0.07, 14), 0, 0.800, 0) ]; [-1, 1].forEach(s => { joint.push(bake(shoulderGeo, s * 0.195, 0.755, 0)); joint.push(bake(elbowGeo, s * 0.228, 0.565, 0.008, 0, 0, Math.PI / 2)); joint.push(bake(handGeo, s * 0.238, 0.400, 0.048)); }); weld(joint, jointMat, root); // 背部模組(霧面銀) weld([ bake(box(0.220, 0.190, 0.070, 0.028), 0, 0.675, -0.145) ], plateMat, root); // 金色腰線與停機環(呼應車內黃色導引燈條) weld([ bake(box(0.200, 0.014, 0.014, 0.006), 0, 0.565, 0.116), bake(new THREE.TorusGeometry(0.215, 0.010, 6, 28), 0, 0.012, 0, Math.PI / 2) ], goldMat, root); const dockGlow = new THREE.Mesh( new THREE.RingGeometry(0.09, 0.200, 26), new THREE.MeshBasicMaterial({ color: 0x34eaff, transparent: true, opacity: 0.14, 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.985, 0); root.add(head); this.pilotHead = head; weld([ bake(box(0.340, 0.300, 0.300, 0.125), 0, 0, 0), bake(box(0.270, 0.045, 0.240, 0.020), 0, 0.155, -0.012) ], shellMat, head); weld([ bake(new THREE.CylinderGeometry(0.052, 0.052, 0.040, 14), -0.172, 0, -0.015, 0, 0, Math.PI / 2), bake(new THREE.CylinderGeometry(0.052, 0.052, 0.040, 14), 0.172, 0, -0.015, 0, 0, Math.PI / 2) ], jointMat, head); // 環繞式深色面罩 const visor = new THREE.Mesh( new THREE.CylinderGeometry(0.185, 0.185, 0.145, 22, 1, true, -0.66, 1.32), visorMat ); visor.position.y = 0.012; head.add(visor); // 面罩下緣光條與耳部光環 weld([ bake(new THREE.CylinderGeometry(0.188, 0.188, 0.012, 22, 1, true, -0.68, 1.36), 0, -0.072, 0), bake(new THREE.TorusGeometry(0.052, 0.007, 6, 14), -0.194, 0, -0.015, 0, Math.PI / 2, 0), bake(new THREE.TorusGeometry(0.052, 0.007, 6, 14), 0.194, 0, -0.015, 0, Math.PI / 2, 0) ], trimMat, head); // 光感雙眼:恆亮,代表系統運行中 const eyeGeo = box(0.088, 0.058, 0.026, 0.026); const eyeAngle = Math.atan2(0.072, 0.1755); const eyes = weld([ bake(eyeGeo, -0.072, 0, 0.1755, 0, -eyeAngle, 0), bake(eyeGeo, 0.072, 0, 0.1755, 0, eyeAngle, 0) ], eyeMat, head); if (eyes) eyes.position.y = 0.018; // 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 === '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','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(0x7c828c); const leafMat = toon(0x26323c, { emissive: 0x0a141c, emissiveIntensity: 0.4 }); const glassMat = toon(0x3a5566, { emissive: 0x16323f, emissiveIntensity: 0.5 }); 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) return; if (!active && this._aisleActive === false) return; this._aisleActive = active; 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.seatMergedMeshes = [ 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) ].filter(Boolean); } 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.seatScreenMeshes = [ 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) ].filter(Boolean); } setReducedPanelRate(reduced) { this.panelRateReduced = Boolean(reduced); this.displayUpdateInterval = this.panelRateReduced ? 0.35 : 0.15; 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.22 : 0.10) : (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, 40); ctx.fillStyle = '#6fd0e8'; ctx.font = '600 13px "Courier New", monospace'; ctx.fillText('NEURAL PILOT · L4 自動駕駛', 160, 60); 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; } } 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.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.redirected = false; this.signBlinking = false; this.signT = 0; this.rideStartedAt = 0; this._navigationStartTimer = 0; this._navigationRecoveryTimer = 0; 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._passengerHeadAnimationEnabled = true; this._panelRateReduced = false; this.motionScale = PREFERS_REDUCED_MOTION ? 0.2 : 1; this._raf = this.animate.bind(this); this._rafId = 0; 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.scheduleArrivalDistrictBuild(); this.bindEvents(); } 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 !== 'low', depth: true, stencil: false, preserveDrawingBuffer: false, precision: QUALITY_MODE === 'high' ? 'highp' : 'mediump', powerPreference: LOW_POWER_DEVICE ? 'default' : 'high-performance' }); this.renderer.setClearColor(0x000000, 1); if (THREE.SRGBColorSpace) this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.toneMapping = THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure = 1.28; this.container.appendChild(this.renderer.domElement); this._contextLost = false; const glCanvas = this.renderer.domElement; this._onContextLost = (event) => { event.preventDefault(); this._contextLost = true; this.stopAnimationLoop(); }; this._onContextRestored = () => { if (this._disposed) return; this._contextLost = false; this._lastFrame = performance.now(); 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(); } 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() { if (this.redirected) return; this.redirected = true; const target = typeof AITW_DESTINATION_URL === 'string' ? AITW_DESTINATION_URL.trim() : ''; let normalizedTarget = ''; let normalizedParent = ''; try { normalizedTarget = target ? new URL(target).href.replace(/\/$/, '') : ''; } catch { /* parent remains the navigation authority */ } try { normalizedParent = AITW_PARENT_HREF ? new URL(AITW_PARENT_HREF).href.replace(/\/$/, '') : ''; } catch { /* ignore malformed parent URL */ } if (normalizedTarget && normalizedParent === normalizedTarget) { this.redirected = false; this.reset(); return; } 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; try { postToParent({ aitwNavigate: true }); if (this._navigationRecoveryTimer) clearTimeout(this._navigationRecoveryTimer); this._navigationRecoveryTimer = window.setTimeout(() => { this._navigationRecoveryTimer = 0; if (!this.redirected) return; this.redirected = false; this.flashing = false; this.flashOpacity = 0; this.hideFlashOverlay(); }, 4000); } catch { this.redirected = false; this.hideFlashOverlay(); } }, 80); } 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(0xcceeff, 0.72); this.scene.add(this.ambient); this.hemi = new THREE.HemisphereLight(0x6fcfff, 0x081321, 0.54); this.scene.add(this.hemi); this.dir = new THREE.DirectionalLight(0xbfefff, 0.48); this.dir.position.set(5,10,5); this.scene.add(this.dir); this.rim = new THREE.DirectionalLight(0x7d68ff, 0.34); 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(); this.passengers.forEach(npc => { this.scene.remove(npc); npc.traverse(o => { if (!o.material) return; const mats = Array.isArray(o.material) ? o.material : [o.material]; mats.forEach(m => { if (m) usedMaterials.add(m); }); }); }); 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' ? 4 : (QUALITY_MODE === 'balanced' ? 5 : 6); const variance = QUALITY_MODE === 'high' ? 5 : 3; let count = baseCount + Math.floor(randomValue() * variance); count = Math.max(4, 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); }); } 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(0xf0f0f0)); 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(0x1d1218)); 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(0xb9a26a)); 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(0x111111); const dashGeo = new THREE.PlaneGeometry(0.22,3.2); const dashSpacing = QUALITY_MODE === 'low' ? 21 : 14; [-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, initialZ: 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 === 'low' ? 82 : 55; 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 === 'low' ? 1 : 2; [-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)); }); [...this.lights, ...this.buildings].forEach(o => { o.userData.initialZ = o.position.z; }); } 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; } resetDashInstances() { for (const dash of this.dashInstances) { dash.z = dash.initialZ; dash._matrixDirty = true; } this.updateDashInstanceMatrices(); } 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 === 'low' ? [-46, -12, 26, 45] : [-46, -29, -12, 7, 26, 45]; const panelCount = QUALITY_MODE === 'low' ? 7 : 11; 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 pixelRatio = Math.min(window.devicePixelRatio || 1, this._pixelRatioCap); 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.redirected = false; this.signBlinking = false; this.signT = 0; this.rideStartedAt = performance.now(); 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, nowMs = performance.now()) { this.phaseT += dt; const cam = this.camera; const inTravelPhase = this.phase === 'riding' || this.phase === 'stopping'; const rideElapsed = this.rideStartedAt > 0 ? Math.max(0, (nowMs - this.rideStartedAt) / 1000) : 0; 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.phase = 'turning'; this.phaseT = 0; } } 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.redirected) { 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.redirected) { this.finishExperience(); } } } reset() { if (this._navigationStartTimer) clearTimeout(this._navigationStartTimer); this._navigationStartTimer = 0; if (this._navigationRecoveryTimer) clearTimeout(this._navigationRecoveryTimer); this._navigationRecoveryTimer = 0; this.bus.lastDisplayT = -999; this.bus.lastDisplaySpeed = -1; this.bus.lastDisplayPhase = ''; this.bus._lastConsoleProg = -1; this.resetDashInstances(); [...this.lights, ...this.buildings].forEach(o => { if (typeof o.userData.initialZ === 'number') o.position.z = o.userData.initialZ; }); this.scroll.position.z = 0; this.pickPlayerSeat(); this.buildPassengers(); this.prepareDirectRide(); this.hideFlashOverlay(); this._paused = false; this._lastFrame = performance.now(); this.clock.getDelta(); this.resizeRenderer(); } updatePassengers(t, dt) { if (!this.passengers) return; const follow = Math.min(1, dt * 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; const cycle = (Math.sin(this.dayTime)+1)/2; this.ambient.intensity = THREE.MathUtils.lerp(0.7,1.05,cycle); this.hemi.intensity = THREE.MathUtils.lerp(0.5,0.8,cycle); this.dir.intensity = THREE.MathUtils.lerp(0.3,0.62,cycle); const lit = 1.0-cycle; this.rim.intensity = 0.2 + lit*0.25; for (const light of this.bus.realLights) light.intensity = 0.9 + lit*0.85; } applyPerformanceDowngrade() { if (this._adaptiveExhausted || this._disposed) return false; 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(); 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._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); 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 = []; } animate() { this._rafId = 0; if (this._disposed || this._paused || this._contextLost) return; 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, now); 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.startAnimationLoop(); } } let game = null; function bootGame() { try { game = new Game(); game.startAnimationLoop(); postToParent({ aitwBusReady: true }); } catch (error) { try { console.error('[AITaiwan Bus] WebGL initialization failed:', error); } catch { /* console may be unavailable */ } const host = document.getElementById('canvas-container'); if (host) { host.innerHTML = '<div style="position:fixed;inset:0;display:grid;place-items:center;padding:24px;background:#02070f;color:#eaffff;font:700 16px/1.7 PingFang TC,Microsoft JhengHei,sans-serif;text-align:center">目前瀏覽器無法啟動 3D 場景,請更新瀏覽器或確認已開啟 WebGL。</div>'; } postToParent({ aitwBusError: true, message: String(error && error.message || error) }); } } 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 (typeof e.data.aitwParentHref === 'string') AITW_PARENT_HREF = e.data.aitwParentHref.slice(0, 2048); if (typeof e.data.aitwDestinationUrl === 'string') AITW_DESTINATION_URL = e.data.aitwDestinationUrl.slice(0, 2048); if (e.data.aitwReadyProbe === true && game) { postToParent({ aitwBusReady: true }); } } function onInnerPageShow(e) { if (!game || game._disposed) return; game._paused = false; if (e.persisted && typeof game.reset === 'function') game.reset(); 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。
確認
了解更多