bep40 commited on
Commit
4e33af5
·
verified ·
1 Parent(s): c039cce

Upload frontend/cave3d.js

Browse files
Files changed (1) hide show
  1. frontend/cave3d.js +677 -31
frontend/cave3d.js CHANGED
@@ -1,7 +1,8 @@
1
  /* =========================================================================
2
  CaveScene — hang 3D đầy đủ như hangmu.online gốc:
3
  vòm + sàn bậc + hố lửa + nước + đuốc + màn hình lớn (board) + vòng huyền thoại
4
- + hội trường đá (benches) + lối vào
 
5
  ========================================================================= */
6
  import * as THREE from 'three'
7
  import React, { useMemo, useRef, useLayoutEffect, useState, useEffect } from 'react'
@@ -335,7 +336,7 @@ function TorchLight({ seed }) {
335
  }
336
 
337
  /* =========================================================================
338
- LỐI VÀO (south +z) — arch + tunnel + exit light
339
  ========================================================================= */
340
  function Entrance() {
341
  const archGeo = useMemo(() => {
@@ -360,10 +361,16 @@ function Entrance() {
360
  <mesh geometry={tunnelGeo} position={[0, 2.6, 9]}>
361
  <meshLambertMaterial vertexColors side={THREE.DoubleSide} />
362
  </mesh>
363
- <mesh position={[0, 3, 17.5]}>
364
- <planeGeometry args={[14, 10]} />
365
- <meshBasicMaterial color="#f4e7cf" side={THREE.DoubleSide} fog={false} />
 
366
  </mesh>
 
 
 
 
 
367
  <pointLight position={[0, 5, 1]} color="#ffe2b8" intensity={30} distance={22} decay={1.6} />
368
  </group>
369
  )
@@ -371,7 +378,6 @@ function Entrance() {
371
 
372
  /* =========================================================================
373
  MÀN HÌNH LỚN (board) — plane + texture "BẢNG TIN HANG MU" + khung gỗ
374
- (video thật qua CSS3D projection — phần app.js)
375
  ========================================================================= */
376
  export const BOARD = { w: 24, h: 14, y: 7.3, z: -47.5 }
377
  function Board() {
@@ -428,7 +434,7 @@ function Board() {
428
  }
429
 
430
  /* =========================================================================
431
- VÒNG HUYỀN THOẠI — 8 ghế đá quanh hố lửa, mỗi ghế 1 người (leaderboard top)
432
  ========================================================================= */
433
  const RING_SLOTS = 8
434
  const RING_R = 8.5
@@ -448,7 +454,6 @@ function LegendRing() {
448
  list.push({ name: row.name, number: row.number, wins: row.wins, game: g, rank: 1 })
449
  }
450
  }
451
- // nếu chưa đủ 8 → thêm người đang trong hang (tránh đứng top)
452
  for (const p of players.values()) {
453
  if (list.length >= RING_SLOTS) break
454
  if (seen.has(p.name)) continue
@@ -465,7 +470,7 @@ function LegendRing() {
465
  const ang = (i / RING_SLOTS) * Math.PI * 2
466
  const bob = Math.sin(t * 1.6 + i * 1.1) * 0.03
467
  child.position.set(Math.sin(ang) * RING_R, 0.25 + bob, Math.cos(ang) * RING_R)
468
- child.rotation.y = -ang + Math.PI / 2
469
  })
470
  })
471
  return (
@@ -561,6 +566,656 @@ function Benches() {
561
  </group>
562
  )
563
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
 
565
  /* =========================================================================
566
  TỔNG HỢP CaveScene
@@ -579,31 +1234,22 @@ export function CaveScene() {
579
  <Board />
580
  <LegendRing />
581
  <Benches />
 
 
 
 
 
 
 
 
 
 
582
  <Entrance />
 
583
  <hemisphereLight args={[0xe8c090, 0x7a6152, 1.5]} />
584
  <ambientLight intensity={0.7} color={0xf4cba0} />
585
  </>
586
  )
587
  }
588
- function BenchRings() {
589
- const items = useMemo(() => {
590
- const out = []
591
- for (let t = 1; t <= CAVE.tiers; t++) {
592
- const r = CAVE.pitRadius + (t - 1) * CAVE.tierWidth + 1.25
593
- const y = -CAVE.pitDepth + (t - 1) * tierH + 0.23
594
- out.push({ r, y, t })
595
- }
596
- return out
597
- }, [])
598
- return (
599
- <group>
600
- {items.map((it, i) => (
601
- <mesh key={i} position={[0, it.y, 0]} rotation={[-Math.PI / 2, 0, 0]}>
602
- <ringGeometry args={[it.r - 1.3, it.r + 1.2, 72]} />
603
- <meshLambertMaterial color="#1c1612" roughness={0.6} metalness={0.5} side={THREE.DoubleSide} />
604
- </mesh>
605
- ))}
606
- </group>
607
- )
608
- }
609
- export { floorHeight, domeHeight }
 
1
  /* =========================================================================
2
  CaveScene — hang 3D đầy đủ như hangmu.online gốc:
3
  vòm + sàn bậc + hố lửa + nước + đuốc + màn hình lớn (board) + vòng huyền thoại
4
+ + hội trường đá (benches) + lối vào + sân ngoài + tường huyền thoại
5
+ + bảng EPL + ngày này năm xưa + cúp + banner + nộm + sân chơi
6
  ========================================================================= */
7
  import * as THREE from 'three'
8
  import React, { useMemo, useRef, useLayoutEffect, useState, useEffect } from 'react'
 
336
  }
337
 
338
  /* =========================================================================
339
+ LỐI VÀO (south +z) — arch + tunnel + exit light + vành sáng + vòm đá
340
  ========================================================================= */
341
  function Entrance() {
342
  const archGeo = useMemo(() => {
 
361
  <mesh geometry={tunnelGeo} position={[0, 2.6, 9]}>
362
  <meshLambertMaterial vertexColors side={THREE.DoubleSide} />
363
  </mesh>
364
+ {/* vành sáng + vòm đá quanh cửa hang (thay tấm chặn mờ) */}
365
+ <mesh position={[0, 3.2, 17.6]}>
366
+ <ringGeometry args={[6.3, 7.5, 30]} />
367
+ <meshLambertMaterial color="#6b5540" side={THREE.DoubleSide} fog={false} />
368
  </mesh>
369
+ <mesh position={[0, 3.3, 17.3]}>
370
+ <torusGeometry args={[6.9, 0.72, 8, 26, Math.PI]} />
371
+ <meshLambertMaterial color="#8a6f55" flatShading side={THREE.DoubleSide} fog={false} />
372
+ </mesh>
373
+ <pointLight position={[0, 10, 15.5]} color="#ffe9c8" intensity={45} distance={30} decay={1.2} fog={false} />
374
  <pointLight position={[0, 5, 1]} color="#ffe2b8" intensity={30} distance={22} decay={1.6} />
375
  </group>
376
  )
 
378
 
379
  /* =========================================================================
380
  MÀN HÌNH LỚN (board) — plane + texture "BẢNG TIN HANG MU" + khung gỗ
 
381
  ========================================================================= */
382
  export const BOARD = { w: 24, h: 14, y: 7.3, z: -47.5 }
383
  function Board() {
 
434
  }
435
 
436
  /* =========================================================================
437
+ VÒNG HUYỀN THOẠI — 8 ghế đá quanh hố lửa (leaderboard top)
438
  ========================================================================= */
439
  const RING_SLOTS = 8
440
  const RING_R = 8.5
 
454
  list.push({ name: row.name, number: row.number, wins: row.wins, game: g, rank: 1 })
455
  }
456
  }
 
457
  for (const p of players.values()) {
458
  if (list.length >= RING_SLOTS) break
459
  if (seen.has(p.name)) continue
 
470
  const ang = (i / RING_SLOTS) * Math.PI * 2
471
  const bob = Math.sin(t * 1.6 + i * 1.1) * 0.03
472
  child.position.set(Math.sin(ang) * RING_R, 0.25 + bob, Math.cos(ang) * RING_R)
473
+ child.rotation.y = -ang + Math.PI
474
  })
475
  })
