dsh-agent-office 4.6.0 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/panel-src/main.js CHANGED
@@ -287,6 +287,12 @@ function applyThemeFrame() {
287
287
  const neonMaterial = neonSign?.material
288
288
  if (neonMaterial) neonMaterial.opacity = NIGHT_THEME.neon + (DAY_THEME.neon - NIGHT_THEME.neon) * mix
289
289
  for (const item of [...themeLights, ...annexLights]) item.light.intensity = item.night + (item.day - item.night) * mix
290
+ const weatherDim = weatherMode === 'storm' ? 0.86 : weatherMode === 'rain' || weatherMode === 'snow' ? 0.92 : weatherMode === 'cloudy' ? 0.95 : weatherMode === 'wind' ? 0.98 : 1
291
+ hemi.intensity *= weatherDim
292
+ key.intensity *= weatherDim
293
+ rim.intensity *= weatherDim
294
+ fill.intensity *= weatherDim
295
+ scene.fog.density = 0.0085 * (weatherMode === 'rain' || weatherMode === 'snow' || weatherMode === 'storm' ? 1.06 : 1)
290
296
  }
291
297
 
292
298
  function syncThemeByClock(immediate = false) {
@@ -306,17 +312,17 @@ const neonSign = (() => {
306
312
  canvas.height = 192
307
313
  const ctx = canvas.getContext('2d')
308
314
  ctx.clearRect(0, 0, canvas.width, canvas.height)
309
- ctx.font = '900 88px Inter, "PingFang SC", system-ui, sans-serif'
315
+ ctx.font = '900 72px Inter, "PingFang SC", system-ui, sans-serif'
310
316
  ctx.textAlign = 'center'
311
317
  ctx.textBaseline = 'middle'
312
- ctx.shadowColor = 'rgba(255,106,61,.85)'
313
- ctx.shadowBlur = 36
314
- ctx.fillStyle = '#ffe9dd'
315
- ctx.fillText('CATORA', 512, 78)
318
+ ctx.shadowColor = 'rgba(77,107,254,.95)'
319
+ ctx.shadowBlur = 40
320
+ ctx.fillStyle = '#eaf1ff'
321
+ ctx.fillText('DEEPSEEK HARNESS', 512, 74)
316
322
  ctx.shadowBlur = 0
317
- ctx.font = '500 28px ui-monospace, Menlo, monospace'
318
- ctx.fillStyle = 'rgba(190,170,155,.85)'
319
- ctx.fillText('DEEPSEEK HARNESS — LIVE SESSION OFFICE', 512, 150)
323
+ ctx.font = '500 26px ui-monospace, Menlo, monospace'
324
+ ctx.fillStyle = 'rgba(160,190,255,.9)'
325
+ ctx.fillText('LIVE SESSION OFFICE', 512, 146)
320
326
  const texture = new THREE.CanvasTexture(canvas)
321
327
  texture.colorSpace = THREE.SRGBColorSpace
322
328
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }))
@@ -445,6 +451,39 @@ const scribbleTexture = (() => {
445
451
  })()
446
452
  const OVERLOAD_COLOR = new THREE.Color(0xff5a5a)
447
453
 
