dsh-agent-office 4.5.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)
@@ -1589,7 +1870,7 @@ function teamList(list) {
1589
1870
 
1590
1871
 
1591
1872
  /* ------------------------------------------------------------------ *
1592
- * 右侧 Annex:三张固定工位 + 讲台 + 小健身房 + 备考隔间
1873
+ * 右侧 Annex:教室布局(讲台 + 通用/备考/题库三席)
1593
1874
  * ------------------------------------------------------------------ */
1594
1875
  const ANNEX_X = 9.6
1595
1876
  const ANNEX_Z = 0.3
@@ -1602,6 +1883,69 @@ let mcpBeads = []
1602
1883
  let mcpDashes = []
1603
1884
  let mcpGroup = null
1604
1885
  let mcpSign = null
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
+ }
1605
1949
 
1606
1950
  function buildAnnex() {
1607
1951
  annex = new THREE.Group()
@@ -1646,11 +1990,18 @@ function buildAnnex() {
1646
1990
  box(0.06, 0.06, ANNEX_D, metal, ANNEX_W / 2 - 0.08, 2.38, 0)
1647
1991
  box(ANNEX_W - 1.0, 0.025, 0.02, cyan, 0, 2.31, -ANNEX_D / 2 + 0.16)
1648
1992
 
1649
- // 三张固定工位
1650
- function addDesk(x, z) {
1993
+ // 教室布局:讲台在后墙,三张工位面朝讲台
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
+ }
2001
+ function addDesk(x, z, matTop = wood) {
1651
2002
  const g = new THREE.Group()
1652
2003
  g.position.set(x, 0, z)
1653
- box(1.16, 0.055, 0.62, wood, 0, 0.72, 0, g)
2004
+ box(1.16, 0.055, 0.62, matTop, 0, 0.72, 0, g)
1654
2005
  for (const [lx, lz] of [[-0.5, -0.24], [0.5, -0.24], [-0.5, 0.24], [0.5, 0.24]]) {
1655
2006
  box(0.055, 0.7, 0.055, dark, lx, 0.36, lz, g)
1656
2007
  }
@@ -1658,9 +2009,8 @@ function buildAnnex() {
1658
2009
  const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.54, 0.3), cyan)
1659
2010
  screen.position.set(0, 0.95, -0.155)
1660
2011
  g.add(screen)
1661
- box(0.02, 0.06, 0.02, metal, 0, 0.73, 0.12, g)
1662
2012
  box(0.5, 0.055, 0.06, warm, 0, 0.76, 0.3, g)
1663
- // 椅子
2013
+ // 椅子(面向讲台 -z)
1664
2014
  box(0.42, 0.06, 0.4, dark, 0, 0.45, 0.55, g)
1665
2015
  box(0.42, 0.5, 0.06, dark, 0, 0.7, 0.75, g)
1666
2016
  const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.42, 8), metal)