476
  return (
 
566
  </group>
567
  )
568
  }
569
+ function BenchRings() {
570
+ const items = useMemo(() => {
571
+ const out = []
572
+ for (let t = 1; t <= CAVE.tiers; t++) {
573
+ const r = CAVE.pitRadius + (t - 1) * CAVE.tierWidth + 1.25
574
+ const y = -CAVE.pitDepth + (t - 1) * tierH + 0.23
575
+ out.push({ r, y, t })
576
+ }
577
+ return out
578
+ }, [])
579
+ return (
580
+ <group>
581
+ {items.map((it, i) => (
582
+ <mesh key={i} position={[0, it.y, 0]} rotation={[-Math.PI / 2, 0, 0]}>
583
+ <ringGeometry args={[it.r - 1.3, it.r + 1.2, 72]} />
584
+ <meshLambertMaterial color="#1c1612" roughness={0.6} metalness={0.5} side={THREE.DoubleSide} />
585
+ </mesh>
586
+ ))}
587
+ </group>
588
+ )
589
+ }
590
+
591
+ /* =========================================================================
592
+ BẢNG XẾP HẠNG NGOẠI HẠNG ANH (EPL) — texture như gốc
593
+ ========================================================================= */
594
+ const EPL_TEAMS = [
595
+ ['Arsenal', 38, 28, 5, 5, 91, 29, 89, 'ucl'], ['Man City', 38, 28, 7, 3, 96, 34, 91, 'ucl'],
596
+ ['Liverpool', 38, 24, 10, 4, 86, 41, 82, 'ucl'], ['Aston Villa', 38, 20, 8, 10, 76, 61, 68, 'ucl'],
597
+ ['Tottenham', 38, 20, 6, 12, 74, 61, 66, 'europe'], ['Chelsea', 38, 18, 9, 11, 77, 63, 63, 'europe'],
598
+ ['Newcastle', 38, 18, 6, 14, 85, 62, 60, 'europe'], ['Man United', 38, 18, 6, 14, 57, 58, 60, 'europe'],
599
+ ['West Ham', 38, 14, 10, 14, 60, 74, 52, ''], ['Crystal Palace', 38, 13, 10, 15, 57, 58, 49, ''],
600
+ ['Brighton', 38, 12, 12, 14, 55, 62, 48, ''], ['Bournemouth', 38, 13, 9, 16, 54, 67, 48, ''],
601
+ ['Fulham', 38, 13, 8, 17, 55, 61, 47, ''], ['Wolves', 38, 13, 7, 18, 50, 65, 46, ''],
602
+ ['Everton', 38, 13, 9, 16, 40, 51, 40, ''], ['Brentford', 38, 10, 9, 19, 56, 65, 39, ''],
603
+ ['Nottm Forest', 38, 9, 9, 20, 49, 67, 32, ''], ['Luton', 38, 6, 8, 24, 52, 85, 26, 'relegation'],
604
+ ['Burnley', 38, 5, 9, 24, 41, 78, 24, 'relegation'], ['Sheffield Utd', 38, 3, 7, 28, 35, 104, 16, 'relegation'],
605
+ ]
606
+ function makeEplTex() {
607
+ const c = document.createElement('canvas'); c.width = 1400; c.height = 1560
608
+ const g = c.getContext('2d')
609
+ const gr = g.createLinearGradient(0, 0, 0, 1560)
610
+ gr.addColorStop(0, '#5c0e1b'); gr.addColorStop(0.5, '#3a0812'); gr.addColorStop(1, '#1b1410')
611
+ g.fillStyle = gr; g.fillRect(0, 0, 1400, 1560)
612
+ g.strokeStyle = 'rgba(255,240,220,0.12)'; g.lineWidth = 14; g.strokeRect(7, 7, 1386, 1546)
613
+ g.textAlign = 'center'; g.fillStyle = '#8a1a2a'
614
+ g.font = '900 96px Bitter, Georgia, serif'; g.fillText('NGOẠI HẠNG ANH', 700, 130)
615
+ g.fillStyle = 'rgba(255,240,220,0.7)'; g.font = '600 42px "Be Vietnam Pro", sans-serif'
616
+ g.fillText('Bảng xếp hạng mùa giải', 700, 195)
617
+ const cols = [700, 1070, 1180, 1280]
618
+ g.fillStyle = 'rgba(201,162,78,0.85)'; g.font = '600 32px "Be Vietnam Pro", sans-serif'
619
+ ;['Đội', 'T', 'H', 'Đ'].forEach((t, i) => g.fillText(t, cols[i], 250))
620
+ for (let i = 0; i < EPL_TEAMS.length; i++) {
621
+ const t = EPL_TEAMS[i]
622
+ const y = 300 + i * 58
623
+ g.fillStyle = i % 2 ? 'rgba(255,240,220,0.045)' : 'rgba(255,240,220,0.0)'
624
+ g.fillRect(20, y - 22, 1360, 56)
625
+ if (i === 7) { g.fillStyle = 'rgba(142,11,32,0.5)'; g.fillRect(20, y - 22, 1360, 56) }
626
+ g.textAlign = 'left'; g.fillStyle = i === 7 ? '#ffd76a' : (t[7] ? '#8e0b20' : 'rgba(255,240,220,0.85)')
627
+ g.font = i === 7 ? '800 42px Bitter' : '600 42px "Be Vietnam Pro", sans-serif'
628
+ g.fillText(t[0], 60, y + 8)
629
+ g.textAlign = 'center'
630
+ g.fillStyle = 'rgba(255,240,220,0.7)'
631
+ ;[t[1], t[5] - t[6], t[6]].forEach((v, k) => { g.fillText(String(v), cols[k + 1], y + 8) })
632
+ g.fillStyle = i === 7 ? '#ffd76a' : 'rgba(255,240,220,0.95)'
633
+ g.font = '800 44px Bitter'; g.fillText(String(t[7]), 1310, y + 8)
634
+ }
635
+ g.textAlign = 'right'; g.fillStyle = 'rgba(255,240,220,0.4)'; g.font = '500 28px "Be Vietnam Pro", sans-serif'
636
+ g.fillText('Dự Cúp C1 · Cúp châu Âu · Xuống hạng · Đ = Điểm', 1360, 1540)
637
+ const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 16
638
+ return tx
639
+ }
640
+ function EplBoard() {
641
+ const [tex, setTex] = React.useState(null)
642
+ React.useEffect(() => {
643
+ let alive = true
644
+ const t = makeEplTex()
645
+ if (alive && t) setTex(t)
646
+ return () => { alive = false }
647
+ }, [])
648
+ if (!tex) return null
649
+ return (
650
+ <group position={[-18.5, 6.5, -44]} rotation={[0, 0.52, 0]}>
651
+ <mesh position={[0, 0, -0.08]}>
652
+ <boxGeometry args={[8.4, 9.4, 0.28]} />
653
+ <meshLambertMaterial color="#3d2a1a" flatShading />
654
+ </mesh>
655
+ <mesh position={[0, 0, 0.1]}>
656
+ <planeGeometry args={[8.0, 9.0]} />
657
+ <meshBasicMaterial map={tex} transparent toneMapped={false} fog={false} />
658
+ </mesh>
659
+ </group>
660
+ )
661
+ }
662
+
663
+ /* =========================================================================
664
+ NGÀY NÀY NĂM XƯA — bảng lịch sử, đổi theo ngày thật
665
+ ========================================================================= */
666
+ const ON_THIS_DAY = [
667
+ { m: 1, d: 21, y: 2017, title: 'Rooney thành vua phá lưới CLB', text: 'Cú sút phạt ở phút bù giờ trước Stoke là bàn thứ 250 — vượt kỷ lục của Sir Bobby Charlton.' },
668
+ { m: 2, d: 5, y: 1985, title: 'Cristiano Ronaldo chào đời', text: 'Sinh ở Madeira. 2008: Quả bóng Vàng đầu tiên trong màu áo Quỷ Đỏ.' },
669
+ { m: 2, d: 6, y: 1958, title: 'Thảm hoạ Munich', text: 'Chuyến bay chở đội bóng gặp nạn trên đường về từ Belgrade — 8 cầu thủ "Busby Babes" ra đi. Mãi không quên.' },
670
+ { m: 2, d: 19, y: 1910, title: 'Trận đầu tiên ở Old Trafford', text: 'Sân mới khai trương với trận gặp Liverpool. "Nhà hát của những giấc mơ" bắt đầu từ đây.' },
671
+ { m: 2, d: 19, y: 1958, title: 'Trận đầu sau Munich', text: '13 ngày sau thảm hoạ, MU thắng Sheffield Wednesday 3–0 ở FA Cup trong nước mắt cả Old Trafford.' },
672
+ { m: 2, d: 21, y: 1958, title: 'Duncan Edwards ra đi', text: 'Viên ngọc của "Busby Babes" qua đời sau 15 ngày chiến đấu với vết thương từ Munich, mới 21 tuổi.' },
673
+ { m: 2, d: 24, y: 1940, title: 'Denis Law chào đời', text: '"The King" của Stretford End, Quả bóng Vàng 1964.' },
674
+ { m: 2, d: 26, y: 1973, title: 'Ole Gunnar Solskjær chào đời', text: 'Người ghi bàn thắng định mệnh ở Camp Nou 1999.' },
675
+ { m: 3, d: 2, y: 1991, title: 'Ryan Giggs ra mắt', text: 'Trận đầu tiên của cầu thủ sau này khoác áo MU nhiều trận nhất lịch sử.' },
676
+ { m: 3, d: 4, y: 1995, title: 'Thắng Ipswich 9–0', text: 'Chiến thắng đậm nhất lịch sử Premier League lúc đó — Andy Cole ghi 5 bàn.' },
677
+ { m: 4, d: 14, y: 1999, title: 'Siêu phẩm của Giggs', text: 'Bán kết lại FA Cup với Arsenal: Giggs solo qua cả hàng thủ ở hiệp phụ, cởi áo ăn mừng.' },
678
+ { m: 4, d: 21, y: 1999, title: 'Ngược dòng ở Turin', text: 'Bị Juventus dẫn 2–0, Keane khơi mào, MU thắng 3–2 vào chung kết Champions League.' },
679
+ { m: 4, d: 22, y: 2013, title: 'Chức vô địch thứ 20', text: 'Van Persie lập hat-trick vào lưới Aston Villa, MU vô địch Ngoại hạng Anh lần thứ 20.' },
680
+ { m: 5, d: 8, y: 2013, title: 'Sir Alex chia tay', text: 'Sau hơn 26 năm và 38 danh hiệu, Sir Alex Ferguson thông báo nghỉ hưu.' },
681
+ { m: 5, d: 11, y: 1996, title: 'FA Cup 1996 — cú đúp thứ hai', text: 'Cantona ghi bàn duy nhất vào lưới Liverpool, MU giành cú đúp quốc nội lần thứ hai.' },
682
+ { m: 5, d: 14, y: 1999, title: 'Cúp C2 châu Âu', text: 'Thắng Barcelona 2–1 ở Rotterdam, Mark Hughes lập cú đúp.' },
683
+ { m: 5, d: 16, y: 1999, title: 'Mảnh ghép thứ nhất của cú ăn ba', text: 'Thắng Tottenham 2–1 ngày cuối mùa, giành chức vô địch Premier League.' },
684
+ { m: 5, d: 17, y: 1990, title: 'Danh hiệu đầu tiên của Sir Alex', text: 'Đá lại chung kết FA Cup, Lee Martin ghi bàn duy nhất vào lưới Crystal Palace.' },
685
+ { m: 5, d: 21, y: 2008, title: 'Vô địch châu Âu ở Moscow', text: 'Hoà Chelsea 1–1 dưới mưa, Van der Sar cản phạt Anelka — MU lần thứ ba vô địch châu Âu.' },
686
+ { m: 5, d: 22, y: 1999, title: 'Mảnh ghép thứ hai của cú ăn ba', text: 'Thắng Newcastle 2–0 ở chung kết FA Cup — Sheringham và Scholes ghi bàn.' },
687
+ { m: 5, d: 24, y: 2017, title: 'Vô địch Europa League', text: 'Thắng Ajax 2–0 ở Stockholm — danh hiệu châu Âu duy nhất còn thiếu trong tủ.' },
688
+ { m: 5, d: 25, y: 2024, title: 'FA Cup 2024', text: 'Thắng Man City 2–1 ở chung kết — Garnacho và Mainoo ghi bàn.' },
689
+ { m: 5, d: 26, y: 1999, title: 'Cú ăn ba — Camp Nou', text: 'Bị Bayern dẫn tới phút 90, Sheringham rồi Solskjær ghi bàn bù giờ — MU vô địch châu Âu, đúng sinh nhật Sir Matt.' },
690
+ { m: 5, d: 29, y: 1968, title: 'Lần đầu vô địch châu Âu', text: 'Thắng Benfica 4–1 sau hiệp phụ ở Wembley — 10 năm sau Munich. Charlton lập cú đúp.' },
691
+ { m: 8, d: 17, y: 1996, title: 'Beckham ghi bàn từ giữa sân', text: 'Cú sút từ vạch giữa sân vào lưới Wimbledon khiến cả nước Anh biết tên số 10 trẻ tuổi.' },
692
+ { m: 8, d: 28, y: 2011, title: 'Thắng Arsenal 8–2', text: 'Rooney lập hat-trick ở Old Trafford trong một chiều không thể quên.' },
693
+ { m: 9, d: 11, y: 2021, title: 'Ronaldo trở về', text: 'Lần thứ hai ra mắt ở Old Trafford, lập cú đúp vào lưới Newcastle.' },
694
+ { m: 10, d: 24, y: 1985, title: 'Wayne Rooney chào đời', text: 'Chân sút vĩ đại nhất lịch sử CLB với 253 bàn thắng.' },
695
+ { m: 11, d: 6, y: 1986, title: 'Sir Alex Ferguson nhậm chức', text: 'Ngày một người Scotland tới Old Trafford — mở ra 26 năm và 38 danh hiệu.' },
696
+ { m: 12, d: 2, y: 2008, title: 'Ronaldo đoạt Quả bóng Vàng', text: 'Cầu thủ MU đầu tiên giành Quả bóng Vàng sau George Best 1968.' },
697
+ { m: 12, d: 31, y: 1941, title: 'Sir Alex Ferguson chào đời', text: 'HLV vĩ đại nhất lịch sử bóng đá Anh, sinh ở Govan, Glasgow.' },
698
+ ]
699
+ function OnThisDayBoard() {
700
+ const [tex, setTex] = React.useState(null)
701
+ React.useEffect(() => {
702
+ let alive = true
703
+ const c = document.createElement('canvas'); c.width = 900; c.height = 1000
704
+ const g = c.getContext('2d')
705
+ const gr = g.createLinearGradient(0, 0, 0, 1000)
706
+ gr.addColorStop(0, '#4f0c18'); gr.addColorStop(0.6, '#2a060d'); gr.addColorStop(1, '#180307')
707
+ g.fillStyle = gr; g.fillRect(0, 0, 900, 1000)
708
+ g.strokeStyle = 'rgba(201,162,78,0.8)'; g.lineWidth = 8; g.strokeRect(8, 8, 884, 984)
709
+ g.textAlign = 'center'; g.fillStyle = '#c9a24e'; g.font = '800 44px Bitter, Georgia, serif'
710
+ g.fillText('NGÀY NÀY NĂM XƯA', 450, 80)
711
+ const now = new Date()
712
+ const today = ON_THIS_DAY.filter((e) => e.m === now.getMonth() + 1 && e.d === now.getDate())
713
+ g.fillStyle = 'rgba(255,240,220,0.5)'; g.font = '600 28px "Be Vietnam Pro", sans-serif'
714
+ g.fillText(today.length ? '' + today.length + ' mốc son hôm nay' : 'Hôm nay chưa có dấu mốc nào — hãy tạo nên lịch sử!', 450, 125)
715
+ if (today.length) {
716
+ today.forEach((e, i) => {
717
+ const y = 210 + i * 210
718
+ g.fillStyle = 'rgba(142,11,32,0.45)'; g.fillRect(60, y - 60, 780, 170)
719
+ g.strokeStyle = 'rgba(201,162,78,0.6)'; g.lineWidth = 3; g.strokeRect(60, y - 60, 780, 170)
720
+ g.textAlign = 'left'; g.fillStyle = '#ffd76a'; g.font = '700 38px "Be Vietnam Pro", sans-serif'
721
+ g.fillText(e.d + ' THÁNG ' + e.m + ' · ' + e.y, 90, y - 22)
722
+ g.fillStyle = '#fff2c8'; g.font = '800 52px Bitter, Georgia, serif'
723
+ g.fillText(e.title.slice(0, 40), 90, y + 25)
724
+ g.fillStyle = 'rgba(255,240,220,0.8)'; g.font = '500 26px "Be Vietnam Pro", sans-serif'
725
+ g.fillText(e.text.length > 62 ? e.text.slice(0, 60) + '…' : e.text, 90, y + 78, 720)
726
+ })
727
+ }
728
+ const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 16
729
+ if (alive) setTex(tx)
730
+ return () => { alive = false }
731
+ }, [])
732
+ if (!tex) return null
733
+ return (
734
+ <group position={[18.5, 6.5, -44]} rotation={[0, -0.5, 0]}>
735
+ <mesh position={[0, 0, -0.08]}>
736
+ <boxGeometry args={[6.6, 7.3, 0.28]} />
737
+ <meshLambertMaterial color="#3d2a1a" flatShading />
738
+ </mesh>
739
+ <mesh position={[0, 0, 0.1]}>
740
+ <planeGeometry args={[6.2, 6.9]} />
741
+ <meshBasicMaterial map={tex} transparent toneMapped={false} fog={false} />
742
+ </mesh>
743
+ </group>
744
+ )
745
+ }
746
+
747
+ /* =========================================================================
748
+ TƯỜNG HUYỀN THOẠI — 3 bảng lớn quanh vòm + 10 chân dung
749
+ ========================================================================= */
750
+ const LEGEND_PANELS = [
751
+ { kicker: 'DI TÍCH LỊCH SỬ', title: 'Hang MU', sub: 'Nơi anh em fan Quỷ Đỏ tụ họp' },
752
+ { kicker: 'MANCHESTER UNITED', title: 'Từ 1878', sub: 'Glory Glory Man United' },
753
+ { kicker: 'TƯỜNG HUYỀN THOẠI', title: 'Thế hệ vàng', sub: 'Mười huyền thoại khoác áo đỏ' },
754
+ ]
755
+ function drawLegendPanel(t) {
756
+ const c = document.createElement('canvas'); c.width = 1024; c.height = 512
757
+ const g = c.getContext('2d')
758
+ const r = g.createRadialGradient(307, 0, 40, 410, 205, 820)
759
+ r.addColorStop(0, '#96202f'); r.addColorStop(0.5, '#7a1424'); r.addColorStop(1, '#4f0c18')
760
+ g.fillStyle = r; g.fillRect(0, 0, 1024, 512)
761
+ g.strokeStyle = '#c9a24e'; g.lineWidth = 22; g.strokeRect(11, 11, 1002, 490)
762
+ g.strokeStyle = 'rgba(236,208,140,0.75)'; g.lineWidth = 4; g.strokeRect(40, 40, 944, 432)
763
+ for (const [x, y] of [[62, 62], [962, 62], [62, 450], [962, 450]]) {
764
+ const rr = g.createRadialGradient(x - 3, y - 3, 1, x, y, 11)
765
+ rr.addColorStop(0, '#f6e2a6'); rr.addColorStop(0.6, '#c9a24e'); rr.addColorStop(1, '#5e4719')
766
+ g.fillStyle = rr; g.beginPath(); g.arc(x, y, 11, 0, Math.PI * 2); g.fill()
767
+ }
768
+ g.textAlign = 'center'; g.textBaseline = 'middle'; g.shadowColor = 'rgba(0,0,0,0.6)'; g.shadowOffsetY = 4; g.shadowBlur = 2
769
+ g.fillStyle = '#c9a24e'; g.font = '800 46px Bitter, Georgia, serif'
770
+ if ('letterSpacing' in g) { g.letterSpacing = '10px' }
771
+ g.fillText(t.kicker, 512, 118, 864)
772
+ if ('letterSpacing' in g) { g.letterSpacing = '0px' }
773
+ g.fillStyle = '#ecd08c'; g.font = '900 150px Bitter, Georgia, serif'
774
+ g.fillText(t.title, 512, 250, 864)
775
+ g.shadowColor = 'transparent'
776
+ g.fillStyle = '#c9a24e'; g.fillRect(150, 342, 724, 4)
777
+ g.fillStyle = '#efe9dd'; g.font = '600 48px "Be Vietnam Pro", system-ui, sans-serif'
778
+ g.fillText(t.sub, 512, 410, 864)
779
+ const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 16
780
+ return tx
781
+ }
782
+ const LEGEND_SLOTS = [
783
+ [-22.5, 11.4, -40.5, 0.52], [-13.4, 8.6, -45.6, 0.9], [12.8, 9.8, -46.5, 1.9],
784
+ ]
785
+ function LegendWall() {
786
+ const [texs, setTexs] = React.useState(null)
787
+ React.useEffect(() => {
788
+ let alive = true
789
+ Promise.all([
790
+ document.fonts.load('900 150px Bitter', 'Hang MU').catch(() => 0),
791
+ document.fonts.load('800 46px Bitter', 'TƯỜNG HUYỀN THOẠI').catch(() => 0),
792
+ document.fonts.load('600 48px "Be Vietnam Pro"', 'Tụ họp').catch(() => 0),
793
+ ]).then(() => {
794
+ const t = LEGEND_PANELS.map(drawLegendPanel)
795
+ if (alive) setTexs(t)
796
+ })
797
+ return () => { alive = false }
798
+ }, [])
799
+ if (!texs) return null
800
+ return (
801
+ <group>
802
+ {LEGEND_SLOTS.map(([x, y, z, rot], i) => (
803
+ <group key={i} position={[x, y, z]} rotation={[0, rot, 0]}>
804
+ <mesh position={[0, 0, -0.12]}>
805
+ <boxGeometry args={[8.9, 4.45, 0.3]} />
806
+ <meshLambertMaterial color="#3d2a1a" flatShading />
807
+ </mesh>
808
+ <mesh position={[0, 0, 0.06]}>
809
+ <planeGeometry args={[8.5, 4.25]} />
810
+ <meshBasicMaterial map={texs[i]} transparent toneMapped={false} fog={false} />
811
+ </mesh>
812
+ </group>
813
+ ))}
814
+ </group>
815
+ )
816
+ }
817
+ const LEGENDS = [
818
+ ['rooney', 'Wayne Rooney', '2004–2017', 24, -42], ['ronaldo', 'Cristiano Ronaldo', '2003–2009, 2021–2022', 28, -40],
819
+ ['giggs', 'Ryan Giggs', '1991–2014', 20, -48], ['scholes', 'Paul Scholes', '1994–2013', 14, -50],
820
+ ['neville', 'Gary Neville', '1992–2011', -26, -44], ['ferdinand', 'Rio Ferdinand', '2002–2014', -20, -48],
821
+ ['vidic', 'Nemanja Vidić', '2006–2014', -14, -50], ['vandersar', 'Edwin van der Sar', '2005–2011', -28, -40],
822
+ ['carrick', 'Michael Carrick', '2006–2018', 8, -52], ['evra', 'Patrice Evra', '2006–2014', -8, -52],
823
+ ]
824
+ function LegendPortraits() {
825
+ const [textures, setTextures] = React.useState(null)
826
+ React.useEffect(() => {
827
+ let alive = true
828
+ const loader = new THREE.TextureLoader()
829
+ const out = {}
830
+ let done = 0
831
+ LEGENDS.forEach(([key]) => {
832
+ loader.load('/images/legends/' + key + '.jpg', (t) => {
833
+ t.colorSpace = THREE.SRGBColorSpace
834
+ out[key] = t
835
+ done++
836
+ if (done >= LEGENDS.length && alive) setTextures({ ...out })
837
+ }, undefined, () => {
838
+ done++
839
+ if (done >= LEGENDS.length && alive) setTextures({ ...out })
840
+ })
841
+ })
842
+ return () => { alive = false }
843
+ }, [])
844
+ return (
845
+ <group>
846
+ {LEGENDS.map(([key, name, years, x, z]) => (
847
+ <group key={key} position={[x, 3.6, z]} rotation={[0, 0.9, 0]}>
848
+ <mesh position={[0, 0, -0.06]}>
849
+ <boxGeometry args={[1.9, 2.5, 0.14]} />
850
+ <meshLambertMaterial color="#3d2a1a" flatShading />
851
+ </mesh>
852
+ <mesh position={[0, 0.12, 0.07]}>
853
+ <planeGeometry args={[1.62, 2.1]} />
854
+ <meshBasicMaterial map={textures && textures[key] ? textures[key] : null}
855
+ color={textures && textures[key] ? '#ffffff' : '#5a4636'} transparent opacity={0.96} toneMapped={false} fog={false} />
856
+ </mesh>
857
+ <mesh position={[0, -1.18, 0.07]}>
858
+ <planeGeometry args={[1.62, 0.34]} />
859
+ <meshBasicMaterial color="#120b08" transparent opacity={0.85} toneMapped={false} fog={false} />
860
+ </mesh>
861
+ </group>
862
+ ))}
863
+ </group>
864
+ )
865
+ }
866
+
867
+ /* =========================================================================
868
+ HỘI TRƯỜNG ĐÁ sign + hình nộm rage + sân kéo co/giữ bóng trên sàn
869
+ ========================================================================= */
870
+ function BenchSign() {
871
+ const [tex, setTex] = React.useState(null)
872
+ React.useEffect(() => {
873
+ let alive = true
874
+ const c = document.createElement('canvas'); c.width = 900; c.height = 200
875
+ const g = c.getContext('2d')
876
+ const gr = g.createLinearGradient(0, 0, 0, 200)
877
+ gr.addColorStop(0, '#8e1b2a'); gr.addColorStop(0.5, '#6a1220'); gr.addColorStop(1, '#3a0a12')
878
+ g.fillStyle = gr; g.fillRect(0, 0, 900, 200)
879
+ g.strokeStyle = 'rgba(236,208,140,0.8)'; g.lineWidth = 6; g.strokeRect(8, 8, 884, 184)
880
+ g.textAlign = 'center'; g.fillStyle = '#ffd76a'; g.font = '800 44px Bitter, Georgia, serif'
881
+ g.fillText('🎙 PHÒNG NÓI CHUYỆN', 450, 90)
882
+ g.fillStyle = '#fff2c8'; g.font = '900 84px Bitter, Georgia, serif'
883
+ g.fillText('Hội trường đá', 450, 160)
884
+ const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 16
885
+ if (alive) setTex(tx)
886
+ return () => { alive = false }
887
+ }, [])
888
+ if (!tex) return null
889
+ return (
890
+ <mesh position={[0, 8.6, -27.2]} rotation={[0.15, 0, 0]}>
891
+ <planeGeometry args={[6.4, 1.5]} />
892
+ <meshBasicMaterial map={tex} transparent toneMapped={false} fog={false} />
893
+ </mesh>
894
+ )
895
+ }
896
+ function RageDummies() {
897
+ const colors = ['#c9a27a', '#b8102a', '#4a4a4a', '#f0c078']
898
+ return (
899
+ <group>
900
+ {colors.map((col, i) => {
901
+ const a = 1.9 + i * 1.2
902
+ const x = Math.sin(a) * 11.5 + (i % 2 ? 1.5 : -1.5)
903
+ const z = Math.cos(a) * 11.5
904
+ const fy = floorHeight(Math.hypot(x, z))
905
+ return (
906
+ <group key={i} position={[x, fy, z]} rotation-y={-a + 1.6}>
907
+ <mesh position={[0, 0.85, 0]}>
908
+ <capsuleGeometry args={[0.32, 1.1, 6, 10]} />
909
+ <meshLambertMaterial color={col} flatShading />
910
+ </mesh>
911
+ <mesh position={[0, 2.05, 0]}>
912
+ <sphereGeometry args={[0.34, 10, 8]} />
913
+ <meshLambertMaterial color="#ffd9c8" />
914
+ </mesh>
915
+ <mesh position={[0, 2.15, 0]} rotation-x={0.35}>
916
+ <coneGeometry args={[0.3, 0.4, 6]} />
917
+ <meshLambertMaterial color={col} flatShading />
918
+ </mesh>
919
+ <mesh position={[0, 0.5, 0]}>
920
+ <cylinderGeometry args={[0.22, 0.26, 0.12, 8]} />
921
+ <meshLambertMaterial color="#3a2a18" flatShading />
922
+ </mesh>
923
+ </group>
924
+ )
925
+ })}
926
+ </group>
927
+ )
928
+ }
929
+ function CourtMarks() {
930
+ return (
931
+ <group>
932
+ {[[-18, -35, 0.35], [18, -35, -0.35]].map(([x, z, rot], i) => (
933
+ <group key={i} position={[x, 0.03, z]} rotation={[0, rot, 0]}>
934
+ <mesh rotation={[-Math.PI / 2, 0, 0]}>
935
+ <planeGeometry args={[10, 8]} />
936
+ <meshBasicMaterial color="#1c1612" transparent opacity={0.55} />
937
+ </mesh>
938
+ <mesh position={[0, 0.01, -4]} rotation={[-Math.PI / 2, 0, 0]}>
939
+ <planeGeometry args={[0.25, 0.4]} />
940
+ <meshBasicMaterial color="#ffd76a" />
941
+ </mesh>
942
+ <mesh position={[0, 0.01, 4]} rotation={[-Math.PI / 2, 0, 0]}>
943
+ <planeGeometry args={[0.25, 0.4]} />
944
+ <meshBasicMaterial color="#ffd76a" />
945
+ </mesh>
946
+ </group>
947
+ ))}
948
+ <group position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
949
+ <mesh>
950
+ <ringGeometry args={[11, 15, 48]} />
951
+ <meshBasicMaterial color="#1c1612" transparent opacity={0.45} />
952
+ </mesh>
953
+ <mesh position={[0, 0.01, -13]}>
954
+ <circleGeometry args={[1.9, 20]} />
955
+ <meshBasicMaterial color="#ffd76a" />
956
+ </mesh>
957
+ </group>
958
+ </group>
959
+ )
960
+ }
961
+
962
+ /* =========================================================================
963
+ CÚP VÀNG TRUNG TÂM (trophy)
964
+ ========================================================================= */
965
+ function Trophy() {
966
+ return (
967
+ <group position={[0, 0, 0]} rotation-y={0.5}>
968
+ <mesh position={[0, 0.12, 0]} castShadow>
969
+ <cylinderGeometry args={[0.85, 1.0, 0.24, 8]} />
970
+ <meshStandardMaterial color="#5a4a2c" metalness={0.6} roughness={0.4} flatShading />
971
+ </mesh>
972
+ <mesh position={[0, 0.34, 0]} castShadow>
973
+ <cylinderGeometry args={[0.55, 0.7, 0.2, 8]} />
974
+ <meshStandardMaterial color="#8a6a2c" metalness={0.7} roughness={0.35} flatShading />
975
+ </mesh>
976
+ <mesh position={[0, 0.85, 0]} castShadow>
977
+ <cylinderGeometry args={[0.42, 0.5, 0.85, 10]} />
978
+ <meshStandardMaterial color="#c9a24e" metalness={0.85} roughness={0.25} flatShading />
979
+ </mesh>
980
+ <mesh position={[0, 1.32, 0]} castShadow>
981
+ <torusGeometry args={[0.5, 0.09, 8, 18]} />
982
+ <meshStandardMaterial color="#c9a24e" metalness={0.85} roughness={0.25} flatShading />
983
+ </mesh>
984
+ <mesh position={[-0.42, 0.85, 0]} rotation-z={0.3}>
985
+ <torusGeometry args={[0.26, 0.07, 8, 14, Math.PI]} />
986
+ <meshStandardMaterial color="#c9a24e" metalness={0.85} roughness={0.25} flatShading />
987
+ </mesh>
988
+ <mesh position={[0.42, 0.85, 0]} rotation-z={-0.3}>
989
+ <torusGeometry args={[0.26, 0.07, 8, 14, Math.PI]} />
990
+ <meshStandardMaterial color="#c9a24e" metalness={0.85} roughness={0.25} flatShading />
991
+ </mesh>
992
+ <mesh position={[0, 1.62, 0]} castShadow>
993
+ <sphereGeometry args={[0.2, 14, 14]} />
994
+ <meshStandardMaterial color="#c9a24e" metalness={0.85} roughness={0.25} flatShading />
995
+ </mesh>
996
+ <pointLight position={[0, 2.6, 0]} color="#ffd76a" intensity={18} distance={9} decay={1.6} />
997
+ </group>
998
+ )
999
+ }
1000
+
1001
+ /* =========================================================================
1002
+ BANNER MU TRÊN VÒM
1003
+ ========================================================================= */
1004
+ function makeBannerTex(text) {
1005
+ const c = document.createElement('canvas')
1006
+ c.width = 512; c.height = 128
1007
+ const g = c.getContext('2d')
1008
+ g.fillStyle = '#c8102e'; g.fillRect(0, 0, 512, 128)
1009
+ g.fillStyle = '#8f0f22'; g.fillRect(6, 6, 500, 116)
1010
+ g.strokeStyle = '#ffd76a'; g.lineWidth = 4; g.strokeRect(6, 6, 500, 116)
1011
+ g.fillStyle = '#ffffff'; g.font = '900 52px "Be Vietnam Pro", sans-serif'
1012
+ g.textAlign = 'center'; g.textBaseline = 'middle'
1013
+ g.fillText(text, 256, 66)
1014
+ const t = new THREE.CanvasTexture(c)
1015
+ t.colorSpace = THREE.SRGBColorSpace
1016
+ return t
1017
+ }
1018
+ function CaveBanners() {
1019
+ const texA = useMemo(() => makeBannerTex('GLORY GLORY'), [])
1020
+ const texB = useMemo(() => makeBannerTex('MAN UNITED'), [])
1021
+ const texC = useMemo(() => makeBannerTex('MU FAN'), [])
1022
+ const conf = [
1023
+ { tex: texA, pos: [-14, 12.5, -24], rot: [0, 0.5, 0] },
1024
+ { tex: texB, pos: [16, 12.8, -30], rot: [0, -0.45, 0] },
1025
+ { tex: texC, pos: [0, 13.2, -8], rot: [0, 0, 0] },
1026
+ ]
1027
+ return (
1028
+ <group>
1029
+ {conf.map((c, i) => (
1030
+ <group key={i} position={c.pos} rotation={c.rot}>
1031
+ <mesh>
1032
+ <planeGeometry args={[6, 1.5]} />
1033
+ <meshBasicMaterial map={c.tex} transparent toneMapped={false} fog={false} />
1034
+ </mesh>
1035
+ </group>
1036
+ ))}
1037
+ </group>
1038
+ )
1039
+ }
1040
+
1041
+ /* =========================================================================
1042
+ ÁO ĐỎ MU TRƯNG BÀY — jersey #7 trong khung gỗ cạnh màn hình lớn
1043
+ ========================================================================= */
1044
+ function _jerseyTex() {
1045
+ if (typeof document === 'undefined') return null
1046
+ const c = document.createElement('canvas'); c.width = 512; c.height = 640
1047
+ const g = c.getContext('2d')
1048
+ g.fillStyle = '#17120d'; g.fillRect(0, 0, 512, 640)
1049
+ g.fillStyle = '#241b12'; g.fillRect(26, 26, 460, 588)
1050
+ g.fillStyle = '#c8102e'
1051
+ g.beginPath(); g.moveTo(138, 168); g.lineTo(374, 168); g.lineTo(374, 468); g.lineTo(138, 468); g.closePath(); g.fill()
1052
+ g.beginPath(); g.moveTo(138, 168); g.lineTo(50, 118); g.lineTo(86, 332); g.lineTo(138, 298); g.closePath(); g.fill()
1053
+ g.beginPath(); g.moveTo(374, 168); g.lineTo(462, 118); g.lineTo(426, 332); g.lineTo(374, 298); g.closePath(); g.fill()
1054
+ g.fillStyle = '#7c0d20'
1055
+ g.beginPath(); g.arc(256, 172, 36, 0, Math.PI * 2); g.fill()
1056
+ g.fillStyle = '#ffffff'
1057
+ g.font = '800 200px Georgia, serif'; g.textAlign = 'center'; g.textBaseline = 'middle'
1058
+ g.fillText('7', 256, 368)
1059
+ g.fillStyle = '#ffe9c0'; g.font = '700 40px Georgia, serif'
1060
+ g.fillText('MANCHESTER UNITED', 256, 548)
1061
+ const t = new THREE.CanvasTexture(c)
1062
+ t.colorSpace = THREE.SRGBColorSpace
1063
+ t.anisotropy = 16
1064
+ return t
1065
+ }
1066
+ function JerseyDisplay() {
1067
+ const [tex, setTex] = React.useState(null)
1068
+ React.useEffect(() => {
1069
+ let alive = true
1070
+ const t = _jerseyTex()
1071
+ if (alive && t) setTex(t)
1072
+ return () => { alive = false }
1073
+ }, [])
1074
+ return (
1075
+ <group position={[15.5, 0, -41]} rotation={[0, -0.42, 0]}>
1076
+ <mesh position={[0, 0.425, -0.12]}>
1077
+ <boxGeometry args={[0.55, 0.85, 0.55]} />
1078
+ <meshLambertMaterial color="#3d2a1a" flatShading />
1079
+ </mesh>
1080
+ <mesh position={[0, 2.15, -0.1]}>
1081
+ <boxGeometry args={[2.7, 3.55, 0.16]} />
1082
+ <meshLambertMaterial color="#5a3b22" flatShading />
1083
+ </mesh>
1084
+ {tex && (
1085
+ <mesh position={[0, 2.18, 0.04]}>
1086
+ <planeGeometry args={[2.35, 2.94]} />
1087
+ <meshBasicMaterial map={tex} transparent fog={false} toneMapped={false} />
1088
+ </mesh>
1089
+ )}
1090
+ </group>
1091
+ )
1092
+ }
1093
+
1094
+ /* =========================================================================
1095
+ SÂN NGOÀI CỬA HANG (+z) — sân, cây, đá, trời, nắng
1096
+ ========================================================================= */
1097
+ function _makeSkyTex() {
1098
+ if (typeof document === 'undefined') return null
1099
+ const c = document.createElement('canvas'); c.width = 64; c.height = 256
1100
+ const g = c.getContext('2d')
1101
+ const gr = g.createLinearGradient(0, 0, 0, 256)
1102
+ gr.addColorStop(0, '#7fb2e0'); gr.addColorStop(0.55, '#cfe3f2'); gr.addColorStop(0.8, '#e8d9bd'); gr.addColorStop(1, '#d8c2a0')
1103
+ g.fillStyle = gr; g.fillRect(0, 0, 64, 256)
1104
+ const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace
1105
+ return t
1106
+ }
1107
+ const _skyTexC = _makeSkyTex()
1108
+ function SkyDome() {
1109
+ const tex = useMemo(() => _skyTexC, [])
1110
+ return (
1111
+ <mesh position={[0, 20, 178]} rotation={[0, Math.PI, 0]} fog={false}>
1112
+ <planeGeometry args={[170, 96]} />
1113
+ <meshBasicMaterial map={tex} fog={false} toneMapped={false} depthWrite={false} />
1114
+ </mesh>
1115
+ )
1116
+ }
1117
+ function OutdoorGround() {
1118
+ const geo = useMemo(() => {
1119
+ const m = new THREE.PlaneGeometry(170, 100, 70, 40)
1120
+ m.rotateX(-Math.PI / 2)
1121
+ const p = m.attributes.position
1122
+ const cols = new Float32Array(p.count * 3)
1123
+ const cA = new THREE.Color('#8a7a5c'), cB = new THREE.Color('#6d5c42'), moss = new THREE.Color('#7d7a45'), tmp = new THREE.Color()
1124
+ for (let i = 0; i < p.count; i++) {
1125
+ const x = p.getX(i), z = p.getZ(i)
1126
+ p.setY(i, fbm3(x * 0.06, z * 0.06, 0, 21) * 1.1 - 0.3)
1127
+ tmp.copy(cA).lerp(cB, fbm3(x * 0.12, z * 0.12, 3, 22) * 0.5 + 0.25)
1128
+ tmp.lerp(moss, Math.max(0, fbm3(x * 0.08, 9, z * 0.08, 23)) * 0.35)
1129
+ cols[i * 3] = tmp.r; cols[i * 3 + 1] = tmp.g; cols[i * 3 + 2] = tmp.b
1130
+ }
1131
+ m.setAttribute('color', new THREE.BufferAttribute(cols, 3))
1132
+ m.computeVertexNormals()
1133
+ return m
1134
+ }, [])
1135
+ return (
1136
+ <mesh geometry={geo} position={[0, 0, 126]} receiveShadow fog={false}>
1137
+ <meshLambertMaterial vertexColors fog={false} />
1138
+ </mesh>
1139
+ )
1140
+ }
1141
+ function OutdoorPath() {
1142
+ return (
1143
+ <mesh position={[0, 0.04, 112]} rotation={[-Math.PI / 2, 0, 0]} fog={false}>
1144
+ <planeGeometry args={[11, 66]} />
1145
+ <meshBasicMaterial color="#a89474" fog={false} transparent opacity={0.85} />
1146
+ </mesh>
1147
+ )
1148
+ }
1149
+ function OutdoorProps() {
1150
+ const items = useMemo(() => {
1151
+ const out = []
1152
+ const boulder = (x, z, s, seed) => {
1153
+ const geo = displaceAndColor(new THREE.IcosahedronGeometry(1, 1),
1154
+ { amp: 0.3, freq: 1.1, seed, base: '#9a8a6c', shade: '#77664c', dark: '#4c3f2e', warm: '#b3a284' })
1155
+ out.push({ kind: 'boulder', geo, x, z, s })
1156
+ }
1157
+ const tree = (x, z, s, seed) => out.push({ kind: 'tree', x, z, s, seed })
1158
+ boulder(-8, 80, 1.6, 101); boulder(9, 82, 2.1, 102); boulder(-14, 86, 2.8, 103)
1159
+ boulder(15, 88, 2.4, 104); boulder(-6, 94, 1.2, 105); boulder(11, 96, 1.4, 106)
1160
+ boulder(-18, 100, 3.2, 107); boulder(19, 102, 3.0, 108)
1161
+ tree(-12, 88, 1.5, 201); tree(12, 90, 1.8, 202); tree(-16, 96, 2.1, 203); tree(16, 98, 2.0, 204)
1162
+ tree(-8, 104, 1.4, 205); tree(8, 108, 1.6, 206)
1163
+ return out
1164
+ }, [])
1165
+ return (
1166
+ <group>
1167
+ {items.map((it, i) => it.kind === 'boulder' ? (
1168
+ <mesh key={i} geometry={it.geo} position={[it.x, it.s * 0.35, it.z]} scale={[it.s, it.s * 0.85, it.s]} fog={false}>
1169
+ <meshLambertMaterial vertexColors flatShading fog={false} />
1170
+ </mesh>
1171
+ ) : (
1172
+ <group key={i} position={[it.x, 0, it.z]} scale={[it.s, it.s, it.s]} fog={false}>
1173
+ <mesh position={[0, 1.1, 0]} fog={false}>
1174
+ <cylinderGeometry args={[0.22, 0.3, 2.2, 6]} />
1175
+ <meshLambertMaterial color="#6b4a2c" flatShading fog={false} />
1176
+ </mesh>
1177
+ {[0, 1, 2].map((k) => (
1178
+ <mesh key={k} position={[Math.sin(k * 2.1) * 0.7, 2.6 + k * 0.55, Math.cos(k * 2.1) * 0.7]} fog={false}>
1179
+ <coneGeometry args={[1.35 - k * 0.25, 1.6, 7]} />
1180
+ <meshLambertMaterial color={k === 0 ? '#3f6b2e' : k === 1 ? '#4c7a38' : '#5a8a42'} flatShading fog={false} />
1181
+ </mesh>
1182
+ ))}
1183
+ </group>
1184
+ ))}
1185
+ </group>
1186
+ )
1187
+ }
1188
+ function OutdoorLight() {
1189
+ const tex = useMemo(() => {
1190
+ const c = document.createElement('canvas'); c.width = c.height = 128
1191
+ const g = c.getContext('2d')
1192
+ const gr = g.createRadialGradient(64, 64, 2, 64, 64, 62)
1193
+ gr.addColorStop(0, 'rgba(255,240,200,0.9)'); gr.addColorStop(0.5, 'rgba(255,214,140,0.35)'); gr.addColorStop(1, 'rgba(255,214,140,0)')
1194
+ g.fillStyle = gr; g.fillRect(0, 0, 128, 128)
1195
+ const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace
1196
+ return t
1197
+ }, [])
1198
+ return (
1199
+ <>
1200
+ <directionalLight position={[15, 30, 140]} intensity={1.6} color="#fff1d6" fog={false} />
1201
+ <pointLight position={[0, 16, 118]} color="#ffe6c0" intensity={5} distance={70} decay={0.5} fog={false} />
1202
+ <sprite position={[0, 34, 132]} scale={[34, 34, 1]} fog={false}>
1203
+ <spriteMaterial map={tex} transparent opacity={0.9} fog={false} depthWrite={false} blending={THREE.AdditiveBlending} />
1204
+ </sprite>
1205
+ </>
1206
+ )
1207
+ }
1208
+ function OutdoorScene() {
1209
+ return (
1210
+ <group>
1211
+ <SkyDome />
1212
+ <OutdoorGround />
1213
+ <OutdoorPath />
1214
+ <OutdoorProps />
1215
+ <OutdoorLight />
1216
+ </group>
1217
+ )
1218
+ }
1219
 
1220
  /* =========================================================================
1221
  TỔNG HỢP CaveScene
 
1234
  <Board />
1235
  <LegendRing />
1236
  <Benches />
1237
+ <LegendWall />
1238
+ <LegendPortraits />
1239
+ <EplBoard />
1240
+ <OnThisDayBoard />
1241
+ <BenchSign />
1242
+ <RageDummies />
1243
+ <CourtMarks />
1244
+ <Trophy />
1245
+ <CaveBanners />
1246
+ <JerseyDisplay />
1247
  <Entrance />
1248
+ <OutdoorScene />
1249
  <hemisphereLight args={[0xe8c090, 0x7a6152, 1.5]} />
1250
  <ambientLight intensity={0.7} color={0xf4cba0} />
1251
  </>
1252
  )
1253
  }
1254
+
1255
+ export { floorHeight, domeHeight }