454
+ const keyboardTexture = (() => {
455
+ const canvas = document.createElement('canvas')
456
+ canvas.width = 256
457
+ canvas.height = 96
458
+ const ctx = canvas.getContext('2d')
459
+ ctx.fillStyle = '#0a1017'
460
+ ctx.fillRect(0, 0, 256, 96)
461
+ const cols = 14
462
+ const rows = 4
463
+ const keyW = 14
464
+ const keyH = 14
465
+ const gap = 2
466
+ const startX = (256 - (cols * (keyW + gap) - gap)) / 2
467
+ for (let r = 0; r < rows; r++) {
468
+ for (let c = 0; c < cols; c++) {
469
+ const x = startX + c * (keyW + gap)
470
+ const y = 10 + r * (keyH + gap)
471
+ ctx.fillStyle = (r === 2 && c > 4 && c < 9) ? '#24445a' : '#1c2a36'
472
+ ctx.beginPath(); ctx.roundRect(x, y, keyW, keyH, 2); ctx.fill()
473
+ ctx.fillStyle = 'rgba(150,220,255,.22)'
474
+ ctx.fillRect(x + 2, y + 2, keyW - 4, 2)
475
+ }
476
+ }
477
+ // spacebar + underglow
478
+ ctx.fillStyle = '#1c2a36'
479
+ ctx.beginPath(); ctx.roundRect(62, 74, 132, 13, 3); ctx.fill()
480
+ ctx.fillStyle = 'rgba(80,220,255,.55)'
481
+ ctx.fillRect(4, 91, 248, 2)
482
+ const texture = new THREE.CanvasTexture(canvas)
483
+ texture.colorSpace = THREE.SRGBColorSpace
484
+ return texture
485
+ })()
486
+
448
487
  const floorTexture = (() => {
449
488
  const canvas = document.createElement('canvas')
450
489
  canvas.width = canvas.height = 256
@@ -464,6 +503,197 @@ const floorTexture = (() => {
464
503
  return texture
465
504
  })()
466
505
 
506
+ /* ------------------------------------------------------------------ *
507
+ * 天气:云层 / 雨 / 雪 / 风
508
+ * ------------------------------------------------------------------ */
509
+ const weatherGroup = new THREE.Group()
510
+ scene.add(weatherGroup)
511
+ const weatherCloudTexture = (() => {
512
+ const canvas = document.createElement('canvas')
513
+ canvas.width = 512
514
+ canvas.height = 192
515
+ const ctx = canvas.getContext('2d')
516
+ ctx.clearRect(0, 0, 512, 192)
517
+ for (const [x, y, rx, ry, alpha] of [[120, 108, 160, 26, .16], [260, 84, 210, 22, .14], [400, 112, 170, 20, .13], [190, 132, 130, 14, .11], [340, 64, 120, 16, .1]]) {
518
+ const grad = ctx.createRadialGradient(x, y, 0, x, y, rx)
519
+ grad.addColorStop(0, `rgba(230,242,250,${alpha})`)
520
+ grad.addColorStop(.5, `rgba(196,214,228,${alpha * .55})`)
521
+ grad.addColorStop(1, 'rgba(170,195,214,0)')
522
+ ctx.fillStyle = grad
523
+ ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); ctx.fill()
524
+ }
525
+ const texture = new THREE.CanvasTexture(canvas)
526
+ texture.colorSpace = THREE.SRGBColorSpace
527
+ return texture
528
+ })()
529
+ const weatherClouds = []
530
+ for (let i = 0; i < 7; i++) {
531
+ const material = new THREE.SpriteMaterial({ map: weatherCloudTexture, color: 0x8d9dab, transparent: true, opacity: 0, depthWrite: false, toneMapped: false })
532
+ const cloud = new THREE.Sprite(material)
533
+ cloud.scale.set(17 + (i % 3) * 3, 2.8 + (i % 2) * .8, 1)
534
+ cloud.position.set(-16 + (i * 5.1) % 32, 6.2 + (i % 4) * .9, 4 + (i * 3.1) % 13)
535
+ cloud.userData = { baseX: cloud.position.x, baseY: cloud.position.y, baseZ: cloud.position.z, phase: i * 0.9 }
536
+ weatherGroup.add(cloud)
537
+ weatherClouds.push(cloud)
538
+ }
539
+
540
+ const WEATHER_COUNT = 560
541
+ const weatherPositions = new Float32Array(WEATHER_COUNT * 3)
542
+ const weatherGeometry = new THREE.BufferGeometry()
543
+ weatherGeometry.setAttribute('position', new THREE.BufferAttribute(weatherPositions, 3))
544
+ const weatherPointTexture = (() => {
545
+ const canvas = document.createElement('canvas')
546
+ canvas.width = canvas.height = 64
547
+ const ctx = canvas.getContext('2d')
548
+ const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32)
549
+ grad.addColorStop(0, 'rgba(255,255,255,1)')
550
+ grad.addColorStop(0.5, 'rgba(235,250,255,.8)')
551
+ grad.addColorStop(1, 'rgba(235,250,255,0)')
552
+ ctx.fillStyle = grad
553
+ ctx.fillRect(0, 0, 64, 64)
554
+ const texture = new THREE.CanvasTexture(canvas)
555
+ texture.colorSpace = THREE.SRGBColorSpace
556
+ return texture
557
+ })()
558
+ const weatherMaterial = new THREE.PointsMaterial({ map: weatherPointTexture, alphaTest: 0.02, color: 0x9fd4ff, size: 0.055, transparent: true, opacity: 0, depthWrite: false, toneMapped: false, sizeAttenuation: true })
559
+ const weatherPoints = new THREE.Points(weatherGeometry, weatherMaterial)
560
+ weatherPoints.frustumCulled = false
561
+ weatherPoints.visible = false
562
+ weatherGroup.add(weatherPoints)
563
+ const rainPositions = new Float32Array(WEATHER_COUNT * 6)
564
+ const rainGeometry = new THREE.BufferGeometry()
565
+ rainGeometry.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3))
566
+ const rainMaterial = new THREE.LineBasicMaterial({ color: 0x9fd4ff, transparent: true, opacity: 0, depthWrite: false, toneMapped: false })
567
+ const rainLines = new THREE.LineSegments(rainGeometry, rainMaterial)
568
+ rainLines.frustumCulled = false
569
+ rainLines.visible = false
570
+ weatherGroup.add(rainLines)
571
+ const RAIN_COUNT = 80
572
+ rainGeometry.setDrawRange(0, RAIN_COUNT * 2)
573
+ const weatherParticles = []
574
+ let weatherMode = 'clear'
575
+ let weatherWind = 0
576
+ let weatherCloudLevel = 0
577
+ let weatherPointLevel = 0
578
+
579
+ function seedWeatherParticles(mode) {
580
+ const isWind = mode === 'wind'
581
+ const isSnow = mode === 'snow'
582
+ for (let i = 0; i < WEATHER_COUNT; i++) {
583
+ const x = (Math.random() - 0.5) * (isWind ? 34 : 26)
584
+ const y = isWind ? 1.5 + Math.random() * 7 : Math.random() * 18
585
+ const z = (Math.random() - 0.5) * (isWind ? 22 : 24)
586
+ weatherParticles[i] = { x, y, z, speed: isSnow ? 0.035 + Math.random() * 0.035 : 0.16 + Math.random() * 0.2, drift: Math.random() * 2 - 1 }
587
+ weatherPositions[i * 3] = x
588
+ weatherPositions[i * 3 + 1] = y
589
+ weatherPositions[i * 3 + 2] = z
590
+ }
591
+ weatherGeometry.attributes.position.needsUpdate = true
592
+ }
593
+
594
+ function setWeather(mode, wind = 0) {
595
+ weatherMode = ['clear', 'cloudy', 'rain', 'snow', 'wind', 'storm'].includes(mode) ? mode : 'clear'
596
+ weatherWind = Number.isFinite(wind) ? wind : 0
597
+ const wet = weatherMode === 'rain' || weatherMode === 'storm'
598
+ const snow = weatherMode === 'snow'
599
+ const windy = weatherMode === 'wind'
600
+ weatherPoints.visible = snow || windy
601
+ rainLines.visible = wet
602
+ weatherMaterial.color.set(snow ? 0xe6f5ff : 0xcfe8ff)
603
+ weatherMaterial.size = snow ? 0.17 : 0.06
604
+ if (wet) rainGeometry.setDrawRange(0, RAIN_COUNT * 2)
605
+ if (wet || snow || windy) seedWeatherParticles(weatherMode)
606
+ }
607
+
608
+ function refreshWeather() {
609
+ let location = null
610
+ try {
611
+ const raw = localStorage.getItem('agent-office-weather-location')
612
+ if (raw) {
613
+ const parsed = JSON.parse(raw)
614
+ if (Number.isFinite(parsed?.lat) && Number.isFinite(parsed?.lon)) location = parsed
615
+ }
616
+ } catch {}
617
+ if (!location) {
618
+ setWeather('clear')
619
+ return
620
+ }
621
+ fetch(`/agent-office/api/weather?lat=${encodeURIComponent(location.lat)}&lon=${encodeURIComponent(location.lon)}`, { cache: 'no-store' })
622
+ .then(response => response.ok ? response.json() : null)
623
+ .then(data => {
624
+ if (!data?.ok) {
625
+ setWeather('clear')
626
+ return
627
+ }
628
+ const code = Number(data.code)
629
+ let mode = 'clear'
630
+ if (code >= 95) mode = 'storm'
631
+ else if (code >= 71 && code <= 86) mode = 'snow'
632
+ else if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) mode = 'rain'
633
+ else if (code === 2 || code === 3 || code === 45 || code === 48) mode = 'cloudy'
634
+ else if (Number(data.wind) >= 28) mode = 'wind'
635
+ setWeather(mode, Number(data.wind))
636
+ })
637
+ .catch(() => setWeather('clear'))
638
+ }
639
+
640
+ function updateWeatherFrame(t) {
641
+ const cloudy = weatherMode === 'cloudy' || weatherMode === 'wind'
642
+ weatherCloudLevel += ((cloudy ? 1 : 0) - weatherCloudLevel) * 0.025
643
+ for (const cloud of weatherClouds) {
644
+ cloud.material.opacity = weatherCloudLevel * 0.22
645
+ cloud.position.x = cloud.userData.baseX + Math.sin(t * 0.035 + cloud.userData.phase) * 2.2
646
+ cloud.position.z = cloud.userData.baseZ + Math.cos(t * 0.03 + cloud.userData.phase) * 1.2
647
+ }
648
+ const active = weatherPoints.visible
649
+ const wet = weatherMode === 'rain' || weatherMode === 'storm'
650
+ weatherPointLevel += (((active || wet) ? 1 : 0) - weatherPointLevel) * 0.06
651
+ weatherMaterial.opacity = weatherPointLevel * (weatherMode === 'snow' ? 0.6 : 0.5)
652
+ rainMaterial.opacity = weatherPointLevel * 0.3
653
+ if (wet) {
654
+ for (let i = 0; i < RAIN_COUNT; i++) {
655
+ const p = weatherParticles[i]
656
+ if (!p) continue
657
+ p.y -= p.speed * 0.95
658
+ p.x += weatherWind * 0.0015 + p.drift * 0.0012
659
+ if (p.y < 0) { p.y = 17; p.x = (Math.random() - 0.5) * 26; p.z = (Math.random() - 0.5) * 24 }
660
+ rainPositions[i * 6] = p.x
661
+ rainPositions[i * 6 + 1] = p.y
662
+ rainPositions[i * 6 + 2] = p.z
663
+ rainPositions[i * 6 + 3] = p.x + weatherWind * 0.0025
664
+ rainPositions[i * 6 + 4] = p.y - 0.22
665
+ rainPositions[i * 6 + 5] = p.z
666
+ }
667
+ rainGeometry.attributes.position.needsUpdate = true
668
+ } else if (active) {
669
+ for (let i = 0; i < WEATHER_COUNT; i++) {
670
+ const p = weatherParticles[i]
671
+ if (!p) continue
672
+ if (weatherMode === 'wind') {
673
+ p.x += 0.11 + Math.abs(weatherWind) * 0.002
674
+ p.y += Math.sin(t * 1.3 + p.drift) * 0.004
675
+ if (p.x > 18) p.x = -18
676
+ } else if (weatherMode === 'snow') {
677
+ p.y -= p.speed * 0.55
678
+ p.x += Math.sin(t * 1.4 + p.drift * 5) * 0.006
679
+ if (p.y < 0) { p.y = 16; p.x = (Math.random() - 0.5) * 26 }
680
+ } else {
681
+ p.y -= p.speed
682
+ p.x += (weatherWind * 0.0015 + p.drift * 0.001)
683
+ if (p.y < 0) { p.y = 17; p.x = (Math.random() - 0.5) * 26; p.z = (Math.random() - 0.5) * 24 }
684
+ }
685
+ weatherPositions[i * 3] = p.x
686
+ weatherPositions[i * 3 + 1] = p.y
687
+ weatherPositions[i * 3 + 2] = p.z
688
+ }
689
+ weatherGeometry.attributes.position.needsUpdate = true
690
+ }
691
+ }
692
+
693
+ window.addEventListener('storage', event => {
694
+ if (event.key === 'agent-office-weather-location') refreshWeather()
695
+ })
696
+
467
697
  function disposeGroup(group) {
468
698
  group.traverse(object => {
469
699
  // CSS2D 标签是独立 DOM,scene 移除后渲染器不会回收,必须手动删掉,否则重建会累积重影
@@ -475,7 +705,7 @@ function disposeGroup(group) {
475
705
  if (object.material) {
476
706
  const materials = Array.isArray(object.material) ? object.material : [object.material]
477
707
  for (const material of materials) {
478
- if (material.map && material.map !== glowTexture && material.map !== shadowTexture && material.map !== floorTexture && material.map !== sweatTexture && material.map !== scribbleTexture) material.map.dispose()
708
+ if (material.map && material.map !== glowTexture && material.map !== shadowTexture && material.map !== floorTexture && material.map !== sweatTexture && material.map !== scribbleTexture && material.map !== keyboardTexture) material.map.dispose()
479
709
  material.dispose()
480
710
  }
481
711
  }
@@ -837,16 +1067,65 @@ function workstation(agent, spot) {
837
1067
  group.add(footrest)
838
1068
  }
839
1069
 
840
- const keyboard = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.02, 0.15), metalMat)
841
- keyboard.position.set(spot.x + 0.24, 0.6, spot.z + 0.82)
842
- group.add(keyboard)
1070
+ // 键盘:底座 + 键帽贴图 + 底部氛围灯
1071
+ const keyboardBase = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.03, 0.18), new THREE.MeshStandardMaterial({ color: 0x111923, roughness: 0.45, metalness: 0.4 }))
1072
+ keyboardBase.position.set(spot.x + 0.24, 0.575, spot.z + 0.8)
1073
+ group.add(keyboardBase)
1074
+ const keyboardTop = new THREE.Mesh(
1075
+ new THREE.PlaneGeometry(0.49, 0.16),
1076
+ new THREE.MeshBasicMaterial({ map: keyboardTexture, toneMapped: false }),
1077
+ )
1078
+ keyboardTop.rotation.x = -Math.PI / 2
1079
+ keyboardTop.position.set(spot.x + 0.24, 0.592, spot.z + 0.8)
1080
+ group.add(keyboardTop)
1081
+ const keyboardGlow = new THREE.Mesh(
1082
+ new THREE.BoxGeometry(0.48, 0.006, 0.012),
1083
+ new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.8, toneMapped: false }),
1084
+ )
1085
+ keyboardGlow.position.set(spot.x + 0.24, 0.58, spot.z + 0.9)
1086
+ group.add(keyboardGlow)
1087
+
1088
+ // 鼠标垫 + 鼠标 + 滚轮
1089
+ const mousePad = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.008, 0.22), new THREE.MeshStandardMaterial({ color: 0x101923, roughness: 0.92, metalness: 0.05 }))
1090
+ mousePad.position.set(spot.x + 0.63, 0.588, spot.z + 0.83)
1091
+ group.add(mousePad)
1092
+ const mouse = new THREE.Mesh(
1093
+ new RoundedBoxGeometry(0.09, 0.04, 0.13, 3, 0.018),
1094
+ new THREE.MeshStandardMaterial({ color: 0x1c2835, roughness: 0.5, metalness: 0.25 }),
1095
+ )
1096
+ mouse.position.set(spot.x + 0.63, 0.61, spot.z + 0.82)
1097
+ mouse.rotation.y = -0.2
1098
+ group.add(mouse)
1099
+ const wheel = new THREE.Mesh(new THREE.BoxGeometry(0.018, 0.018, 0.035), new THREE.MeshBasicMaterial({ color }))
1100
+ wheel.position.set(spot.x + 0.63, 0.64, spot.z + 0.79)
1101
+ group.add(wheel)
1102
+
1103
+ // 键盘 / 鼠标线缆
1104
+ const cableMat = new THREE.MeshStandardMaterial({ color: 0x607385, roughness: 0.38, metalness: 0.3 })
1105
+ const keyboardCable = new THREE.Mesh(
1106
+ new THREE.TubeGeometry(new THREE.CatmullRomCurve3([
1107
+ new THREE.Vector3(spot.x + 0.24, 0.592, spot.z + 0.71),
1108
+ new THREE.Vector3(spot.x + 0.18, 0.592, spot.z + 0.62),
1109
+ new THREE.Vector3(spot.x - 0.06, 0.592, spot.z + 0.54),
1110
+ new THREE.Vector3(spot.x - 0.34, 0.592, spot.z + 0.48),
1111
+ new THREE.Vector3(spot.x - 0.48, 0.6, spot.z + 0.42),
1112
+ ], false, 'centripetal'), 18, 0.008, 5, false),
1113
+ cableMat,
1114
+ )
1115
+ group.add(keyboardCable)
1116
+ const mouseCable = new THREE.Mesh(
1117
+ new THREE.TubeGeometry(new THREE.CatmullRomCurve3([
1118
+ new THREE.Vector3(spot.x + 0.63, 0.6, spot.z + 0.76),
1119
+ new THREE.Vector3(spot.x + 0.64, 0.592, spot.z + 0.62),
1120
+ new THREE.Vector3(spot.x + 0.34, 0.592, spot.z + 0.54),
1121
+ new THREE.Vector3(spot.x - 0.08, 0.592, spot.z + 0.48),
1122
+ new THREE.Vector3(spot.x - 0.46, 0.6, spot.z + 0.42),
1123
+ ], false, 'centripetal'), 18, 0.007, 5, false),
1124
+ cableMat,
1125
+ )
1126
+ group.add(mouseCable)
1127
+
843
1128
 