@@ -1670,14 +2020,157 @@ function buildAnnex() {
1670
2020
  base.position.set(0, 0.02, 0.55)
1671
2021
  g.add(base)
1672
2022
  annex.add(g)
1673
- }
1674
- addDesk(-2.35, -1.55)
1675
- addDesk(0, -1.55)
1676
- addDesk(2.35, -1.55)
2023
+ addFrostedHeader(g)
2024
+ return g
2025
+ }
2026
+ addDesk(-2.35, 0.85)
2027
+
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)
2055
+
2056
+ // 3) 题库工位:科幻桌面 + 浮动 OCR / 数据库 / 试卷投影
2057
+ const sci = new THREE.Group()
2058
+ sci.position.set(2.35, 0, 0.85)
2059
+ box(1.16, 0.07, 0.62, metal, 0, 0.72, 0, sci)
2060
+ for (const [lx, lz] of [[-0.5, -0.24], [0.5, -0.24], [-0.5, 0.24], [0.5, 0.24]]) {
2061
+ box(0.05, 0.7, 0.05, dark, lx, 0.36, lz, sci)
2062
+ }
2063
+ // 全息桌屏
2064
+ const holoCanvas = document.createElement('canvas')
2065
+ holoCanvas.width = 256
2066
+ holoCanvas.height = 128
2067
+ const hctx = holoCanvas.getContext('2d')
2068
+ hctx.clearRect(0, 0, 256, 128)
2069
+ hctx.strokeStyle = 'rgba(110,220,255,.7)'
2070
+ hctx.lineWidth = 2
2071
+ for (let x = 0; x <= 256; x += 24) {
2072
+ hctx.beginPath(); hctx.moveTo(x, 0); hctx.lineTo(x, 128); hctx.stroke()
2073
+ }
2074
+ for (let y = 0; y <= 128; y += 24) {
2075
+ hctx.beginPath(); hctx.moveTo(0, y); hctx.lineTo(256, y); hctx.stroke()
2076
+ }
2077
+ hctx.fillStyle = 'rgba(120,230,255,.16)'
2078
+ hctx.fillRect(16, 22, 224, 20)
2079
+ hctx.fillRect(16, 62, 150, 10)
2080
+ hctx.fillRect(16, 86, 190, 10)
2081
+ const holoTexture = new THREE.CanvasTexture(holoCanvas)
2082
+ holoTexture.colorSpace = THREE.SRGBColorSpace
2083
+ const holo = new THREE.Mesh(new THREE.PlaneGeometry(0.86, 0.42), new THREE.MeshBasicMaterial({ map: holoTexture, transparent: true, opacity: 0.55, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }))
2084
+ holo.position.set(0, 1.08, -0.08)
2085
+ holo.rotation.x = -0.18
2086
+ sci.add(holo)
2087
+ box(0.5, 0.055, 0.06, cyan, 0, 0.76, 0.3, sci)
2088
+ // 椅子
2089
+ box(0.42, 0.06, 0.4, dark, 0, 0.45, 0.55, sci)
2090
+ box(0.42, 0.5, 0.06, dark, 0, 0.7, 0.75, sci)
2091
+ const sciStem = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.42, 8), metal)
2092
+ sciStem.position.set(0, 0.22, 0.55)
2093
+ sci.add(sciStem)
2094
+ const sciBase = new THREE.Mesh(new THREE.CylinderGeometry(0.23, 0.23, 0.03, 16), dark)
2095
+ sciBase.position.set(0, 0.02, 0.55)
2096
+ sci.add(sciBase)
2097
+ annex.add(sci)
2098
+ addFrostedHeader(sci)
2099
+ const halo = new THREE.Mesh(
2100
+ new THREE.RingGeometry(0.4, 0.44, 48),
2101
+ new THREE.MeshBasicMaterial({ color: 0x6fdcff, transparent: true, opacity: 0.35, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }),
2102
+ )
2103
+ halo.rotation.x = -Math.PI / 2
2104
+ halo.position.set(2.35, 1.58, 0.98)
2105
+ annex.add(halo)
2106
+
2107
+ // 浮动图标:OCR 相机 / 数据库 / 试卷(billboard,远看也清楚)
2108
+ const iconCanvas = (draw) => {
2109
+ const canvas = document.createElement('canvas')
2110
+ canvas.width = canvas.height = 160
2111
+ const ctx = canvas.getContext('2d')
2112
+ ctx.clearRect(0, 0, 160, 160)
2113
+ ctx.strokeStyle = '#9fe7ff'
2114
+ ctx.fillStyle = '#9fe7ff'
2115
+ ctx.lineWidth = 7
2116
+ ctx.lineCap = 'round'
2117
+ ctx.lineJoin = 'round'
2118
+ ctx.shadowColor = 'rgba(80,200,255,.9)'
2119
+ ctx.shadowBlur = 10
2120
+ draw(ctx)
2121
+ const texture = new THREE.CanvasTexture(canvas)
2122
+ texture.colorSpace = THREE.SRGBColorSpace
2123
+ return texture
2124
+ }
2125
+ const iconSprite = (texture, x, y, z, scale = 0.4) => {
2126
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false, toneMapped: false }))
2127
+ sprite.scale.set(scale, scale, 1)
2128
+ sprite.position.set(2.35 + x, y, 0.85 + z)
2129
+ sprite.material.opacity = 0
2130
+ annex.add(sprite)
2131
+ sprite.userData.baseY = y
2132
+ sprite.userData.baseScale = scale
2133
+ sprite.userData.phase = sciFiIcons.length * 0.9
2134
+ sciFiIcons.push(sprite)
2135
+ return sprite
2136
+ }
2137
+
2138
+ const ocrTexture = iconCanvas(ctx => {
2139
+ ctx.beginPath(); ctx.roundRect(32, 44, 78, 58, 10); ctx.stroke()
2140
+ ctx.beginPath(); ctx.arc(73, 73, 17, 0, Math.PI * 2); ctx.stroke()
2141
+ ctx.beginPath(); ctx.arc(73, 73, 6, 0, Math.PI * 2); ctx.fill()
2142
+ ctx.fillRect(42, 34, 22, 10)
2143
+ ctx.font = '700 15px ui-monospace, Menlo, monospace'
2144
+ ctx.textAlign = 'center'
2145
+ })
2146
+ const dbTexture = iconCanvas(ctx => {
2147
+ for (let i = 0; i < 3; i++) {
2148
+ const y = 48 + i * 24
2149
+ ctx.beginPath(); ctx.ellipse(80, y, 28, 9, 0, 0, Math.PI * 2); ctx.stroke()
2150
+ ctx.beginPath(); ctx.moveTo(52, y); ctx.lineTo(52, y + 22); ctx.stroke()
2151
+ ctx.beginPath(); ctx.moveTo(108, y); ctx.lineTo(108, y + 22); ctx.stroke()
2152
+ ctx.beginPath(); ctx.ellipse(80, y + 22, 28, 9, 0, 0, Math.PI * 2); ctx.stroke()
2153
+ }
2154
+ ctx.font = '700 15px ui-monospace, Menlo, monospace'
2155
+ ctx.textAlign = 'center'
2156
+ })
2157
+ const paperTexture = iconCanvas(ctx => {
2158
+ ctx.beginPath(); ctx.roundRect(46, 28, 68, 92, 5); ctx.stroke()
2159
+ ctx.beginPath(); ctx.moveTo(98, 28); ctx.lineTo(114, 44); ctx.lineTo(98, 44); ctx.closePath(); ctx.stroke()
2160
+ for (let i = 0; i < 5; i++) {
2161
+ ctx.beginPath(); ctx.moveTo(60, 54 + i * 13); ctx.lineTo(100, 54 + i * 13); ctx.stroke()
2162
+ }
2163
+ ctx.font = '700 15px ui-monospace, Menlo, monospace'
2164
+ ctx.textAlign = 'center'
2165
+ })
2166
+
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)
1677
2170
 
