/* patch_3d.mjs — Bổ sung vật thể/scene/nhân vật 3D còn thiếu so với hangmu.online. Chạy SAU patch_app.mjs (pattern trỏ vào source ĐÃ patch). - app.js: avatar mặc định áo đỏ MU #c8102e (ưu tiên kit nếu server gửi), thêm vai + giày, va chạm ghế đá hội trường (5×10). - home3d.js: thiên nhiên quanh cửa hang (cỏ/bụi/lá/đá instancedMesh), bảng chuyên cần attendance trên bia đá. - cave3d.js: cúp vàng trung tâm + banner MU trên vòm. 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, 70)}`) } for (const [o, n] of reps) rep(o, n) if (failures.length) { console.error(`PATCH 3D FAILED (${path}):`) failures.forEach((f) => console.error(' -', f)) process.exit(1) } fs.writeFileSync(path, s) console.log('patched ok (3d):', path) } /* ================= app.js ================= */ patchFile('frontend/app.js', [ /* Palette: thêm đỏ MU da291c */ [ "const COLORS = ['#c8102e', '#fdbd10', '#ffffff', '#4ade80', '#60a5fa', '#f472b6', '#a78bfa', '#fb923c']", "const COLORS = ['#c8102e', '#da291c', '#fdbd10', '#4ade80', '#60a5fa', '#f472b6', '#a78bfa', '#fb923c']" ], /* Avatar: mặc định áo đỏ MU nếu server không gửi kit */ [ `function PlayerAvatar({ id, p, isLocal }) { const color = COLORS[parseInt(id.slice(-1), 16) % COLORS.length]`, `function PlayerAvatar({ id, p, isLocal }) { const color = (p.kit != null && COLORS[p.kit % COLORS.length]) || '#c8102e'` ], /* Va chạm ghế đá hội trường (5 hàng × 10 ghế, y hệt hall.js gốc) */ [ "const KEYMAP = { ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down', ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right' }", `const KEYMAP = { ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down', ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right' } /* Va chạm ghế đá (hội trường trước màn hình lớn) — 5 hàng × 10 ghế, y hệt hall.js gốc */ const BENCH_COLLIDE = (() => { const out = [] for (let row = 0; row < 5; row++) { const z = -37 + row * 2.6 for (let col = 0; col < 10; col++) { const x = -10.8 + col * 2.4 out.push({ x, z, r: 1.05 }) } } return out })() function benchBlocked(nx, nz) { for (let i = 0; i < BENCH_COLLIDE.length; i++) { const b = BENCH_COLLIDE[i] const dx = nx - b.x, dz = nz - b.z if (dx * dx + dz * dz < b.r * b.r) return true } return false }` ], /* Chặn đi xuyên ghế */ [ ` const fireSafe = 8.2 const d0 = Math.hypot(me.pos.x, me.pos.z) if (d0 >= fireSafe && Math.hypot(nx, nz) < fireSafe) { const s = fireSafe / Math.max(Math.hypot(nx, nz), 1e-6) nx *= s; nz *= s } me.pos.x = nx; me.pos.z = nz`, ` const fireSafe = 8.2 const d0 = Math.hypot(me.pos.x, me.pos.z) if (d0 >= fireSafe && Math.hypot(nx, nz) < fireSafe) { const s = fireSafe / Math.max(Math.hypot(nx, nz), 1e-6) nx *= s; nz *= s } // va chạm ghế đá hội trường: không cho đi xuyên qua if (benchBlocked(nx, nz)) { if (!benchBlocked(nx, me.pos.z)) nz = me.pos.z else if (!benchBlocked(me.pos.x, nz)) nx = me.pos.x else { nx = me.pos.x; nz = me.pos.z } } me.pos.x = nx; me.pos.z = nz` ], /* Vai + giày cho avatar */ [ ` `, ` {/* vai — cầu vai rộng kiểu áo bóng đá */} {/* giày — đế dưới chân */} ` ], ]) /* ================= home3d.js ================= */ patchFile('frontend/home3d.js', [ /* Thiên nhiên quanh cửa hang */ [ `const FULLR = Math.PI * 2`, `const FULLR = Math.PI * 2 /* ========================================================================= THIÊN NHIÊN QUANH CỬA HANG — cỏ / bụi / lá / đá (instancedMesh, y hệt gốc) ========================================================================= */ function _instancedNature() { const rand = mulberry(31) const grass = [], bushes = [], leaves = [], stones = [] const push = (arr, geo, px, py, pz, sx, sy, sz, ry) => arr.push({ geo, px, py, pz, sx, sy, sz, ry }) const gGeo = new THREE.ConeGeometry(0.05, 0.42, 3) for (let i = 0; i < 220; i++) { const a = rand() * Math.PI * 2, r = 4 + rand() * 13 push(grass, gGeo, Math.sin(a) * r * 0.9, 0.14 + rand() * 0.2, Math.cos(a) * r * 0.8 + 1.5, 0.8 + rand() * 0.9, 0.8 + rand() * 0.9, 0.8 + rand() * 0.9, rand() * Math.PI) } const bGeo = new THREE.IcosahedronGeometry(0.5, 1) for (let i = 0; i < 26; i++) { const a = rand() * Math.PI * 2, r = 6 + rand() * 15 push(bushes, bGeo, Math.sin(a) * r * 0.9, 0.25 + rand() * 0.25, Math.cos(a) * r * 0.8 + 1.5, 0.5 + rand() * 0.7, 0.5 + rand() * 0.5, 0.5 + rand() * 0.7, rand() * Math.PI) } const lGeo = new THREE.PlaneGeometry(0.09, 0.05) for (let i = 0; i < 40; i++) { const a = rand() * Math.PI * 2, r = 3 + rand() * 16 push(leaves, lGeo, Math.sin(a) * r * 0.9, 0.6 + rand() * 1.8, Math.cos(a) * r * 0.8 + 1.5, 1, 1, 1, rand() * Math.PI) } const sGeo = new THREE.IcosahedronGeometry(0.28, 1) for (let i = 0; i < 14; i++) { const a = rand() * Math.PI * 2, r = 4.5 + rand() * 14 push(stones, sGeo, Math.sin(a) * r * 0.9, 0.12 + rand() * 0.18, Math.cos(a) * r * 0.8 + 1.5, 0.5 + rand() * 1.2, 0.45 + rand() * 0.9, 0.5 + rand() * 1.2, rand() * Math.PI) } return { grass, bushes, leaves, stones } } const _nature = _instancedNature() function NatureGround() { const gRef = useRef(null), bRef = useRef(null), lRef = useRef(null), sRef = useRef(null) const green = new THREE.Color('#6d7d38'), green2 = new THREE.Color('#56662d'), leaf = new THREE.Color('#7f9a4a') const stone = new THREE.Color('#9a8a6c'), stone2 = new THREE.Color('#77664c') useMemo(() => { const dummy = new THREE.Object3D() const fill = (ref, arr, cA, cB) => { if (!ref.current) return arr.forEach((it, i) => { dummy.position.set(it.px, it.py, it.pz); dummy.scale.set(it.sx, it.sy, it.sz); dummy.rotation.y = it.ry dummy.updateMatrix(); ref.current.setMatrixAt(i, dummy.matrix) ref.current.setColorAt(i, i % 2 ? cB : cA) }) ref.current.instanceMatrix.needsUpdate = true ref.current.instanceColor.needsUpdate = true } fill(gRef, _nature.grass, green, green2) fill(bRef, _nature.bushes, green, green2) fill(lRef, _nature.leaves, leaf, leaf) fill(sRef, _nature.stones, stone, stone2) }, []) return ( <> ) }` ], /* Dây leo quanh vòm cửa + bảng chuyên cần đứng — vines/board như gốc */ [ `function GroundGeom() {`, `function Vines() { const strands = useMemo(() => { const out = [] const rand = mulberry(13) for (let i = 0; i < 26; i++) { const a = Math.PI + (i - 13) * 0.12 const r = 4.9 const x = Math.sin(a) * r * 1.15 + 1.2, z = Math.cos(a) * r - 1.5 const len = 1.6 + rand() * 2.2 out.push({ x, z, len, sway: rand() * Math.PI * 2, leaf: rand() * 5 }) } return out }, []) return ( {strands.map((v, i) => ( ))} ) } function AttendanceBoard() { const [rows, setRows] = React.useState(null) React.useEffect(() => { let alive = true fetch('/api/attendance').then(r => r.ok ? r.json() : Promise.reject()).then(d => { if (alive && d && d.rows) setRows(d.rows) }).catch(() => { if (!alive) return const r = [] for (let i = 0; i < 5; i++) { const row = [] for (let j = 0; j < 10; j++) row.push(Math.random() > 0.35 ? { name: '', number: 0 } : null) r.push(row) } setRows(r) }) return () => { alive = false } }, []) const tex = React.useMemo(() => { const c = document.createElement('canvas'); c.width = 640; c.height = 320 const g = c.getContext('2d') g.fillStyle = '#7a5f42'; g.fillRect(0, 0, 640, 320) g.strokeStyle = '#4a3522'; g.lineWidth = 4; g.strokeRect(4, 4, 632, 312) g.fillStyle = '#3a2a18'; g.font = '700 26px "Be Vietnam Pro", sans-serif'; g.textAlign = 'center' g.fillText('CHUYÊN CẦN THÁNG NÀY', 320, 36) for (let r = 0; r < 5; r++) for (let c = 0; c < 10; c++) { const x = 16 + c * 61, y = 50 + r * 50 g.fillStyle = '#5a442c'; g.fillRect(x, y, 56, 42) if (rows && rows[r] && rows[r][c]) { g.fillStyle = '#c8102e'; g.fillRect(x + 3, y + 3, 50, 36) g.fillStyle = '#fff'; g.font = '600 18px "Be Vietnam Pro", sans-serif' g.fillText('✓', x + 28, y + 27) } else { g.fillStyle = '#8a6f4e'; g.fillRect(x + 3, y + 3, 50, 36) } } const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace return t }, [rows]) if (!rows) return null return ( ) } function GroundGeom() {` ], /* Wire AttendanceBoard vào PostAndPlaque (trụ bảng) */ [ ` )}`, ` )} ` ], /* Wire NatureGround + Vines vào HomeScene */ [ ` `, ` ` ], /* Bảng chuyên cần attendance trên bia đá */ [ `/* ---- Camera rig + layout (MA gốc) ---- */`, `/* ========================================================================= BẢNG CHUYÊN CẦN (attendance) — tái hiện Mn() hangmu.online: 5×10 ô trên bia đá, mỗi ô = người có mặt hôm nay ========================================================================= */ function SteleTop() { const [rows, setRows] = React.useState(null) React.useEffect(() => { let alive = true fetch('/api/attendance').then(r => r.ok ? r.json() : Promise.reject()).then(d => { if (alive && d && d.rows) setRows(d.rows) }).catch(() => { if (!alive) return const r = [] for (let i = 0; i < 5; i++) { const row = [] for (let j = 0; j < 10; j++) row.push(Math.random() > 0.35 ? { name: '', number: 0 } : null) r.push(row) } setRows(r) }) return () => { alive = false } }, []) const boardTex = React.useMemo(() => { const c = document.createElement('canvas') c.width = 640; c.height = 320 const g = c.getContext('2d') g.fillStyle = '#8a6f4e'; g.fillRect(0, 0, 640, 320) g.fillStyle = '#7a5f42'; g.fillRect(8, 8, 624, 304) g.strokeStyle = '#4a3522'; g.lineWidth = 4; g.strokeRect(8, 8, 624, 304) g.fillStyle = '#3a2a18'; g.font = '700 26px "Be Vietnam Pro", sans-serif' g.textAlign = 'center' g.fillText('CHUYÊN CẦN THÁNG NÀY', 320, 38) if (rows) { for (let r = 0; r < 5; r++) { for (let c = 0; c < 10; c++) { const x = 18 + c * 61, y = 52 + r * 50 g.fillStyle = '#5a442c'; g.fillRect(x, y, 56, 42) if (rows[r] && rows[r][c]) { g.fillStyle = '#c8102e'; g.fillRect(x + 3, y + 3, 50, 36) g.fillStyle = '#fff'; g.font = '600 18px "Be Vietnam Pro", sans-serif' g.fillText('✓', x + 28, y + 26) } else { g.fillStyle = '#8a6f4e'; g.fillRect(x + 3, y + 3, 50, 36) } } } } const t = new THREE.CanvasTexture(c) t.colorSpace = THREE.SRGBColorSpace return t }, [rows]) if (!rows) return null return ( ) } /* ---- Camera rig + layout (MA gốc) ---- */` ], /* Wire SteleTop vào StelePlate */ [ ` ) }`, ` ) }` ], ]) /* ================= cave3d.js ================= */ patchFile('frontend/cave3d.js', [ /* Cúp vàng + banner MU trên vòm */ [ `export function CaveScene() {`, `/* ========================================================================= CÚP VÀNG TRUNG TÂM (trophy) — trước vòng huyền thoại, kỷ vật MU ========================================================================= */ function Trophy() { return ( ) } /* ========================================================================= BANNER MU TRÊN VÒM — không khí fan quanh hang ========================================================================= */ function makeBannerTex(text) { const c = document.createElement('canvas') c.width = 512; c.height = 128 const g = c.getContext('2d') g.fillStyle = '#c8102e'; g.fillRect(0, 0, 512, 128) g.fillStyle = '#8f0f22'; g.fillRect(6, 6, 500, 116) g.strokeStyle = '#ffd76a'; g.lineWidth = 4; g.strokeRect(6, 6, 500, 116) g.fillStyle = '#ffffff'; g.font = '900 52px "Be Vietnam Pro", sans-serif' g.textAlign = 'center'; g.textBaseline = 'middle' g.fillText(text, 256, 66) const t = new THREE.CanvasTexture(c) t.colorSpace = THREE.SRGBColorSpace return t } function CaveBanners() { const texA = useMemo(() => makeBannerTex('GLORY GLORY'), []) const texB = useMemo(() => makeBannerTex('MAN UNITED'), []) const texC = useMemo(() => makeBannerTex('MU FAN'), []) const conf = [ { tex: texA, pos: [-14, 12.5, -24], rot: [0, 0.5, 0] }, { tex: texB, pos: [16, 12.8, -30], rot: [0, -0.45, 0] }, { tex: texC, pos: [0, 13.2, -8], rot: [0, 0, 0] }, ] return ( {conf.map((c, i) => ( ))} ) } export function CaveScene() {` ], /* Wire vào CaveScene (pattern sau patch_app: có sẵn) */ [ ` `, ` ` ], ])