844
- const plate = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.16, 0.02), new THREE.MeshBasicMaterial({ color: 0x0e151d }))
845
- plate.position.set(spot.x + 0.6, 0.63, spot.z + 0.86)
846
- group.add(plate)
847
- const plateDot = new THREE.Mesh(new THREE.CircleGeometry(0.035, 12), new THREE.MeshBasicMaterial({ color }))
848
- plateDot.position.set(spot.x + 0.55, 0.63, spot.z + 0.872)
849
- group.add(plateDot)
850
1129
 
851
1130
  return group
852
1131
  }
@@ -1347,6 +1626,8 @@ let targetGoal = null
1347
1626
  let zoomGoal = null
1348
1627
  let tower = null
1349
1628
  let loading = true
1629
+ let introPlayed = false
1630
+ let introTl = null
1350
1631
 
1351
1632
  function viewScale() {
1352
1633
  return clamp(1638 / Math.max(900, window.innerWidth), 0.82, 1.5)
@@ -1603,6 +1884,68 @@ let mcpDashes = []
1603
1884
  let mcpGroup = null
1604
1885
  let mcpSign = null
1605
1886
  let sciFiIcons = []
1887
+ let studyClock = null
1888
+ let annexCats = []
1889
+
1890
+ function drawStudyClock(seconds, alarm) {
1891
+ if (!studyClock) return
1892
+ const ctx = studyClock.ctx
1893
+ const w = 512
1894
+ const h = 512
1895
+ const total = Math.max(0, Math.min(25 * 60, seconds))
1896
+ const mm = String(Math.floor(total / 60)).padStart(2, '0')
1897
+ const ss = String(total % 60).padStart(2, '0')
1898
+ ctx.clearRect(0, 0, w, h)
1899
+ ctx.save()
1900
+ ctx.translate(256, 256)
1901
+ // 背后柔光
1902
+ const glow = ctx.createRadialGradient(0, 0, 40, 0, 0, 240)
1903
+ glow.addColorStop(0, alarm ? 'rgba(255,92,92,.34)' : 'rgba(80,210,255,.24)')
1904
+ glow.addColorStop(1, 'rgba(80,210,255,0)')
1905
+ ctx.fillStyle = glow
1906
+ ctx.beginPath(); ctx.arc(0, 0, 240, 0, Math.PI * 2); ctx.fill()
1907
+ // 铃铛
1908
+ ctx.fillStyle = '#263744'
1909
+ ctx.strokeStyle = alarm ? '#ff6b6b' : '#9fe7ff'
1910
+ ctx.lineWidth = 10
1911
+ for (const x of [-104, 104]) {
1912
+ ctx.beginPath(); ctx.arc(x, -190, 54, Math.PI, 0); ctx.fill(); ctx.stroke()
1913
+ ctx.beginPath(); ctx.moveTo(x - 54, -190); ctx.lineTo(x + 54, -190); ctx.stroke()
1914
+ }
1915
+ // 支脚
1916
+ ctx.lineCap = 'round'
1917
+ ctx.beginPath(); ctx.moveTo(-110, 190); ctx.lineTo(-160, 248); ctx.stroke()
1918
+ ctx.beginPath(); ctx.moveTo(110, 190); ctx.lineTo(160, 248); ctx.stroke()
1919
+ // 表身
1920
+ ctx.fillStyle = '#e9e2d2'
1921
+ ctx.beginPath(); ctx.arc(0, 0, 170, 0, Math.PI * 2); ctx.fill()
1922
+ ctx.lineWidth = 22
1923
+ ctx.strokeStyle = alarm ? '#d84343' : '#294654'
1924
+ ctx.beginPath(); ctx.arc(0, 0, 170, 0, Math.PI * 2); ctx.stroke()
1925
+ // 内圈
1926
+ ctx.fillStyle = alarm ? '#3a1418' : '#112029'
1927
+ ctx.beginPath(); ctx.arc(0, 0, 140, 0, Math.PI * 2); ctx.fill()
1928
+ ctx.strokeStyle = alarm ? '#ff8f8f' : '#8fe7ff'
1929
+ ctx.lineWidth = 3
1930
+ ctx.beginPath(); ctx.arc(0, 0, 140, 0, Math.PI * 2); ctx.stroke()
1931
+ // 数字
1932
+ ctx.textAlign = 'center'
1933
+ ctx.textBaseline = 'middle'
1934
+ ctx.fillStyle = alarm ? '#ffb3b3' : '#9fe7ff'
1935
+ ctx.font = '600 26px ui-monospace, Menlo, monospace'
1936
+ ctx.fillText('FOCUS TIMER', 0, -88)
1937
+ ctx.fillStyle = alarm ? '#ffd0d0' : '#f2fbff'
1938
+ ctx.font = '800 98px ui-monospace, Menlo, monospace'
1939
+ ctx.fillText(mm + ':' + ss, 0, 8)
1940
+ ctx.fillStyle = alarm ? '#ff8f8f' : 'rgba(159,231,255,.75)'
1941
+ ctx.font = '600 22px "PingFang SC", system-ui, sans-serif'
1942
+ ctx.fillText(alarm ? '时间到' : '备考倒计时', 0, 72)
1943
+ // 顶部小灯
1944
+ ctx.fillStyle = alarm ? '#ff6b6b' : '#7dd3a0'
1945
+ ctx.beginPath(); ctx.arc(0, -118, 7, 0, Math.PI * 2); ctx.fill()
1946
+ ctx.restore()
1947
+ studyClock.texture.needsUpdate = true
1948
+ }
1606
1949
 
1607
1950
  function buildAnnex() {
1608
1951
  annex = new THREE.Group()
@@ -1649,6 +1992,12 @@ function buildAnnex() {
1649
1992
 
1650
1993
  // 教室布局:讲台在后墙,三张工位面朝讲台
1651
1994
  // 1) 通用工位
1995
+ const frosted = new THREE.MeshStandardMaterial({ color: 0xdcefff, transparent: true, opacity: 0.16, roughness: 0.86, metalness: 0.02, depthWrite: false, side: THREE.DoubleSide })
1996
+ function addFrostedHeader(parent, x = 0, width = 1.28, y = 1.16, z = -0.38) {
1997
+ box(width, 0.42, 0.035, frosted, x, y, z, parent)
1998
+ box(width, 0.025, 0.05, metal, x, y + 0.23, z, parent)
1999
+ box(width, 0.025, 0.05, metal, x, y - 0.23, z, parent)
2000
+ }
1652
2001
  function addDesk(x, z, matTop = wood) {
1653
2002
  const g = new THREE.Group()
1654
2003
  g.position.set(x, 0, z)
@@ -1671,29 +2020,38 @@ function buildAnnex() {
1671
2020
  base.position.set(0, 0.02, 0.55)
1672
2021
  g.add(base)
1673
2022
  annex.add(g)
2023
+ addFrostedHeader(g)
2024
+ return g
1674
2025
  }
1675
2026
  addDesk(-2.35, 0.85)
1676
2027
 
1677
- // 2) 备考隔间(产品里的学习空间)
1678
- const booth = new THREE.Group()
1679
- booth.position.set(0, 0, 0.85)
1680
- box(1.55, 1.78, 0.04, glass, 0, 0.9, -0.66, booth)
1681
- box(0.04, 1.78, 1.28, glass, -0.76, 0.9, 0, booth)
1682
- box(0.04, 1.78, 1.28, glass, 0.76, 0.9, 0, booth)
1683
- box(1.58, 0.06, 0.06, metal, 0, 1.79, -0.66, booth)
1684
- box(0.06, 0.06, 1.32, metal, -0.76, 1.79, 0, booth)
1685
- box(0.06, 0.06, 1.32, metal, 0.76, 1.79, 0, booth)
1686
- box(0.74, 0.055, 0.44, wood, 0, 0.7, -0.2, booth)
1687
- box(0.56, 0.34, 0.04, dark, 0, 0.93, -0.4, booth)
1688
- const boothScreen = new THREE.Mesh(new THREE.PlaneGeometry(0.48, 0.25), cyan)
1689
- boothScreen.position.set(0, 0.93, -0.375)
1690
- booth.add(boothScreen)
1691
- box(0.42, 0.06, 0.38, dark, 0, 0.44, 0.2, booth)
1692
- box(0.42, 0.5, 0.06, dark, 0, 0.69, 0.4, booth)
1693
- const boothLamp = new THREE.Mesh(new THREE.SphereGeometry(0.05, 10, 10), warm)
1694
- boothLamp.position.set(0.5, 1.34, -0.32)
1695
- booth.add(boothLamp)
1696
- annex.add(booth)
2028
+ // 2) 备考隔间:同一排的学习桌 + 透明磨砂隐私隔板
2029
+ const studyDesk = addDesk(0, 0.85)
2030
+ // 两侧磨砂挡板,形成半包围学习位
2031
+ box(0.035, 1.15, 1.15, frosted, -0.72, 0.85, 0.12, studyDesk)
2032
+ box(0.035, 1.15, 1.15, frosted, 0.72, 0.85, 0.12, studyDesk)
2033
+
2034
+ // 备考倒计时闹钟
2035
+ const clockCanvas = document.createElement('canvas')
2036
+ clockCanvas.width = clockCanvas.height = 512
2037
+ const clockCtx = clockCanvas.getContext('2d')
2038
+ const clockTexture = new THREE.CanvasTexture(clockCanvas)
2039
+ clockTexture.colorSpace = THREE.SRGBColorSpace
2040
+ const clockSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: clockTexture, transparent: true, depthWrite: false, toneMapped: false }))
2041
+ clockSprite.position.set(0, 2.45, 0.85)
2042
+ clockSprite.scale.set(0.98, 0.98, 1)
2043
+ annex.add(clockSprite)
2044
+ studyClock = {
2045
+ sprite: clockSprite,
2046
+ ctx: clockCtx,
2047
+ texture: clockTexture,
2048
+ endAt: Date.now() + 25 * 60 * 1000,
2049
+ lastSecond: -1,
2050
+ alarmAt: 0,
2051
+ }
2052
+ drawStudyClock(25 * 60, false)
2053
+ box(0.035, 0.025, 1.15, metal, -0.72, 1.51, 0.12, studyDesk)
2054
+ box(0.035, 0.025, 1.15, metal, 0.72, 1.51, 0.12, studyDesk)
1697
2055
 
1698
2056
  // 3) 题库工位:科幻桌面 + 浮动 OCR / 数据库 / 试卷投影
1699
2057
  const sci = new THREE.Group()
@@ -1737,6 +2095,7 @@ function buildAnnex() {
1737
2095
  sciBase.position.set(0, 0.02, 0.55)
1738
2096
  sci.add(sciBase)
1739
2097
  annex.add(sci)
2098
+ addFrostedHeader(sci)
1740
2099
  const halo = new THREE.Mesh(
1741
2100
  new THREE.RingGeometry(0.4, 0.44, 48),
1742
2101
  new THREE.MeshBasicMaterial({ color: 0x6fdcff, transparent: true, opacity: 0.35, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }),
@@ -1767,8 +2126,10 @@ function buildAnnex() {
1767
2126
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false, toneMapped: false }))
1768
2127
  sprite.scale.set(scale, scale, 1)
1769
2128
  sprite.position.set(2.35 + x, y, 0.85 + z)
2129
+ sprite.material.opacity = 0
1770
2130
  annex.add(sprite)
1771
2131
  sprite.userData.baseY = y
2132
+ sprite.userData.baseScale = scale
1772
2133
  sprite.userData.phase = sciFiIcons.length * 0.9
1773
2134
  sciFiIcons.push(sprite)
1774
2135
  return sprite
@@ -1781,7 +2142,6 @@ function buildAnnex() {
1781
2142
  ctx.fillRect(42, 34, 22, 10)
1782
2143
  ctx.font = '700 15px ui-monospace, Menlo, monospace'
1783
2144
  ctx.textAlign = 'center'
1784
- ctx.fillText('OCR', 80, 128)
1785
2145
  })
