Files
2026-07-01 17:56:31 +08:00

277 lines
9.9 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react'
import type { GameBridge } from '../game/GameBridge'
import {
DEFAULT_MAP_STR,
DEFAULT_SEATS,
parseMapStr,
gridToMapStr,
} from '../game/map/OfficeMapBuilder'
import { getOffices, type OfficeConfig } from '../game/map/OfficeStore'
import { OFFICE_COLS, OFFICE_ROWS, TILE_SIZE } from '../game/config'
interface Props {
bridge: GameBridge
}
type EditorMode = 'wall' | 'floor' | 'seat'
const BG_ASSET_URL = 'assets/office-bg.png'
export function CollisionEditor({ bridge }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const bgImgRef = useRef<HTMLImageElement | null>(null)
const isPainting = useRef(false)
const [offices, setOffices] = useState<OfficeConfig[]>(() => getOffices())
const [selectedOfficeId, setSelectedOfficeId] = useState<string>(() => getOffices()[0]?.id ?? 'office-0')
const selectedOffice = offices.find(o => o.id === selectedOfficeId) ?? offices[0]
const [grid, setGrid] = useState<number[][]>(() => parseMapStr(selectedOffice?.mapStr ?? DEFAULT_MAP_STR, OFFICE_COLS, OFFICE_ROWS))
const [seats, setSeats] = useState<[number, number][]>(() => [...(selectedOffice?.seats ?? DEFAULT_SEATS)])
const [mode, setMode] = useState<EditorMode>('wall')
const [bgLoaded, setBgLoaded] = useState(false)
const [showExport, setShowExport] = useState(false)
const [applied, setApplied] = useState(false)
const [zoom, setZoom] = useState(1)
const [showGrid, setShowGrid] = useState(true)
const cols = OFFICE_COLS
const rows = OFFICE_ROWS
const switchOffice = (officeId: string) => {
const refreshed = getOffices()
setOffices(refreshed)
setSelectedOfficeId(officeId)
const office = refreshed.find(o => o.id === officeId)
if (office) {
setGrid(parseMapStr(office.mapStr, OFFICE_COLS, OFFICE_ROWS))
setSeats([...office.seats])
}
setApplied(false)
}
useEffect(() => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => { bgImgRef.current = img; setBgLoaded(true) }
img.onerror = () => { bgImgRef.current = null; setBgLoaded(true) }
img.src = BG_ASSET_URL
}, [])
const render = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = cols * TILE_SIZE
const h = rows * TILE_SIZE
canvas.width = w
canvas.height = h
ctx.clearRect(0, 0, w, h)
if (bgImgRef.current) {
ctx.drawImage(bgImgRef.current, 0, 0, w, h)
} else {
ctx.fillStyle = '#3d3d3d'
ctx.fillRect(0, 0, w, h)
}
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x0 = c * TILE_SIZE
const y0 = r * TILE_SIZE
const blocked = grid[r]?.[c] === 1
ctx.fillStyle = blocked ? 'rgba(255, 40, 40, 0.35)' : 'rgba(40, 255, 40, 0.2)'
ctx.fillRect(x0, y0, TILE_SIZE, TILE_SIZE)
}
}
for (const [c, r] of seats) {
const cx = c * TILE_SIZE + TILE_SIZE / 2
const cy = r * TILE_SIZE + TILE_SIZE / 2
ctx.beginPath()
ctx.arc(cx, cy, 9, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 220, 0, 0.85)'
ctx.fill()
ctx.strokeStyle = '#000'
ctx.lineWidth = 1.5
ctx.stroke()
}
if (showGrid) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'
ctx.lineWidth = 0.5
for (let c = 0; c <= cols; c++) {
ctx.beginPath(); ctx.moveTo(c * TILE_SIZE, 0); ctx.lineTo(c * TILE_SIZE, h); ctx.stroke()
}
for (let r = 0; r <= rows; r++) {
ctx.beginPath(); ctx.moveTo(0, r * TILE_SIZE); ctx.lineTo(w, r * TILE_SIZE); ctx.stroke()
}
ctx.font = '10px monospace'
ctx.textBaseline = 'top'
for (let c = 0; c < cols; c++) { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText(String(c), c * TILE_SIZE + 2, 2) }
for (let r = 0; r < rows; r++) { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText(String(r), 2, r * TILE_SIZE + 2) }
}
}, [grid, seats, cols, rows, showGrid, bgLoaded])
useEffect(() => { render() }, [render])
const getCellFromEvent = (e: React.MouseEvent<HTMLCanvasElement>): { c: number; r: number } | null => {
const canvas = canvasRef.current
if (!canvas) return null
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
const c = Math.floor((e.clientX - rect.left) * scaleX / TILE_SIZE)
const r = Math.floor((e.clientY - rect.top) * scaleY / TILE_SIZE)
if (c < 0 || c >= cols || r < 0 || r >= rows) return null
return { c, r }
}
const paint = useCallback((c: number, r: number) => {
setApplied(false)
if (mode === 'seat') {
setSeats(prev => {
const idx = prev.findIndex(([sc, sr]) => sc === c && sr === r)
if (idx >= 0) return prev.filter((_, i) => i !== idx)
return [...prev, [c, r]]
})
setGrid(prev => {
if (prev[r]?.[c] === 1) { const next = prev.map(row => [...row]); next[r][c] = 0; return next }
return prev
})
} else {
const value = mode === 'wall' ? 1 : 0
setGrid(prev => {
if (prev[r]?.[c] === value) return prev
const next = prev.map(row => [...row]); next[r][c] = value; return next
})
if (mode === 'wall') setSeats(prev => prev.filter(([sc, sr]) => !(sc === c && sr === r)))
}
}, [mode])
const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return
isPainting.current = true
const cell = getCellFromEvent(e)
if (cell) paint(cell.c, cell.r)
}
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isPainting.current) return
if (mode === 'seat') return
const cell = getCellFromEvent(e)
if (cell) paint(cell.c, cell.r)
}
const onMouseUp = () => { isPainting.current = false }
const handleApply = () => {
const mapStr = gridToMapStr(grid)
bridge.rebuildOfficeCollision(selectedOfficeId, mapStr, seats)
setApplied(true)
setOffices(getOffices())
setTimeout(() => setApplied(false), 2000)
}
const handleExport = () => setShowExport(v => !v)
const handleReset = () => {
setGrid(parseMapStr(DEFAULT_MAP_STR, OFFICE_COLS, OFFICE_ROWS))
setSeats([...DEFAULT_SEATS])
setApplied(false)
}
const exportText = (() => {
const mapStr = gridToMapStr(grid)
const mapLines = mapStr.map((line, i) => ` '${line}', // ${i}`).join('\n')
const seatLines = seats.map(([c, r]) => ` [${c}, ${r}],`).join('\n')
return `const MAP_STR: string[] = [\n${mapLines}\n]\n\nconst SEATS: [number, number][] = [\n${seatLines}\n]`
})()
const zoomIn = () => setZoom(z => Math.min(z + 0.25, 3))
const zoomOut = () => setZoom(z => Math.max(z - 0.25, 0.5))
const wallCount = grid.flat().filter(v => v === 1).length
const floorCount = grid.flat().filter(v => v === 0).length
return (
<div className="collision-editor">
<div className="ce-toolbar">
<div className="ce-toolbar-group">
<span className="ce-title">Map Editor</span>
<select
className="ce-office-select"
value={selectedOfficeId}
onChange={e => switchOffice(e.target.value)}
>
{offices.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
<div className="ce-toolbar-group">
<button className={`ce-mode-btn ${mode === 'wall' ? 'active wall' : ''}`} onClick={() => setMode('wall')}>
<span className="ce-mode-dot wall" /> Wall
</button>
<button className={`ce-mode-btn ${mode === 'floor' ? 'active floor' : ''}`} onClick={() => setMode('floor')}>
<span className="ce-mode-dot floor" /> Floor
</button>
<button className={`ce-mode-btn ${mode === 'seat' ? 'active seat' : ''}`} onClick={() => setMode('seat')}>
<span className="ce-mode-dot seat" /> Seat
</button>
</div>
<div className="ce-toolbar-group">
<button className="ce-btn" onClick={zoomOut}>-</button>
<span className="ce-zoom-label">{Math.round(zoom * 100)}%</span>
<button className="ce-btn" onClick={zoomIn}>+</button>
<button className={`ce-btn${showGrid ? ' active' : ''}`} onClick={() => setShowGrid(v => !v)}>Grid</button>
</div>
<div className="ce-toolbar-group">
<button className={`ce-btn apply${applied ? ' success' : ''}`} onClick={handleApply}>
{applied ? 'Applied' : 'Apply'}
</button>
<button className={`ce-btn${showExport ? ' active' : ''}`} onClick={handleExport}>Export</button>
<button className="ce-btn danger" onClick={handleReset}>Reset</button>
</div>
<div className="ce-toolbar-group ce-stats">
<span>Walls: {wallCount}</span>
<span>Floor: {floorCount}</span>
<span>Seats: {seats.length}</span>
</div>
</div>
<div className="ce-body">
<div className="ce-canvas-wrap" style={{ overflow: 'auto' }}>
<canvas
ref={canvasRef}
style={{
width: cols * TILE_SIZE * zoom,
height: rows * TILE_SIZE * zoom,
imageRendering: 'pixelated',
cursor: mode === 'seat' ? 'crosshair' : 'cell',
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
/>
</div>
{showExport && (
<div className="ce-export-panel">
<div className="ce-export-header">
<span>Export copy to source code</span>
<button className="ce-btn" onClick={() => navigator.clipboard.writeText(exportText)}>Copy</button>
</div>
<textarea className="ce-export-textarea" value={exportText} readOnly rows={20} />
</div>
)}
</div>
</div>
)
}