Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,276 @@
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>
)
}
@@ -0,0 +1,117 @@
import { useState, useCallback } from 'react'
import type { Project } from '../types/kanban'
interface ProjectSelectorProps {
projects: Project[]
activeId: string
onSelect: (id: string) => void
onCreate: (id: string) => void
onDelete?: (id: string) => void
}
export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDelete }: ProjectSelectorProps) {
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const handleCreate = useCallback(() => {
const id = newName.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '')
if (!id) return
onCreate(id)
setNewName('')
setCreating(false)
}, [newName, onCreate])
const handleDelete = useCallback(() => {
if (!confirmDelete || !onDelete) return
onDelete(confirmDelete)
setConfirmDelete(null)
}, [confirmDelete, onDelete])
return (
<div className="project-selector" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select
className="theme-select"
value={activeId}
onChange={e => onSelect(e.target.value)}
title="Switch project"
>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{creating ? (
<form
onSubmit={e => { e.preventDefault(); handleCreate() }}
style={{ display: 'flex', gap: 4 }}
>
<input
autoFocus
className="theme-select"
value={newName}
placeholder="project-name"
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') setCreating(false) }}
style={{ width: 120 }}
/>
<button type="submit" className="pill-btn" style={{ fontSize: 11, padding: '2px 8px' }}>+</button>
</form>
) : (
<button
className="pill-btn"
onClick={() => setCreating(true)}
title="New project"
style={{ fontSize: 11, padding: '2px 8px' }}
>
+
</button>
)}
{onDelete && activeId !== 'default' && !confirmDelete && (
<button
className="pill-btn"
onClick={() => setConfirmDelete(activeId)}
title="Delete project"
style={{ fontSize: 11, padding: '2px 8px', color: '#ef4444' }}
>
Del
</button>
)}
{confirmDelete && (
<div className="project-delete-confirm" style={{
position: 'fixed', inset: 0, zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
}}>
<div style={{
background: 'var(--bg-surface, #1e1e2e)', borderRadius: 12, padding: '24px 32px',
maxWidth: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.4)', textAlign: 'center',
}}>
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}>
Delete project "{confirmDelete}"?
</p>
<p style={{ margin: '0 0 20px', fontSize: 13, opacity: 0.7 }}>
All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button
className="pill-btn"
onClick={() => setConfirmDelete(null)}
style={{ padding: '6px 18px', fontSize: 13 }}
>
Cancel
</button>
<button
className="pill-btn"
onClick={handleDelete}
style={{ padding: '6px 18px', fontSize: 13, background: '#ef4444', color: '#fff' }}
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}