/* 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) */
[ `
`,
`
` ],
])