1786
2146
  const dbTexture = iconCanvas(ctx => {
1787
2147
  for (let i = 0; i < 3; i++) {
@@ -1793,7 +2153,6 @@ function buildAnnex() {
1793
2153
  }
1794
2154
  ctx.font = '700 15px ui-monospace, Menlo, monospace'
1795
2155
  ctx.textAlign = 'center'
1796
- ctx.fillText('DB', 80, 140)
1797
2156
  })
1798
2157
  const paperTexture = iconCanvas(ctx => {
1799
2158
  ctx.beginPath(); ctx.roundRect(46, 28, 68, 92, 5); ctx.stroke()
@@ -1803,12 +2162,11 @@ function buildAnnex() {
1803
2162
  }
1804
2163
  ctx.font = '700 15px ui-monospace, Menlo, monospace'
1805
2164
  ctx.textAlign = 'center'
1806
- ctx.fillText('EXAM', 80, 142)
1807
2165
  })
1808
2166
 
1809
- iconSprite(ocrTexture, -0.5, 2.08, 0.12, 0.48)
1810
- iconSprite(dbTexture, 0, 2.2, 0.12, 0.48)
1811
- iconSprite(paperTexture, 0.5, 2.08, 0.12, 0.48)
2167
+ iconSprite(ocrTexture, 0, 2.12, 0.12, 0.95)
2168
+ iconSprite(dbTexture, 0, 2.12, 0.12, 0.95)
2169
+ iconSprite(paperTexture, 0, 2.12, 0.12, 0.95)
1812
2170
 
1813
2171
  // 讲台(教室最前面,背靠后墙,面向三张工位)
1814
2172
  const podium = new THREE.Group()
@@ -1833,6 +2191,46 @@ function buildAnnex() {
1833
2191
  box(3.3, 1.25, 0.04, dark, 0, 1.55, -2.86)
1834
2192
  box(3.1, 0.03, 0.05, cyan, 0, 2.18, -2.83)
1835
2193
 
2194
+ // 讲台 + 三席的猫咪图片
2195
+ const addAnnexCat = (x, y, z, tint, scale = 0.48, shadowY = null) => {
2196
+ const material = new THREE.SpriteMaterial({ map: faceTextures.happy, color: tint, transparent: true, depthWrite: false, toneMapped: false })
2197
+ const sprite = new THREE.Sprite(material)
2198
+ sprite.scale.set(scale, scale, 1)
2199
+ sprite.position.set(x, y, z)
2200
+ annex.add(sprite)
2201
+ annexCats.push({ sprite, baseY: y, phase: annexCats.length * 1.3 })
2202
+ if (shadowY !== null) {
2203
+ const shadow = new THREE.Mesh(new THREE.PlaneGeometry(scale * 1.5, scale * 1.15), new THREE.MeshBasicMaterial({ map: shadowTexture, transparent: true, opacity: 0.34, depthWrite: false }))
2204
+ shadow.rotation.x = -Math.PI / 2
2205
+ shadow.position.set(x, shadowY, z)
2206
+ annex.add(shadow)
2207
+ }
2208
+ }
2209
+ addAnnexCat(-2.35, 0.74, 1.28, 0x7fa8ff, 0.46, 0.56)
2210
+ addAnnexCat(0, 0.74, 1.28, 0xff6347, 0.46, 0.56)
2211
+ addAnnexCat(2.35, 0.74, 1.28, 0x5ee7ff, 0.46, 0.56)
2212
+ addAnnexCat(0, 1.5, -1.85, 0xffffff, 0.6, null)
2213
+
2214
+ // Annex 上方 Catora 招牌
2215
+ const annexSignCanvas = document.createElement('canvas')
2216
+ annexSignCanvas.width = 640
2217
+ annexSignCanvas.height = 160
2218
+ const actx = annexSignCanvas.getContext('2d')
2219
+ actx.clearRect(0, 0, 640, 160)
2220
+ actx.font = '900 76px Inter, "PingFang SC", system-ui, sans-serif'
2221
+ actx.textAlign = 'center'
2222
+ actx.textBaseline = 'middle'
2223
+ actx.shadowColor = 'rgba(90,190,255,.85)'
2224
+ actx.shadowBlur = 32
2225
+ actx.fillStyle = '#e9f6ff'
2226
+ actx.fillText('CATORA', 320, 80)
2227
+ const annexSignTexture = new THREE.CanvasTexture(annexSignCanvas)
2228
+ annexSignTexture.colorSpace = THREE.SRGBColorSpace
2229
+ const annexSign = new THREE.Sprite(new THREE.SpriteMaterial({ map: annexSignTexture, transparent: true, depthWrite: false, toneMapped: false }))
2230
+ annexSign.scale.set(2.8, 0.7, 1)
2231
+ annexSign.position.set(0, 3.35, -0.9)
2232
+ annex.add(annexSign)
2233
+
1836
2234
  // 室内灯
1837
2235
  const lightWarm = new THREE.PointLight(0xffd8b0, 2.6, 11, 2)
1838
2236
  lightWarm.position.set(0, 2.15, 0.4)
@@ -1843,20 +2241,6 @@ function buildAnnex() {
1843
2241
  annex.add(lightCool)
1844
2242
  annexLights.push({ light: lightCool, night: 1.8, day: 0.8 })
1845
2243
 
1846
- // 区域标签
1847
- const tag = (text, x, y, z) => {
1848
- const el = document.createElement('div')
1849
- el.className = 'officeTag'
1850
- el.textContent = text
1851
- const obj = new CSS2DObject(el)
1852
- obj.position.set(x, y, z)
1853
- annex.add(obj)
1854
- }
1855
- tag('讲台', 0, 1.9, -1.95)
1856
- tag('通用', -2.35, 1.45, 0.85)
1857
- tag('备考', 0, 2.0, 0.85)
1858
- tag('题库', 2.35, 1.27, 0.85)
1859
-
1860
2244
  // MCP 虚线 + 流光 + 暂未接通标牌
1861
2245
  mcpGroup = new THREE.Group()
1862
2246
  scene.add(mcpGroup)
@@ -1910,26 +2294,33 @@ function buildAnnex() {
1910
2294
  sctx.beginPath(); sctx.moveTo(cx - size, cy - size); sctx.lineTo(cx + size, cy + size); sctx.stroke()
1911
2295
  sctx.beginPath(); sctx.moveTo(cx + size, cy - size); sctx.lineTo(cx - size, cy + size); sctx.stroke()
1912
2296
  }
1913
- drawX(58, 70, 19)
1914
- drawX(454, 70, 19)
2297
+ drawX(74, 74, 12)
2298
+ drawX(438, 74, 12)
1915
2299
  sctx.fillStyle = '#d8efff'
1916
2300
  sctx.textAlign = 'center'
1917
- sctx.font = '700 58px ui-monospace, Menlo, monospace'
1918
- sctx.fillText('MCP', 256, 89)
1919
- sctx.fillStyle = 'rgba(120,190,255,.82)'
1920
- sctx.font = '500 22px ui-monospace, Menlo, monospace'
1921
- sctx.fillText('暂未接通', 256, 128)
2301
+ sctx.font = '700 30px ui-monospace, Menlo, monospace'
2302
+ sctx.fillText('MCP', 256, 86)
1922
2303
  const signTex = new THREE.CanvasTexture(signCanvas)
1923
2304
  signTex.colorSpace = THREE.SRGBColorSpace
1924
2305
  mcpSign = new THREE.Sprite(new THREE.SpriteMaterial({ map: signTex, transparent: true, depthWrite: false, toneMapped: false }))
1925
- mcpSign.position.set(5.6, 4.55, 1.18)
1926
- mcpSign.scale.set(3.35, 1.05, 1)
2306
+ mcpSign.position.set(5.6, 4.3, 1.18)
2307
+ mcpSign.scale.set(1.8, 0.45, 1)
2308
+ mcpSign.visible = false
1927
2309
  mcpGroup.add(mcpSign)
1928
2310
 
1929
2311
  const signGlow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTexture, color: 0x3f9dff, transparent: true, opacity: 0.24, blending: THREE.AdditiveBlending, depthWrite: false }))