1678
- // 讲台
2171
+ // 讲台(教室最前面,背靠后墙,面向三张工位)
1679
2172
  const podium = new THREE.Group()
1680
- podium.position.set(0, 0, 1.55)
2173
+ podium.position.set(0, 0, -1.95)
1681
2174
  box(1.35, 0.14, 0.95, dark, 0, 0.07, 0, podium)
1682
2175
  const lectern = box(0.7, 0.98, 0.5, wood, 0, 0.56, 0, podium)
1683
2176
  lectern.rotation.x = -0.04
@@ -1694,49 +2187,49 @@ function buildAnnex() {
1694
2187
  micHead.position.set(0.24, 1.4, 0.02)
1695
2188
  podium.add(micHead)
1696
2189
  annex.add(podium)
1697
-
1698
- // 小健身房
1699
- const gym = new THREE.Group()
1700
- gym.position.set(2.45, 0, 1.5)
1701
- const belt = box(0.95, 0.14, 0.48, dark, 0, 0.16, 0, gym)
1702
- box(0.72, 0.025, 0.34, new THREE.MeshStandardMaterial({ color: 0x101820, roughness: 0.9 }), 0, 0.24, 0, gym)
1703
- for (const side of [-0.5, 0.5]) {
1704
- box(0.05, 0.62, 0.05, metal, side, 0.46, -0.17, gym)
1705
- box(0.05, 0.05, 0.5, metal, side, 0.76, -0.02, gym)
1706
- }
1707
- box(0.6, 0.05, 0.05, metal, 0, 0.72, -0.17, gym)
1708
- box(0.3, 0.2, 0.04, dark, 0, 0.83, -0.22, gym)
1709
- const mat = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 0.8), new THREE.MeshBasicMaterial({ color: 0x1c3b3d, transparent: true, opacity: 0.85 }))
1710
- mat.rotation.x = -Math.PI / 2
1711
- mat.position.set(-0.7, 0.018, 0.15)
1712
- gym.add(mat)
1713
- // 哑铃架
1714
- box(0.65, 0.24, 0.3, dark, -0.15, 0.12, -0.62, gym)
1715
- for (const [dx, w] of [[-0.2, 0.26], [0, 0.3], [0.2, 0.22]]) {
1716
- const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, w, 8), metal)
1717
- bar.rotation.z = Math.PI / 2
1718
- bar.position.set(-0.15 + dx, 0.3, -0.62)
1719
- gym.add(bar)
1720
- }
1721
- annex.add(gym)
1722
-
1723
- // 备考隔间
1724
- const study = new THREE.Group()
1725
- study.position.set(-2.55, 0, 1.5)
1726
- box(1.55, 1.78, 0.04, glass, 0, 0.9, -0.66, study)
1727
- box(0.04, 1.78, 1.28, glass, -0.76, 0.9, 0, study)
1728
- box(0.04, 1.78, 1.28, glass, 0.76, 0.9, 0, study)
1729
- box(1.55, 0.06, 0.06, metal, 0, 1.79, -0.66, study)
1730
- box(0.06, 0.06, 1.32, metal, -0.76, 1.79, 0, study)
1731
- box(0.06, 0.06, 1.32, metal, 0.76, 1.79, 0, study)
1732
- box(0.7, 0.05, 0.42, wood, 0, 0.7, -0.28, study)
1733
- box(0.7, 0.58, 0.04, dark, 0, 0.95, -0.49, study)
1734
- box(0.4, 0.05, 0.36, dark, 0, 0.43, 0.14, study)
1735
- box(0.4, 0.42, 0.05, dark, 0, 0.65, 0.32, study)
1736
- const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.05, 10, 10), warm)
1737
- lamp.position.set(0.48, 1.35, -0.3)
1738
- study.add(lamp)
1739
- annex.add(study)
2190
+ // 讲台后的黑板/示教板
2191
+ box(3.3, 1.25, 0.04, dark, 0, 1.55, -2.86)
2192
+ box(3.1, 0.03, 0.05, cyan, 0, 2.18, -2.83)
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)
1740
2233
 
