Spaces:
Running
Running
Download patch_full.mjs from bep40/hangmu-docker: direct link, hf CLI and curl.
- Browser
- Download file 37.3 kB
-
https://huggingface.co/spaces/bep40/hangmu-docker/resolve/main/patch_full.mjs
- Command line
-
hf download hf://spaces/bep40/hangmu-docker/patch_full.mjs
-
curl -L -o patch_full.mjs https://huggingface.co/spaces/bep40/hangmu-docker/resolve/main/patch_full.mjs
37.3 kB
| /* ========================================================================= | |
| patch_full.mjs — Hoàn thiện 100% giao diện + scene 3D so với hangmu.online: | |
| (chạy SAU patch_app.mjs + patch_3d.mjs, TRƯỚC esbuild) | |
| - cave3d.js: TƯỜNG HUYỀN THOẠI (3 bảng + 10 chân dung), bảng GGMU run rally, | |
| BẢNG XẾP HẠNG Ngoại hạng Anh (EPL), NGÀY NÀY NĂM XƯA (lịch sử 70 mốc), | |
| bảng hiệu HỘI TRƯỜNG ĐÁ, hình nộm PHÒNG XẢ GIẬN (4 nộm quanh hố lửa), | |
| SÂN KÉO CO + SÂN GIỮ BÓNG vẽ trên sàn (2 bên trái/phải hang), | |
| avatar nhân vật thêm SỐ ÁO (jersey number texture kiểu gốc). | |
| Nếu pattern không khớp -> exit(1). | |
| ========================================================================= */ | |
| import fs from 'node:fs' | |
| function patchFile(path, reps) { | |
| let s = fs.readFileSync(path, 'utf8') | |
| const failures = [] | |
| function toRe(str) { | |
| const esc = str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| return esc.replace(/\s+/g, '\\s+') | |
| } | |
| function rep(oldStr, newStr) { | |
| const exact = s.split(oldStr).length - 1 | |
| if (exact === 1) { s = s.replace(oldStr, newStr); return } | |
| const re = new RegExp(toRe(oldStr), 'g') | |
| const m = s.match(re) | |
| if (m && m.length === 1) { s = s.replace(re, newStr); return } | |
| failures.push(`pattern not unique/found (exact=${exact}, re=${m ? m.length : 0}): ${oldStr.slice(0, 80)}`) | |
| } | |
| for (const [o, n] of reps) rep(o, n) | |
| if (failures.length) { | |
| console.error(`PATCH FULL FAILED (${path}):`) | |
| failures.forEach((f) => console.error(' -', f)) | |
| process.exit(1) | |
| } | |
| fs.writeFileSync(path, s) | |
| console.log('patched ok (full):', path) | |
| } | |
| /* ================= cave3d.js ================= */ | |
| patchFile('frontend/cave3d.js', [ | |
| /* ---- EPL standings texture (bảng xếp hạng Ngoại hạng Anh, giữ liệu khi lỗi) ---- */ | |
| [ `export function CaveScene() {`, | |
| `/* ========================================================================= | |
| BẢNG XẾP HẠNG NGOẠI HẠNG ANH (EPL) — texture vẽ như gốc, dữ liệu tĩnh mùa gần nhất | |
| ========================================================================= */ | |
| const EPL_TEAMS = [ | |
| ['Arsenal', 38, 28, 5, 5, 91, 29, 89, 'ucl'], ['Man City', 38, 28, 7, 3, 96, 34, 91, 'ucl'], | |
| ['Liverpool', 38, 24, 10, 4, 86, 41, 82, 'ucl'], ['Aston Villa', 38, 20, 8, 10, 76, 61, 68, 'ucl'], | |
| ['Tottenham', 38, 20, 6, 12, 74, 61, 66, 'europe'], ['Chelsea', 38, 18, 9, 11, 77, 63, 63, 'europe'], | |
| ['Newcastle', 38, 18, 6, 14, 85, 62, 60, 'europe'], ['Man United', 38, 18, 6, 14, 57, 58, 60, 'europe'], | |
| ['West Ham', 38, 14, 10, 14, 60, 74, 52, ''], ['Crystal Palace', 38, 13, 10, 15, 57, 58, 49, ''], | |
| ['Brighton', 38, 12, 12, 14, 55, 62, 48, ''], ['Bournemouth', 38, 13, 9, 16, 54, 67, 48, ''], | |
| ['Fulham', 38, 13, 8, 17, 55, 61, 47, ''], ['Wolves', 38, 13, 7, 18, 50, 65, 46, ''], | |
| ['Everton', 38, 13, 9, 16, 40, 51, 40, ''], ['Brentford', 38, 10, 9, 19, 56, 65, 39, ''], | |
| ['Nottm Forest', 38, 9, 9, 20, 49, 67, 32, ''], ['Luton', 38, 6, 8, 24, 52, 85, 26, 'relegation'], | |
| ['Burnley', 38, 5, 9, 24, 41, 78, 24, 'relegation'], ['Sheffield Utd', 38, 3, 7, 28, 35, 104, 16, 'relegation'], | |
| ] | |
| function makeEplTex() { | |
| const c = document.createElement('canvas'); c.width = 1400; c.height = 1560 | |
| const g = c.getContext('2d') | |
| const gr = g.createLinearGradient(0, 0, 0, 1560) | |
| gr.addColorStop(0, '#5c0e1b'); gr.addColorStop(0.5, '#3a0812'); gr.addColorStop(1, '#1b1410') | |
| g.fillStyle = gr; g.fillRect(0, 0, 1400, 1560) | |
| g.strokeStyle = 'rgba(255,240,220,0.12)'; g.lineWidth = 14; g.strokeRect(7, 7, 1386, 1546) | |
| g.textAlign = 'center'; g.fillStyle = '#8a1a2a' | |
| g.font = '900 96px Bitter, Georgia, serif'; g.fillText('NGOẠI HẠNG ANH', 700, 130) | |
| g.fillStyle = 'rgba(255,240,220,0.7)'; g.font = '600 42px "Be Vietnam Pro", sans-serif' | |
| g.fillText('Bảng xếp hạng mùa giải', 700, 195) | |
| const cols = [700, 1070, 1180, 1280] | |
| g.fillStyle = 'rgba(201,162,78,0.85)'; g.font = '600 32px "Be Vietnam Pro", sans-serif' | |
| ;['Đội', 'T', 'H', 'Đ'].forEach((t, i) => g.fillText(t, cols[i], 250)) | |
| for (let i = 0; i < EPL_TEAMS.length; i++) { | |
| const t = EPL_TEAMS[i] | |
| const y = 300 + i * 58 | |
| g.fillStyle = i % 2 ? 'rgba(255,240,220,0.045)' : 'rgba(255,240,220,0.0)' | |
| g.fillRect(20, y - 22, 1360, 56) | |
| if (i === 7) { g.fillStyle = 'rgba(142,11,32,0.5)'; g.fillRect(20, y - 22, 1360, 56) } | |
| g.textAlign = 'left'; g.fillStyle = i === 7 ? '#ffd76a' : (t[7] ? '#8e0b20' : 'rgba(255,240,220,0.85)') | |
| g.font = i === 7 ? '800 42px Bitter' : '600 42px "Be Vietnam Pro", sans-serif' | |
| g.fillText(t[0], 60, y + 8) | |
| g.textAlign = 'center' | |
| g.fillStyle = 'rgba(255,240,220,0.7)' | |
| ;[t[1], t[5] - t[6], t[6]].forEach((v, k) => { g.fillText(String(v), cols[k + 1], y + 8) }) | |
| g.fillStyle = i === 7 ? '#ffd76a' : 'rgba(255,240,220,0.95)' | |
| g.font = '800 44px Bitter'; g.fillText(String(t[7]), 1310, y + 8) | |
| } | |
| g.textAlign = 'right'; g.fillStyle = 'rgba(255,240,220,0.4)'; g.font = '500 28px "Be Vietnam Pro", sans-serif' | |
| g.fillText('Dự Cúp C1 · Cúp châu Âu · Xuống hạng · Đ = Điểm', 1360, 1540) | |
| const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 16 | |
| return tx | |
| } | |
| function EplBoard() { | |
| const [tex, setTex] = React.useState(null) | |
| React.useEffect(() => { | |
| let alive = true | |
| const t = makeEplTex() | |
| if (alive && t) setTex(t) | |
| return () => { alive = false } | |
| }, []) | |
| if (!tex) return null | |
| return ( | |
| <group position={[-18.5, 6.5, -44]} rotation={[0, 0.52, 0]}> | |
| <mesh position={[0, 0, -0.08]}> | |
| <boxGeometry args={[8.4, 9.4, 0.28]} /> | |
| <meshLambertMaterial color="#3d2a1a" flatShading /> | |
| </mesh> | |
| <mesh position={[0, 0, 0.1]}> | |
| <planeGeometry args={[8.0, 9.0]} /> | |
| <meshBasicMaterial map={tex} transparent toneMapped={false} fog={false} /> | |
| </mesh> | |
| </group> | |
| ) | |
| } | |
| /* ========================================================================= | |
| NGÀY NÀY NĂM XƯA — bảng lịch sử 70 mốc MU, đổi theo ngày thật | |
| ========================================================================= */ | |
| const ON_THIS_DAY = [ | |
| { 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.' }, | |
| { 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ỷ Đỏ.' }, | |
| { 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.' }, | |
| { m: 2, d: 18, y: 1975, title: 'Gary Neville chào đời', text: 'Đội trưởng, hậu vệ phải trung thành trọn đời với Manchester United.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { m: 2, d: 26, y: 2006, title: 'Cúp Liên đoàn 2006', text: 'MU đè bẹp Wigan 4–0 ở chung kết, Rooney lập cú đúp.' }, | |
| { m: 2, d: 26, y: 2017, title: 'Cúp Liên đoàn 2017', text: 'Thắng Southampton 3–2 ở Wembley, Ibrahimović ghi bàn quyết định.' }, | |
| { m: 2, d: 26, y: 2023, title: 'Cúp Liên đoàn 2023', text: 'Thắng Newcastle 2–0 ở Wembley — danh hiệu đầu tiên sau 6 năm.' }, | |
| { m: 2, d: 28, y: 2010, title: 'Cúp Liên đoàn 2010', text: 'Thắng Aston Villa 2–1 ở chung kết, giữ cúp năm thứ hai liên tiếp.' }, | |
| { m: 3, d: 1, y: 2009, title: 'Cúp Liên đoàn 2009', text: 'Hoà Tottenham 0–0, thắng 4–1 trên chấm luân lưu ở Wembley.' }, | |
| { 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ử.' }, | |
| { 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.' }, | |
| { m: 4, d: 12, y: 1992, title: 'Cúp Liên đoàn đầu tiên', text: 'Thắng Nottingham Forest 1–0 ở Wembley, Brian McClair ghi bàn.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { m: 4, d: 24, y: 1948, title: 'FA Cup 1948', text: 'Đội của Matt Busby thắng Blackpool 4–2 ở Wembley — danh hiệu đầu tiên thời Busby.' }, | |
| { m: 5, d: 2, y: 1975, title: 'David Beckham chào đời', text: 'Số 7 với những cú sút phạt và tạt bóng "thương hiệu", một phần của cú ăn ba 1999.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { m: 5, d: 14, y: 1994, title: 'Cú đúp đầu tiên', text: 'Thắng Chelsea 4–0 ở chung kết FA Cup — cú đúp vô địch + FA Cup đầu tiên của CLB.' }, | |
| { m: 5, d: 14, y: 2011, title: 'Chức vô địch thứ 19', text: 'Hoà Blackburn 1–1, MU vượt Liverpool về số lần vô địch nước Anh.' }, | |
| { m: 5, d: 15, y: 1981, title: 'Patrice Evra chào đời', text: 'Hậu vệ trái của 5 chức vô địch Premier League.' }, | |
| { m: 5, d: 15, y: 1991, title: 'Cúp C2 châu Âu', text: 'Thắng Barcelona 2–1 ở Rotterdam, Mark Hughes lập cú đúp.' }, | |
| { 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.' }, | |
| { m: 5, d: 16, y: 2009, title: 'Chức vô địch thứ 18', text: 'Hoà Arsenal 0–0 ở Old Trafford, vô địch Premier League năm thứ ba liên tiếp.' }, | |
| { 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.' }, | |
| { m: 5, d: 18, y: 1985, title: 'FA Cup 1985', text: 'Chơi thiếu người, MU vẫn thắng Everton 1–0 nhờ cú cứa lòng của Norman Whiteside ở hiệp phụ.' }, | |
| { m: 5, d: 19, y: 2013, title: 'Trận cuối của Sir Alex', text: 'Hoà West Brom 5–5 — một trận cầu điên rồ khép lại kỷ nguyên vĩ đại nhất của CLB.' }, | |
| { m: 5, d: 21, y: 1977, title: 'FA Cup 1977', text: 'Thắng Liverpool 2–1 ở Wembley, chặn đứng giấc mơ cú ăn ba của đối thủ.' }, | |
| { 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.' }, | |
| { m: 5, d: 21, y: 2016, title: 'FA Cup 2016', text: 'Lingard volley ở hiệp phụ, thắng Crystal Palace 2–1 — FA Cup thứ 12.' }, | |
| { m: 5, d: 22, y: 1946, title: 'George Best chào đời', text: 'Thiên tài người Bắc Ireland, Quả bóng Vàng 1968.' }, | |
| { 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.' }, | |
| { m: 5, d: 22, y: 2004, title: 'FA Cup 2004', text: 'Thắng Millwall 3–0 ở Cardiff, Van Nistelrooy lập cú đúp.' }, | |
| { m: 5, d: 24, y: 1966, title: 'Eric Cantona chào đời', text: '"Le Roi" cổ áo dựng đứng, người thắp lửa kỷ nguyên Premier League.' }, | |
| { 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ủ.' }, | |
| { m: 5, d: 25, y: 1963, title: 'FA Cup 1963', text: 'Thắng Leicester 3–1 ở Wembley, danh hiệu đầu tiên sau thảm hoạ Munich.' }, | |
| { 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.' }, | |
| { m: 5, d: 26, y: 1909, title: 'Sir Matt Busby chào đời', text: 'Người dựng lại CLB từ đống tro tàn, đưa MU lên đỉnh châu Âu 1968.' }, | |
| { m: 5, d: 26, y: 1983, title: 'FA Cup 1983', text: 'Đá lại chung kết, MU thắng Brighton 4–0.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { m: 7, d: 28, y: 1981, title: 'Michael Carrick chào đời', text: 'Nhạc trưởng thầm lặng của tuyến giữa, vô địch Champions League 2008.' }, | |
| { m: 8, d: 10, y: 1971, title: 'Roy Keane chào đời', text: 'Đội trưởng thép, linh hồn của những năm 1990.' }, | |
| { m: 8, d: 16, y: 2003, title: 'Ronaldo ra mắt Old Trafford', text: 'Vào sân từ ghế dự bị trước Bolton, cậu bé 18 tuổi khiến cả sân đứng dậy.' }, | |
| { 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.' }, | |
| { 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.' }, | |
| { m: 9, d: 8, y: 1994, title: 'Bruno Fernandes chào đời', text: 'Đội trưởng, nhạc trưởng của Quỷ Đỏ thời hiện đại.' }, | |
| { 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.' }, | |
| { m: 9, d: 28, y: 2004, title: 'Rooney ra mắt bằng hat-trick', text: 'Trận đầu cho MU, Rooney 18 tuổi ghi 3 bàn vào lưới Fenerbahçe ở Champions League.' }, | |
| { m: 10, d: 1, y: 1936, title: 'Duncan Edwards chào đời', text: 'Viên ngọc sáng nhất của "Busby Babes".' }, | |
| { m: 10, d: 11, y: 1937, title: 'Sir Bobby Charlton chào đời', text: 'Người sống sót ở Munich, vô địch World Cup 1966 và châu Âu 1968, biểu tượng muôn đời của CLB.' }, | |
| { m: 10, d: 21, y: 1981, title: 'Nemanja Vidić chào đời', text: 'Bức tường thép cùng Rio Ferdinand, đội trưởng mùa vô địch 2011.' }, | |
| { 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.' }, | |
| { m: 10, d: 29, y: 1970, title: 'Edwin van der Sar chào đời', text: 'Người gác đền, anh hùng loạt luân lưu Moscow 2008.' }, | |
| { 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.' }, | |
| { m: 11, d: 7, y: 1978, title: 'Rio Ferdinand chào đời', text: 'Trung vệ thanh lịch của 6 chức vô địch Premier League.' }, | |
| { m: 11, d: 16, y: 1974, title: 'Paul Scholes chào đời', text: '"Hoàng tử Ginger", người được cả những huyền thoại gọi là tiền vệ hay nhất họ từng gặp.' }, | |
| { m: 11, d: 18, y: 1963, title: 'Peter Schmeichel chào đời', text: 'Người khổng lồ Đan Mạch, đội trưởng đêm Camp Nou 1999.' }, | |
| { m: 11, d: 26, y: 1992, title: 'Cantona tới Old Trafford', text: 'Bản hợp đồng từ Leeds thay đổi tất cả: mùa đó MU vô địch sau 26 năm chờ đợi.' }, | |
| { m: 11, d: 29, y: 1973, title: 'Ryan Giggs chào đời', text: 'Cầu thủ khoác áo MU nhiều trận nhất, 13 lần vô địch nước Anh.' }, | |
| { m: 11, d: 30, y: 1999, title: 'Vô địch Cúp Liên lục địa', text: 'Thắng Palmeiras 1–0 ở Tokyo, Keane ghi bàn — MU lên đỉnh thế giới.' }, | |
| { 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.' }, | |
| { m: 12, d: 21, y: 2008, title: 'Vô địch thế giới các CLB', text: 'Thắng LDU Quito 1–0 ở Yokohama, Rooney ghi bàn.' }, | |
| { 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.' }, | |
| ] | |
| function OnThisDayBoard() { | |
| const [tex, setTex] = React.useState(null) | |
| React.useEffect(() => { | |
| let alive = true | |
| const c = document.createElement('canvas'); c.width = 900; c.height = 1000 | |
| const g = c.getContext('2d') | |
| const gr = g.createLinearGradient(0, 0, 0, 1000) | |
| gr.addColorStop(0, '#4f0c18'); gr.addColorStop(0.6, '#2a060d'); gr.addColorStop(1, '#180307') | |
| g.fillStyle = gr; g.fillRect(0, 0, 900, 1000) | |
| g.strokeStyle = 'rgba(201,162,78,0.8)'; g.lineWidth = 8; g.strokeRect(8, 8, 884, 984) | |
| g.textAlign = 'center'; g.fillStyle = '#c9a24e'; g.font = '800 44px Bitter, Georgia, serif' | |
| g.fillText('NGÀY NÀY NĂM XƯA', 450, 80) | |
| const now = new Date() | |
| const today = ON_THIS_DAY.filter((e) => e.m === now.getMonth() + 1 && e.d === now.getDate()) | |
| g.fillStyle = 'rgba(255,240,220,0.5)'; g.font = '600 28px "Be Vietnam Pro", sans-serif' | |
| 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) | |
| if (today.length) { | |
| today.forEach((e, i) => { | |
| const y = 210 + i * 210 | |
| g.fillStyle = 'rgba(142,11,32,0.45)'; g.fillRect(60, y - 60, 780, 170) | |
| g.strokeStyle = 'rgba(201,162,78,0.6)'; g.lineWidth = 3; g.strokeRect(60, y - 60, 780, 170) | |
| g.textAlign = 'left'; g.fillStyle = '#ffd76a'; g.font = '700 38px "Be Vietnam Pro", sans-serif' | |
| g.fillText(e.d + ' THÁNG ' + e.m + ' · ' + e.y, 90, y - 22) | |
| g.fillStyle = '#fff2c8'; g.font = '800 52px Bitter, Georgia, serif' | |
| g.fillText(e.title.slice(0, 40), 90, y + 25) | |
| g.fillStyle = 'rgba(255,240,220,0.8)'; g.font = '500 26px "Be Vietnam Pro", sans-serif' | |
| g.fillText(e.text.length > 62 ? e.text.slice(0, 60) + '…' : e.text, 90, y + 78, 720) | |
| }) | |
| } | |
| const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 16 | |
| if (alive) setTex(tx) | |
| return () => { alive = false } | |
| }, []) | |
| if (!tex) return null | |
| return ( | |
| <group position={[18.5, 6.5, -44]} rotation={[0, -0.5, 0]}> | |
| <mesh position={[0, 0, -0.08]}> | |
| <boxGeometry args={[6.6, 7.3, 0.28]} /> | |
| <meshLambertMaterial color="#3d2a1a" flatShading /> | |
| </mesh> | |
| <mesh position={[0, 0, 0.1]}> | |
| <planeGeometry args={[6.2, 6.9]} /> | |
| <meshBasicMaterial map={tex} transparent toneMapped={false} fog={false} /> | |
| </mesh> | |
| </group> | |
| ) | |
| } | |
| /* ========================================================================= | |
| TƯỜNG HUYỀN THOẠI — 3 bảng lớn quanh vòm (y như gốc PA/PB/PC) + 10 chân dung | |
| ========================================================================= */ | |
| const LEGEND_PANELS = [ | |
| { kicker: 'DI TÍCH LỊCH SỬ', title: 'Hang MU', sub: 'Nơi anh em fan Quỷ Đỏ tụ họp' }, | |
| { kicker: 'MANCHESTER UNITED', title: 'Từ 1878', sub: 'Glory Glory Man United' }, | |
| { kicker: 'TƯỜNG HUYỀN THOẠI', title: 'Thế hệ vàng', sub: 'Mười huyền thoại khoác áo đỏ' }, | |
| ] | |
| function drawLegendPanel(t) { | |
| const c = document.createElement('canvas'); c.width = 1024; c.height = 512 | |
| const g = c.getContext('2d') | |
| const r = g.createRadialGradient(307, 0, 40, 410, 205, 820) | |
| r.addColorStop(0, '#96202f'); r.addColorStop(0.5, '#7a1424'); r.addColorStop(1, '#4f0c18') | |
| g.fillStyle = r; g.fillRect(0, 0, 1024, 512) | |
| g.strokeStyle = '#c9a24e'; g.lineWidth = 22; g.strokeRect(11, 11, 1002, 490) | |
| g.strokeStyle = 'rgba(236,208,140,0.75)'; g.lineWidth = 4; g.strokeRect(40, 40, 944, 432) | |
| for (const [x, y] of [[62, 62], [962, 62], [62, 450], [962, 450]]) { | |
| const rr = g.createRadialGradient(x - 3, y - 3, 1, x, y, 11) | |
| rr.addColorStop(0, '#f6e2a6'); rr.addColorStop(0.6, '#c9a24e'); rr.addColorStop(1, '#5e4719') | |
| g.fillStyle = rr; g.beginPath(); g.arc(x, y, 11, 0, Math.PI * 2); g.fill() | |
| } | |
| g.textAlign = 'center'; g.textBaseline = 'middle'; g.shadowColor = 'rgba(0,0,0,0.6)'; g.shadowOffsetY = 4; g.shadowBlur = 2 | |
| g.fillStyle = '#c9a24e'; g.font = '800 46px Bitter, Georgia, serif' | |
| if ('letterSpacing' in g) { g.letterSpacing = '10px' } | |
| g.fillText(t.kicker, 512, 118, 864) | |
| if ('letterSpacing' in g) { g.letterSpacing = '0px' } | |
| g.fillStyle = '#ecd08c'; g.font = '900 150px Bitter, Georgia, serif' | |
| g.fillText(t.title, 512, 250, 864) | |
| g.shadowColor = 'transparent' | |
| g.fillStyle = '#c9a24e'; g.fillRect(150, 342, 724, 4) | |
| g.fillStyle = '#efe9dd'; g.font = '600 48px "Be Vietnam Pro", system-ui, sans-serif' | |
| g.fillText(t.sub, 512, 410, 864) | |
| const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 16 | |
| return tx | |
| } | |
| const LEGEND_SLOTS = [ | |
| [-22.5, 11.4, -40.5, 0.52], [-13.4, 8.6, -45.6, 0.9], [12.8, 9.8, -46.5, 1.9], | |
| ] | |
| function LegendWall() { | |
| const [texs, setTexs] = React.useState(null) | |
| React.useEffect(() => { | |
| let alive = true | |
| Promise.all([ | |
| document.fonts.load('900 150px Bitter', 'Hang MU').catch(() => 0), | |
| document.fonts.load('800 46px Bitter', 'TƯỜNG HUYỀN THOẠI').catch(() => 0), | |
| document.fonts.load('600 48px "Be Vietnam Pro"', 'Tụ họp').catch(() => 0), | |
| ]).then(() => { | |
| const t = LEGEND_PANELS.map(drawLegendPanel) | |
| if (alive) setTexs(t) | |
| }) | |
| return () => { alive = false } | |
| }, []) | |
| if (!texs) return null | |
| return ( | |
| <group> | |
| {LEGEND_SLOTS.map(([x, y, z, rot], i) => ( | |
| <group key={i} position={[x, y, z]} rotation={[0, rot, 0]}> | |
| <mesh position={[0, 0, -0.12]}> | |
| <boxGeometry args={[8.9, 4.45, 0.3]} /> | |
| <meshLambertMaterial color="#3d2a1a" flatShading /> | |
| </mesh> | |
| <mesh position={[0, 0, 0.06]}> | |
| <planeGeometry args={[8.5, 4.25]} /> | |
| <meshBasicMaterial map={texs[i]} transparent toneMapped={false} fog={false} /> | |
| </mesh> | |
| </group> | |
| ))} | |
| </group> | |
| ) | |
| } | |
| const LEGENDS = [ | |
| ['rooney', 'Wayne Rooney', '2004–2017', 24, -42], ['ronaldo', 'Cristiano Ronaldo', '2003–2009, 2021–2022', 28, -40], | |
| ['giggs', 'Ryan Giggs', '1991–2014', 20, -48], ['scholes', 'Paul Scholes', '1994–2013', 14, -50], | |
| ['neville', 'Gary Neville', '1992–2011', -26, -44], ['ferdinand', 'Rio Ferdinand', '2002–2014', -20, -48], | |
| ['vidic', 'Nemanja Vidić', '2006–2014', -14, -50], ['vandersar', 'Edwin van der Sar', '2005–2011', -28, -40], | |
| ['carrick', 'Michael Carrick', '2006–2018', 8, -52], ['evra', 'Patrice Evra', '2006–2014', -8, -52], | |
| ] | |
| function LegendPortraits() { | |
| const [textures, setTextures] = React.useState(null) | |
| React.useEffect(() => { | |
| let alive = true | |
| const loader = new THREE.TextureLoader() | |
| const out = {} | |
| let done = 0 | |
| LEGENDS.forEach(([key]) => { | |
| loader.load('/images/legends/' + key + '.jpg', (t) => { | |
| t.colorSpace = THREE.SRGBColorSpace | |
| out[key] = t | |
| done++ | |
| if (done >= LEGENDS.length && alive) setTextures({ ...out }) | |
| }, undefined, () => { | |
| done++ | |
| if (done >= LEGENDS.length && alive) setTextures({ ...out }) | |
| }) | |
| }) | |
| return () => { alive = false } | |
| }, []) | |
| return ( | |
| <group> | |
| {LEGENDS.map(([key, name, years, x, z]) => ( | |
| <group key={key} position={[x, 3.6, z]} rotation={[0, 0.9, 0]}> | |
| <mesh position={[0, 0, -0.06]}> | |
| <boxGeometry args={[1.9, 2.5, 0.14]} /> | |
| <meshLambertMaterial color="#3d2a1a" flatShading /> | |
| </mesh> | |
| <mesh position={[0, 0.12, 0.07]}> | |
| <planeGeometry args={[1.62, 2.1]} /> | |
| <meshBasicMaterial map={textures && textures[key] ? textures[key] : null} | |
| color={textures && textures[key] ? '#ffffff' : '#5a4636'} transparent opacity={0.96} toneMapped={false} fog={false} /> | |
| </mesh> | |
| <mesh position={[0, -1.18, 0.07]}> | |
| <planeGeometry args={[1.62, 0.34]} /> | |
| <meshBasicMaterial color="#120b08" transparent opacity={0.85} toneMapped={false} fog={false} /> | |
| </mesh> | |
| </group> | |
| ))} | |
| </group> | |
| ) | |
| } | |
| /* ========================================================================= | |
| HỘI TRƯỜNG ĐÁ sign + hình nộm rage + sân kéo co/giữ bóng trên sàn | |
| ========================================================================= */ | |
| function BenchSign() { | |
| const [tex, setTex] = React.useState(null) | |
| React.useEffect(() => { | |
| let alive = true | |
| const c = document.createElement('canvas'); c.width = 900; c.height = 200 | |
| const g = c.getContext('2d') | |
| const gr = g.createLinearGradient(0, 0, 0, 200) | |
| gr.addColorStop(0, '#8e1b2a'); gr.addColorStop(0.5, '#6a1220'); gr.addColorStop(1, '#3a0a12') | |
| g.fillStyle = gr; g.fillRect(0, 0, 900, 200) | |
| g.strokeStyle = 'rgba(236,208,140,0.8)'; g.lineWidth = 6; g.strokeRect(8, 8, 884, 184) | |
| g.textAlign = 'center'; g.fillStyle = '#ffd76a'; g.font = '800 44px Bitter, Georgia, serif' | |
| g.fillText('🎙 PHÒNG NÓI CHUYỆN', 450, 90) | |
| g.fillStyle = '#fff2c8'; g.font = '900 84px Bitter, Georgia, serif' | |
| g.fillText('Hội trường đá', 450, 160) | |
| const tx = new THREE.CanvasTexture(c); tx.colorSpace = THREE.SRGBColorSpace; tx.anisotropy = 16 | |
| if (alive) setTex(tx) | |
| return () => { alive = false } | |
| }, []) | |
| if (!tex) return null | |
| return ( | |
| <mesh position={[0, 8.6, -27.2]} rotation={[0.15, 0, 0]}> | |
| <planeGeometry args={[6.4, 1.5]} /> | |
| <meshBasicMaterial map={tex} transparent toneMapped={false} fog={false} /> | |
| </mesh> | |
| ) | |
| } | |
| function RageDummies() { | |
| const colors = ['#c9a27a', '#b8102a', '#4a4a4a', '#f0c078'] | |
| return ( | |
| <group> | |
| {colors.map((col, i) => { | |
| const a = 1.9 + i * 1.2 | |
| const x = Math.sin(a) * 11.5 + (i % 2 ? 1.5 : -1.5) | |
| const z = Math.cos(a) * 11.5 | |
| const fy = floorHeight(Math.hypot(x, z)) | |
| return ( | |
| <group key={i} position={[x, fy, z]} rotation-y={-a + 1.6}> | |
| <mesh position={[0, 0.85, 0]}> | |
| <capsuleGeometry args={[0.32, 1.1, 6, 10]} /> | |
| <meshLambertMaterial color={col} flatShading /> | |
| </mesh> | |
| <mesh position={[0, 2.05, 0]}> | |
| <sphereGeometry args={[0.34, 10, 8]} /> | |
| <meshLambertMaterial color="#ffd9c8" /> | |
| </mesh> | |
| <mesh position={[0, 2.15, 0]} rotation-x={0.35}> | |
| <coneGeometry args={[0.3, 0.4, 6]} /> | |
| <meshLambertMaterial color={col} flatShading /> | |
| </mesh> | |
| <mesh position={[0, 0.5, 0]}> | |
| <cylinderGeometry args={[0.22, 0.26, 0.12, 8]} /> | |
| <meshLambertMaterial color="#3a2a18" flatShading /> | |
| </mesh> | |
| </group> | |
| ) | |
| })} | |
| </group> | |
| ) | |
| } | |
| function CourtMarks() { | |
| return ( | |
| <group> | |
| {/* sân kéo co 2 bên trái/phải (trên sàn phẳng, 2 phía màn hình lớn) */} | |
| {[[-18, -35, 0.35], [18, -35, -0.35]].map(([x, z, rot], i) => ( | |
| <group key={i} position={[x, 0.03, z]} rotation={[0, rot, 0]}> | |
| <mesh rotation={[-Math.PI / 2, 0, 0]}> | |
| <planeGeometry args={[10, 8]} /> | |
| <meshBasicMaterial color="#1c1612" transparent opacity={0.55} /> | |
| </mesh> | |
| <mesh position={[0, 0.01, -4]} rotation={[-Math.PI / 2, 0, 0]}> | |
| <planeGeometry args={[0.25, 0.4]} /> | |
| <meshBasicMaterial color="#ffd76a" /> | |
| </mesh> | |
| <mesh position={[0, 0.01, 4]} rotation={[-Math.PI / 2, 0, 0]}> | |
| <planeGeometry args={[0.25, 0.4]} /> | |
| <meshBasicMaterial color="#ffd76a" /> | |
| </mesh> | |
| </group> | |
| ))} | |
| {/* sân giữ bóng vòng quanh hố lửa (d=11..15, sàn tier 1-2) */} | |
| <group position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}> | |
| <mesh> | |
| <ringGeometry args={[11, 15, 48]} /> | |
| <meshBasicMaterial color="#1c1612" transparent opacity={0.45} /> | |
| </mesh> | |
| <mesh position={[0, 0.01, -13]}> | |
| <circleGeometry args={[1.9, 20]} /> | |
| <meshBasicMaterial color="#ffd76a" /> | |
| </mesh> | |
| </group> | |
| </group> | |
| ) | |
| } | |
| export function CaveScene() {` ], | |
| /* Wire vào CaveScene (sau patch_3d: Trophy/CaveBanners/JerseyDisplay có sẵn) */ | |
| [ ` <Trophy /> | |
| <CaveBanners /> | |
| <JerseyDisplay /> | |
| <Entrance />`, | |
| ` <LegendWall /> | |
| <LegendPortraits /> | |
| <EplBoard /> | |
| <OnThisDayBoard /> | |
| <BenchSign /> | |
| <RageDummies /> | |
| <CourtMarks /> | |
| <Trophy /> | |
| <CaveBanners /> | |
| <JerseyDisplay /> | |
| <Entrance />` ], | |
| ]) | |
| /* NOTE: avatar số áo (jersey number) đã có sẵn trong source app.js qua useNumTex — | |
| không patch thêm để tránh trùng lặp. */ | |
| patchFile('frontend/app.js', [ | |
| /* ============ RAGE ROOM — Phòng xả giận (ném cà chua 🍅) ============ */ | |
| [ ` <TalkLayer touch={touch} /> | |
| <DuelLayer touch={touch} /> | |
| <PenaltyLayer touch={touch} /> | |
| <TugLayer touch={touch} /> | |
| <BallLayer touch={touch} />`, | |
| ` <RageLayer touch={touch} /> | |
| <TalkLayer touch={touch} /> | |
| <DuelLayer touch={touch} /> | |
| <PenaltyLayer touch={touch} /> | |
| <TugLayer touch={touch} /> | |
| <BallLayer touch={touch} />` ], | |
| [ `function DuelLayer({ touch }) {`, | |
| `/* ========================================================================= | |
| PHÒNG XẢ GIẬN (rage room) — lại gần hình nộm quanh hố lửa → nhấn E vào phòng, | |
| ném cà chua vào hình nộm (giữ chuột lấy lực, thả để ném). Biệt danh nộm theo | |
| vị trí (hậu vệ / tiền vệ / tiền đạo / HLV / Thủ môn) — 4 nộm quanh hố lửa. | |
| ========================================================================= */ | |
| const RAGE_DUMMY_POS = [ | |
| { x: 9.4, z: -3.7 }, { x: 2.0, z: -11.5 }, { x: -12.0, z: -4.6 }, { x: -6.6, z: 8.1 }, | |
| ] | |
| const RAGE_NAMES = ['Hậu vệ', 'Tiền vệ', 'Tiền đạo', 'HLV'] | |
| function RageLayer({ touch }) { | |
| const rage = useRage() | |
| const players = useGame((s) => s.players) | |
| const playerId = useGame((s) => s.playerId) | |
| const me = players.get(playerId) | |
| const [near, setNear] = React.useState(-1) | |
| const [power, setPower] = React.useState(0) | |
| const [charging, setCharging] = React.useState(false) | |
| const [score, setScore] = React.useState({ hits: 0, total: 0 }) | |
| const [slot, setSlot] = React.useState(0) | |
| const active = rage.active | |
| const ivRef = React.useRef(null) | |
| // gần nộm nào? | |
| React.useEffect(() => { | |
| if (active) return | |
| const id = setInterval(() => { | |
| if (!me || !me.pos) return | |
| let found = -1 | |
| for (let i = 0; i < RAGE_DUMMY_POS.length; i++) { | |
| const dx = me.pos.x - RAGE_DUMMY_POS[i].x, dz = me.pos.z - RAGE_DUMMY_POS[i].z | |
| if (dx * dx + dz * dz < 8 * 8) { found = i; break } | |
| } | |
| setNear(found) | |
| }, 400) | |
| return () => clearInterval(id) | |
| }, [active, me && me.pos && me.pos.x, me && me.pos && me.pos.z]) | |
| // phím E vào phòng, Esc ra | |
| React.useEffect(() => { | |
| const down = (e) => { | |
| if (e.code === 'KeyE' && near >= 0 && !active) { | |
| rage.set({ active: true }) | |
| if (document.exitPointerLock) document.exitPointerLock() | |
| } | |
| if (e.code === 'Escape' && active) { | |
| rage.set({ active: false }); setPower(0); setCharging(false) | |
| } | |
| } | |
| window.addEventListener('keydown', down) | |
| return () => window.removeEventListener('keydown', down) | |
| }, [near, active]) | |
| const start = () => { | |
| if (charging) return | |
| setCharging(true); setPower(0) | |
| ivRef.current = setInterval(() => setPower((p) => Math.min(100, p + 7)), 60) | |
| } | |
| const release = () => { | |
| if (ivRef.current) clearInterval(ivRef.current) | |
| const p = power | |
| setCharging(false); setPower(0) | |
| // ném: trúng nếu lực trong dải 35–95 (dễ trúng hơn khi giữ vừa) | |
| if (active && p > 0) { | |
| const hit = p >= 35 && p <= 95 | |
| setScore((s) => ({ hits: s.hits + (hit ? 1 : 0), total: s.total + 1 })) | |
| // hiệu ứng flash | |
| const el = document.querySelector('.rage-cross') | |
| if (el) { el.style.outlineColor = hit ? '#3fb36b' : '#ff4a3a'; setTimeout(() => { el.style.outlineColor = '' }, 350) } | |
| } | |
| } | |
| if (!active && near < 0) return null | |
| return ( | |
| <> | |
| {!active && near >= 0 && ( | |
| <div className="rage-prompt" role="status"> | |
| <span className="rage-prompt-title">🍅 Phòng xả giận</span> | |
| <span className="rage-prompt-sub">ném cà chua vào hình nộm</span> | |
| {touch ? ( | |
| <button type="button" className="rage-enter rage-prompt-act" onClick={() => rage.set({ active: true })}>Vào phòng</button> | |
| ) : ( | |
| <span className="rage-prompt-act"><kbd>E</kbd> để vào</span> | |
| )} | |
| </div> | |
| )} | |
| {active && ( | |
| <div className="rage" data-activity="rage"> | |
| <div className="rage-cross" style={{ left: '50%', top: '50%', position: 'absolute', transform: 'translate(-50%,-50%)', width: 36, height: 36, outline: '3px solid rgba(255,255,255,0.75)', borderRadius: '50%' }} /> | |
| <button type="button" className="rage-exit" onClick={() => { rage.set({ active: false }); setPower(0); setCharging(false) }}>✕ Rời phòng (Esc)</button> | |
| <div className="rage-counter" role="status"> | |
| 🍅 {score.hits}/{score.total} trúng | |
| <span style={{ opacity: 0.8, fontWeight: 400 }}> · nộm: {RAGE_NAMES[slot]}</span> | |
| </div> | |
| <div className="rage-picker"> | |
| <div className="rage-picker-head"> | |
| <div> | |
| <h2>Biệt danh cho hình nộm</h2> | |
| <p>Chọn mục tiêu — nộm đang được highlight trong hang</p> | |
| </div> | |
| </div> | |
| <div className="rage-slots"> | |
| {RAGE_NAMES.map((n, r) => ( | |
| <button key={r} type="button" className={'rage-slot' + (slot === r ? ' is-on' : '')} onClick={() => setSlot(r)}> | |
| {r + 1}. {n} | |
| </button> | |
| ))} | |
| </div> | |
| <button type="button" className="rage-chip rage-random" onClick={() => setSlot(Math.floor(Math.random() * RAGE_NAMES.length))}>🎭 Ngẫu nhiên</button> | |
| <p style={{ opacity: 0.7, fontSize: 12, marginTop: 8 }}>Giữ / chạm để lấy lực, thả để ném 🍅 — lực 35–95% là trúng.</p> | |
| </div> | |
| <div className="rage-disclaimer" data-on={power > 0 || undefined} style={{ position: 'absolute', bottom: 96, left: '50%', transform: 'translateX(-50%)', textAlign: 'center', color: '#fff6ec', background: 'rgba(12,8,6,0.85)', padding: '10px 18px', borderRadius: 12, border: '1px solid #ecd08c66', maxWidth: 'min(420px, calc(100vw - 32px))' }}> | |
| {power > 0 ? '🍅 Lực ' + power + '%' + (power >= 35 && power <= 95 ? ' — thả để ném!' : ' — hơi non/già!') : 'Giữ / chạm để lấy lực, thả để ném'} | |
| <div style={{ height: 6, background: '#0009', borderRadius: 3, marginTop: 8, overflow: 'hidden' }}> | |
| <div style={{ width: power + '%', height: '100%', background: power >= 35 && power <= 95 ? '#3fb36b' : '#ff4a3a', transition: 'width 60ms linear' }} /> | |
| </div> | |
| </div> | |
| <button | |
| type="button" | |
| className="rage-throw" | |
| style={{ position: 'absolute', bottom: 24, left: '50%', transform: 'translateX(-50%)', color: '#fff4e6', background: 'linear-gradient(180deg,#ff4a3a,#c8102e)', border: '2px solid #ffd76a', borderRadius: 999, padding: '14px 32px', fontWeight: 800, fontSize: 16, cursor: 'pointer', boxShadow: '0 6px 16px #000a' }} | |
| onPointerDown={start} | |
| onPointerUp={release} | |
| onPointerLeave={release} | |
| >🍅 NÉM!</button> | |
| </div> | |
| )} | |
| </> | |
| ) | |
| } | |
| function DuelLayer({ touch }) {` ], | |
| ]) | |