1930
2312
  signGlow.position.copy(mcpSign.position)
1931
- signGlow.scale.set(4.4, 1.8, 1)
2313
+ signGlow.scale.set(2.5, 0.8, 1)
2314
+ signGlow.visible = false
1932
2315
  mcpGroup.add(signGlow)
2316
+
2317
+ // 小号 MCP 标签:固定屏幕字号,和小号说明文字一致
2318
+ const mcpTagEl = document.createElement('div')
2319
+ mcpTagEl.className = 'mcpTag'
2320
+ mcpTagEl.innerHTML = '<span class="x">✕</span><span>MCP</span><span class="x">✕</span>'
2321
+ const mcpTag = new CSS2DObject(mcpTagEl)
2322
+ mcpTag.position.set(5.55, 3.62, 1.02)
2323
+ mcpGroup.add(mcpTag)
1933
2324
  }
1934
2325
 
1935
2326
  function rebuild(list) {
@@ -2067,6 +2458,7 @@ function tick() {
2067
2458
  const t = clock.getElapsedTime()
2068
2459
  requestAnimationFrame(tick)
2069
2460
  applyThemeFrame()
2461
+ updateWeatherFrame(t)
2070
2462
  if (mcpCurve && mcpBeads.length > 0) {
2071
2463
  const flow = clock.getElapsedTime()
2072
2464
  for (const bead of mcpBeads) {
@@ -2084,9 +2476,40 @@ function tick() {
2084
2476
  dash.mesh.position.copy(point)
2085
2477
  dash.mesh.quaternion.setFromUnitVectors(axis, tangent.normalize())
2086
2478
  }
2087
- if (mcpSign) mcpSign.position.y = 4.55 + Math.sin(flow * 0.9) * 0.04
2088
- for (const icon of sciFiIcons) {
2089
- icon.position.y = icon.userData.baseY + Math.sin(flow * 2.2 + icon.userData.phase) * 0.04
2479
+ if (mcpSign) mcpSign.position.y = 4.3 + Math.sin(flow * 0.9) * 0.025
2480
+ if (studyClock) {
2481
+ const now = Date.now()
2482
+ if (now >= studyClock.endAt) {
2483
+ if (!studyClock.alarmAt) studyClock.alarmAt = now
2484
+ if (now - studyClock.alarmAt > 8000) {
2485
+ studyClock.endAt = now + 25 * 60 * 1000
2486
+ studyClock.alarmAt = 0
2487
+ }
2488
+ }
2489
+ const remainMs = Math.max(0, studyClock.endAt - now)
2490
+ const remainSec = Math.ceil(remainMs / 1000)
2491
+ if (remainSec !== studyClock.lastSecond) {
2492
+ studyClock.lastSecond = remainSec
2493
+ drawStudyClock(remainSec, remainMs === 0)
2494
+ }
2495
+ studyClock.sprite.position.y = 2.45 + Math.sin(flow * 1.1) * 0.03
2496
+ }
2497
+
2498
+ for (const cat of annexCats) {
2499
+ cat.sprite.position.y = cat.baseY + Math.sin(flow * 1.7 + cat.phase) * 0.065
2500
+ }
2501
+
2502
+ // 三个图标轮流展示:一次只亮一个,淡入淡出
2503
+ const iconCycle = 2.4
2504
+ const activeIcon = Math.floor(flow / iconCycle) % Math.max(1, sciFiIcons.length)
2505
+ for (let i = 0; i < sciFiIcons.length; i++) {
2506
+ const icon = sciFiIcons[i]
2507
+ const on = i === activeIcon
2508
+ icon.material.opacity += ((on ? 1 : 0) - icon.material.opacity) * 0.14
2509
+ const targetScale = icon.userData.baseScale * (on ? 1.12 : 0.86)
2510
+ icon.scale.x += (targetScale - icon.scale.x) * 0.14
2511
+ icon.scale.y += (targetScale - icon.scale.y) * 0.14
2512
+ icon.position.y = icon.userData.baseY + Math.sin(flow * 2.2 + icon.userData.phase) * 0.04 + (on ? 0.04 : 0)
2090
2513
  }
2091
2514
  }
2092
2515
 
@@ -2295,6 +2718,81 @@ async function refreshBalance() {
2295
2718
  }
2296
2719
  }
2297
2720
 
2721
+ function finishIntro() {
2722
+ if (introTl) {
2723
+ introTl.kill()
2724
+ introTl = null
2725
+ }
2726
+ for (const floor of floors) {
2727
+ floor.group.scale.set(1, 1, 1)
2728
+ floor.group.position.y = floor.offset.y
2729
+ }
2730
+ if (annex) {
2731
+ annex.visible = true
2732
+ annex.scale.setScalar(0.84)
2733
+ annex.position.y = 0
2734
+ }
2735
+ if (mcpGroup) {
2736
+ mcpGroup.visible = true
2737
+ mcpGroup.scale.set(1, 1, 1)
2738
+ }
2739
+ const pose = globalPose(floors.length)
2740
+ controls.target.copy(pose.target)
2741
+ camera.position.copy(pose.position)
2742
+ controls.update()
2743
+ document.body.classList.remove('intro')
2744
+ const intro = $('#intro')
2745
+ if (intro) {
2746
+ intro.classList.add('done')
2747
+ setTimeout(() => intro.remove(), 650)
2748
+ }
2749
+ introPlayed = true
2750
+ }
2751
+
2752
+ function playIntro() {
2753
+ if (introPlayed) return
2754
+ const intro = $('#intro')
2755
+ if (!intro) return
2756
+ introPlayed = true
2757
+ if (reduceMotion) {
2758
+ finishIntro()
2759
+ return
2760
+ }
2761
+ if (annex) {
2762
+ annex.visible = true
2763
+ annex.scale.setScalar(0.02)
2764
+ annex.position.y = -2.4
2765
+ }
2766
+ if (mcpGroup) {
2767
+ mcpGroup.visible = true
2768
+ mcpGroup.scale.setScalar(0.01)
2769
+ }
2770
+ for (const floor of floors) {
2771
+ floor.group.scale.set(0.02, 0.02, 0.02)
2772
+ floor.group.position.y = floor.offset.y - 5.5
2773
+ }
2774
+ const pose = globalPose(floors.length)
2775
+ camera.position.set(pose.position.x + 9, pose.position.y + 11, pose.position.z + 9)
2776
+ controls.target.copy(pose.target)
2777
+ controls.update()
2778
+
2779
+ introTl = gsap.timeline()
2780
+ introTl.to({ value: 0 }, { value: 1, duration: 2.4, ease: 'none' }, 0)
2781
+ introTl.to(camera.position, { x: pose.position.x, y: pose.position.y, z: pose.position.z, duration: 2.2, ease: 'power2.inOut' }, 0.15)
2782
+ introTl.to(controls.target, { x: pose.target.x, y: pose.target.y, z: pose.target.z, duration: 2.2, ease: 'power2.inOut' }, 0.15)
2783
+ floors.forEach((floor, i) => {
2784
+ introTl.to(floor.group.scale, { x: 1, y: 1, z: 1, duration: 0.85, ease: 'back.out(1.35)' }, 0.25 + i * 0.3)
2785
+ introTl.to(floor.group.position, { y: floor.offset.y, duration: 0.85, ease: 'power2.out' }, 0.25 + i * 0.3)
2786
+ })
2787
+ if (annex) {
2788
+ introTl.to(annex.scale, { x: 0.84, y: 0.84, z: 0.84, duration: 0.95, ease: 'back.out(1.25)' }, 1.05)
2789
+ introTl.to(annex.position, { y: 0, duration: 0.95, ease: 'power2.out' }, 1.05)
2790
+ }
2791
+ if (mcpGroup) introTl.to(mcpGroup.scale, { x: 1, y: 1, z: 1, duration: 0.8, ease: 'power2.out' }, 1.45)
2792
+ introTl.call(() => document.body.classList.remove('intro'), null, 2.25)
2793
+ introTl.to(intro, { opacity: 0, duration: 0.5, onComplete: () => intro.remove() }, 2.3)
2794
+ }
2795
+
2298
2796
  async function poll() {
2299
2797
  try {
2300
2798
  const response = await fetch(API, { cache: 'no-store' })
@@ -2312,6 +2810,7 @@ async function poll() {
2312
2810
  if (sig !== structureSig) {
2313
2811
  structureSig = sig
2314
2812
  rebuild(list)
2813
+ if (!introPlayed) playIntro()
2315
2814
  } else {
2316
2815
  for (const agent of list) {
2317
2816
  const entry = agentsById.get(agent.id)
@@ -2356,6 +2855,7 @@ window.__NS = {
2356
2855
  get focusFloor() { return focusFloor },
2357
2856
  setFloorFocus,
2358
2857
  toggleFloorFocus,
2858
+ setWeather,
2359
2859
  }
2360
2860
  rebuild([])
2361
2861
  $('#offline').classList.add('show')
@@ -2363,9 +2863,11 @@ $('#offline').textContent = '正在读取本机会话…'
2363
2863
  buildAnnex()
2364
2864
  applyGlassSetting()
2365
2865
  loadCustomFace()
2866
+ refreshWeather()
2366
2867
  poll()
2367
2868
  setInterval(poll, 2000)
2368
2869
  refreshBalance()
2369
2870
  setInterval(refreshBalance, 60_000)
2370
2871
  tick()
2371
2872
  setTimeout(() => $('#hint').classList.add('hide'), 9000)
2873
+ setTimeout(() => { if (!introPlayed) finishIntro() }, 9000)