1741
2234
  // 室内灯
1742
2235
  const lightWarm = new THREE.PointLight(0xffd8b0, 2.6, 11, 2)
@@ -1748,19 +2241,6 @@ function buildAnnex() {
1748
2241
  annex.add(lightCool)
1749
2242
  annexLights.push({ light: lightCool, night: 1.8, day: 0.8 })
1750
2243
 
1751
- // 区域标签
1752
- const tag = (text, x, y, z) => {
1753
- const el = document.createElement('div')
1754
- el.className = 'officeTag'
1755
- el.textContent = text
1756
- const obj = new CSS2DObject(el)
1757
- obj.position.set(x, y, z)
1758
- annex.add(obj)
1759
- }
1760
- tag('讲台', 0, 1.8, 1.55)
1761
- tag('健身', 2.45, 1.95, 1.5)
1762
- tag('备考', -2.55, 2.0, 1.5)
1763
-
1764
2244
  // MCP 虚线 + 流光 + 暂未接通标牌
1765
2245
  mcpGroup = new THREE.Group()
1766
2246
  scene.add(mcpGroup)
@@ -1814,26 +2294,33 @@ function buildAnnex() {
1814
2294
  sctx.beginPath(); sctx.moveTo(cx - size, cy - size); sctx.lineTo(cx + size, cy + size); sctx.stroke()
1815
2295
  sctx.beginPath(); sctx.moveTo(cx + size, cy - size); sctx.lineTo(cx - size, cy + size); sctx.stroke()
1816
2296
  }
1817
- drawX(58, 70, 19)
1818
- drawX(454, 70, 19)
2297
+ drawX(74, 74, 12)
2298
+ drawX(438, 74, 12)
1819
2299
  sctx.fillStyle = '#d8efff'
1820
2300
  sctx.textAlign = 'center'
1821
- sctx.font = '700 58px ui-monospace, Menlo, monospace'
1822
- sctx.fillText('MCP', 256, 89)
1823
- sctx.fillStyle = 'rgba(120,190,255,.82)'
1824
- sctx.font = '500 22px ui-monospace, Menlo, monospace'
1825
- sctx.fillText('暂未接通', 256, 128)
2301
+ sctx.font = '700 30px ui-monospace, Menlo, monospace'
2302
+ sctx.fillText('MCP', 256, 86)
1826
2303
  const signTex = new THREE.CanvasTexture(signCanvas)
1827
2304
  signTex.colorSpace = THREE.SRGBColorSpace
1828
2305
  mcpSign = new THREE.Sprite(new THREE.SpriteMaterial({ map: signTex, transparent: true, depthWrite: false, toneMapped: false }))
1829
- mcpSign.position.set(5.6, 4.55, 1.18)
1830
- 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
1831
2309
  mcpGroup.add(mcpSign)
1832
2310
 
1833
2311
  const signGlow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTexture, color: 0x3f9dff, transparent: true, opacity: 0.24, blending: THREE.AdditiveBlending, depthWrite: false }))
1834
2312
  signGlow.position.copy(mcpSign.position)
1835
- signGlow.scale.set(4.4, 1.8, 1)
2313
+ signGlow.scale.set(2.5, 0.8, 1)
2314
+ signGlow.visible = false
1836
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)
1837
2324
  }
1838
2325
 
1839
2326
  function rebuild(list) {
@@ -1971,6 +2458,7 @@ function tick() {
1971
2458
  const t = clock.getElapsedTime()
1972
2459
  requestAnimationFrame(tick)
1973
2460
  applyThemeFrame()
2461
+ updateWeatherFrame(t)
1974
2462
  if (mcpCurve && mcpBeads.length > 0) {
1975
2463
  const flow = clock.getElapsedTime()
1976
2464
  for (const bead of mcpBeads) {
@@ -1988,7 +2476,41 @@ function tick() {
1988
2476
  dash.mesh.position.copy(point)
1989
2477
  dash.mesh.quaternion.setFromUnitVectors(axis, tangent.normalize())
1990
2478
  }
1991
- if (mcpSign) mcpSign.position.y = 4.55 + Math.sin(flow * 0.9) * 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)
2513
+ }
1992
2514
  }
1993
2515
 
1994
2516
  const focusedTeam = focusFloor === null ? null : floors.find(floor => floor.index === focusFloor)?.team
@@ -2196,6 +2718,81 @@ async function refreshBalance() {
2196
2718
  }
2197
2719
  }
2198
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
+
2199
2796
  async function poll() {
2200
2797
  try {
2201
2798
  const response = await fetch(API, { cache: 'no-store' })
@@ -2213,6 +2810,7 @@ async function poll() {
2213
2810
  if (sig !== structureSig) {
2214
2811
  structureSig = sig
2215
2812
  rebuild(list)
2813
+ if (!introPlayed) playIntro()
2216
2814
  } else {
2217
2815
  for (const agent of list) {
2218
2816
  const entry = agentsById.get(agent.id)
@@ -2257,6 +2855,7 @@ window.__NS = {
2257
2855
  get focusFloor() { return focusFloor },
2258
2856
  setFloorFocus,
2259
2857
  toggleFloorFocus,
2858
+ setWeather,
2260
2859
  }
2261
2860
  rebuild([])
2262
2861
  $('#offline').classList.add('show')
@@ -2264,9 +2863,11 @@ $('#offline').textContent = '正在读取本机会话…'
2264
2863
  buildAnnex()
2265
2864
  applyGlassSetting()
2266
2865
  loadCustomFace()
2866
+ refreshWeather()
2267
2867
  poll()
2268
2868
  setInterval(poll, 2000)
2269
2869
  refreshBalance()
2270
2870
  setInterval(refreshBalance, 60_000)
2271
2871
  tick()
2272
2872
  setTimeout(() => $('#hint').classList.add('hide'), 9000)
2873
+ setTimeout(() => { if (!introPlayed) finishIntro() }, 9000)