mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat: implement Pixel Office Phase 2 - layout editor, persistence, sound, sub-agent visualization
- Add layout editor with floor/wall paint, erase, furniture place/move/rotate/delete tools - Add editor state management (undo/redo, selection, drag, ghost preview) - Add EditorToolbar and EditActionBar UI components - Add layout persistence API (GET/POST to ~/.openclaw/pixel-office/layout.json) - Add notification sound system (Web Audio API ascending chime on agent completion) - Add sub-agent visualization via session JSONL parsing - Fix agent activity API to read from agents.list config and correct session paths - Add agent name labels above character sprites in canvas renderer - Add i18n translations for editor features (zh/en)
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { promises as fs, existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
export interface SubagentInfo {
|
||||
toolId: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
agentId: string
|
||||
name: string
|
||||
emoji: string
|
||||
state: 'idle' | 'working' | 'waiting' | 'offline'
|
||||
currentTool?: string
|
||||
toolStatus?: string
|
||||
lastActive: number
|
||||
subagents?: SubagentInfo[]
|
||||
}
|
||||
|
||||
/** Parse the last N lines of the most recent session file for subtask patterns */
|
||||
async function parseSubagents(agentSessionsDir: string): Promise<SubagentInfo[]> {
|
||||
const subagents: SubagentInfo[] = []
|
||||
try {
|
||||
const files = await fs.readdir(agentSessionsDir)
|
||||
if (files.length === 0) return subagents
|
||||
|
||||
// Find most recent file
|
||||
let latestFile = ''
|
||||
let latestTime = 0
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
const filePath = path.join(agentSessionsDir, file)
|
||||
const stat = await fs.stat(filePath)
|
||||
if (stat.mtimeMs > latestTime) {
|
||||
latestTime = stat.mtimeMs
|
||||
latestFile = filePath
|
||||
}
|
||||
}
|
||||
if (!latestFile) return subagents
|
||||
|
||||
// Read last 8KB for recent activity
|
||||
const stat = await fs.stat(latestFile)
|
||||
const readSize = Math.min(8192, stat.size)
|
||||
const handle = await fs.open(latestFile, 'r')
|
||||
const buffer = Buffer.alloc(readSize)
|
||||
await handle.read(buffer, 0, readSize, Math.max(0, stat.size - readSize))
|
||||
await handle.close()
|
||||
|
||||
const content = buffer.toString('utf-8')
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
|
||||
// Look for active subtask tool_use entries
|
||||
const activeSubtasks = new Map<string, string>()
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const record = JSON.parse(line)
|
||||
if (record.type === 'assistant' && record.message?.content) {
|
||||
const blocks = Array.isArray(record.message.content) ? record.message.content : []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'tool_use' && typeof block.input?.description === 'string') {
|
||||
const desc = block.input.description as string
|
||||
if (desc.startsWith('Subtask:') || desc.includes('subtask')) {
|
||||
activeSubtasks.set(block.id, desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear completed subtasks
|
||||
if (record.type === 'user' && record.message?.content) {
|
||||
const blocks = Array.isArray(record.message.content) ? record.message.content : []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'tool_result' && block.tool_use_id) {
|
||||
activeSubtasks.delete(block.tool_use_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
|
||||
for (const [toolId, label] of activeSubtasks) {
|
||||
subagents.push({ toolId, label })
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return subagents
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const openclawDir = path.join(os.homedir(), '.openclaw')
|
||||
const configPath = path.join(openclawDir, 'openclaw.json')
|
||||
const agentsDir = path.join(openclawDir, 'agents')
|
||||
|
||||
const agents: AgentActivity[] = []
|
||||
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const configContent = await fs.readFile(configPath, 'utf8')
|
||||
const config = JSON.parse(configContent)
|
||||
|
||||
const agentList = Array.isArray(config.agents) ? config.agents : config.agents?.list
|
||||
if (agentList && Array.isArray(agentList)) {
|
||||
const now = Date.now()
|
||||
|
||||
for (const agent of agentList) {
|
||||
let lastActive = 0
|
||||
let agentSessionsDir = ''
|
||||
|
||||
if (existsSync(agentsDir)) {
|
||||
agentSessionsDir = path.join(agentsDir, agent.id, 'sessions')
|
||||
if (existsSync(agentSessionsDir)) {
|
||||
try {
|
||||
const files = await fs.readdir(agentSessionsDir)
|
||||
for (const file of files) {
|
||||
const filePath = path.join(agentSessionsDir, file)
|
||||
const stat = await fs.stat(filePath)
|
||||
if (stat.mtimeMs > lastActive) {
|
||||
lastActive = stat.mtimeMs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let state: 'idle' | 'working' | 'waiting' | 'offline'
|
||||
const timeDiff = now - lastActive
|
||||
if (lastActive === 0 || timeDiff > 5 * 60 * 1000) {
|
||||
state = 'offline'
|
||||
} else if (timeDiff <= 30 * 1000) {
|
||||
state = 'working'
|
||||
} else {
|
||||
state = 'idle'
|
||||
}
|
||||
|
||||
// Parse subagents for working agents
|
||||
let subagents: SubagentInfo[] | undefined
|
||||
if (state === 'working' && agentSessionsDir && existsSync(agentSessionsDir)) {
|
||||
subagents = await parseSubagents(agentSessionsDir)
|
||||
if (subagents.length === 0) subagents = undefined
|
||||
}
|
||||
|
||||
agents.push({
|
||||
agentId: agent.id,
|
||||
name: agent.name || agent.id,
|
||||
emoji: agent.identity?.emoji || agent.emoji || '🤖',
|
||||
state,
|
||||
lastActive,
|
||||
subagents,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading agent activity:', error)
|
||||
}
|
||||
|
||||
return NextResponse.json({ agents })
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
const LAYOUT_DIR = path.join(os.homedir(), '.openclaw', 'pixel-office')
|
||||
const LAYOUT_FILE = path.join(LAYOUT_DIR, 'layout.json')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (!fs.existsSync(LAYOUT_FILE)) {
|
||||
return NextResponse.json({ layout: null })
|
||||
}
|
||||
const data = fs.readFileSync(LAYOUT_FILE, 'utf-8')
|
||||
const layout = JSON.parse(data)
|
||||
return NextResponse.json({ layout })
|
||||
} catch {
|
||||
return NextResponse.json({ layout: null })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { layout } = await request.json()
|
||||
if (!layout || layout.version !== 1 || !Array.isArray(layout.tiles)) {
|
||||
return NextResponse.json({ error: 'Invalid layout' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(LAYOUT_DIR)) {
|
||||
fs.mkdirSync(LAYOUT_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// Atomic write: write to .tmp then rename
|
||||
const tmpFile = LAYOUT_FILE + '.tmp'
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(layout, null, 2), 'utf-8')
|
||||
fs.renameSync(tmpFile, LAYOUT_FILE)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
|
||||
interface EditActionBarProps {
|
||||
isDirty: boolean
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onSave: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
const barBtnStyle: React.CSSProperties = {
|
||||
padding: '3px 10px',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
border: '2px solid #4a4a6a',
|
||||
borderRadius: 0,
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
const disabledBtnStyle: React.CSSProperties = {
|
||||
...barBtnStyle,
|
||||
opacity: 0.3,
|
||||
cursor: 'default',
|
||||
}
|
||||
|
||||
export function EditActionBar({ isDirty, canUndo, canRedo, onUndo, onRedo, onSave, onReset }: EditActionBarProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!isDirty && !canUndo && !canRedo) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top: 10, left: '50%', transform: 'translateX(-50%)', zIndex: 50,
|
||||
background: '#1e1e2e', border: '2px solid #4a4a6a', borderRadius: 0,
|
||||
padding: '4px 8px', display: 'flex', gap: 4,
|
||||
boxShadow: '2px 2px 0px #0a0a14',
|
||||
}}>
|
||||
<button style={canUndo ? barBtnStyle : disabledBtnStyle} onClick={onUndo} disabled={!canUndo} title="Ctrl+Z">
|
||||
{t('pixelOffice.undo')}
|
||||
</button>
|
||||
<button style={canRedo ? barBtnStyle : disabledBtnStyle} onClick={onRedo} disabled={!canRedo} title="Ctrl+Y">
|
||||
{t('pixelOffice.redo')}
|
||||
</button>
|
||||
{isDirty && (
|
||||
<>
|
||||
<button style={{ ...barBtnStyle, background: 'rgba(90, 140, 255, 0.25)', border: '2px solid #5a8cff' }} onClick={onSave}>
|
||||
{t('pixelOffice.save')}
|
||||
</button>
|
||||
<button style={barBtnStyle} onClick={onReset}>
|
||||
{t('pixelOffice.reset')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { EditTool } from '@/lib/pixel-office/types'
|
||||
import type { TileType as TileTypeVal, FloorColor } from '@/lib/pixel-office/types'
|
||||
import { getCatalogByCategory, getActiveCategories } from '@/lib/pixel-office/layout/furnitureCatalog'
|
||||
import type { FurnitureCategory } from '@/lib/pixel-office/layout/furnitureCatalog'
|
||||
import { getCachedSprite } from '@/lib/pixel-office/sprites/spriteCache'
|
||||
import { getColorizedFloorSprite, getFloorPatternCount, hasFloorSprites } from '@/lib/pixel-office/floorTiles'
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '3px 8px',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
border: '2px solid transparent',
|
||||
borderRadius: 0,
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
const activeBtnStyle: React.CSSProperties = {
|
||||
...btnStyle,
|
||||
background: 'rgba(90, 140, 255, 0.25)',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '2px solid #5a8cff',
|
||||
}
|
||||
|
||||
const tabStyle: React.CSSProperties = {
|
||||
padding: '2px 6px',
|
||||
fontSize: '11px',
|
||||
background: 'transparent',
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
border: '2px solid transparent',
|
||||
borderRadius: 0,
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
const activeTabStyle: React.CSSProperties = {
|
||||
...tabStyle,
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
border: '2px solid #5a8cff',
|
||||
}
|
||||
|
||||
function FloorPatternPreview({ patternIndex, color, selected, onClick }: {
|
||||
patternIndex: number
|
||||
color: FloorColor
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const displaySize = 32
|
||||
const tileZoom = 2
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
canvas.width = displaySize
|
||||
canvas.height = displaySize
|
||||
ctx.imageSmoothingEnabled = false
|
||||
if (!hasFloorSprites()) {
|
||||
ctx.fillStyle = '#444'
|
||||
ctx.fillRect(0, 0, displaySize, displaySize)
|
||||
return
|
||||
}
|
||||
const sprite = getColorizedFloorSprite(patternIndex, color)
|
||||
const cached = getCachedSprite(sprite, tileZoom)
|
||||
ctx.drawImage(cached, 0, 0)
|
||||
}, [patternIndex, color])
|
||||
|
||||
return (
|
||||
<button onClick={onClick} title={`Floor ${patternIndex}`} style={{
|
||||
width: displaySize, height: displaySize, padding: 0,
|
||||
border: selected ? '2px solid #5a8cff' : '2px solid #4a4a6a',
|
||||
borderRadius: 0, cursor: 'pointer', overflow: 'hidden', flexShrink: 0, background: '#2A2A3A',
|
||||
}}>
|
||||
<canvas ref={canvasRef} style={{ width: displaySize, height: displaySize, display: 'block' }} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorSlider({ label, value, min, max, onChange }: {
|
||||
label: string; value: number; min: number; max: number; onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: '11px', color: '#999', width: 16, textAlign: 'right', flexShrink: 0 }}>{label}</span>
|
||||
<input type="range" min={min} max={max} value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{ flex: 1, height: 12, accentColor: 'rgba(90, 140, 255, 0.8)' }} />
|
||||
<span style={{ fontSize: '11px', color: '#999', width: 32, textAlign: 'right', flexShrink: 0 }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_FURNITURE_COLOR: FloorColor = { h: 0, s: 0, b: 0, c: 0 }
|
||||
|
||||
interface EditorToolbarProps {
|
||||
activeTool: EditTool
|
||||
selectedTileType: TileTypeVal
|
||||
selectedFurnitureType: string
|
||||
selectedFurnitureUid: string | null
|
||||
selectedFurnitureColor: FloorColor | null
|
||||
floorColor: FloorColor
|
||||
wallColor: FloorColor
|
||||
onToolChange: (tool: EditTool) => void
|
||||
onTileTypeChange: (type: TileTypeVal) => void
|
||||
onFloorColorChange: (color: FloorColor) => void
|
||||
onWallColorChange: (color: FloorColor) => void
|
||||
onSelectedFurnitureColorChange: (color: FloorColor | null) => void
|
||||
onFurnitureTypeChange: (type: string) => void
|
||||
}
|
||||
|
||||
export function EditorToolbar({
|
||||
activeTool, selectedTileType, selectedFurnitureType,
|
||||
selectedFurnitureUid, selectedFurnitureColor,
|
||||
floorColor, wallColor,
|
||||
onToolChange, onTileTypeChange, onFloorColorChange, onWallColorChange,
|
||||
onSelectedFurnitureColorChange, onFurnitureTypeChange,
|
||||
}: EditorToolbarProps) {
|
||||
const [activeCategory, setActiveCategory] = useState<FurnitureCategory>('desks')
|
||||
const [showColor, setShowColor] = useState(false)
|
||||
const [showWallColor, setShowWallColor] = useState(false)
|
||||
const [showFurnitureColor, setShowFurnitureColor] = useState(false)
|
||||
|
||||
const handleColorChange = useCallback((key: keyof FloorColor, value: number) => {
|
||||
onFloorColorChange({ ...floorColor, [key]: value })
|
||||
}, [floorColor, onFloorColorChange])
|
||||
|
||||
const handleWallColorChange = useCallback((key: keyof FloorColor, value: number) => {
|
||||
onWallColorChange({ ...wallColor, [key]: value })
|
||||
}, [wallColor, onWallColorChange])
|
||||
|
||||
const effectiveColor = selectedFurnitureColor ?? DEFAULT_FURNITURE_COLOR
|
||||
const handleSelFurnColorChange = useCallback((key: keyof FloorColor, value: number) => {
|
||||
onSelectedFurnitureColorChange({ ...effectiveColor, [key]: value })
|
||||
}, [effectiveColor, onSelectedFurnitureColorChange])
|
||||
|
||||
const categoryItems = getCatalogByCategory(activeCategory)
|
||||
const patternCount = getFloorPatternCount()
|
||||
const floorPatterns = Array.from({ length: patternCount }, (_, i) => i + 1)
|
||||
const thumbSize = 36
|
||||
|
||||
const isFloorActive = activeTool === EditTool.TILE_PAINT || activeTool === EditTool.EYEDROPPER
|
||||
const isWallActive = activeTool === EditTool.WALL_PAINT
|
||||
const isEraseActive = activeTool === EditTool.ERASE
|
||||
const isFurnitureActive = activeTool === EditTool.FURNITURE_PLACE || activeTool === EditTool.FURNITURE_PICK
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 12, left: 10, zIndex: 50,
|
||||
background: '#1e1e2e', border: '2px solid #4a4a6a', borderRadius: 0,
|
||||
padding: '6px 8px', display: 'flex', flexDirection: 'column-reverse', gap: 6,
|
||||
boxShadow: '2px 2px 0px #0a0a14', maxWidth: 'calc(100vw - 20px)',
|
||||
}}>
|
||||
{/* Tool row */}
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button style={isFloorActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.TILE_PAINT)} title="Paint floor tiles">Floor</button>
|
||||
<button style={isWallActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.WALL_PAINT)} title="Paint walls">Wall</button>
|
||||
<button style={isEraseActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.ERASE)} title="Erase tiles">Erase</button>
|
||||
<button style={isFurnitureActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.FURNITURE_PLACE)} title="Place furniture">Furniture</button>
|
||||
</div>
|
||||
|
||||
{/* Floor sub-panel */}
|
||||
{isFloorActive && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column-reverse', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button style={showColor ? activeBtnStyle : btnStyle} onClick={() => setShowColor(v => !v)} title="Adjust floor color">Color</button>
|
||||
<button style={activeTool === EditTool.EYEDROPPER ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.EYEDROPPER)} title="Pick floor pattern + color">Pick</button>
|
||||
</div>
|
||||
{showColor && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '4px 6px', background: '#181828', border: '2px solid #4a4a6a', borderRadius: 0 }}>
|
||||
<ColorSlider label="H" value={floorColor.h} min={0} max={360} onChange={v => handleColorChange('h', v)} />
|
||||
<ColorSlider label="S" value={floorColor.s} min={0} max={100} onChange={v => handleColorChange('s', v)} />
|
||||
<ColorSlider label="B" value={floorColor.b} min={-100} max={100} onChange={v => handleColorChange('b', v)} />
|
||||
<ColorSlider label="C" value={floorColor.c} min={-100} max={100} onChange={v => handleColorChange('c', v)} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 4, overflowX: 'auto', flexWrap: 'nowrap', paddingBottom: 2 }}>
|
||||
{floorPatterns.map(patIdx => (
|
||||
<FloorPatternPreview key={patIdx} patternIndex={patIdx} color={floorColor} selected={selectedTileType === patIdx}
|
||||
onClick={() => onTileTypeChange(patIdx as TileTypeVal)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wall sub-panel */}
|
||||
{isWallActive && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column-reverse', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button style={showWallColor ? activeBtnStyle : btnStyle} onClick={() => setShowWallColor(v => !v)} title="Adjust wall color">Color</button>
|
||||
</div>
|
||||
{showWallColor && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '4px 6px', background: '#181828', border: '2px solid #4a4a6a', borderRadius: 0 }}>
|
||||
<ColorSlider label="H" value={wallColor.h} min={0} max={360} onChange={v => handleWallColorChange('h', v)} />
|
||||
<ColorSlider label="S" value={wallColor.s} min={0} max={100} onChange={v => handleWallColorChange('s', v)} />
|
||||
<ColorSlider label="B" value={wallColor.b} min={-100} max={100} onChange={v => handleWallColorChange('b', v)} />
|
||||
<ColorSlider label="C" value={wallColor.c} min={-100} max={100} onChange={v => handleWallColorChange('c', v)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Furniture sub-panel */}
|
||||
{isFurnitureActive && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column-reverse', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{getActiveCategories().map(cat => (
|
||||
<button key={cat.id} style={activeCategory === cat.id ? activeTabStyle : tabStyle} onClick={() => setActiveCategory(cat.id)}>{cat.label}</button>
|
||||
))}
|
||||
<div style={{ width: 1, height: 14, background: 'rgba(255,255,255,0.15)', margin: '0 2px', flexShrink: 0 }} />
|
||||
<button style={activeTool === EditTool.FURNITURE_PICK ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.FURNITURE_PICK)} title="Pick furniture type">Pick</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, overflowX: 'auto', flexWrap: 'nowrap', paddingBottom: 2 }}>
|
||||
{categoryItems.map(entry => {
|
||||
const cached = getCachedSprite(entry.sprite, 2)
|
||||
const isSelected = selectedFurnitureType === entry.type
|
||||
return (
|
||||
<button key={entry.type} onClick={() => onFurnitureTypeChange(entry.type)} title={entry.label} style={{
|
||||
width: thumbSize, height: thumbSize, background: '#2A2A3A',
|
||||
border: isSelected ? '2px solid #5a8cff' : '2px solid #4a4a6a',
|
||||
borderRadius: 0, cursor: 'pointer', padding: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
<canvas ref={el => {
|
||||
if (!el) return
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return
|
||||
const scale = Math.min(thumbSize / cached.width, thumbSize / cached.height) * 0.85
|
||||
el.width = thumbSize; el.height = thumbSize
|
||||
ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, thumbSize, thumbSize)
|
||||
const dw = cached.width * scale; const dh = cached.height * scale
|
||||
ctx.drawImage(cached, (thumbSize - dw) / 2, (thumbSize - dh) / 2, dw, dh)
|
||||
}} style={{ width: thumbSize, height: thumbSize }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected furniture color panel */}
|
||||
{selectedFurnitureUid && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column-reverse', gap: 3 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button style={showFurnitureColor ? activeBtnStyle : btnStyle} onClick={() => setShowFurnitureColor(v => !v)} title="Adjust selected furniture color">Color</button>
|
||||
{selectedFurnitureColor && (
|
||||
<button style={{ ...btnStyle, fontSize: '11px', padding: '2px 6px' }} onClick={() => onSelectedFurnitureColorChange(null)} title="Remove color">Clear</button>
|
||||
)}
|
||||
</div>
|
||||
{showFurnitureColor && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '4px 6px', background: '#181828', border: '2px solid #4a4a6a', borderRadius: 0 }}>
|
||||
{effectiveColor.colorize ? (
|
||||
<>
|
||||
<ColorSlider label="H" value={effectiveColor.h} min={0} max={360} onChange={v => handleSelFurnColorChange('h', v)} />
|
||||
<ColorSlider label="S" value={effectiveColor.s} min={0} max={100} onChange={v => handleSelFurnColorChange('s', v)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ColorSlider label="H" value={effectiveColor.h} min={-180} max={180} onChange={v => handleSelFurnColorChange('h', v)} />
|
||||
<ColorSlider label="S" value={effectiveColor.s} min={-100} max={100} onChange={v => handleSelFurnColorChange('s', v)} />
|
||||
</>
|
||||
)}
|
||||
<ColorSlider label="B" value={effectiveColor.b} min={-100} max={100} onChange={v => handleSelFurnColorChange('b', v)} />
|
||||
<ColorSlider label="C" value={effectiveColor.c} min={-100} max={100} onChange={v => handleSelFurnColorChange('c', v)} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '11px', color: '#999', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!effectiveColor.colorize}
|
||||
onChange={e => onSelectedFurnitureColorChange({ ...effectiveColor, colorize: e.target.checked || undefined })}
|
||||
style={{ accentColor: 'rgba(90, 140, 255, 0.8)' }} />
|
||||
Colorize
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { OfficeState } from '@/lib/pixel-office/engine/officeState'
|
||||
import { renderFrame } from '@/lib/pixel-office/engine/renderer'
|
||||
import type { EditorRenderState } from '@/lib/pixel-office/engine/renderer'
|
||||
import { syncAgentsToOffice, AgentActivity } from '@/lib/pixel-office/agentBridge'
|
||||
import { EditorState } from '@/lib/pixel-office/editor/editorState'
|
||||
import {
|
||||
paintTile, placeFurniture, removeFurniture, moveFurniture,
|
||||
rotateFurniture, toggleFurnitureState, canPlaceFurniture,
|
||||
expandLayout, getWallPlacementRow,
|
||||
} from '@/lib/pixel-office/editor/editorActions'
|
||||
import type { ExpandDirection } from '@/lib/pixel-office/editor/editorActions'
|
||||
import { TILE_SIZE, ZOOM_MIN, ZOOM_MAX } from '@/lib/pixel-office/constants'
|
||||
import { TileType, EditTool } from '@/lib/pixel-office/types'
|
||||
import type { TileType as TileTypeVal, FloorColor, OfficeLayout } from '@/lib/pixel-office/types'
|
||||
import { getCatalogEntry, isRotatable } from '@/lib/pixel-office/layout/furnitureCatalog'
|
||||
import { createDefaultLayout, serializeLayout } from '@/lib/pixel-office/layout/layoutSerializer'
|
||||
import { playDoneSound, unlockAudio, setSoundEnabled, isSoundEnabled } from '@/lib/pixel-office/notificationSound'
|
||||
import { useI18n } from '@/lib/i18n'
|
||||
import { EditorToolbar } from './components/EditorToolbar'
|
||||
import { EditActionBar } from './components/EditActionBar'
|
||||
|
||||
/** Convert mouse event to tile coordinates */
|
||||
function mouseToTile(
|
||||
e: React.MouseEvent, canvas: HTMLCanvasElement, office: OfficeState, zoom: number, pan: { x: number; y: number }
|
||||
): { col: number; row: number; worldX: number; worldY: number } {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
const cols = office.layout.cols
|
||||
const rows = office.layout.rows
|
||||
const mapW = cols * TILE_SIZE * zoom
|
||||
const mapH = rows * TILE_SIZE * zoom
|
||||
const offsetX = (rect.width - mapW) / 2 + pan.x
|
||||
const offsetY = (rect.height - mapH) / 2 + pan.y
|
||||
const worldX = (x - offsetX) / zoom
|
||||
const worldY = (y - offsetY) / zoom
|
||||
const col = Math.floor(worldX / TILE_SIZE)
|
||||
const row = Math.floor(worldY / TILE_SIZE)
|
||||
return { col, row, worldX, worldY }
|
||||
}
|
||||
|
||||
/** Detect ghost border tile (expansion zone) */
|
||||
function getGhostBorderDirection(col: number, row: number, cols: number, rows: number): ExpandDirection | null {
|
||||
if (row === -1) return 'up'
|
||||
if (row === rows) return 'down'
|
||||
if (col === -1) return 'left'
|
||||
if (col === cols) return 'right'
|
||||
return null
|
||||
}
|
||||
|
||||
export default function PixelOfficePage() {
|
||||
const { t } = useI18n()
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const officeRef = useRef<OfficeState | null>(null)
|
||||
const editorRef = useRef<EditorState>(new EditorState())
|
||||
const agentIdMapRef = useRef<Map<string, number>>(new Map())
|
||||
const nextIdRef = useRef<{ current: number }>({ current: 1 })
|
||||
const zoomRef = useRef<number>(4)
|
||||
const panRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const savedLayoutRef = useRef<OfficeLayout | null>(null)
|
||||
const animationFrameIdRef = useRef<number | null>(null)
|
||||
const prevAgentStatesRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
const [agents, setAgents] = useState<AgentActivity[]>([])
|
||||
const [hoveredAgentId, setHoveredAgentId] = useState<number | null>(null)
|
||||
const [isEditMode, setIsEditMode] = useState(false)
|
||||
const [soundOn, setSoundOn] = useState(true)
|
||||
const [editorTick, setEditorTick] = useState(0)
|
||||
const [officeReady, setOfficeReady] = useState(false)
|
||||
|
||||
const forceEditorUpdate = useCallback(() => setEditorTick(t => t + 1), [])
|
||||
|
||||
// Load saved layout and sound preference
|
||||
useEffect(() => {
|
||||
const loadLayout = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/pixel-office/layout')
|
||||
const data = await res.json()
|
||||
if (data.layout) {
|
||||
officeRef.current = new OfficeState(data.layout)
|
||||
savedLayoutRef.current = data.layout
|
||||
} else {
|
||||
officeRef.current = new OfficeState()
|
||||
}
|
||||
} catch {
|
||||
officeRef.current = new OfficeState()
|
||||
}
|
||||
setOfficeReady(true)
|
||||
}
|
||||
loadLayout()
|
||||
|
||||
const savedSound = localStorage.getItem('pixel-office-sound')
|
||||
if (savedSound !== null) {
|
||||
const enabled = savedSound !== 'false'
|
||||
setSoundOn(enabled)
|
||||
setSoundEnabled(enabled)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrameIdRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameIdRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Game loop
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !officeRef.current || !containerRef.current) return
|
||||
const canvas = canvasRef.current
|
||||
const office = officeRef.current
|
||||
const container = containerRef.current
|
||||
const editor = editorRef.current
|
||||
let lastTime = 0
|
||||
|
||||
const render = (time: number) => {
|
||||
const dt = lastTime === 0 ? 0 : Math.min((time - lastTime) / 1000, 0.1)
|
||||
lastTime = time
|
||||
office.update(dt)
|
||||
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
let editorRender: EditorRenderState | undefined
|
||||
if (editor.isEditMode) {
|
||||
const sel = editor.selectedFurnitureUid
|
||||
const selItem = sel ? office.layout.furniture.find(f => f.uid === sel) : null
|
||||
const selEntry = selItem ? getCatalogEntry(selItem.type) : null
|
||||
const ghostEntry = (editor.activeTool === EditTool.FURNITURE_PLACE)
|
||||
? getCatalogEntry(editor.selectedFurnitureType) : null
|
||||
const showGhostBorder = editor.activeTool === EditTool.TILE_PAINT ||
|
||||
editor.activeTool === EditTool.WALL_PAINT || editor.activeTool === EditTool.ERASE
|
||||
|
||||
editorRender = {
|
||||
showGrid: true,
|
||||
ghostSprite: ghostEntry?.sprite ?? null,
|
||||
ghostCol: editor.ghostCol,
|
||||
ghostRow: editor.ghostRow,
|
||||
ghostValid: editor.ghostValid,
|
||||
selectedCol: selItem?.col ?? 0,
|
||||
selectedRow: selItem?.row ?? 0,
|
||||
selectedW: selEntry?.footprintW ?? 0,
|
||||
selectedH: selEntry?.footprintH ?? 0,
|
||||
hasSelection: !!selItem,
|
||||
isRotatable: selItem ? isRotatable(selItem.type) : false,
|
||||
deleteButtonBounds: null,
|
||||
rotateButtonBounds: null,
|
||||
showGhostBorder,
|
||||
ghostBorderHoverCol: editor.ghostCol,
|
||||
ghostBorderHoverRow: editor.ghostRow,
|
||||
}
|
||||
}
|
||||
|
||||
renderFrame(ctx, width, height, office.tileMap, office.furniture, office.getCharacters(),
|
||||
zoomRef.current, panRef.current.x, panRef.current.y,
|
||||
{ selectedAgentId: null, hoveredAgentId, hoveredTile: null, seats: office.seats, characters: office.characters },
|
||||
editorRender, office.layout.tileColors, office.layout.cols, office.layout.rows)
|
||||
}
|
||||
animationFrameIdRef.current = requestAnimationFrame(render)
|
||||
}
|
||||
animationFrameIdRef.current = requestAnimationFrame(render)
|
||||
return () => {
|
||||
if (animationFrameIdRef.current !== null) cancelAnimationFrame(animationFrameIdRef.current)
|
||||
}
|
||||
}, [hoveredAgentId, editorTick, officeReady])
|
||||
|
||||
// Poll for agent activity + sound notification
|
||||
useEffect(() => {
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/agent-activity')
|
||||
const data = await res.json()
|
||||
const newAgents: AgentActivity[] = data.agents || []
|
||||
setAgents(newAgents)
|
||||
|
||||
if (officeRef.current) {
|
||||
syncAgentsToOffice(newAgents, officeRef.current, agentIdMapRef.current, nextIdRef.current)
|
||||
}
|
||||
|
||||
// Play sound when agent transitions to waiting
|
||||
for (const agent of newAgents) {
|
||||
const prev = prevAgentStatesRef.current.get(agent.agentId)
|
||||
if (agent.state === 'waiting' && prev && prev !== 'waiting') {
|
||||
playDoneSound()
|
||||
}
|
||||
}
|
||||
const stateMap = new Map<string, string>()
|
||||
for (const a of newAgents) stateMap.set(a.agentId, a.state)
|
||||
prevAgentStatesRef.current = stateMap
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch agents:', e)
|
||||
}
|
||||
}
|
||||
fetchAgents()
|
||||
const interval = setInterval(fetchAgents, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// ── Editor helpers ──────────────────────────────────────────
|
||||
const applyEdit = useCallback((newLayout: OfficeLayout) => {
|
||||
const office = officeRef.current
|
||||
const editor = editorRef.current
|
||||
if (!office) return
|
||||
if (newLayout === office.layout) return
|
||||
editor.pushUndo(office.layout)
|
||||
editor.clearRedo()
|
||||
editor.isDirty = true
|
||||
office.rebuildFromLayout(newLayout)
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
const office = officeRef.current
|
||||
const editor = editorRef.current
|
||||
if (!office) return
|
||||
const prev = editor.popUndo()
|
||||
if (!prev) return
|
||||
editor.pushRedo(office.layout)
|
||||
office.rebuildFromLayout(prev)
|
||||
editor.isDirty = true
|
||||
editor.clearSelection()
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
const office = officeRef.current
|
||||
const editor = editorRef.current
|
||||
if (!office) return
|
||||
const next = editor.popRedo()
|
||||
if (!next) return
|
||||
editor.pushUndo(office.layout)
|
||||
office.rebuildFromLayout(next)
|
||||
editor.isDirty = true
|
||||
editor.clearSelection()
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const office = officeRef.current
|
||||
const editor = editorRef.current
|
||||
if (!office) return
|
||||
try {
|
||||
await fetch('/api/pixel-office/layout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: serializeLayout(office.layout),
|
||||
})
|
||||
savedLayoutRef.current = office.layout
|
||||
editor.isDirty = false
|
||||
forceEditorUpdate()
|
||||
} catch (e) {
|
||||
console.error('Failed to save layout:', e)
|
||||
}
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const office = officeRef.current
|
||||
const editor = editorRef.current
|
||||
if (!office) return
|
||||
const defaultLayout = savedLayoutRef.current || createDefaultLayout()
|
||||
editor.pushUndo(office.layout)
|
||||
editor.clearRedo()
|
||||
office.rebuildFromLayout(defaultLayout)
|
||||
editor.isDirty = false
|
||||
editor.clearSelection()
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
// ── Mouse events ──────────────────────────────────────────
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.5 : 0.5
|
||||
zoomRef.current = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoomRef.current + delta))
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!canvasRef.current || !officeRef.current) return
|
||||
const office = officeRef.current
|
||||
const editor = editorRef.current
|
||||
const { col, row, worldX, worldY } = mouseToTile(e, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
|
||||
if (editor.isEditMode) {
|
||||
// Update ghost preview
|
||||
if (editor.activeTool === EditTool.FURNITURE_PLACE) {
|
||||
const entry = getCatalogEntry(editor.selectedFurnitureType)
|
||||
if (entry) {
|
||||
const placementRow = entry.canPlaceOnWalls ? getWallPlacementRow(editor.selectedFurnitureType, row) : row
|
||||
editor.ghostCol = col
|
||||
editor.ghostRow = placementRow
|
||||
editor.ghostValid = canPlaceFurniture(office.layout, editor.selectedFurnitureType, col, placementRow)
|
||||
}
|
||||
} else if (editor.activeTool === EditTool.TILE_PAINT || editor.activeTool === EditTool.WALL_PAINT || editor.activeTool === EditTool.ERASE) {
|
||||
editor.ghostCol = col
|
||||
editor.ghostRow = row
|
||||
// Drag painting
|
||||
if (editor.isDragging && col >= 0 && col < office.layout.cols && row >= 0 && row < office.layout.rows) {
|
||||
if (editor.activeTool === EditTool.TILE_PAINT) {
|
||||
applyEdit(paintTile(office.layout, col, row, editor.selectedTileType, editor.floorColor))
|
||||
} else if (editor.activeTool === EditTool.WALL_PAINT) {
|
||||
const currentTile = office.layout.tiles[row * office.layout.cols + col]
|
||||
if (editor.wallDragAdding === null) {
|
||||
editor.wallDragAdding = currentTile !== TileType.WALL
|
||||
}
|
||||
if (editor.wallDragAdding && currentTile !== TileType.WALL) {
|
||||
applyEdit(paintTile(office.layout, col, row, TileType.WALL, editor.wallColor))
|
||||
} else if (!editor.wallDragAdding && currentTile === TileType.WALL) {
|
||||
applyEdit(paintTile(office.layout, col, row, TileType.FLOOR_1, editor.floorColor))
|
||||
}
|
||||
} else if (editor.activeTool === EditTool.ERASE) {
|
||||
applyEdit(paintTile(office.layout, col, row, TileType.VOID as TileTypeVal))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
editor.ghostCol = col
|
||||
editor.ghostRow = row
|
||||
}
|
||||
|
||||
// Drag-to-move furniture
|
||||
if (editor.dragUid) {
|
||||
const dx = col - editor.dragStartCol
|
||||
const dy = row - editor.dragStartRow
|
||||
if (!editor.isDragMoving && (Math.abs(dx) > 0 || Math.abs(dy) > 0)) {
|
||||
editor.isDragMoving = true
|
||||
}
|
||||
if (editor.isDragMoving) {
|
||||
const newCol = col - editor.dragOffsetCol
|
||||
const newRow = row - editor.dragOffsetRow
|
||||
const newLayout = moveFurniture(office.layout, editor.dragUid, newCol, newRow)
|
||||
if (newLayout !== office.layout) {
|
||||
office.rebuildFromLayout(newLayout)
|
||||
editor.isDirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal mode: hover detection
|
||||
const id = office.getCharacterAt(worldX, worldY)
|
||||
setHoveredAgentId(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
unlockAudio()
|
||||
if (!canvasRef.current || !officeRef.current) return
|
||||
const office = officeRef.current
|
||||
const editor = editorRef.current
|
||||
if (!editor.isEditMode) return
|
||||
const { col, row } = mouseToTile(e, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
|
||||
if (e.button === 0) {
|
||||
// Left click
|
||||
const tool = editor.activeTool
|
||||
if (tool === EditTool.TILE_PAINT || tool === EditTool.WALL_PAINT || tool === EditTool.ERASE) {
|
||||
editor.isDragging = true
|
||||
editor.wallDragAdding = null
|
||||
|
||||
// Check ghost border expansion
|
||||
const dir = getGhostBorderDirection(col, row, office.layout.cols, office.layout.rows)
|
||||
if (dir) {
|
||||
const result = expandLayout(office.layout, dir)
|
||||
if (result) {
|
||||
applyEdit(result.layout)
|
||||
office.rebuildFromLayout(result.layout, result.shift)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (col >= 0 && col < office.layout.cols && row >= 0 && row < office.layout.rows) {
|
||||
if (tool === EditTool.TILE_PAINT) {
|
||||
applyEdit(paintTile(office.layout, col, row, editor.selectedTileType, editor.floorColor))
|
||||
} else if (tool === EditTool.WALL_PAINT) {
|
||||
const currentTile = office.layout.tiles[row * office.layout.cols + col]
|
||||
editor.wallDragAdding = currentTile !== TileType.WALL
|
||||
if (editor.wallDragAdding) {
|
||||
applyEdit(paintTile(office.layout, col, row, TileType.WALL, editor.wallColor))
|
||||
} else {
|
||||
applyEdit(paintTile(office.layout, col, row, TileType.FLOOR_1, editor.floorColor))
|
||||
}
|
||||
} else if (tool === EditTool.ERASE) {
|
||||
applyEdit(paintTile(office.layout, col, row, TileType.VOID as TileTypeVal))
|
||||
}
|
||||
}
|
||||
} else if (tool === EditTool.FURNITURE_PLACE) {
|
||||
if (editor.ghostValid && col >= 0) {
|
||||
const entry = getCatalogEntry(editor.selectedFurnitureType)
|
||||
if (entry) {
|
||||
const placementRow = entry.canPlaceOnWalls ? getWallPlacementRow(editor.selectedFurnitureType, row) : row
|
||||
const uid = `furn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const item = {
|
||||
uid, type: editor.selectedFurnitureType, col, row: placementRow,
|
||||
...(editor.pickedFurnitureColor ? { color: editor.pickedFurnitureColor } : {}),
|
||||
}
|
||||
applyEdit(placeFurniture(office.layout, item))
|
||||
}
|
||||
}
|
||||
} else if (tool === EditTool.SELECT) {
|
||||
// Check if clicking on placed furniture
|
||||
const clickedItem = office.layout.furniture.find(f => {
|
||||
const entry = getCatalogEntry(f.type)
|
||||
if (!entry) return false
|
||||
return col >= f.col && col < f.col + entry.footprintW && row >= f.row && row < f.row + entry.footprintH
|
||||
})
|
||||
if (clickedItem) {
|
||||
editor.selectedFurnitureUid = clickedItem.uid
|
||||
editor.startDrag(clickedItem.uid, col, row, col - clickedItem.col, row - clickedItem.row)
|
||||
} else {
|
||||
editor.clearSelection()
|
||||
}
|
||||
forceEditorUpdate()
|
||||
} else if (tool === EditTool.EYEDROPPER) {
|
||||
if (col >= 0 && col < office.layout.cols && row >= 0 && row < office.layout.rows) {
|
||||
const idx = row * office.layout.cols + col
|
||||
const tile = office.layout.tiles[idx]
|
||||
if (tile !== TileType.WALL && tile !== TileType.VOID) {
|
||||
editor.selectedTileType = tile
|
||||
const color = office.layout.tileColors?.[idx]
|
||||
if (color) editor.floorColor = { ...color }
|
||||
} else if (tile === TileType.WALL) {
|
||||
const color = office.layout.tileColors?.[idx]
|
||||
if (color) editor.wallColor = { ...color }
|
||||
editor.activeTool = EditTool.WALL_PAINT
|
||||
}
|
||||
editor.activeTool = editor.activeTool === EditTool.EYEDROPPER ? EditTool.TILE_PAINT : editor.activeTool
|
||||
forceEditorUpdate()
|
||||
}
|
||||
} else if (tool === EditTool.FURNITURE_PICK) {
|
||||
const pickedItem = office.layout.furniture.find(f => {
|
||||
const entry = getCatalogEntry(f.type)
|
||||
if (!entry) return false
|
||||
return col >= f.col && col < f.col + entry.footprintW && row >= f.row && row < f.row + entry.footprintH
|
||||
})
|
||||
if (pickedItem) {
|
||||
editor.selectedFurnitureType = pickedItem.type
|
||||
editor.pickedFurnitureColor = pickedItem.color ? { ...pickedItem.color } : null
|
||||
editor.activeTool = EditTool.FURNITURE_PLACE
|
||||
forceEditorUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const editor = editorRef.current
|
||||
if (editor.isDragging) {
|
||||
editor.isDragging = false
|
||||
editor.wallDragAdding = null
|
||||
}
|
||||
if (editor.dragUid) {
|
||||
if (editor.isDragMoving) {
|
||||
// Commit the drag move to undo stack
|
||||
editor.isDirty = true
|
||||
forceEditorUpdate()
|
||||
}
|
||||
editor.clearDrag()
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor.isEditMode) return
|
||||
e.preventDefault()
|
||||
if (!canvasRef.current || !officeRef.current) return
|
||||
const office = officeRef.current
|
||||
const { col, row } = mouseToTile(e, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
if (col >= 0 && col < office.layout.cols && row >= 0 && row < office.layout.rows) {
|
||||
applyEdit(paintTile(office.layout, col, row, TileType.VOID as TileTypeVal))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard events ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const editor = editorRef.current
|
||||
const office = officeRef.current
|
||||
if (!editor.isEditMode || !office) return
|
||||
|
||||
if (e.key === 'r' || e.key === 'R') {
|
||||
if (editor.selectedFurnitureUid) {
|
||||
applyEdit(rotateFurniture(office.layout, editor.selectedFurnitureUid, e.shiftKey ? 'ccw' : 'cw'))
|
||||
}
|
||||
} else if (e.key === 't' || e.key === 'T') {
|
||||
if (editor.selectedFurnitureUid) {
|
||||
applyEdit(toggleFurnitureState(office.layout, editor.selectedFurnitureUid))
|
||||
}
|
||||
} else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleUndo()
|
||||
} else if ((e.key === 'y' && (e.ctrlKey || e.metaKey)) || (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
handleRedo()
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (editor.selectedFurnitureUid) {
|
||||
applyEdit(removeFurniture(office.layout, editor.selectedFurnitureUid))
|
||||
editor.clearSelection()
|
||||
forceEditorUpdate()
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
// Multi-stage escape
|
||||
if (editor.activeTool === EditTool.FURNITURE_PICK) {
|
||||
editor.activeTool = EditTool.FURNITURE_PLACE
|
||||
} else if (editor.selectedFurnitureUid) {
|
||||
editor.clearSelection()
|
||||
} else if (editor.activeTool !== EditTool.SELECT) {
|
||||
editor.activeTool = EditTool.SELECT
|
||||
} else {
|
||||
editor.isEditMode = false
|
||||
setIsEditMode(false)
|
||||
}
|
||||
forceEditorUpdate()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [applyEdit, handleUndo, handleRedo, forceEditorUpdate])
|
||||
|
||||
// ── Editor toolbar callbacks ──────────────────────────────────
|
||||
const handleToolChange = useCallback((tool: EditTool) => {
|
||||
editorRef.current.activeTool = tool
|
||||
editorRef.current.clearSelection()
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleTileTypeChange = useCallback((type: TileTypeVal) => {
|
||||
editorRef.current.selectedTileType = type
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleFloorColorChange = useCallback((color: FloorColor) => {
|
||||
editorRef.current.floorColor = color
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleWallColorChange = useCallback((color: FloorColor) => {
|
||||
editorRef.current.wallColor = color
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleFurnitureTypeChange = useCallback((type: string) => {
|
||||
editorRef.current.selectedFurnitureType = type
|
||||
forceEditorUpdate()
|
||||
}, [forceEditorUpdate])
|
||||
|
||||
const handleSelectedFurnitureColorChange = useCallback((color: FloorColor | null) => {
|
||||
const editor = editorRef.current
|
||||
const office = officeRef.current
|
||||
if (!office || !editor.selectedFurnitureUid) return
|
||||
const newLayout = {
|
||||
...office.layout,
|
||||
furniture: office.layout.furniture.map(f =>
|
||||
f.uid === editor.selectedFurnitureUid ? { ...f, color: color ?? undefined } : f
|
||||
),
|
||||
}
|
||||
applyEdit(newLayout)
|
||||
}, [applyEdit])
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
editor.isEditMode = !editor.isEditMode
|
||||
if (!editor.isEditMode) {
|
||||
editor.reset()
|
||||
}
|
||||
setIsEditMode(editor.isEditMode)
|
||||
}, [])
|
||||
|
||||
const toggleSound = useCallback(() => {
|
||||
const newVal = !isSoundEnabled()
|
||||
setSoundEnabled(newVal)
|
||||
setSoundOn(newVal)
|
||||
localStorage.setItem('pixel-office-sound', String(newVal))
|
||||
}, [])
|
||||
|
||||
const editor = editorRef.current
|
||||
const selectedItem = editor.selectedFurnitureUid
|
||||
? officeRef.current?.layout.furniture.find(f => f.uid === editor.selectedFurnitureUid) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Top bar: agent tags + controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 p-4 border-b border-[var(--border)]">
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{agents.map(agent => (
|
||||
<div key={agent.agentId} className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
agent.state === 'working' ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]' :
|
||||
agent.state === 'idle' ? 'bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]' :
|
||||
'bg-[var(--bg)] border-[var(--border)] text-[var(--text-muted)] opacity-50'
|
||||
}`}>
|
||||
<span>{agent.emoji}</span>
|
||||
<span className="text-sm">{agent.name}</span>
|
||||
{agent.state === 'working' && <span className="text-[10px] uppercase tracking-wider opacity-70">working</span>}
|
||||
{agent.state === 'idle' && <span className="text-[10px] uppercase tracking-wider opacity-50">idle</span>}
|
||||
</div>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<div className="text-[var(--text-muted)] text-sm">{t('common.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={toggleSound}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
soundOn ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]'
|
||||
: 'bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]'
|
||||
}`}>
|
||||
{soundOn ? '🔔' : '🔕'} {t('pixelOffice.sound')}
|
||||
</button>
|
||||
<button onClick={toggleEditMode}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
isEditMode ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]'
|
||||
: 'bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]'
|
||||
}`}>
|
||||
{isEditMode ? t('pixelOffice.exitEdit') : t('pixelOffice.editMode')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div ref={containerRef} className="flex-1 relative overflow-hidden bg-[#1a1a2e]">
|
||||
<canvas ref={canvasRef}
|
||||
onWheel={handleWheel} onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}
|
||||
onContextMenu={handleContextMenu}
|
||||
className="w-full h-full" />
|
||||
|
||||
{/* Editor overlays */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
<EditActionBar
|
||||
isDirty={editor.isDirty}
|
||||
canUndo={editor.undoStack.length > 0}
|
||||
canRedo={editor.redoStack.length > 0}
|
||||
onUndo={handleUndo} onRedo={handleRedo}
|
||||
onSave={handleSave} onReset={handleReset} />
|
||||
<EditorToolbar
|
||||
activeTool={editor.activeTool}
|
||||
selectedTileType={editor.selectedTileType}
|
||||
selectedFurnitureType={editor.selectedFurnitureType}
|
||||
selectedFurnitureUid={editor.selectedFurnitureUid}
|
||||
selectedFurnitureColor={selectedItem?.color ?? null}
|
||||
floorColor={editor.floorColor}
|
||||
wallColor={editor.wallColor}
|
||||
onToolChange={handleToolChange}
|
||||
onTileTypeChange={handleTileTypeChange}
|
||||
onFloorColorChange={handleFloorColorChange}
|
||||
onWallColorChange={handleWallColorChange}
|
||||
onSelectedFurnitureColorChange={handleSelectedFurnitureColorChange}
|
||||
onFurnitureTypeChange={handleFurnitureTypeChange} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const NAV_ITEMS = [
|
||||
{ href: "/sessions", icon: "💬", labelKey: "nav.sessions" },
|
||||
{ href: "/stats", icon: "📊", labelKey: "nav.stats" },
|
||||
{ href: "/alerts", icon: "🔔", labelKey: "nav.alerts" },
|
||||
{ href: "/pixel-office", icon: "🎮", labelKey: "nav.pixelOffice" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"nav.monitor": "监控",
|
||||
"nav.sessions": "会话列表",
|
||||
"nav.stats": "消息统计",
|
||||
"nav.pixelOffice": "像素办公室",
|
||||
"nav.config": "配置",
|
||||
"nav.skills": "技能管理",
|
||||
"nav.alerts": "告警中心",
|
||||
@@ -201,6 +202,15 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"gateway.healthy": "Gateway 运行正常",
|
||||
"gateway.unhealthy": "Gateway 异常",
|
||||
"gateway.fetchError": "无法检查 Gateway 状态",
|
||||
|
||||
// pixel office
|
||||
"pixelOffice.editMode": "编辑布局",
|
||||
"pixelOffice.exitEdit": "退出编辑",
|
||||
"pixelOffice.save": "保存",
|
||||
"pixelOffice.reset": "重置",
|
||||
"pixelOffice.undo": "撤销",
|
||||
"pixelOffice.redo": "重做",
|
||||
"pixelOffice.sound": "音效",
|
||||
},
|
||||
en: {
|
||||
// layout
|
||||
@@ -214,6 +224,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"nav.monitor": "Monitor",
|
||||
"nav.sessions": "Sessions",
|
||||
"nav.stats": "Statistics",
|
||||
"nav.pixelOffice": "Pixel Office",
|
||||
"nav.config": "Config",
|
||||
"nav.skills": "Skills",
|
||||
"nav.alerts": "Alerts",
|
||||
@@ -398,6 +409,15 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"gateway.healthy": "Gateway is running",
|
||||
"gateway.unhealthy": "Gateway is down",
|
||||
"gateway.fetchError": "Cannot check Gateway status",
|
||||
|
||||
// pixel office
|
||||
"pixelOffice.editMode": "Edit Layout",
|
||||
"pixelOffice.exitEdit": "Exit Edit",
|
||||
"pixelOffice.save": "Save",
|
||||
"pixelOffice.reset": "Reset",
|
||||
"pixelOffice.undo": "Undo",
|
||||
"pixelOffice.redo": "Redo",
|
||||
"pixelOffice.sound": "Sound",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { OfficeState } from './engine/officeState'
|
||||
|
||||
export interface SubagentInfo {
|
||||
toolId: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
agentId: string
|
||||
name: string
|
||||
emoji: string
|
||||
state: 'idle' | 'working' | 'waiting' | 'offline'
|
||||
currentTool?: string
|
||||
toolStatus?: string
|
||||
lastActive: number
|
||||
subagents?: SubagentInfo[]
|
||||
}
|
||||
|
||||
/** Track which subagent toolIds were active last sync, per parent agent */
|
||||
const prevSubagentKeys = new Map<string, Set<string>>()
|
||||
|
||||
export function syncAgentsToOffice(
|
||||
activities: AgentActivity[],
|
||||
office: OfficeState,
|
||||
agentIdMap: Map<string, number>,
|
||||
nextIdRef: { current: number },
|
||||
): void {
|
||||
const currentAgentIds = new Set(activities.map(a => a.agentId))
|
||||
|
||||
// Remove agents that are no longer present
|
||||
for (const [agentId, charId] of agentIdMap) {
|
||||
if (!currentAgentIds.has(agentId)) {
|
||||
office.removeAllSubagents(charId)
|
||||
office.removeAgent(charId)
|
||||
agentIdMap.delete(agentId)
|
||||
prevSubagentKeys.delete(agentId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const activity of activities) {
|
||||
if (activity.state === 'offline') {
|
||||
if (agentIdMap.has(activity.agentId)) {
|
||||
const charId = agentIdMap.get(activity.agentId)!
|
||||
office.removeAllSubagents(charId)
|
||||
office.removeAgent(charId)
|
||||
agentIdMap.delete(activity.agentId)
|
||||
prevSubagentKeys.delete(activity.agentId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let charId = agentIdMap.get(activity.agentId)
|
||||
if (charId === undefined) {
|
||||
charId = nextIdRef.current++
|
||||
agentIdMap.set(activity.agentId, charId)
|
||||
office.addAgent(charId)
|
||||
}
|
||||
|
||||
// Set label (agent name or id)
|
||||
const ch = office.characters.get(charId)
|
||||
if (ch) {
|
||||
ch.label = activity.name || activity.agentId
|
||||
}
|
||||
|
||||
switch (activity.state) {
|
||||
case 'working':
|
||||
office.setAgentActive(charId, true)
|
||||
office.setAgentTool(charId, activity.currentTool || null)
|
||||
break
|
||||
case 'idle':
|
||||
office.setAgentActive(charId, false)
|
||||
office.setAgentTool(charId, null)
|
||||
break
|
||||
case 'waiting':
|
||||
office.setAgentActive(charId, true)
|
||||
office.showWaitingBubble(charId)
|
||||
break
|
||||
}
|
||||
|
||||
// Sync subagents
|
||||
const currentSubKeys = new Set<string>()
|
||||
if (activity.subagents) {
|
||||
for (const sub of activity.subagents) {
|
||||
currentSubKeys.add(sub.toolId)
|
||||
const existingSubId = office.getSubagentId(charId, sub.toolId)
|
||||
if (existingSubId === null) {
|
||||
const subId = office.addSubagent(charId, sub.toolId)
|
||||
office.setAgentActive(subId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove subagents that are no longer active
|
||||
const prevKeys = prevSubagentKeys.get(activity.agentId)
|
||||
if (prevKeys) {
|
||||
for (const toolId of prevKeys) {
|
||||
if (!currentSubKeys.has(toolId)) {
|
||||
office.removeSubagent(charId, toolId)
|
||||
}
|
||||
}
|
||||
}
|
||||
prevSubagentKeys.set(activity.agentId, currentSubKeys)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Shared sprite colorization module.
|
||||
*/
|
||||
|
||||
import type { SpriteData, FloorColor } from './types'
|
||||
|
||||
const colorizeCache = new Map<string, SpriteData>()
|
||||
|
||||
export function getColorizedSprite(cacheKey: string, sprite: SpriteData, color: FloorColor): SpriteData {
|
||||
const cached = colorizeCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const result = color.colorize ? colorizeSprite(sprite, color) : adjustSprite(sprite, color)
|
||||
colorizeCache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function clearColorizeCache(): void {
|
||||
colorizeCache.clear()
|
||||
}
|
||||
|
||||
export function colorizeSprite(sprite: SpriteData, color: FloorColor): SpriteData {
|
||||
const { h, s, b, c } = color
|
||||
const result: SpriteData = []
|
||||
for (const row of sprite) {
|
||||
const newRow: string[] = []
|
||||
for (const pixel of row) {
|
||||
if (pixel === '') { newRow.push(''); continue }
|
||||
const r = parseInt(pixel.slice(1, 3), 16)
|
||||
const g = parseInt(pixel.slice(3, 5), 16)
|
||||
const bv = parseInt(pixel.slice(5, 7), 16)
|
||||
let lightness = (0.299 * r + 0.587 * g + 0.114 * bv) / 255
|
||||
if (c !== 0) { const factor = (100 + c) / 100; lightness = 0.5 + (lightness - 0.5) * factor }
|
||||
if (b !== 0) { lightness = lightness + b / 200 }
|
||||
lightness = Math.max(0, Math.min(1, lightness))
|
||||
const satFrac = s / 100
|
||||
const hex = hslToHex(h, satFrac, lightness)
|
||||
newRow.push(hex)
|
||||
}
|
||||
result.push(newRow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s
|
||||
const hp = h / 60
|
||||
const x = c * (1 - Math.abs(hp % 2 - 1))
|
||||
let r1 = 0, g1 = 0, b1 = 0
|
||||
if (hp < 1) { r1 = c; g1 = x; b1 = 0 }
|
||||
else if (hp < 2) { r1 = x; g1 = c; b1 = 0 }
|
||||
else if (hp < 3) { r1 = 0; g1 = c; b1 = x }
|
||||
else if (hp < 4) { r1 = 0; g1 = x; b1 = c }
|
||||
else if (hp < 5) { r1 = x; g1 = 0; b1 = c }
|
||||
else { r1 = c; g1 = 0; b1 = x }
|
||||
const m = l - c / 2
|
||||
const r = Math.round((r1 + m) * 255)
|
||||
const g = Math.round((g1 + m) * 255)
|
||||
const bOut = Math.round((b1 + m) * 255)
|
||||
return `#${clamp255(r).toString(16).padStart(2, '0')}${clamp255(g).toString(16).padStart(2, '0')}${clamp255(bOut).toString(16).padStart(2, '0')}`.toUpperCase()
|
||||
}
|
||||
|
||||
function clamp255(v: number): number {
|
||||
return Math.max(0, Math.min(255, v))
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rf = r / 255, gf = g / 255, bf = b / 255
|
||||
const max = Math.max(rf, gf, bf), min = Math.min(rf, gf, bf)
|
||||
const l = (max + min) / 2
|
||||
if (max === min) return [0, 0, l]
|
||||
const d = max - min
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
let h = 0
|
||||
if (max === rf) h = ((gf - bf) / d + (gf < bf ? 6 : 0)) * 60
|
||||
else if (max === gf) h = ((bf - rf) / d + 2) * 60
|
||||
else h = ((rf - gf) / d + 4) * 60
|
||||
return [h, s, l]
|
||||
}
|
||||
|
||||
export function adjustSprite(sprite: SpriteData, color: FloorColor): SpriteData {
|
||||
const { h: hShift, s: sShift, b, c } = color
|
||||
const result: SpriteData = []
|
||||
for (const row of sprite) {
|
||||
const newRow: string[] = []
|
||||
for (const pixel of row) {
|
||||
if (pixel === '') { newRow.push(''); continue }
|
||||
const r = parseInt(pixel.slice(1, 3), 16)
|
||||
const g = parseInt(pixel.slice(3, 5), 16)
|
||||
const bv = parseInt(pixel.slice(5, 7), 16)
|
||||
const [origH, origS, origL] = rgbToHsl(r, g, bv)
|
||||
const newH = ((origH + hShift) % 360 + 360) % 360
|
||||
const newS = Math.max(0, Math.min(1, origS + sShift / 100))
|
||||
let lightness = origL
|
||||
if (c !== 0) { const factor = (100 + c) / 100; lightness = 0.5 + (lightness - 0.5) * factor }
|
||||
if (b !== 0) { lightness = lightness + b / 200 }
|
||||
lightness = Math.max(0, Math.min(1, lightness))
|
||||
const hex = hslToHex(newH, newS, lightness)
|
||||
newRow.push(hex)
|
||||
}
|
||||
result.push(newRow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { FloorColor } from './types'
|
||||
|
||||
// ── Grid & Layout ────────────────────────────────────────────
|
||||
export const TILE_SIZE = 16
|
||||
export const DEFAULT_COLS = 20
|
||||
export const DEFAULT_ROWS = 11
|
||||
export const MAX_COLS = 64
|
||||
export const MAX_ROWS = 64
|
||||
|
||||
// ── Character Animation ─────────────────────────────────────
|
||||
export const WALK_SPEED_PX_PER_SEC = 48
|
||||
export const WALK_FRAME_DURATION_SEC = 0.15
|
||||
export const TYPE_FRAME_DURATION_SEC = 0.3
|
||||
export const WANDER_PAUSE_MIN_SEC = 2.0
|
||||
export const WANDER_PAUSE_MAX_SEC = 20.0
|
||||
export const WANDER_MOVES_BEFORE_REST_MIN = 3
|
||||
export const WANDER_MOVES_BEFORE_REST_MAX = 6
|
||||
export const SEAT_REST_MIN_SEC = 120.0
|
||||
export const SEAT_REST_MAX_SEC = 240.0
|
||||
|
||||
// ── Matrix Effect ────────────────────────────────────────────
|
||||
export const MATRIX_EFFECT_DURATION_SEC = 0.3
|
||||
export const MATRIX_TRAIL_LENGTH = 6
|
||||
export const MATRIX_SPRITE_COLS = 16
|
||||
export const MATRIX_SPRITE_ROWS = 24
|
||||
export const MATRIX_FLICKER_FPS = 30
|
||||
export const MATRIX_FLICKER_VISIBILITY_THRESHOLD = 180
|
||||
export const MATRIX_COLUMN_STAGGER_RANGE = 0.3
|
||||
export const MATRIX_HEAD_COLOR = '#ccffcc'
|
||||
export const MATRIX_TRAIL_OVERLAY_ALPHA = 0.6
|
||||
export const MATRIX_TRAIL_EMPTY_ALPHA = 0.5
|
||||
export const MATRIX_TRAIL_MID_THRESHOLD = 0.33
|
||||
export const MATRIX_TRAIL_DIM_THRESHOLD = 0.66
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────
|
||||
export const CHARACTER_SITTING_OFFSET_PX = 6
|
||||
export const CHARACTER_Z_SORT_OFFSET = 0.5
|
||||
export const OUTLINE_Z_SORT_OFFSET = 0.001
|
||||
export const SELECTED_OUTLINE_ALPHA = 1.0
|
||||
export const HOVERED_OUTLINE_ALPHA = 0.5
|
||||
export const GHOST_PREVIEW_SPRITE_ALPHA = 0.5
|
||||
export const GHOST_PREVIEW_TINT_ALPHA = 0.25
|
||||
export const SELECTION_DASH_PATTERN: [number, number] = [4, 3]
|
||||
export const BUTTON_MIN_RADIUS = 6
|
||||
export const BUTTON_RADIUS_ZOOM_FACTOR = 3
|
||||
export const BUTTON_ICON_SIZE_FACTOR = 0.45
|
||||
export const BUTTON_LINE_WIDTH_MIN = 1.5
|
||||
export const BUTTON_LINE_WIDTH_ZOOM_FACTOR = 0.5
|
||||
export const BUBBLE_FADE_DURATION_SEC = 0.5
|
||||
export const BUBBLE_SITTING_OFFSET_PX = 10
|
||||
export const BUBBLE_VERTICAL_OFFSET_PX = 24
|
||||
export const FALLBACK_FLOOR_COLOR = '#808080'
|
||||
|
||||
// ── Rendering - Overlay Colors ───────────────────────────────
|
||||
export const SEAT_OWN_COLOR = 'rgba(0, 127, 212, 0.35)'
|
||||
export const SEAT_AVAILABLE_COLOR = 'rgba(0, 200, 80, 0.35)'
|
||||
export const SEAT_BUSY_COLOR = 'rgba(220, 50, 50, 0.35)'
|
||||
export const GRID_LINE_COLOR = 'rgba(255,255,255,0.12)'
|
||||
export const VOID_TILE_OUTLINE_COLOR = 'rgba(255,255,255,0.08)'
|
||||
export const VOID_TILE_DASH_PATTERN: [number, number] = [2, 2]
|
||||
export const GHOST_BORDER_HOVER_FILL = 'rgba(60, 130, 220, 0.25)'
|
||||
export const GHOST_BORDER_HOVER_STROKE = 'rgba(60, 130, 220, 0.5)'
|
||||
export const GHOST_BORDER_STROKE = 'rgba(255, 255, 255, 0.06)'
|
||||
export const GHOST_VALID_TINT = '#00ff00'
|
||||
export const GHOST_INVALID_TINT = '#ff0000'
|
||||
export const SELECTION_HIGHLIGHT_COLOR = '#007fd4'
|
||||
export const DELETE_BUTTON_BG = 'rgba(200, 50, 50, 0.85)'
|
||||
export const ROTATE_BUTTON_BG = 'rgba(50, 120, 200, 0.85)'
|
||||
|
||||
// ── Camera ───────────────────────────────────────────────────
|
||||
export const CAMERA_FOLLOW_LERP = 0.1
|
||||
export const CAMERA_FOLLOW_SNAP_THRESHOLD = 0.5
|
||||
|
||||
// ── Zoom ─────────────────────────────────────────────────────
|
||||
export const ZOOM_MIN = 1
|
||||
export const ZOOM_MAX = 10
|
||||
export const ZOOM_DEFAULT_DPR_FACTOR = 2
|
||||
export const ZOOM_LEVEL_FADE_DELAY_MS = 1500
|
||||
export const ZOOM_LEVEL_HIDE_DELAY_MS = 2000
|
||||
export const ZOOM_LEVEL_FADE_DURATION_SEC = 0.5
|
||||
export const ZOOM_SCROLL_THRESHOLD = 50
|
||||
export const PAN_MARGIN_FRACTION = 0.25
|
||||
|
||||
// ── Editor ───────────────────────────────────────────────────
|
||||
export const UNDO_STACK_MAX_SIZE = 50
|
||||
export const LAYOUT_SAVE_DEBOUNCE_MS = 500
|
||||
export const DEFAULT_FLOOR_COLOR: FloorColor = { h: 35, s: 30, b: 15, c: 0 }
|
||||
export const DEFAULT_WALL_COLOR: FloorColor = { h: 240, s: 25, b: 0, c: 0 }
|
||||
export const DEFAULT_NEUTRAL_COLOR: FloorColor = { h: 0, s: 0, b: 0, c: 0 }
|
||||
|
||||
// ── Notification Sound ───────────────────────────────────────
|
||||
export const NOTIFICATION_NOTE_1_HZ = 659.25 // E5
|
||||
export const NOTIFICATION_NOTE_2_HZ = 1318.51 // E6
|
||||
export const NOTIFICATION_NOTE_1_START_SEC = 0
|
||||
export const NOTIFICATION_NOTE_2_START_SEC = 0.1
|
||||
export const NOTIFICATION_NOTE_DURATION_SEC = 0.18
|
||||
export const NOTIFICATION_VOLUME = 0.14
|
||||
|
||||
// ── Game Logic ───────────────────────────────────────────────
|
||||
export const MAX_DELTA_TIME_SEC = 0.1
|
||||
export const WAITING_BUBBLE_DURATION_SEC = 2.0
|
||||
export const DISMISS_BUBBLE_FAST_FADE_SEC = 0.3
|
||||
export const INACTIVE_SEAT_TIMER_MIN_SEC = 3.0
|
||||
export const INACTIVE_SEAT_TIMER_RANGE_SEC = 2.0
|
||||
export const PALETTE_COUNT = 6
|
||||
export const HUE_SHIFT_MIN_DEG = 45
|
||||
export const HUE_SHIFT_RANGE_DEG = 271
|
||||
export const AUTO_ON_FACING_DEPTH = 3
|
||||
export const AUTO_ON_SIDE_DEPTH = 2
|
||||
export const CHARACTER_HIT_HALF_WIDTH = 8
|
||||
export const CHARACTER_HIT_HEIGHT = 24
|
||||
export const TOOL_OVERLAY_VERTICAL_OFFSET = 32
|
||||
export const PULSE_ANIMATION_DURATION_SEC = 1.5
|
||||
@@ -0,0 +1,209 @@
|
||||
import { TileType, MAX_COLS, MAX_ROWS } from '../types'
|
||||
import { DEFAULT_NEUTRAL_COLOR } from '../constants'
|
||||
import type { TileType as TileTypeVal, OfficeLayout, PlacedFurniture, FloorColor } from '../types'
|
||||
import { getCatalogEntry, getRotatedType, getToggledType } from '../layout/furnitureCatalog'
|
||||
import { getPlacementBlockedTiles } from '../layout/layoutSerializer'
|
||||
|
||||
/** Paint a single tile with pattern and color. Returns new layout (immutable). */
|
||||
export function paintTile(layout: OfficeLayout, col: number, row: number, tileType: TileTypeVal, color?: FloorColor): OfficeLayout {
|
||||
const idx = row * layout.cols + col
|
||||
if (idx < 0 || idx >= layout.tiles.length) return layout
|
||||
|
||||
const existingColors = layout.tileColors || new Array(layout.tiles.length).fill(null)
|
||||
const newColor = color ?? (tileType === TileType.WALL || tileType === TileType.VOID ? null : { ...DEFAULT_NEUTRAL_COLOR })
|
||||
|
||||
if (layout.tiles[idx] === tileType) {
|
||||
const existingColor = existingColors[idx]
|
||||
if (newColor === null && existingColor === null) return layout
|
||||
if (newColor && existingColor &&
|
||||
newColor.h === existingColor.h && newColor.s === existingColor.s &&
|
||||
newColor.b === existingColor.b && newColor.c === existingColor.c &&
|
||||
!!newColor.colorize === !!existingColor.colorize) return layout
|
||||
}
|
||||
|
||||
const tiles = [...layout.tiles]
|
||||
tiles[idx] = tileType
|
||||
const tileColors = [...existingColors]
|
||||
tileColors[idx] = newColor
|
||||
return { ...layout, tiles, tileColors }
|
||||
}
|
||||
|
||||
/** Place furniture. Returns new layout (immutable). */
|
||||
export function placeFurniture(layout: OfficeLayout, item: PlacedFurniture): OfficeLayout {
|
||||
if (!canPlaceFurniture(layout, item.type, item.col, item.row)) return layout
|
||||
return { ...layout, furniture: [...layout.furniture, item] }
|
||||
}
|
||||
|
||||
/** Remove furniture by uid. Returns new layout (immutable). */
|
||||
export function removeFurniture(layout: OfficeLayout, uid: string): OfficeLayout {
|
||||
const filtered = layout.furniture.filter((f) => f.uid !== uid)
|
||||
if (filtered.length === layout.furniture.length) return layout
|
||||
return { ...layout, furniture: filtered }
|
||||
}
|
||||
|
||||
/** Move furniture to new position. Returns new layout (immutable). */
|
||||
export function moveFurniture(layout: OfficeLayout, uid: string, newCol: number, newRow: number): OfficeLayout {
|
||||
const item = layout.furniture.find((f) => f.uid === uid)
|
||||
if (!item) return layout
|
||||
if (!canPlaceFurniture(layout, item.type, newCol, newRow, uid)) return layout
|
||||
return {
|
||||
...layout,
|
||||
furniture: layout.furniture.map((f) => (f.uid === uid ? { ...f, col: newCol, row: newRow } : f)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Rotate furniture to the next orientation. Returns new layout (immutable). */
|
||||
export function rotateFurniture(layout: OfficeLayout, uid: string, direction: 'cw' | 'ccw'): OfficeLayout {
|
||||
const item = layout.furniture.find((f) => f.uid === uid)
|
||||
if (!item) return layout
|
||||
const newType = getRotatedType(item.type, direction)
|
||||
if (!newType) return layout
|
||||
return {
|
||||
...layout,
|
||||
furniture: layout.furniture.map((f) => (f.uid === uid ? { ...f, type: newType } : f)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle furniture state (on/off). Returns new layout (immutable). */
|
||||
export function toggleFurnitureState(layout: OfficeLayout, uid: string): OfficeLayout {
|
||||
const item = layout.furniture.find((f) => f.uid === uid)
|
||||
if (!item) return layout
|
||||
const newType = getToggledType(item.type)
|
||||
if (!newType) return layout
|
||||
return {
|
||||
...layout,
|
||||
furniture: layout.furniture.map((f) => (f.uid === uid ? { ...f, type: newType } : f)),
|
||||
}
|
||||
}
|
||||
|
||||
/** For wall items, offset the row so the bottom row aligns with the hovered tile. */
|
||||
export function getWallPlacementRow(type: string, row: number): number {
|
||||
const entry = getCatalogEntry(type)
|
||||
if (!entry?.canPlaceOnWalls) return row
|
||||
return row - (entry.footprintH - 1)
|
||||
}
|
||||
|
||||
/** Check if furniture can be placed at (col, row) without overlapping. */
|
||||
export function canPlaceFurniture(
|
||||
layout: OfficeLayout,
|
||||
type: string,
|
||||
col: number,
|
||||
row: number,
|
||||
excludeUid?: string,
|
||||
): boolean {
|
||||
const entry = getCatalogEntry(type)
|
||||
if (!entry) return false
|
||||
|
||||
if (entry.canPlaceOnWalls) {
|
||||
const bottomRow = row + entry.footprintH - 1
|
||||
if (col < 0 || col + entry.footprintW > layout.cols || bottomRow < 0 || bottomRow >= layout.rows) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (col < 0 || row < 0 || col + entry.footprintW > layout.cols || row + entry.footprintH > layout.rows) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const bgRows = entry.backgroundTiles || 0
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
if (dr < bgRows) continue
|
||||
if (row + dr < 0) continue
|
||||
if (entry.canPlaceOnWalls && dr < entry.footprintH - 1) continue
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
const idx = (row + dr) * layout.cols + (col + dc)
|
||||
const tileVal = layout.tiles[idx]
|
||||
if (entry.canPlaceOnWalls) {
|
||||
if (tileVal !== TileType.WALL) return false
|
||||
} else {
|
||||
if (tileVal === TileType.VOID) return false
|
||||
if (tileVal === TileType.WALL) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const occupied = getPlacementBlockedTiles(layout.furniture, excludeUid)
|
||||
|
||||
let deskTiles: Set<string> | null = null
|
||||
if (entry.canPlaceOnSurfaces) {
|
||||
deskTiles = new Set<string>()
|
||||
for (const item of layout.furniture) {
|
||||
if (item.uid === excludeUid) continue
|
||||
const itemEntry = getCatalogEntry(item.type)
|
||||
if (!itemEntry || !itemEntry.isDesk) continue
|
||||
for (let dr = 0; dr < itemEntry.footprintH; dr++) {
|
||||
for (let dc = 0; dc < itemEntry.footprintW; dc++) {
|
||||
deskTiles.add(`${item.col + dc},${item.row + dr}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newBgRows = entry.backgroundTiles || 0
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
if (dr < newBgRows) continue
|
||||
if (row + dr < 0) continue
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
const key = `${col + dc},${row + dr}`
|
||||
if (occupied.has(key) && !(deskTiles?.has(key))) return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export type ExpandDirection = 'left' | 'right' | 'up' | 'down'
|
||||
|
||||
/**
|
||||
* Expand layout by 1 tile in the given direction. New tiles are VOID.
|
||||
* Returns { layout, shift } or null if exceeding MAX_COLS/MAX_ROWS.
|
||||
*/
|
||||
export function expandLayout(
|
||||
layout: OfficeLayout,
|
||||
direction: ExpandDirection,
|
||||
): { layout: OfficeLayout; shift: { col: number; row: number } } | null {
|
||||
const { cols, rows, tiles, furniture, tileColors } = layout
|
||||
const existingColors = tileColors || new Array(tiles.length).fill(null)
|
||||
|
||||
let newCols = cols
|
||||
let newRows = rows
|
||||
let shiftCol = 0
|
||||
let shiftRow = 0
|
||||
|
||||
if (direction === 'right') {
|
||||
newCols = cols + 1
|
||||
} else if (direction === 'left') {
|
||||
newCols = cols + 1
|
||||
shiftCol = 1
|
||||
} else if (direction === 'down') {
|
||||
newRows = rows + 1
|
||||
} else if (direction === 'up') {
|
||||
newRows = rows + 1
|
||||
shiftRow = 1
|
||||
}
|
||||
|
||||
if (newCols > MAX_COLS || newRows > MAX_ROWS) return null
|
||||
|
||||
const newTiles: TileTypeVal[] = new Array(newCols * newRows).fill(TileType.VOID as TileTypeVal)
|
||||
const newColors: Array<FloorColor | null> = new Array(newCols * newRows).fill(null)
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const oldIdx = r * cols + c
|
||||
const newIdx = (r + shiftRow) * newCols + (c + shiftCol)
|
||||
newTiles[newIdx] = tiles[oldIdx]
|
||||
newColors[newIdx] = existingColors[oldIdx]
|
||||
}
|
||||
}
|
||||
|
||||
const newFurniture: PlacedFurniture[] = furniture.map((f) => ({
|
||||
...f,
|
||||
col: f.col + shiftCol,
|
||||
row: f.row + shiftRow,
|
||||
}))
|
||||
|
||||
return {
|
||||
layout: { ...layout, cols: newCols, rows: newRows, tiles: newTiles, tileColors: newColors, furniture: newFurniture },
|
||||
shift: { col: shiftCol, row: shiftRow },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { EditTool, TileType } from '../types'
|
||||
import type { TileType as TileTypeVal, OfficeLayout, FloorColor } from '../types'
|
||||
import { UNDO_STACK_MAX_SIZE, DEFAULT_FLOOR_COLOR, DEFAULT_WALL_COLOR } from '../constants'
|
||||
|
||||
export class EditorState {
|
||||
isEditMode = false
|
||||
activeTool: EditTool = EditTool.SELECT
|
||||
selectedTileType: TileTypeVal = TileType.FLOOR_1
|
||||
selectedFurnitureType: string = 'desk'
|
||||
|
||||
floorColor: FloorColor = { ...DEFAULT_FLOOR_COLOR }
|
||||
wallColor: FloorColor = { ...DEFAULT_WALL_COLOR }
|
||||
|
||||
// Tracks toggle direction during wall drag
|
||||
wallDragAdding: boolean | null = null
|
||||
|
||||
// Picked furniture color (copied by pick tool)
|
||||
pickedFurnitureColor: FloorColor | null = null
|
||||
|
||||
// Ghost preview position
|
||||
ghostCol = -1
|
||||
ghostRow = -1
|
||||
ghostValid = false
|
||||
|
||||
// Selection
|
||||
selectedFurnitureUid: string | null = null
|
||||
|
||||
// Mouse drag state
|
||||
isDragging = false
|
||||
|
||||
// Undo / Redo stacks
|
||||
undoStack: OfficeLayout[] = []
|
||||
redoStack: OfficeLayout[] = []
|
||||
|
||||
isDirty = false
|
||||
|
||||
// Drag-to-move state
|
||||
dragUid: string | null = null
|
||||
dragStartCol = 0
|
||||
dragStartRow = 0
|
||||
dragOffsetCol = 0
|
||||
dragOffsetRow = 0
|
||||
isDragMoving = false
|
||||
|
||||
pushUndo(layout: OfficeLayout): void {
|
||||
this.undoStack.push(layout)
|
||||
if (this.undoStack.length > UNDO_STACK_MAX_SIZE) {
|
||||
this.undoStack.shift()
|
||||
}
|
||||
}
|
||||
|
||||
popUndo(): OfficeLayout | null {
|
||||
return this.undoStack.pop() || null
|
||||
}
|
||||
|
||||
pushRedo(layout: OfficeLayout): void {
|
||||
this.redoStack.push(layout)
|
||||
if (this.redoStack.length > UNDO_STACK_MAX_SIZE) {
|
||||
this.redoStack.shift()
|
||||
}
|
||||
}
|
||||
|
||||
popRedo(): OfficeLayout | null {
|
||||
return this.redoStack.pop() || null
|
||||
}
|
||||
|
||||
clearRedo(): void {
|
||||
this.redoStack = []
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedFurnitureUid = null
|
||||
}
|
||||
|
||||
clearGhost(): void {
|
||||
this.ghostCol = -1
|
||||
this.ghostRow = -1
|
||||
this.ghostValid = false
|
||||
}
|
||||
|
||||
startDrag(uid: string, startCol: number, startRow: number, offsetCol: number, offsetRow: number): void {
|
||||
this.dragUid = uid
|
||||
this.dragStartCol = startCol
|
||||
this.dragStartRow = startRow
|
||||
this.dragOffsetCol = offsetCol
|
||||
this.dragOffsetRow = offsetRow
|
||||
this.isDragMoving = false
|
||||
}
|
||||
|
||||
clearDrag(): void {
|
||||
this.dragUid = null
|
||||
this.isDragMoving = false
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.activeTool = EditTool.SELECT
|
||||
this.selectedFurnitureUid = null
|
||||
this.ghostCol = -1
|
||||
this.ghostRow = -1
|
||||
this.ghostValid = false
|
||||
this.isDragging = false
|
||||
this.wallDragAdding = null
|
||||
this.undoStack = []
|
||||
this.redoStack = []
|
||||
this.isDirty = false
|
||||
this.dragUid = null
|
||||
this.isDragMoving = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { CharacterState, Direction, TILE_SIZE } from '../types'
|
||||
import type { Character, Seat, SpriteData, TileType as TileTypeVal } from '../types'
|
||||
import type { CharacterSprites } from '../sprites/spriteData'
|
||||
import { findPath } from '../layout/tileMap'
|
||||
import {
|
||||
WALK_SPEED_PX_PER_SEC,
|
||||
WALK_FRAME_DURATION_SEC,
|
||||
TYPE_FRAME_DURATION_SEC,
|
||||
WANDER_PAUSE_MIN_SEC,
|
||||
WANDER_PAUSE_MAX_SEC,
|
||||
WANDER_MOVES_BEFORE_REST_MIN,
|
||||
WANDER_MOVES_BEFORE_REST_MAX,
|
||||
SEAT_REST_MIN_SEC,
|
||||
SEAT_REST_MAX_SEC,
|
||||
} from '../constants'
|
||||
|
||||
/** Tools that show reading animation instead of typing */
|
||||
const READING_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch'])
|
||||
|
||||
export function isReadingTool(tool: string | null): boolean {
|
||||
if (!tool) return false
|
||||
return READING_TOOLS.has(tool)
|
||||
}
|
||||
|
||||
/** Pixel center of a tile */
|
||||
function tileCenter(col: number, row: number): { x: number; y: number } {
|
||||
return {
|
||||
x: col * TILE_SIZE + TILE_SIZE / 2,
|
||||
y: row * TILE_SIZE + TILE_SIZE / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/** Direction from one tile to an adjacent tile */
|
||||
function directionBetween(fromCol: number, fromRow: number, toCol: number, toRow: number): Direction {
|
||||
const dc = toCol - fromCol
|
||||
const dr = toRow - fromRow
|
||||
if (dc > 0) return Direction.RIGHT
|
||||
if (dc < 0) return Direction.LEFT
|
||||
if (dr > 0) return Direction.DOWN
|
||||
return Direction.UP
|
||||
}
|
||||
|
||||
export function createCharacter(
|
||||
id: number,
|
||||
palette: number,
|
||||
seatId: string | null,
|
||||
seat: Seat | null,
|
||||
hueShift = 0,
|
||||
): Character {
|
||||
const col = seat ? seat.seatCol : 1
|
||||
const row = seat ? seat.seatRow : 1
|
||||
const center = tileCenter(col, row)
|
||||
return {
|
||||
id,
|
||||
state: CharacterState.TYPE,
|
||||
dir: seat ? seat.facingDir : Direction.DOWN,
|
||||
x: center.x,
|
||||
y: center.y,
|
||||
tileCol: col,
|
||||
tileRow: row,
|
||||
path: [],
|
||||
moveProgress: 0,
|
||||
currentTool: null,
|
||||
palette,
|
||||
hueShift,
|
||||
frame: 0,
|
||||
frameTimer: 0,
|
||||
wanderTimer: 0,
|
||||
wanderCount: 0,
|
||||
wanderLimit: randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX),
|
||||
isActive: true,
|
||||
seatId,
|
||||
bubbleType: null,
|
||||
bubbleTimer: 0,
|
||||
seatTimer: 0,
|
||||
isSubagent: false,
|
||||
parentAgentId: null,
|
||||
label: '',
|
||||
matrixEffect: null,
|
||||
matrixEffectTimer: 0,
|
||||
matrixEffectSeeds: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCharacter(
|
||||
ch: Character,
|
||||
dt: number,
|
||||
walkableTiles: Array<{ col: number; row: number }>,
|
||||
seats: Map<string, Seat>,
|
||||
tileMap: TileTypeVal[][],
|
||||
blockedTiles: Set<string>,
|
||||
): void {
|
||||
ch.frameTimer += dt
|
||||
|
||||
switch (ch.state) {
|
||||
case CharacterState.TYPE: {
|
||||
if (ch.frameTimer >= TYPE_FRAME_DURATION_SEC) {
|
||||
ch.frameTimer -= TYPE_FRAME_DURATION_SEC
|
||||
ch.frame = (ch.frame + 1) % 2
|
||||
}
|
||||
// If no longer active, stand up and start wandering (after seatTimer expires)
|
||||
if (!ch.isActive) {
|
||||
if (ch.seatTimer > 0) {
|
||||
ch.seatTimer -= dt
|
||||
break
|
||||
}
|
||||
ch.seatTimer = 0 // clear sentinel
|
||||
ch.state = CharacterState.IDLE
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
ch.wanderTimer = randomRange(WANDER_PAUSE_MIN_SEC, WANDER_PAUSE_MAX_SEC)
|
||||
ch.wanderCount = 0
|
||||
ch.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case CharacterState.IDLE: {
|
||||
// No idle animation — static pose
|
||||
ch.frame = 0
|
||||
if (ch.seatTimer < 0) ch.seatTimer = 0 // clear turn-end sentinel
|
||||
// If became active, pathfind to seat
|
||||
if (ch.isActive) {
|
||||
if (!ch.seatId) {
|
||||
// No seat assigned — type in place
|
||||
ch.state = CharacterState.TYPE
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
break
|
||||
}
|
||||
const seat = seats.get(ch.seatId)
|
||||
if (seat) {
|
||||
const path = findPath(ch.tileCol, ch.tileRow, seat.seatCol, seat.seatRow, tileMap, blockedTiles)
|
||||
if (path.length > 0) {
|
||||
ch.path = path
|
||||
ch.moveProgress = 0
|
||||
ch.state = CharacterState.WALK
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
} else {
|
||||
// Already at seat or no path — sit down
|
||||
ch.state = CharacterState.TYPE
|
||||
ch.dir = seat.facingDir
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
// Countdown wander timer
|
||||
ch.wanderTimer -= dt
|
||||
if (ch.wanderTimer <= 0) {
|
||||
// Check if we've wandered enough — return to seat for a rest
|
||||
if (ch.wanderCount >= ch.wanderLimit && ch.seatId) {
|
||||
const seat = seats.get(ch.seatId)
|
||||
if (seat) {
|
||||
const path = findPath(ch.tileCol, ch.tileRow, seat.seatCol, seat.seatRow, tileMap, blockedTiles)
|
||||
if (path.length > 0) {
|
||||
ch.path = path
|
||||
ch.moveProgress = 0
|
||||
ch.state = CharacterState.WALK
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (walkableTiles.length > 0) {
|
||||
const target = walkableTiles[Math.floor(Math.random() * walkableTiles.length)]
|
||||
const path = findPath(ch.tileCol, ch.tileRow, target.col, target.row, tileMap, blockedTiles)
|
||||
if (path.length > 0) {
|
||||
ch.path = path
|
||||
ch.moveProgress = 0
|
||||
ch.state = CharacterState.WALK
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
ch.wanderCount++
|
||||
}
|
||||
}
|
||||
ch.wanderTimer = randomRange(WANDER_PAUSE_MIN_SEC, WANDER_PAUSE_MAX_SEC)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case CharacterState.WALK: {
|
||||
// Walk animation
|
||||
if (ch.frameTimer >= WALK_FRAME_DURATION_SEC) {
|
||||
ch.frameTimer -= WALK_FRAME_DURATION_SEC
|
||||
ch.frame = (ch.frame + 1) % 4
|
||||
}
|
||||
|
||||
if (ch.path.length === 0) {
|
||||
// Path complete — snap to tile center and transition
|
||||
const center = tileCenter(ch.tileCol, ch.tileRow)
|
||||
ch.x = center.x
|
||||
ch.y = center.y
|
||||
|
||||
if (ch.isActive) {
|
||||
if (!ch.seatId) {
|
||||
// No seat — type in place
|
||||
ch.state = CharacterState.TYPE
|
||||
} else {
|
||||
const seat = seats.get(ch.seatId)
|
||||
if (seat && ch.tileCol === seat.seatCol && ch.tileRow === seat.seatRow) {
|
||||
ch.state = CharacterState.TYPE
|
||||
ch.dir = seat.facingDir
|
||||
} else {
|
||||
ch.state = CharacterState.IDLE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if arrived at assigned seat — sit down for a rest before wandering again
|
||||
if (ch.seatId) {
|
||||
const seat = seats.get(ch.seatId)
|
||||
if (seat && ch.tileCol === seat.seatCol && ch.tileRow === seat.seatRow) {
|
||||
ch.state = CharacterState.TYPE
|
||||
ch.dir = seat.facingDir
|
||||
// seatTimer < 0 is a sentinel from setAgentActive(false) meaning
|
||||
// "turn just ended" — skip the long rest so idle transition is immediate
|
||||
if (ch.seatTimer < 0) {
|
||||
ch.seatTimer = 0
|
||||
} else {
|
||||
ch.seatTimer = randomRange(SEAT_REST_MIN_SEC, SEAT_REST_MAX_SEC)
|
||||
}
|
||||
ch.wanderCount = 0
|
||||
ch.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
ch.state = CharacterState.IDLE
|
||||
ch.wanderTimer = randomRange(WANDER_PAUSE_MIN_SEC, WANDER_PAUSE_MAX_SEC)
|
||||
}
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
break
|
||||
}
|
||||
|
||||
// Move toward next tile in path
|
||||
const nextTile = ch.path[0]
|
||||
ch.dir = directionBetween(ch.tileCol, ch.tileRow, nextTile.col, nextTile.row)
|
||||
|
||||
ch.moveProgress += (WALK_SPEED_PX_PER_SEC / TILE_SIZE) * dt
|
||||
|
||||
const fromCenter = tileCenter(ch.tileCol, ch.tileRow)
|
||||
const toCenter = tileCenter(nextTile.col, nextTile.row)
|
||||
const t = Math.min(ch.moveProgress, 1)
|
||||
ch.x = fromCenter.x + (toCenter.x - fromCenter.x) * t
|
||||
ch.y = fromCenter.y + (toCenter.y - fromCenter.y) * t
|
||||
|
||||
if (ch.moveProgress >= 1) {
|
||||
// Arrived at next tile
|
||||
ch.tileCol = nextTile.col
|
||||
ch.tileRow = nextTile.row
|
||||
ch.x = toCenter.x
|
||||
ch.y = toCenter.y
|
||||
ch.path.shift()
|
||||
ch.moveProgress = 0
|
||||
}
|
||||
|
||||
// If became active while wandering, repath to seat
|
||||
if (ch.isActive && ch.seatId) {
|
||||
const seat = seats.get(ch.seatId)
|
||||
if (seat) {
|
||||
const lastStep = ch.path[ch.path.length - 1]
|
||||
if (!lastStep || lastStep.col !== seat.seatCol || lastStep.row !== seat.seatRow) {
|
||||
const newPath = findPath(ch.tileCol, ch.tileRow, seat.seatCol, seat.seatRow, tileMap, blockedTiles)
|
||||
if (newPath.length > 0) {
|
||||
ch.path = newPath
|
||||
ch.moveProgress = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the correct sprite frame for a character's current state and direction */
|
||||
export function getCharacterSprite(ch: Character, sprites: CharacterSprites): SpriteData {
|
||||
switch (ch.state) {
|
||||
case CharacterState.TYPE:
|
||||
if (isReadingTool(ch.currentTool)) {
|
||||
return sprites.reading[ch.dir][ch.frame % 2]
|
||||
}
|
||||
return sprites.typing[ch.dir][ch.frame % 2]
|
||||
case CharacterState.WALK:
|
||||
return sprites.walk[ch.dir][ch.frame % 4]
|
||||
case CharacterState.IDLE:
|
||||
return sprites.walk[ch.dir][1]
|
||||
default:
|
||||
return sprites.walk[ch.dir][1]
|
||||
}
|
||||
}
|
||||
|
||||
function randomRange(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min)
|
||||
}
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
return min + Math.floor(Math.random() * (max - min + 1))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MAX_DELTA_TIME_SEC } from '../constants'
|
||||
|
||||
export interface GameLoopCallbacks {
|
||||
update: (dt: number) => void
|
||||
render: (ctx: CanvasRenderingContext2D) => void
|
||||
}
|
||||
|
||||
export function startGameLoop(
|
||||
canvas: HTMLCanvasElement,
|
||||
callbacks: GameLoopCallbacks,
|
||||
): () => void {
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
let lastTime = 0
|
||||
let rafId = 0
|
||||
let stopped = false
|
||||
|
||||
const frame = (time: number) => {
|
||||
if (stopped) return
|
||||
const dt = lastTime === 0 ? 0 : Math.min((time - lastTime) / 1000, MAX_DELTA_TIME_SEC)
|
||||
lastTime = time
|
||||
|
||||
callbacks.update(dt)
|
||||
|
||||
ctx.imageSmoothingEnabled = false
|
||||
callbacks.render(ctx)
|
||||
|
||||
rafId = requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(frame)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { Character, SpriteData } from '../types'
|
||||
import { MATRIX_EFFECT_DURATION } from '../types'
|
||||
import {
|
||||
MATRIX_TRAIL_LENGTH,
|
||||
MATRIX_SPRITE_COLS,
|
||||
MATRIX_SPRITE_ROWS,
|
||||
MATRIX_FLICKER_FPS,
|
||||
MATRIX_FLICKER_VISIBILITY_THRESHOLD,
|
||||
MATRIX_COLUMN_STAGGER_RANGE,
|
||||
MATRIX_HEAD_COLOR,
|
||||
MATRIX_TRAIL_OVERLAY_ALPHA,
|
||||
MATRIX_TRAIL_EMPTY_ALPHA,
|
||||
MATRIX_TRAIL_MID_THRESHOLD,
|
||||
MATRIX_TRAIL_DIM_THRESHOLD,
|
||||
} from '../constants'
|
||||
|
||||
/** Hash-based flicker: ~70% visible for shimmer effect */
|
||||
function flickerVisible(col: number, row: number, time: number): boolean {
|
||||
const t = Math.floor(time * MATRIX_FLICKER_FPS)
|
||||
const hash = ((col * 7 + row * 13 + t * 31) & 0xff)
|
||||
return hash < MATRIX_FLICKER_VISIBILITY_THRESHOLD
|
||||
}
|
||||
|
||||
function generateSeeds(): number[] {
|
||||
const seeds: number[] = []
|
||||
for (let i = 0; i < MATRIX_SPRITE_COLS; i++) {
|
||||
seeds.push(Math.random())
|
||||
}
|
||||
return seeds
|
||||
}
|
||||
|
||||
export { generateSeeds as matrixEffectSeeds }
|
||||
|
||||
/**
|
||||
* Render a character with a Matrix-style digital rain spawn/despawn effect.
|
||||
* Per-pixel rendering: each column sweeps top-to-bottom with a bright head and fading green trail.
|
||||
*/
|
||||
export function renderMatrixEffect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ch: Character,
|
||||
spriteData: SpriteData,
|
||||
drawX: number,
|
||||
drawY: number,
|
||||
zoom: number,
|
||||
): void {
|
||||
const progress = ch.matrixEffectTimer / MATRIX_EFFECT_DURATION
|
||||
const isSpawn = ch.matrixEffect === 'spawn'
|
||||
const time = ch.matrixEffectTimer
|
||||
const totalSweep = MATRIX_SPRITE_ROWS + MATRIX_TRAIL_LENGTH
|
||||
|
||||
for (let col = 0; col < MATRIX_SPRITE_COLS; col++) {
|
||||
// Stagger: each column starts at a slightly different time
|
||||
const stagger = (ch.matrixEffectSeeds[col] ?? 0) * MATRIX_COLUMN_STAGGER_RANGE
|
||||
const colProgress = Math.max(0, Math.min(1, (progress - stagger) / (1 - MATRIX_COLUMN_STAGGER_RANGE)))
|
||||
const headRow = colProgress * totalSweep
|
||||
|
||||
for (let row = 0; row < MATRIX_SPRITE_ROWS; row++) {
|
||||
const pixel = spriteData[row]?.[col]
|
||||
const hasPixel = pixel && pixel !== ''
|
||||
const distFromHead = headRow - row
|
||||
const px = drawX + col * zoom
|
||||
const py = drawY + row * zoom
|
||||
|
||||
if (isSpawn) {
|
||||
// Spawn: head sweeps down revealing character pixels
|
||||
if (distFromHead < 0) {
|
||||
// Above head: invisible
|
||||
continue
|
||||
} else if (distFromHead < 1) {
|
||||
// Head pixel: bright white-green
|
||||
ctx.fillStyle = MATRIX_HEAD_COLOR
|
||||
ctx.fillRect(px, py, zoom, zoom)
|
||||
} else if (distFromHead < MATRIX_TRAIL_LENGTH) {
|
||||
// Trail zone: show character pixel with green overlay, or just green if no pixel
|
||||
const trailPos = distFromHead / MATRIX_TRAIL_LENGTH
|
||||
if (hasPixel) {
|
||||
// Draw original pixel
|
||||
ctx.fillStyle = pixel
|
||||
ctx.fillRect(px, py, zoom, zoom)
|
||||
// Green overlay that fades as trail progresses
|
||||
const greenAlpha = (1 - trailPos) * MATRIX_TRAIL_OVERLAY_ALPHA
|
||||
if (flickerVisible(col, row, time)) {
|
||||
ctx.fillStyle = `rgba(0, 255, 65, ${greenAlpha})`
|
||||
ctx.fillRect(px, py, zoom, zoom)
|
||||
}
|
||||
} else {
|
||||
// No character pixel: fading green trail
|
||||
if (flickerVisible(col, row, time)) {
|
||||
const alpha = (1 - trailPos) * MATRIX_TRAIL_EMPTY_ALPHA
|
||||
ctx.fillStyle = trailPos < MATRIX_TRAIL_MID_THRESHOLD ? `rgba(0, 255, 65, ${alpha})`
|
||||
: trailPos < MATRIX_TRAIL_DIM_THRESHOLD ? `rgba(0, 170, 40, ${alpha})`
|
||||
: `rgba(0, 85, 20, ${alpha})`
|
||||
ctx.fillRect(px, py, zoom, zoom)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Below trail: normal character pixel
|
||||
if (hasPixel) {
|
||||
ctx.fillStyle = pixel
|
||||
ctx.fillRect(px, py, zoom, zoom)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Despawn: head sweeps down consuming character pixels
|
||||
if (distFromHead < 0) {
|
||||
// Above head: normal character pixel (not yet consumed)
|
||||
if (hasPixel) {
|
||||
ctx.fillStyle = pixel
|
||||
ctx.fillRect(px, py, zoom, zoom)
|
||||
}
|
||||
} else if (distFromHead < 1) {
|
||||
// Head pixel: bright white-green
|
||||
ctx.fillStyle = MATRIX_HEAD_COLOR
|
||||
ctx.fillRect(px, py, zoom, zoom)
|
||||
} else if (distFromHead < MATRIX_TRAIL_LENGTH) {
|
||||
// Trail zone: fading green
|
||||
if (flickerVisible(col, row, time)) {
|
||||
const trailPos = distFromHead / MATRIX_TRAIL_LENGTH
|
||||
const alpha = (1 - trailPos) * MATRIX_TRAIL_EMPTY_ALPHA
|
||||
ctx.fillStyle = trailPos < MATRIX_TRAIL_MID_THRESHOLD ? `rgba(0, 255, 65, ${alpha})`
|
||||
: trailPos < MATRIX_TRAIL_DIM_THRESHOLD ? `rgba(0, 170, 40, ${alpha})`
|
||||
: `rgba(0, 85, 20, ${alpha})`
|
||||
ctx.fillRect(px, py, zoom, zoom)
|
||||
}
|
||||
}
|
||||
// Below trail: nothing (consumed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
import { TILE_SIZE, MATRIX_EFFECT_DURATION, CharacterState, Direction } from '../types'
|
||||
import {
|
||||
PALETTE_COUNT,
|
||||
HUE_SHIFT_MIN_DEG,
|
||||
HUE_SHIFT_RANGE_DEG,
|
||||
WAITING_BUBBLE_DURATION_SEC,
|
||||
DISMISS_BUBBLE_FAST_FADE_SEC,
|
||||
INACTIVE_SEAT_TIMER_MIN_SEC,
|
||||
INACTIVE_SEAT_TIMER_RANGE_SEC,
|
||||
AUTO_ON_FACING_DEPTH,
|
||||
AUTO_ON_SIDE_DEPTH,
|
||||
CHARACTER_SITTING_OFFSET_PX,
|
||||
CHARACTER_HIT_HALF_WIDTH,
|
||||
CHARACTER_HIT_HEIGHT,
|
||||
} from '../constants'
|
||||
import type { Character, Seat, FurnitureInstance, TileType as TileTypeVal, OfficeLayout, PlacedFurniture } from '../types'
|
||||
import { createCharacter, updateCharacter } from './characters'
|
||||
import { matrixEffectSeeds } from './matrixEffect'
|
||||
import { isWalkable, getWalkableTiles, findPath } from '../layout/tileMap'
|
||||
import {
|
||||
createDefaultLayout,
|
||||
layoutToTileMap,
|
||||
layoutToFurnitureInstances,
|
||||
layoutToSeats,
|
||||
getBlockedTiles,
|
||||
} from '../layout/layoutSerializer'
|
||||
import { getCatalogEntry, getOnStateType } from '../layout/furnitureCatalog'
|
||||
|
||||
export class OfficeState {
|
||||
layout: OfficeLayout
|
||||
tileMap: TileTypeVal[][]
|
||||
seats: Map<string, Seat>
|
||||
blockedTiles: Set<string>
|
||||
furniture: FurnitureInstance[]
|
||||
walkableTiles: Array<{ col: number; row: number }>
|
||||
characters: Map<number, Character> = new Map()
|
||||
selectedAgentId: number | null = null
|
||||
cameraFollowId: number | null = null
|
||||
hoveredAgentId: number | null = null
|
||||
hoveredTile: { col: number; row: number } | null = null
|
||||
/** Maps "parentId:toolId" → sub-agent character ID (negative) */
|
||||
subagentIdMap: Map<string, number> = new Map()
|
||||
/** Reverse lookup: sub-agent character ID → parent info */
|
||||
subagentMeta: Map<number, { parentAgentId: number; parentToolId: string }> = new Map()
|
||||
private nextSubagentId = -1
|
||||
|
||||
constructor(layout?: OfficeLayout) {
|
||||
this.layout = layout || createDefaultLayout()
|
||||
this.tileMap = layoutToTileMap(this.layout)
|
||||
this.seats = layoutToSeats(this.layout.furniture)
|
||||
this.blockedTiles = getBlockedTiles(this.layout.furniture)
|
||||
this.furniture = layoutToFurnitureInstances(this.layout.furniture)
|
||||
this.walkableTiles = getWalkableTiles(this.tileMap, this.blockedTiles)
|
||||
}
|
||||
|
||||
/** Rebuild all derived state from a new layout. Reassigns existing characters.
|
||||
* @param shift Optional pixel shift to apply when grid expands left/up */
|
||||
rebuildFromLayout(layout: OfficeLayout, shift?: { col: number; row: number }): void {
|
||||
this.layout = layout
|
||||
this.tileMap = layoutToTileMap(layout)
|
||||
this.seats = layoutToSeats(layout.furniture)
|
||||
this.blockedTiles = getBlockedTiles(layout.furniture)
|
||||
this.rebuildFurnitureInstances()
|
||||
this.walkableTiles = getWalkableTiles(this.tileMap, this.blockedTiles)
|
||||
|
||||
// Shift character positions when grid expands left/up
|
||||
if (shift && (shift.col !== 0 || shift.row !== 0)) {
|
||||
for (const ch of this.characters.values()) {
|
||||
ch.tileCol += shift.col
|
||||
ch.tileRow += shift.row
|
||||
ch.x += shift.col * TILE_SIZE
|
||||
ch.y += shift.row * TILE_SIZE
|
||||
// Clear path since tile coords changed
|
||||
ch.path = []
|
||||
ch.moveProgress = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Reassign characters to new seats, preserving existing assignments when possible
|
||||
for (const seat of this.seats.values()) {
|
||||
seat.assigned = false
|
||||
}
|
||||
|
||||
// First pass: try to keep characters at their existing seats
|
||||
for (const ch of this.characters.values()) {
|
||||
if (ch.seatId && this.seats.has(ch.seatId)) {
|
||||
const seat = this.seats.get(ch.seatId)!
|
||||
if (!seat.assigned) {
|
||||
seat.assigned = true
|
||||
// Snap character to seat position
|
||||
ch.tileCol = seat.seatCol
|
||||
ch.tileRow = seat.seatRow
|
||||
const cx = seat.seatCol * TILE_SIZE + TILE_SIZE / 2
|
||||
const cy = seat.seatRow * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.x = cx
|
||||
ch.y = cy
|
||||
ch.dir = seat.facingDir
|
||||
continue
|
||||
}
|
||||
}
|
||||
ch.seatId = null // will be reassigned below
|
||||
}
|
||||
|
||||
// Second pass: assign remaining characters to free seats
|
||||
for (const ch of this.characters.values()) {
|
||||
if (ch.seatId) continue
|
||||
const seatId = this.findFreeSeat()
|
||||
if (seatId) {
|
||||
this.seats.get(seatId)!.assigned = true
|
||||
ch.seatId = seatId
|
||||
const seat = this.seats.get(seatId)!
|
||||
ch.tileCol = seat.seatCol
|
||||
ch.tileRow = seat.seatRow
|
||||
ch.x = seat.seatCol * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.y = seat.seatRow * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.dir = seat.facingDir
|
||||
}
|
||||
}
|
||||
|
||||
// Relocate any characters that ended up outside bounds or on non-walkable tiles
|
||||
for (const ch of this.characters.values()) {
|
||||
if (ch.seatId) continue // seated characters are fine
|
||||
if (ch.tileCol < 0 || ch.tileCol >= layout.cols || ch.tileRow < 0 || ch.tileRow >= layout.rows) {
|
||||
this.relocateCharacterToWalkable(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a character to a random walkable tile */
|
||||
private relocateCharacterToWalkable(ch: Character): void {
|
||||
if (this.walkableTiles.length === 0) return
|
||||
const spawn = this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
|
||||
ch.tileCol = spawn.col
|
||||
ch.tileRow = spawn.row
|
||||
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.path = []
|
||||
ch.moveProgress = 0
|
||||
}
|
||||
|
||||
getLayout(): OfficeLayout {
|
||||
return this.layout
|
||||
}
|
||||
|
||||
/** Get the blocked-tile key for a character's own seat, or null */
|
||||
private ownSeatKey(ch: Character): string | null {
|
||||
if (!ch.seatId) return null
|
||||
const seat = this.seats.get(ch.seatId)
|
||||
if (!seat) return null
|
||||
return `${seat.seatCol},${seat.seatRow}`
|
||||
}
|
||||
|
||||
/** Temporarily unblock a character's own seat, run fn, then re-block */
|
||||
private withOwnSeatUnblocked<T>(ch: Character, fn: () => T): T {
|
||||
const key = this.ownSeatKey(ch)
|
||||
if (key) this.blockedTiles.delete(key)
|
||||
const result = fn()
|
||||
if (key) this.blockedTiles.add(key)
|
||||
return result
|
||||
}
|
||||
|
||||
private findFreeSeat(): string | null {
|
||||
for (const [uid, seat] of this.seats) {
|
||||
if (!seat.assigned) return uid
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a diverse palette for a new agent based on currently active agents.
|
||||
* First 6 agents each get a unique skin (random order). Beyond 6, skins
|
||||
* repeat in balanced rounds with a random hue shift (≥45°).
|
||||
*/
|
||||
private pickDiversePalette(): { palette: number; hueShift: number } {
|
||||
// Count how many non-sub-agents use each base palette (0-5)
|
||||
const counts = new Array(PALETTE_COUNT).fill(0) as number[]
|
||||
for (const ch of this.characters.values()) {
|
||||
if (ch.isSubagent) continue
|
||||
counts[ch.palette]++
|
||||
}
|
||||
const minCount = Math.min(...counts)
|
||||
// Available = palettes at the minimum count (least used)
|
||||
const available: number[] = []
|
||||
for (let i = 0; i < PALETTE_COUNT; i++) {
|
||||
if (counts[i] === minCount) available.push(i)
|
||||
}
|
||||
const palette = available[Math.floor(Math.random() * available.length)]
|
||||
// First round (minCount === 0): no hue shift. Subsequent rounds: random ≥45°.
|
||||
let hueShift = 0
|
||||
if (minCount > 0) {
|
||||
hueShift = HUE_SHIFT_MIN_DEG + Math.floor(Math.random() * HUE_SHIFT_RANGE_DEG)
|
||||
}
|
||||
return { palette, hueShift }
|
||||
}
|
||||
|
||||
addAgent(id: number, preferredPalette?: number, preferredHueShift?: number, preferredSeatId?: string, skipSpawnEffect?: boolean): void {
|
||||
if (this.characters.has(id)) return
|
||||
|
||||
let palette: number
|
||||
let hueShift: number
|
||||
if (preferredPalette !== undefined) {
|
||||
palette = preferredPalette
|
||||
hueShift = preferredHueShift ?? 0
|
||||
} else {
|
||||
const pick = this.pickDiversePalette()
|
||||
palette = pick.palette
|
||||
hueShift = pick.hueShift
|
||||
}
|
||||
|
||||
// Try preferred seat first, then any free seat
|
||||
let seatId: string | null = null
|
||||
if (preferredSeatId && this.seats.has(preferredSeatId)) {
|
||||
const seat = this.seats.get(preferredSeatId)!
|
||||
if (!seat.assigned) {
|
||||
seatId = preferredSeatId
|
||||
}
|
||||
}
|
||||
if (!seatId) {
|
||||
seatId = this.findFreeSeat()
|
||||
}
|
||||
|
||||
let ch: Character
|
||||
if (seatId) {
|
||||
const seat = this.seats.get(seatId)!
|
||||
seat.assigned = true
|
||||
ch = createCharacter(id, palette, seatId, seat, hueShift)
|
||||
} else {
|
||||
// No seats — spawn at random walkable tile
|
||||
const spawn = this.walkableTiles.length > 0
|
||||
? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
|
||||
: { col: 1, row: 1 }
|
||||
ch = createCharacter(id, palette, null, null, hueShift)
|
||||
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.tileCol = spawn.col
|
||||
ch.tileRow = spawn.row
|
||||
}
|
||||
|
||||
if (!skipSpawnEffect) {
|
||||
ch.matrixEffect = 'spawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
}
|
||||
this.characters.set(id, ch)
|
||||
}
|
||||
|
||||
removeAgent(id: number): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (!ch) return
|
||||
if (ch.matrixEffect === 'despawn') return // already despawning
|
||||
// Free seat and clear selection immediately
|
||||
if (ch.seatId) {
|
||||
const seat = this.seats.get(ch.seatId)
|
||||
if (seat) seat.assigned = false
|
||||
}
|
||||
if (this.selectedAgentId === id) this.selectedAgentId = null
|
||||
if (this.cameraFollowId === id) this.cameraFollowId = null
|
||||
// Start despawn animation instead of immediate delete
|
||||
ch.matrixEffect = 'despawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
ch.bubbleType = null
|
||||
}
|
||||
|
||||
/** Find seat uid at a given tile position, or null */
|
||||
getSeatAtTile(col: number, row: number): string | null {
|
||||
for (const [uid, seat] of this.seats) {
|
||||
if (seat.seatCol === col && seat.seatRow === row) return uid
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Reassign an agent from their current seat to a new seat */
|
||||
reassignSeat(agentId: number, seatId: string): void {
|
||||
const ch = this.characters.get(agentId)
|
||||
if (!ch) return
|
||||
// Unassign old seat
|
||||
if (ch.seatId) {
|
||||
const old = this.seats.get(ch.seatId)
|
||||
if (old) old.assigned = false
|
||||
}
|
||||
// Assign new seat
|
||||
const seat = this.seats.get(seatId)
|
||||
if (!seat || seat.assigned) return
|
||||
seat.assigned = true
|
||||
ch.seatId = seatId
|
||||
// Pathfind to new seat (unblock own seat tile for this query)
|
||||
const path = this.withOwnSeatUnblocked(ch, () =>
|
||||
findPath(ch.tileCol, ch.tileRow, seat.seatCol, seat.seatRow, this.tileMap, this.blockedTiles)
|
||||
)
|
||||
if (path.length > 0) {
|
||||
ch.path = path
|
||||
ch.moveProgress = 0
|
||||
ch.state = CharacterState.WALK
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
} else {
|
||||
// Already at seat or no path — sit down
|
||||
ch.state = CharacterState.TYPE
|
||||
ch.dir = seat.facingDir
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
if (!ch.isActive) {
|
||||
ch.seatTimer = INACTIVE_SEAT_TIMER_MIN_SEC + Math.random() * INACTIVE_SEAT_TIMER_RANGE_SEC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Send an agent back to their currently assigned seat */
|
||||
sendToSeat(agentId: number): void {
|
||||
const ch = this.characters.get(agentId)
|
||||
if (!ch || !ch.seatId) return
|
||||
const seat = this.seats.get(ch.seatId)
|
||||
if (!seat) return
|
||||
const path = this.withOwnSeatUnblocked(ch, () =>
|
||||
findPath(ch.tileCol, ch.tileRow, seat.seatCol, seat.seatRow, this.tileMap, this.blockedTiles)
|
||||
)
|
||||
if (path.length > 0) {
|
||||
ch.path = path
|
||||
ch.moveProgress = 0
|
||||
ch.state = CharacterState.WALK
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
} else {
|
||||
// Already at seat — sit down
|
||||
ch.state = CharacterState.TYPE
|
||||
ch.dir = seat.facingDir
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
if (!ch.isActive) {
|
||||
ch.seatTimer = INACTIVE_SEAT_TIMER_MIN_SEC + Math.random() * INACTIVE_SEAT_TIMER_RANGE_SEC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk an agent to an arbitrary walkable tile (right-click command) */
|
||||
walkToTile(agentId: number, col: number, row: number): boolean {
|
||||
const ch = this.characters.get(agentId)
|
||||
if (!ch || ch.isSubagent) return false
|
||||
if (!isWalkable(col, row, this.tileMap, this.blockedTiles)) {
|
||||
// Also allow walking to own seat tile (blocked for others but not self)
|
||||
const key = this.ownSeatKey(ch)
|
||||
if (!key || key !== `${col},${row}`) return false
|
||||
}
|
||||
const path = this.withOwnSeatUnblocked(ch, () =>
|
||||
findPath(ch.tileCol, ch.tileRow, col, row, this.tileMap, this.blockedTiles)
|
||||
)
|
||||
if (path.length === 0) return false
|
||||
ch.path = path
|
||||
ch.moveProgress = 0
|
||||
ch.state = CharacterState.WALK
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
return true
|
||||
}
|
||||
|
||||
/** Create a sub-agent character with the parent's palette. Returns the sub-agent ID. */
|
||||
addSubagent(parentAgentId: number, parentToolId: string): number {
|
||||
const key = `${parentAgentId}:${parentToolId}`
|
||||
if (this.subagentIdMap.has(key)) return this.subagentIdMap.get(key)!
|
||||
|
||||
const id = this.nextSubagentId--
|
||||
const parentCh = this.characters.get(parentAgentId)
|
||||
const palette = parentCh ? parentCh.palette : 0
|
||||
const hueShift = parentCh ? parentCh.hueShift : 0
|
||||
|
||||
// Find the free seat closest to the parent agent
|
||||
const parentCol = parentCh ? parentCh.tileCol : 0
|
||||
const parentRow = parentCh ? parentCh.tileRow : 0
|
||||
const dist = (c: number, r: number) =>
|
||||
Math.abs(c - parentCol) + Math.abs(r - parentRow)
|
||||
|
||||
let bestSeatId: string | null = null
|
||||
let bestDist = Infinity
|
||||
for (const [uid, seat] of this.seats) {
|
||||
if (!seat.assigned) {
|
||||
const d = dist(seat.seatCol, seat.seatRow)
|
||||
if (d < bestDist) {
|
||||
bestDist = d
|
||||
bestSeatId = uid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ch: Character
|
||||
if (bestSeatId) {
|
||||
const seat = this.seats.get(bestSeatId)!
|
||||
seat.assigned = true
|
||||
ch = createCharacter(id, palette, bestSeatId, seat, hueShift)
|
||||
} else {
|
||||
// No seats — spawn at closest walkable tile to parent
|
||||
let spawn = { col: 1, row: 1 }
|
||||
if (this.walkableTiles.length > 0) {
|
||||
let closest = this.walkableTiles[0]
|
||||
let closestDist = dist(closest.col, closest.row)
|
||||
for (let i = 1; i < this.walkableTiles.length; i++) {
|
||||
const d = dist(this.walkableTiles[i].col, this.walkableTiles[i].row)
|
||||
if (d < closestDist) {
|
||||
closest = this.walkableTiles[i]
|
||||
closestDist = d
|
||||
}
|
||||
}
|
||||
spawn = closest
|
||||
}
|
||||
ch = createCharacter(id, palette, null, null, hueShift)
|
||||
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.tileCol = spawn.col
|
||||
ch.tileRow = spawn.row
|
||||
}
|
||||
ch.isSubagent = true
|
||||
ch.parentAgentId = parentAgentId
|
||||
ch.matrixEffect = 'spawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
this.characters.set(id, ch)
|
||||
|
||||
this.subagentIdMap.set(key, id)
|
||||
this.subagentMeta.set(id, { parentAgentId, parentToolId })
|
||||
return id
|
||||
}
|
||||
|
||||
/** Remove a specific sub-agent character and free its seat */
|
||||
removeSubagent(parentAgentId: number, parentToolId: string): void {
|
||||
const key = `${parentAgentId}:${parentToolId}`
|
||||
const id = this.subagentIdMap.get(key)
|
||||
if (id === undefined) return
|
||||
|
||||
const ch = this.characters.get(id)
|
||||
if (ch) {
|
||||
if (ch.matrixEffect === 'despawn') {
|
||||
// Already despawning — just clean up maps
|
||||
this.subagentIdMap.delete(key)
|
||||
this.subagentMeta.delete(id)
|
||||
return
|
||||
}
|
||||
if (ch.seatId) {
|
||||
const seat = this.seats.get(ch.seatId)
|
||||
if (seat) seat.assigned = false
|
||||
}
|
||||
// Start despawn animation — keep character in map for rendering
|
||||
ch.matrixEffect = 'despawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
ch.bubbleType = null
|
||||
}
|
||||
// Clean up tracking maps immediately so keys don't collide
|
||||
this.subagentIdMap.delete(key)
|
||||
this.subagentMeta.delete(id)
|
||||
if (this.selectedAgentId === id) this.selectedAgentId = null
|
||||
if (this.cameraFollowId === id) this.cameraFollowId = null
|
||||
}
|
||||
|
||||
/** Remove all sub-agents belonging to a parent agent */
|
||||
removeAllSubagents(parentAgentId: number): void {
|
||||
const toRemove: string[] = []
|
||||
for (const [key, id] of this.subagentIdMap) {
|
||||
const meta = this.subagentMeta.get(id)
|
||||
if (meta && meta.parentAgentId === parentAgentId) {
|
||||
const ch = this.characters.get(id)
|
||||
if (ch) {
|
||||
if (ch.matrixEffect === 'despawn') {
|
||||
// Already despawning — just clean up maps
|
||||
this.subagentMeta.delete(id)
|
||||
toRemove.push(key)
|
||||
continue
|
||||
}
|
||||
if (ch.seatId) {
|
||||
const seat = this.seats.get(ch.seatId)
|
||||
if (seat) seat.assigned = false
|
||||
}
|
||||
// Start despawn animation
|
||||
ch.matrixEffect = 'despawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
ch.bubbleType = null
|
||||
}
|
||||
this.subagentMeta.delete(id)
|
||||
if (this.selectedAgentId === id) this.selectedAgentId = null
|
||||
if (this.cameraFollowId === id) this.cameraFollowId = null
|
||||
toRemove.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of toRemove) {
|
||||
this.subagentIdMap.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up the sub-agent character ID for a given parent+toolId, or null */
|
||||
getSubagentId(parentAgentId: number, parentToolId: string): number | null {
|
||||
return this.subagentIdMap.get(`${parentAgentId}:${parentToolId}`) ?? null
|
||||
}
|
||||
|
||||
setAgentActive(id: number, active: boolean): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (ch) {
|
||||
ch.isActive = active
|
||||
if (!active) {
|
||||
// Sentinel -1: signals turn just ended, skip next seat rest timer.
|
||||
// Prevents the WALK handler from setting a 2-4 min rest on arrival.
|
||||
ch.seatTimer = -1
|
||||
ch.path = []
|
||||
ch.moveProgress = 0
|
||||
}
|
||||
this.rebuildFurnitureInstances()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild furniture instances with auto-state applied (active agents turn electronics ON) */
|
||||
private rebuildFurnitureInstances(): void {
|
||||
// Collect tiles where active agents face desks
|
||||
const autoOnTiles = new Set<string>()
|
||||
for (const ch of this.characters.values()) {
|
||||
if (!ch.isActive || !ch.seatId) continue
|
||||
const seat = this.seats.get(ch.seatId)
|
||||
if (!seat) continue
|
||||
// Find the desk tile(s) the agent faces from their seat
|
||||
const dCol = seat.facingDir === Direction.RIGHT ? 1 : seat.facingDir === Direction.LEFT ? -1 : 0
|
||||
const dRow = seat.facingDir === Direction.DOWN ? 1 : seat.facingDir === Direction.UP ? -1 : 0
|
||||
// Check tiles in the facing direction (desk could be 1-3 tiles deep)
|
||||
for (let d = 1; d <= AUTO_ON_FACING_DEPTH; d++) {
|
||||
const tileCol = seat.seatCol + dCol * d
|
||||
const tileRow = seat.seatRow + dRow * d
|
||||
autoOnTiles.add(`${tileCol},${tileRow}`)
|
||||
}
|
||||
// Also check tiles to the sides of the facing direction (desks can be wide)
|
||||
for (let d = 1; d <= AUTO_ON_SIDE_DEPTH; d++) {
|
||||
const baseCol = seat.seatCol + dCol * d
|
||||
const baseRow = seat.seatRow + dRow * d
|
||||
if (dCol !== 0) {
|
||||
// Facing left/right: check tiles above and below
|
||||
autoOnTiles.add(`${baseCol},${baseRow - 1}`)
|
||||
autoOnTiles.add(`${baseCol},${baseRow + 1}`)
|
||||
} else {
|
||||
// Facing up/down: check tiles left and right
|
||||
autoOnTiles.add(`${baseCol - 1},${baseRow}`)
|
||||
autoOnTiles.add(`${baseCol + 1},${baseRow}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (autoOnTiles.size === 0) {
|
||||
this.furniture = layoutToFurnitureInstances(this.layout.furniture)
|
||||
return
|
||||
}
|
||||
|
||||
// Build modified furniture list with auto-state applied
|
||||
const modifiedFurniture: PlacedFurniture[] = this.layout.furniture.map((item) => {
|
||||
const entry = getCatalogEntry(item.type)
|
||||
if (!entry) return item
|
||||
// Check if any tile of this furniture overlaps an auto-on tile
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
if (autoOnTiles.has(`${item.col + dc},${item.row + dr}`)) {
|
||||
const onType = getOnStateType(item.type)
|
||||
if (onType !== item.type) {
|
||||
return { ...item, type: onType }
|
||||
}
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
this.furniture = layoutToFurnitureInstances(modifiedFurniture)
|
||||
}
|
||||
|
||||
setAgentTool(id: number, tool: string | null): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (ch) {
|
||||
ch.currentTool = tool
|
||||
}
|
||||
}
|
||||
|
||||
showPermissionBubble(id: number): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (ch) {
|
||||
ch.bubbleType = 'permission'
|
||||
ch.bubbleTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
clearPermissionBubble(id: number): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (ch && ch.bubbleType === 'permission') {
|
||||
ch.bubbleType = null
|
||||
ch.bubbleTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
showWaitingBubble(id: number): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (ch) {
|
||||
ch.bubbleType = 'waiting'
|
||||
ch.bubbleTimer = WAITING_BUBBLE_DURATION_SEC
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss bubble on click — permission: instant, waiting: quick fade */
|
||||
dismissBubble(id: number): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (!ch || !ch.bubbleType) return
|
||||
if (ch.bubbleType === 'permission') {
|
||||
ch.bubbleType = null
|
||||
ch.bubbleTimer = 0
|
||||
} else if (ch.bubbleType === 'waiting') {
|
||||
// Trigger immediate fade (0.3s remaining)
|
||||
ch.bubbleTimer = Math.min(ch.bubbleTimer, DISMISS_BUBBLE_FAST_FADE_SEC)
|
||||
}
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
const toDelete: number[] = []
|
||||
for (const ch of this.characters.values()) {
|
||||
// Handle matrix effect animation
|
||||
if (ch.matrixEffect) {
|
||||
ch.matrixEffectTimer += dt
|
||||
if (ch.matrixEffectTimer >= MATRIX_EFFECT_DURATION) {
|
||||
if (ch.matrixEffect === 'spawn') {
|
||||
// Spawn complete — clear effect, resume normal FSM
|
||||
ch.matrixEffect = null
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = []
|
||||
} else {
|
||||
// Despawn complete — mark for deletion
|
||||
toDelete.push(ch.id)
|
||||
}
|
||||
}
|
||||
continue // skip normal FSM while effect is active
|
||||
}
|
||||
|
||||
// Temporarily unblock own seat so character can pathfind to it
|
||||
this.withOwnSeatUnblocked(ch, () =>
|
||||
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles)
|
||||
)
|
||||
|
||||
// Tick bubble timer for waiting bubbles
|
||||
if (ch.bubbleType === 'waiting') {
|
||||
ch.bubbleTimer -= dt
|
||||
if (ch.bubbleTimer <= 0) {
|
||||
ch.bubbleType = null
|
||||
ch.bubbleTimer = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove characters that finished despawn
|
||||
for (const id of toDelete) {
|
||||
this.characters.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
getCharacters(): Character[] {
|
||||
return Array.from(this.characters.values())
|
||||
}
|
||||
|
||||
/** Get character at pixel position (for hit testing). Returns id or null. */
|
||||
getCharacterAt(worldX: number, worldY: number): number | null {
|
||||
const chars = this.getCharacters().sort((a, b) => b.y - a.y)
|
||||
for (const ch of chars) {
|
||||
// Skip characters that are despawning
|
||||
if (ch.matrixEffect === 'despawn') continue
|
||||
// Character sprite is 16x24, anchored bottom-center
|
||||
// Apply sitting offset to match visual position
|
||||
const sittingOffset = ch.state === CharacterState.TYPE ? CHARACTER_SITTING_OFFSET_PX : 0
|
||||
const anchorY = ch.y + sittingOffset
|
||||
const left = ch.x - CHARACTER_HIT_HALF_WIDTH
|
||||
const right = ch.x + CHARACTER_HIT_HALF_WIDTH
|
||||
const top = anchorY - CHARACTER_HIT_HEIGHT
|
||||
const bottom = anchorY
|
||||
if (worldX >= left && worldX <= right && worldY >= top && worldY <= bottom) {
|
||||
return ch.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
import { TileType, TILE_SIZE, CharacterState } from '../types'
|
||||
import type { TileType as TileTypeVal, FurnitureInstance, Character, SpriteData, Seat, FloorColor } from '../types'
|
||||
import { getCachedSprite, getOutlineSprite } from '../sprites/spriteCache'
|
||||
import { getCharacterSprites, BUBBLE_PERMISSION_SPRITE, BUBBLE_WAITING_SPRITE } from '../sprites/spriteData'
|
||||
import { getCharacterSprite } from './characters'
|
||||
import { renderMatrixEffect } from './matrixEffect'
|
||||
import { getColorizedFloorSprite, hasFloorSprites, WALL_COLOR } from '../floorTiles'
|
||||
import { hasWallSprites, getWallInstances, wallColorToHex } from '../wallTiles'
|
||||
import {
|
||||
CHARACTER_SITTING_OFFSET_PX,
|
||||
CHARACTER_Z_SORT_OFFSET,
|
||||
OUTLINE_Z_SORT_OFFSET,
|
||||
SELECTED_OUTLINE_ALPHA,
|
||||
HOVERED_OUTLINE_ALPHA,
|
||||
GHOST_PREVIEW_SPRITE_ALPHA,
|
||||
GHOST_PREVIEW_TINT_ALPHA,
|
||||
SELECTION_DASH_PATTERN,
|
||||
BUTTON_MIN_RADIUS,
|
||||
BUTTON_RADIUS_ZOOM_FACTOR,
|
||||
BUTTON_ICON_SIZE_FACTOR,
|
||||
BUTTON_LINE_WIDTH_MIN,
|
||||
BUTTON_LINE_WIDTH_ZOOM_FACTOR,
|
||||
BUBBLE_FADE_DURATION_SEC,
|
||||
BUBBLE_SITTING_OFFSET_PX,
|
||||
BUBBLE_VERTICAL_OFFSET_PX,
|
||||
FALLBACK_FLOOR_COLOR,
|
||||
SEAT_OWN_COLOR,
|
||||
SEAT_AVAILABLE_COLOR,
|
||||
SEAT_BUSY_COLOR,
|
||||
GRID_LINE_COLOR,
|
||||
VOID_TILE_OUTLINE_COLOR,
|
||||
VOID_TILE_DASH_PATTERN,
|
||||
GHOST_BORDER_HOVER_FILL,
|
||||
GHOST_BORDER_HOVER_STROKE,
|
||||
GHOST_BORDER_STROKE,
|
||||
GHOST_VALID_TINT,
|
||||
GHOST_INVALID_TINT,
|
||||
SELECTION_HIGHLIGHT_COLOR,
|
||||
DELETE_BUTTON_BG,
|
||||
ROTATE_BUTTON_BG,
|
||||
} from '../constants'
|
||||
|
||||
// ── Render functions ────────────────────────────────────────────
|
||||
|
||||
export function renderTileGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
tileMap: TileTypeVal[][],
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
tileColors?: Array<FloorColor | null>,
|
||||
cols?: number,
|
||||
): void {
|
||||
const s = TILE_SIZE * zoom
|
||||
const useSpriteFloors = hasFloorSprites()
|
||||
const tmRows = tileMap.length
|
||||
const tmCols = tmRows > 0 ? tileMap[0].length : 0
|
||||
const layoutCols = cols ?? tmCols
|
||||
|
||||
// Floor tiles + wall base color
|
||||
for (let r = 0; r < tmRows; r++) {
|
||||
for (let c = 0; c < tmCols; c++) {
|
||||
const tile = tileMap[r][c]
|
||||
|
||||
// Skip VOID tiles entirely (transparent)
|
||||
if (tile === TileType.VOID) continue
|
||||
|
||||
if (tile === TileType.WALL || !useSpriteFloors) {
|
||||
// Wall tiles or fallback: solid color
|
||||
if (tile === TileType.WALL) {
|
||||
const colorIdx = r * layoutCols + c
|
||||
const wallColor = tileColors?.[colorIdx]
|
||||
ctx.fillStyle = wallColor ? wallColorToHex(wallColor) : WALL_COLOR
|
||||
} else {
|
||||
ctx.fillStyle = FALLBACK_FLOOR_COLOR
|
||||
}
|
||||
ctx.fillRect(offsetX + c * s, offsetY + r * s, s, s)
|
||||
continue
|
||||
}
|
||||
|
||||
// Floor tile: get colorized sprite
|
||||
const colorIdx = r * layoutCols + c
|
||||
const color = tileColors?.[colorIdx] ?? { h: 0, s: 0, b: 0, c: 0 }
|
||||
const sprite = getColorizedFloorSprite(tile, color)
|
||||
const cached = getCachedSprite(sprite, zoom)
|
||||
ctx.drawImage(cached, offsetX + c * s, offsetY + r * s)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface ZDrawable {
|
||||
zY: number
|
||||
draw: (ctx: CanvasRenderingContext2D) => void
|
||||
}
|
||||
|
||||
export function renderScene(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
furniture: FurnitureInstance[],
|
||||
characters: Character[],
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
selectedAgentId: number | null,
|
||||
hoveredAgentId: number | null,
|
||||
): void {
|
||||
const drawables: ZDrawable[] = []
|
||||
|
||||
// Furniture
|
||||
for (const f of furniture) {
|
||||
const cached = getCachedSprite(f.sprite, zoom)
|
||||
const fx = offsetX + f.x * zoom
|
||||
const fy = offsetY + f.y * zoom
|
||||
drawables.push({
|
||||
zY: f.zY,
|
||||
draw: (c) => {
|
||||
c.drawImage(cached, fx, fy)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Characters
|
||||
for (const ch of characters) {
|
||||
const sprites = getCharacterSprites(ch.palette, ch.hueShift)
|
||||
const spriteData = getCharacterSprite(ch, sprites)
|
||||
const cached = getCachedSprite(spriteData, zoom)
|
||||
// Sitting offset: shift character down when seated so they visually sit in the chair
|
||||
const sittingOffset = ch.state === CharacterState.TYPE ? CHARACTER_SITTING_OFFSET_PX : 0
|
||||
// Anchor at bottom-center of character — round to integer device pixels
|
||||
const drawX = Math.round(offsetX + ch.x * zoom - cached.width / 2)
|
||||
const drawY = Math.round(offsetY + (ch.y + sittingOffset) * zoom - cached.height)
|
||||
|
||||
// Sort characters by bottom of their tile (not center) so they render
|
||||
// in front of same-row furniture (e.g. chairs) but behind furniture
|
||||
// at lower rows (e.g. desks, bookshelves that occlude from below).
|
||||
const charZY = ch.y + TILE_SIZE / 2 + CHARACTER_Z_SORT_OFFSET
|
||||
|
||||
// Matrix spawn/despawn effect — skip outline, use per-pixel rendering
|
||||
if (ch.matrixEffect) {
|
||||
const mDrawX = drawX
|
||||
const mDrawY = drawY
|
||||
const mSpriteData = spriteData
|
||||
const mCh = ch
|
||||
drawables.push({
|
||||
zY: charZY,
|
||||
draw: (c) => {
|
||||
renderMatrixEffect(c, mCh, mSpriteData, mDrawX, mDrawY, zoom)
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// White outline: full opacity for selected, 50% for hover
|
||||
const isSelected = selectedAgentId !== null && ch.id === selectedAgentId
|
||||
const isHovered = hoveredAgentId !== null && ch.id === hoveredAgentId
|
||||
if (isSelected || isHovered) {
|
||||
const outlineAlpha = isSelected ? SELECTED_OUTLINE_ALPHA : HOVERED_OUTLINE_ALPHA
|
||||
const outlineData = getOutlineSprite(spriteData)
|
||||
const outlineCached = getCachedSprite(outlineData, zoom)
|
||||
const olDrawX = drawX - zoom // 1 sprite-pixel offset, scaled
|
||||
const olDrawY = drawY - zoom // outline follows sitting offset via drawY
|
||||
drawables.push({
|
||||
zY: charZY - OUTLINE_Z_SORT_OFFSET, // sort just before character
|
||||
draw: (c) => {
|
||||
c.save()
|
||||
c.globalAlpha = outlineAlpha
|
||||
c.drawImage(outlineCached, olDrawX, olDrawY)
|
||||
c.restore()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
drawables.push({
|
||||
zY: charZY,
|
||||
draw: (c) => {
|
||||
c.drawImage(cached, drawX, drawY)
|
||||
},
|
||||
})
|
||||
|
||||
// Agent label above head
|
||||
if (ch.label) {
|
||||
const labelX = Math.round(offsetX + ch.x * zoom)
|
||||
const labelY = drawY - 2 * zoom
|
||||
const fontSize = Math.max(8, Math.round(3.5 * zoom))
|
||||
drawables.push({
|
||||
zY: charZY + 0.1,
|
||||
draw: (c) => {
|
||||
c.save()
|
||||
c.font = `bold ${fontSize}px sans-serif`
|
||||
c.textAlign = 'center'
|
||||
c.textBaseline = 'bottom'
|
||||
c.fillStyle = 'rgba(0,0,0,0.6)'
|
||||
c.fillText(ch.label, labelX, labelY + 1)
|
||||
c.fillStyle = '#fff'
|
||||
c.fillText(ch.label, labelX, labelY)
|
||||
c.restore()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by Y (lower = in front = drawn later)
|
||||
drawables.sort((a, b) => a.zY - b.zY)
|
||||
|
||||
for (const d of drawables) {
|
||||
d.draw(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seat indicators ─────────────────────────────────────────────
|
||||
|
||||
export function renderSeatIndicators(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
seats: Map<string, Seat>,
|
||||
characters: Map<number, Character>,
|
||||
selectedAgentId: number | null,
|
||||
hoveredTile: { col: number; row: number } | null,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
): void {
|
||||
if (selectedAgentId === null || !hoveredTile) return
|
||||
const selectedChar = characters.get(selectedAgentId)
|
||||
if (!selectedChar) return
|
||||
|
||||
// Only show indicator for the hovered seat tile
|
||||
for (const [uid, seat] of seats) {
|
||||
if (seat.seatCol !== hoveredTile.col || seat.seatRow !== hoveredTile.row) continue
|
||||
|
||||
const s = TILE_SIZE * zoom
|
||||
const x = offsetX + seat.seatCol * s
|
||||
const y = offsetY + seat.seatRow * s
|
||||
|
||||
if (selectedChar.seatId === uid) {
|
||||
// Selected agent's own seat — blue
|
||||
ctx.fillStyle = SEAT_OWN_COLOR
|
||||
} else if (!seat.assigned) {
|
||||
// Available seat — green
|
||||
ctx.fillStyle = SEAT_AVAILABLE_COLOR
|
||||
} else {
|
||||
// Busy (assigned to another agent) — red
|
||||
ctx.fillStyle = SEAT_BUSY_COLOR
|
||||
}
|
||||
ctx.fillRect(x, y, s, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit mode overlays ──────────────────────────────────────────
|
||||
|
||||
export function renderGridOverlay(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
tileMap?: TileTypeVal[][],
|
||||
): void {
|
||||
const s = TILE_SIZE * zoom
|
||||
ctx.strokeStyle = GRID_LINE_COLOR
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
// Vertical lines — offset by 0.5 for crisp 1px lines
|
||||
for (let c = 0; c <= cols; c++) {
|
||||
const x = offsetX + c * s + 0.5
|
||||
ctx.moveTo(x, offsetY)
|
||||
ctx.lineTo(x, offsetY + rows * s)
|
||||
}
|
||||
// Horizontal lines
|
||||
for (let r = 0; r <= rows; r++) {
|
||||
const y = offsetY + r * s + 0.5
|
||||
ctx.moveTo(offsetX, y)
|
||||
ctx.lineTo(offsetX + cols * s, y)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Draw faint dashed outlines on VOID tiles
|
||||
if (tileMap) {
|
||||
ctx.save()
|
||||
ctx.strokeStyle = VOID_TILE_OUTLINE_COLOR
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash(VOID_TILE_DASH_PATTERN)
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (tileMap[r]?.[c] === TileType.VOID) {
|
||||
ctx.strokeRect(offsetX + c * s + 0.5, offsetY + r * s + 0.5, s - 1, s - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw faint expansion placeholders 1 tile outside grid bounds (ghost border). */
|
||||
export function renderGhostBorder(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
ghostHoverCol: number,
|
||||
ghostHoverRow: number,
|
||||
): void {
|
||||
const s = TILE_SIZE * zoom
|
||||
ctx.save()
|
||||
|
||||
// Collect ghost border tiles: one ring around the grid
|
||||
const ghostTiles: Array<{ c: number; r: number }> = []
|
||||
// Top and bottom rows
|
||||
for (let c = -1; c <= cols; c++) {
|
||||
ghostTiles.push({ c, r: -1 })
|
||||
ghostTiles.push({ c, r: rows })
|
||||
}
|
||||
// Left and right columns (excluding corners already added)
|
||||
for (let r = 0; r < rows; r++) {
|
||||
ghostTiles.push({ c: -1, r })
|
||||
ghostTiles.push({ c: cols, r })
|
||||
}
|
||||
|
||||
for (const { c, r } of ghostTiles) {
|
||||
const x = offsetX + c * s
|
||||
const y = offsetY + r * s
|
||||
const isHovered = c === ghostHoverCol && r === ghostHoverRow
|
||||
if (isHovered) {
|
||||
ctx.fillStyle = GHOST_BORDER_HOVER_FILL
|
||||
ctx.fillRect(x, y, s, s)
|
||||
}
|
||||
ctx.strokeStyle = isHovered ? GHOST_BORDER_HOVER_STROKE : GHOST_BORDER_STROKE
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash(VOID_TILE_DASH_PATTERN)
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
export function renderGhostPreview(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sprite: SpriteData,
|
||||
col: number,
|
||||
row: number,
|
||||
valid: boolean,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
): void {
|
||||
const cached = getCachedSprite(sprite, zoom)
|
||||
const x = offsetX + col * TILE_SIZE * zoom
|
||||
const y = offsetY + row * TILE_SIZE * zoom
|
||||
ctx.save()
|
||||
ctx.globalAlpha = GHOST_PREVIEW_SPRITE_ALPHA
|
||||
ctx.drawImage(cached, x, y)
|
||||
// Tint overlay
|
||||
ctx.globalAlpha = GHOST_PREVIEW_TINT_ALPHA
|
||||
ctx.fillStyle = valid ? GHOST_VALID_TINT : GHOST_INVALID_TINT
|
||||
ctx.fillRect(x, y, cached.width, cached.height)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
export function renderSelectionHighlight(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
col: number,
|
||||
row: number,
|
||||
w: number,
|
||||
h: number,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
): void {
|
||||
const s = TILE_SIZE * zoom
|
||||
const x = offsetX + col * s
|
||||
const y = offsetY + row * s
|
||||
ctx.save()
|
||||
ctx.strokeStyle = SELECTION_HIGHLIGHT_COLOR
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash(SELECTION_DASH_PATTERN)
|
||||
ctx.strokeRect(x + 1, y + 1, w * s - 2, h * s - 2)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
export function renderDeleteButton(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
col: number,
|
||||
row: number,
|
||||
w: number,
|
||||
_h: number,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
): DeleteButtonBounds {
|
||||
const s = TILE_SIZE * zoom
|
||||
// Position at top-right corner of selected furniture
|
||||
const cx = offsetX + (col + w) * s + 1
|
||||
const cy = offsetY + row * s - 1
|
||||
const radius = Math.max(BUTTON_MIN_RADIUS, zoom * BUTTON_RADIUS_ZOOM_FACTOR)
|
||||
|
||||
// Circle background
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
|
||||
ctx.fillStyle = DELETE_BUTTON_BG
|
||||
ctx.fill()
|
||||
|
||||
// X mark
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = Math.max(BUTTON_LINE_WIDTH_MIN, zoom * BUTTON_LINE_WIDTH_ZOOM_FACTOR)
|
||||
ctx.lineCap = 'round'
|
||||
const xSize = radius * BUTTON_ICON_SIZE_FACTOR
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx - xSize, cy - xSize)
|
||||
ctx.lineTo(cx + xSize, cy + xSize)
|
||||
ctx.moveTo(cx + xSize, cy - xSize)
|
||||
ctx.lineTo(cx - xSize, cy + xSize)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
return { cx, cy, radius }
|
||||
}
|
||||
|
||||
export function renderRotateButton(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
col: number,
|
||||
row: number,
|
||||
_w: number,
|
||||
_h: number,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
): RotateButtonBounds {
|
||||
const s = TILE_SIZE * zoom
|
||||
// Position to the left of the delete button (which is at top-right corner)
|
||||
const radius = Math.max(BUTTON_MIN_RADIUS, zoom * BUTTON_RADIUS_ZOOM_FACTOR)
|
||||
const cx = offsetX + col * s - 1
|
||||
const cy = offsetY + row * s - 1
|
||||
|
||||
// Circle background
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
|
||||
ctx.fillStyle = ROTATE_BUTTON_BG
|
||||
ctx.fill()
|
||||
|
||||
// Circular arrow icon
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = Math.max(BUTTON_LINE_WIDTH_MIN, zoom * BUTTON_LINE_WIDTH_ZOOM_FACTOR)
|
||||
ctx.lineCap = 'round'
|
||||
const arcR = radius * BUTTON_ICON_SIZE_FACTOR
|
||||
ctx.beginPath()
|
||||
// Draw a 270-degree arc
|
||||
ctx.arc(cx, cy, arcR, -Math.PI * 0.8, Math.PI * 0.7)
|
||||
ctx.stroke()
|
||||
// Draw arrowhead at the end of the arc
|
||||
const endAngle = Math.PI * 0.7
|
||||
const endX = cx + arcR * Math.cos(endAngle)
|
||||
const endY = cy + arcR * Math.sin(endAngle)
|
||||
const arrowSize = radius * 0.35
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(endX + arrowSize * 0.6, endY - arrowSize * 0.3)
|
||||
ctx.lineTo(endX, endY)
|
||||
ctx.lineTo(endX + arrowSize * 0.7, endY + arrowSize * 0.5)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
return { cx, cy, radius }
|
||||
}
|
||||
|
||||
// ── Speech bubbles ──────────────────────────────────────────────
|
||||
|
||||
export function renderBubbles(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
characters: Character[],
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
zoom: number,
|
||||
): void {
|
||||
for (const ch of characters) {
|
||||
if (!ch.bubbleType) continue
|
||||
|
||||
const sprite = ch.bubbleType === 'permission'
|
||||
? BUBBLE_PERMISSION_SPRITE
|
||||
: BUBBLE_WAITING_SPRITE
|
||||
|
||||
// Compute opacity: permission = full, waiting = fade in last 0.5s
|
||||
let alpha = 1.0
|
||||
if (ch.bubbleType === 'waiting' && ch.bubbleTimer < BUBBLE_FADE_DURATION_SEC) {
|
||||
alpha = ch.bubbleTimer / BUBBLE_FADE_DURATION_SEC
|
||||
}
|
||||
|
||||
const cached = getCachedSprite(sprite, zoom)
|
||||
// Position: centered above the character's head
|
||||
// Character is anchored bottom-center at (ch.x, ch.y), sprite is 16x24
|
||||
// Place bubble above head with a small gap; follow sitting offset
|
||||
const sittingOff = ch.state === CharacterState.TYPE ? BUBBLE_SITTING_OFFSET_PX : 0
|
||||
const bubbleX = Math.round(offsetX + ch.x * zoom - cached.width / 2)
|
||||
const bubbleY = Math.round(offsetY + (ch.y + sittingOff - BUBBLE_VERTICAL_OFFSET_PX) * zoom - cached.height - 1 * zoom)
|
||||
|
||||
ctx.save()
|
||||
if (alpha < 1.0) ctx.globalAlpha = alpha
|
||||
ctx.drawImage(cached, bubbleX, bubbleY)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
export interface ButtonBounds {
|
||||
/** Center X in device pixels */
|
||||
cx: number
|
||||
/** Center Y in device pixels */
|
||||
cy: number
|
||||
/** Radius in device pixels */
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type DeleteButtonBounds = ButtonBounds
|
||||
export type RotateButtonBounds = ButtonBounds
|
||||
|
||||
export interface EditorRenderState {
|
||||
showGrid: boolean
|
||||
ghostSprite: SpriteData | null
|
||||
ghostCol: number
|
||||
ghostRow: number
|
||||
ghostValid: boolean
|
||||
selectedCol: number
|
||||
selectedRow: number
|
||||
selectedW: number
|
||||
selectedH: number
|
||||
hasSelection: boolean
|
||||
isRotatable: boolean
|
||||
/** Updated each frame by renderDeleteButton */
|
||||
deleteButtonBounds: DeleteButtonBounds | null
|
||||
/** Updated each frame by renderRotateButton */
|
||||
rotateButtonBounds: RotateButtonBounds | null
|
||||
/** Whether to show ghost border (expansion tiles outside grid) */
|
||||
showGhostBorder: boolean
|
||||
/** Hovered ghost border tile col (-1 to cols) */
|
||||
ghostBorderHoverCol: number
|
||||
/** Hovered ghost border tile row (-1 to rows) */
|
||||
ghostBorderHoverRow: number
|
||||
}
|
||||
|
||||
export interface SelectionRenderState {
|
||||
selectedAgentId: number | null
|
||||
hoveredAgentId: number | null
|
||||
hoveredTile: { col: number; row: number } | null
|
||||
seats: Map<string, Seat>
|
||||
characters: Map<number, Character>
|
||||
}
|
||||
|
||||
export function renderFrame(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
tileMap: TileTypeVal[][],
|
||||
furniture: FurnitureInstance[],
|
||||
characters: Character[],
|
||||
zoom: number,
|
||||
panX: number,
|
||||
panY: number,
|
||||
selection?: SelectionRenderState,
|
||||
editor?: EditorRenderState,
|
||||
tileColors?: Array<FloorColor | null>,
|
||||
layoutCols?: number,
|
||||
layoutRows?: number,
|
||||
): { offsetX: number; offsetY: number } {
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight)
|
||||
|
||||
// Use layout dimensions (fallback to tileMap size)
|
||||
const cols = layoutCols ?? (tileMap.length > 0 ? tileMap[0].length : 0)
|
||||
const rows = layoutRows ?? tileMap.length
|
||||
|
||||
// Center map in viewport + pan offset (integer device pixels)
|
||||
const mapW = cols * TILE_SIZE * zoom
|
||||
const mapH = rows * TILE_SIZE * zoom
|
||||
const offsetX = Math.floor((canvasWidth - mapW) / 2) + Math.round(panX)
|
||||
const offsetY = Math.floor((canvasHeight - mapH) / 2) + Math.round(panY)
|
||||
|
||||
// Draw tiles (floor + wall base color)
|
||||
renderTileGrid(ctx, tileMap, offsetX, offsetY, zoom, tileColors, layoutCols)
|
||||
|
||||
// Seat indicators (below furniture/characters, on top of floor)
|
||||
if (selection) {
|
||||
renderSeatIndicators(ctx, selection.seats, selection.characters, selection.selectedAgentId, selection.hoveredTile, offsetX, offsetY, zoom)
|
||||
}
|
||||
|
||||
// Build wall instances for z-sorting with furniture and characters
|
||||
const wallInstances = hasWallSprites()
|
||||
? getWallInstances(tileMap, tileColors, layoutCols)
|
||||
: []
|
||||
const allFurniture = wallInstances.length > 0
|
||||
? [...wallInstances, ...furniture]
|
||||
: furniture
|
||||
|
||||
// Draw walls + furniture + characters (z-sorted)
|
||||
const selectedId = selection?.selectedAgentId ?? null
|
||||
const hoveredId = selection?.hoveredAgentId ?? null
|
||||
renderScene(ctx, allFurniture, characters, offsetX, offsetY, zoom, selectedId, hoveredId)
|
||||
|
||||
// Speech bubbles (always on top of characters)
|
||||
renderBubbles(ctx, characters, offsetX, offsetY, zoom)
|
||||
|
||||
// Editor overlays
|
||||
if (editor) {
|
||||
if (editor.showGrid) {
|
||||
renderGridOverlay(ctx, offsetX, offsetY, zoom, cols, rows, tileMap)
|
||||
}
|
||||
if (editor.showGhostBorder) {
|
||||
renderGhostBorder(ctx, offsetX, offsetY, zoom, cols, rows, editor.ghostBorderHoverCol, editor.ghostBorderHoverRow)
|
||||
}
|
||||
if (editor.ghostSprite && editor.ghostCol >= 0) {
|
||||
renderGhostPreview(ctx, editor.ghostSprite, editor.ghostCol, editor.ghostRow, editor.ghostValid, offsetX, offsetY, zoom)
|
||||
}
|
||||
if (editor.hasSelection) {
|
||||
renderSelectionHighlight(ctx, editor.selectedCol, editor.selectedRow, editor.selectedW, editor.selectedH, offsetX, offsetY, zoom)
|
||||
editor.deleteButtonBounds = renderDeleteButton(ctx, editor.selectedCol, editor.selectedRow, editor.selectedW, editor.selectedH, offsetX, offsetY, zoom)
|
||||
if (editor.isRotatable) {
|
||||
editor.rotateButtonBounds = renderRotateButton(ctx, editor.selectedCol, editor.selectedRow, editor.selectedW, editor.selectedH, offsetX, offsetY, zoom)
|
||||
} else {
|
||||
editor.rotateButtonBounds = null
|
||||
}
|
||||
} else {
|
||||
editor.deleteButtonBounds = null
|
||||
editor.rotateButtonBounds = null
|
||||
}
|
||||
}
|
||||
|
||||
return { offsetX, offsetY }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Floor tile pattern storage and caching.
|
||||
*
|
||||
* Stores 7 grayscale floor patterns loaded from floors.png.
|
||||
* Uses shared colorize module for HSL tinting (Photoshop-style Colorize).
|
||||
* Caches colorized SpriteData by (pattern, h, s, b, c) key.
|
||||
*/
|
||||
|
||||
import type { SpriteData, FloorColor } from './types'
|
||||
import { getColorizedSprite, clearColorizeCache } from './colorize'
|
||||
import { TILE_SIZE, FALLBACK_FLOOR_COLOR } from './constants'
|
||||
|
||||
/** Default solid gray 16×16 tile used when floors.png is not loaded */
|
||||
const DEFAULT_FLOOR_SPRITE: SpriteData = Array.from(
|
||||
{ length: TILE_SIZE },
|
||||
() => Array(TILE_SIZE).fill(FALLBACK_FLOOR_COLOR) as string[],
|
||||
)
|
||||
|
||||
/** Module-level storage for floor tile sprites (set once on load) */
|
||||
let floorSprites: SpriteData[] = []
|
||||
|
||||
/** Wall color constant */
|
||||
export const WALL_COLOR = '#3A3A5C'
|
||||
|
||||
/** Set floor tile sprites (called once when extension sends floorTilesLoaded) */
|
||||
export function setFloorSprites(sprites: SpriteData[]): void {
|
||||
floorSprites = sprites
|
||||
clearColorizeCache()
|
||||
}
|
||||
|
||||
/** Get the raw (grayscale) floor sprite for a pattern index (1-7 -> array index 0-6).
|
||||
* Falls back to the default solid gray tile when floors.png is not loaded. */
|
||||
export function getFloorSprite(patternIndex: number): SpriteData | null {
|
||||
const idx = patternIndex - 1
|
||||
if (idx < 0) return null
|
||||
if (idx < floorSprites.length) return floorSprites[idx]
|
||||
// No PNG sprites loaded — return default solid tile for any valid pattern index
|
||||
if (floorSprites.length === 0 && patternIndex >= 1) return DEFAULT_FLOOR_SPRITE
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if floor sprites are available (always true — falls back to default solid tile) */
|
||||
export function hasFloorSprites(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Get count of available floor patterns (at least 1 for the default solid tile) */
|
||||
export function getFloorPatternCount(): number {
|
||||
return floorSprites.length > 0 ? floorSprites.length : 1
|
||||
}
|
||||
|
||||
/** Get all floor sprites (for preview rendering, falls back to default solid tile) */
|
||||
export function getAllFloorSprites(): SpriteData[] {
|
||||
return floorSprites.length > 0 ? floorSprites : [DEFAULT_FLOOR_SPRITE]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a colorized version of a floor sprite.
|
||||
* Uses Photoshop-style Colorize: grayscale -> HSL with given hue/saturation,
|
||||
* then brightness/contrast adjustment.
|
||||
*/
|
||||
export function getColorizedFloorSprite(patternIndex: number, color: FloorColor): SpriteData {
|
||||
const key = `floor-${patternIndex}-${color.h}-${color.s}-${color.b}-${color.c}`
|
||||
|
||||
const base = getFloorSprite(patternIndex)
|
||||
if (!base) {
|
||||
// Return a 16x16 magenta error tile
|
||||
const err: SpriteData = Array.from({ length: 16 }, () => Array(16).fill('#FF00FF'))
|
||||
return err
|
||||
}
|
||||
|
||||
// Floor tiles are always colorized (grayscale patterns need Photoshop-style Colorize)
|
||||
return getColorizedSprite(key, base, { ...color, colorize: true })
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { FurnitureType } from '../types'
|
||||
import type { FurnitureCatalogEntry, SpriteData } from '../types'
|
||||
import {
|
||||
DESK_SQUARE_SPRITE,
|
||||
BOOKSHELF_SPRITE,
|
||||
PLANT_SPRITE,
|
||||
COOLER_SPRITE,
|
||||
WHITEBOARD_SPRITE,
|
||||
CHAIR_SPRITE,
|
||||
PC_SPRITE,
|
||||
LAMP_SPRITE,
|
||||
} from '../sprites/spriteData'
|
||||
|
||||
export interface LoadedAssetData {
|
||||
catalog: Array<{
|
||||
id: string
|
||||
label: string
|
||||
category: string
|
||||
width: number
|
||||
height: number
|
||||
footprintW: number
|
||||
footprintH: number
|
||||
isDesk: boolean
|
||||
groupId?: string
|
||||
orientation?: string // 'front' | 'back' | 'left' | 'right'
|
||||
state?: string // 'on' | 'off'
|
||||
canPlaceOnSurfaces?: boolean
|
||||
backgroundTiles?: number
|
||||
canPlaceOnWalls?: boolean
|
||||
}>
|
||||
sprites: Record<string, SpriteData>
|
||||
}
|
||||
|
||||
export type FurnitureCategory = 'desks' | 'chairs' | 'storage' | 'decor' | 'electronics' | 'wall' | 'misc'
|
||||
|
||||
export interface CatalogEntryWithCategory extends FurnitureCatalogEntry {
|
||||
category: FurnitureCategory
|
||||
}
|
||||
|
||||
export const FURNITURE_CATALOG: CatalogEntryWithCategory[] = [
|
||||
// ── Original hand-drawn sprites ──
|
||||
{ type: FurnitureType.DESK, label: 'Desk', footprintW: 2, footprintH: 2, sprite: DESK_SQUARE_SPRITE, isDesk: true, category: 'desks' },
|
||||
{ type: FurnitureType.BOOKSHELF, label: 'Bookshelf', footprintW: 1, footprintH: 2, sprite: BOOKSHELF_SPRITE, isDesk: false, category: 'storage' },
|
||||
{ type: FurnitureType.PLANT, label: 'Plant', footprintW: 1, footprintH: 1, sprite: PLANT_SPRITE, isDesk: false, category: 'decor' },
|
||||
{ type: FurnitureType.COOLER, label: 'Cooler', footprintW: 1, footprintH: 1, sprite: COOLER_SPRITE, isDesk: false, category: 'misc' },
|
||||
{ type: FurnitureType.WHITEBOARD, label: 'Whiteboard', footprintW: 2, footprintH: 1, sprite: WHITEBOARD_SPRITE, isDesk: false, category: 'decor' },
|
||||
{ type: FurnitureType.CHAIR, label: 'Chair', footprintW: 1, footprintH: 1, sprite: CHAIR_SPRITE, isDesk: false, category: 'chairs' },
|
||||
{ type: FurnitureType.PC, label: 'PC', footprintW: 1, footprintH: 1, sprite: PC_SPRITE, isDesk: false, category: 'electronics' },
|
||||
{ type: FurnitureType.LAMP, label: 'Lamp', footprintW: 1, footprintH: 1, sprite: LAMP_SPRITE, isDesk: false, category: 'decor' },
|
||||
|
||||
]
|
||||
|
||||
// ── Rotation groups ──────────────────────────────────────────────
|
||||
// Flexible rotation: supports 2+ orientations (not just all 4)
|
||||
interface RotationGroup {
|
||||
/** Ordered list of orientations available for this group */
|
||||
orientations: string[]
|
||||
/** Maps orientation → asset ID (for the default/off state) */
|
||||
members: Record<string, string>
|
||||
}
|
||||
|
||||
// Maps any member asset ID → its rotation group
|
||||
const rotationGroups = new Map<string, RotationGroup>()
|
||||
|
||||
// ── State groups ────────────────────────────────────────────────
|
||||
// Maps asset ID → its on/off counterpart (symmetric for toggle)
|
||||
const stateGroups = new Map<string, string>()
|
||||
// Directional maps for getOnStateType / getOffStateType
|
||||
const offToOn = new Map<string, string>() // off asset → on asset
|
||||
const onToOff = new Map<string, string>() // on asset → off asset
|
||||
|
||||
// Internal catalog (includes all variants for getCatalogEntry lookups)
|
||||
let internalCatalog: CatalogEntryWithCategory[] | null = null
|
||||
|
||||
// Dynamic catalog built from loaded assets (when available)
|
||||
// Only includes "front" variants for grouped items (shown in editor palette)
|
||||
let dynamicCatalog: CatalogEntryWithCategory[] | null = null
|
||||
let dynamicCategories: FurnitureCategory[] | null = null
|
||||
|
||||
/**
|
||||
* Build catalog from loaded assets. Returns true if successful.
|
||||
* Once built, all getCatalog* functions use the dynamic catalog.
|
||||
* Uses ONLY custom assets (excludes hardcoded furniture when assets are loaded).
|
||||
*/
|
||||
export function buildDynamicCatalog(assets: LoadedAssetData): boolean {
|
||||
if (!assets?.catalog || !assets?.sprites) return false
|
||||
|
||||
// Build all entries (including non-front variants)
|
||||
const allEntries = assets.catalog.map((asset) => {
|
||||
const sprite = assets.sprites[asset.id]
|
||||
if (!sprite) {
|
||||
console.warn(`No sprite data for asset ${asset.id}`)
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: asset.id,
|
||||
label: asset.label,
|
||||
footprintW: asset.footprintW,
|
||||
footprintH: asset.footprintH,
|
||||
sprite,
|
||||
isDesk: asset.isDesk,
|
||||
category: asset.category as FurnitureCategory,
|
||||
...(asset.orientation ? { orientation: asset.orientation } : {}),
|
||||
...(asset.canPlaceOnSurfaces ? { canPlaceOnSurfaces: true } : {}),
|
||||
...(asset.backgroundTiles ? { backgroundTiles: asset.backgroundTiles } : {}),
|
||||
...(asset.canPlaceOnWalls ? { canPlaceOnWalls: true } : {}),
|
||||
}
|
||||
}).filter((e): e is CatalogEntryWithCategory => e !== null)
|
||||
|
||||
if (allEntries.length === 0) return false
|
||||
|
||||
// Build rotation groups from groupId + orientation metadata
|
||||
rotationGroups.clear()
|
||||
stateGroups.clear()
|
||||
offToOn.clear()
|
||||
onToOff.clear()
|
||||
|
||||
// Phase 1: Collect orientations per group (only "off" or stateless variants for rotation)
|
||||
const groupMap = new Map<string, Map<string, string>>() // groupId → (orientation → assetId)
|
||||
for (const asset of assets.catalog) {
|
||||
if (asset.groupId && asset.orientation) {
|
||||
// For rotation groups, only use the "off" or stateless variant
|
||||
if (asset.state && asset.state !== 'off') continue
|
||||
let orientMap = groupMap.get(asset.groupId)
|
||||
if (!orientMap) {
|
||||
orientMap = new Map()
|
||||
groupMap.set(asset.groupId, orientMap)
|
||||
}
|
||||
orientMap.set(asset.orientation, asset.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Register rotation groups with 2+ orientations
|
||||
const nonFrontIds = new Set<string>()
|
||||
const orientationOrder = ['front', 'right', 'back', 'left']
|
||||
for (const orientMap of groupMap.values()) {
|
||||
if (orientMap.size < 2) continue
|
||||
// Build ordered list of available orientations
|
||||
const orderedOrients = orientationOrder.filter((o) => orientMap.has(o))
|
||||
if (orderedOrients.length < 2) continue
|
||||
const members: Record<string, string> = {}
|
||||
for (const o of orderedOrients) {
|
||||
members[o] = orientMap.get(o)!
|
||||
}
|
||||
const rg: RotationGroup = { orientations: orderedOrients, members }
|
||||
for (const id of Object.values(members)) {
|
||||
rotationGroups.set(id, rg)
|
||||
}
|
||||
// Track non-front IDs to exclude from visible catalog
|
||||
for (const [orient, id] of Object.entries(members)) {
|
||||
if (orient !== 'front') nonFrontIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Build state groups (on ↔ off pairs within same groupId + orientation)
|
||||
const stateMap = new Map<string, Map<string, string>>() // "groupId|orientation" → (state → assetId)
|
||||
for (const asset of assets.catalog) {
|
||||
if (asset.groupId && asset.state) {
|
||||
const key = `${asset.groupId}|${asset.orientation || ''}`
|
||||
let sm = stateMap.get(key)
|
||||
if (!sm) {
|
||||
sm = new Map()
|
||||
stateMap.set(key, sm)
|
||||
}
|
||||
sm.set(asset.state, asset.id)
|
||||
}
|
||||
}
|
||||
for (const sm of stateMap.values()) {
|
||||
const onId = sm.get('on')
|
||||
const offId = sm.get('off')
|
||||
if (onId && offId) {
|
||||
stateGroups.set(onId, offId)
|
||||
stateGroups.set(offId, onId)
|
||||
offToOn.set(offId, onId)
|
||||
onToOff.set(onId, offId)
|
||||
}
|
||||
}
|
||||
|
||||
// Also register rotation groups for "on" state variants (so rotation works on on-state items too)
|
||||
for (const asset of assets.catalog) {
|
||||
if (asset.groupId && asset.orientation && asset.state === 'on') {
|
||||
// Find the off-variant's rotation group
|
||||
const offCounterpart = stateGroups.get(asset.id)
|
||||
if (offCounterpart) {
|
||||
const offGroup = rotationGroups.get(offCounterpart)
|
||||
if (offGroup) {
|
||||
// Build an equivalent group for the "on" state
|
||||
const onMembers: Record<string, string> = {}
|
||||
for (const orient of offGroup.orientations) {
|
||||
const offId = offGroup.members[orient]
|
||||
const onId = stateGroups.get(offId)
|
||||
// Use on-state variant if available, otherwise fall back to off-state
|
||||
onMembers[orient] = onId ?? offId
|
||||
}
|
||||
const onGroup: RotationGroup = { orientations: offGroup.orientations, members: onMembers }
|
||||
for (const id of Object.values(onMembers)) {
|
||||
if (!rotationGroups.has(id)) {
|
||||
rotationGroups.set(id, onGroup)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track "on" variant IDs to exclude from visible catalog
|
||||
const onStateIds = new Set<string>()
|
||||
for (const asset of assets.catalog) {
|
||||
if (asset.state === 'on') onStateIds.add(asset.id)
|
||||
}
|
||||
|
||||
// Store full internal catalog (all variants — for getCatalogEntry lookups)
|
||||
internalCatalog = allEntries
|
||||
|
||||
// Visible catalog: exclude non-front variants and "on" state variants
|
||||
const visibleEntries = allEntries.filter((e) => !nonFrontIds.has(e.type) && !onStateIds.has(e.type))
|
||||
|
||||
// Strip orientation/state suffix from labels for grouped variants
|
||||
for (const entry of visibleEntries) {
|
||||
if (rotationGroups.has(entry.type) || stateGroups.has(entry.type)) {
|
||||
entry.label = entry.label
|
||||
.replace(/ - Front - Off$/, '')
|
||||
.replace(/ - Front$/, '')
|
||||
.replace(/ - Off$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
dynamicCatalog = visibleEntries
|
||||
dynamicCategories = Array.from(new Set(visibleEntries.map((e) => e.category)))
|
||||
.filter((c): c is FurnitureCategory => !!c)
|
||||
.sort()
|
||||
|
||||
const rotGroupCount = new Set(Array.from(rotationGroups.values())).size
|
||||
console.log(`✓ Built dynamic catalog with ${allEntries.length} assets (${visibleEntries.length} visible, ${rotGroupCount} rotation groups, ${stateGroups.size / 2} state pairs)`)
|
||||
return true
|
||||
}
|
||||
|
||||
export function getCatalogEntry(type: string): CatalogEntryWithCategory | undefined {
|
||||
// Check internal catalog first (includes all variants, e.g., non-front rotations)
|
||||
if (internalCatalog) {
|
||||
return internalCatalog.find((e) => e.type === type)
|
||||
}
|
||||
const catalog = dynamicCatalog || FURNITURE_CATALOG
|
||||
return catalog.find((e) => e.type === type)
|
||||
}
|
||||
|
||||
export function getCatalogByCategory(category: FurnitureCategory): CatalogEntryWithCategory[] {
|
||||
const catalog = dynamicCatalog || FURNITURE_CATALOG
|
||||
return catalog.filter((e) => e.category === category)
|
||||
}
|
||||
|
||||
export function getActiveCatalog(): CatalogEntryWithCategory[] {
|
||||
return dynamicCatalog || FURNITURE_CATALOG
|
||||
}
|
||||
|
||||
export function getActiveCategories(): Array<{ id: FurnitureCategory; label: string }> {
|
||||
const categories = dynamicCategories || (FURNITURE_CATEGORIES.map((c) => c.id) as FurnitureCategory[])
|
||||
return FURNITURE_CATEGORIES.filter((c) => categories.includes(c.id))
|
||||
}
|
||||
|
||||
export const FURNITURE_CATEGORIES: Array<{ id: FurnitureCategory; label: string }> = [
|
||||
{ id: 'desks', label: 'Desks' },
|
||||
{ id: 'chairs', label: 'Chairs' },
|
||||
{ id: 'storage', label: 'Storage' },
|
||||
{ id: 'electronics', label: 'Tech' },
|
||||
{ id: 'decor', label: 'Decor' },
|
||||
{ id: 'wall', label: 'Wall' },
|
||||
{ id: 'misc', label: 'Misc' },
|
||||
]
|
||||
|
||||
// ── Rotation helpers ─────────────────────────────────────────────
|
||||
|
||||
/** Returns the next asset ID in the rotation group (cw or ccw), or null if not rotatable. */
|
||||
export function getRotatedType(currentType: string, direction: 'cw' | 'ccw'): string | null {
|
||||
const group = rotationGroups.get(currentType)
|
||||
if (!group) return null
|
||||
const order = group.orientations.map((o) => group.members[o])
|
||||
const idx = order.indexOf(currentType)
|
||||
if (idx === -1) return null
|
||||
const step = direction === 'cw' ? 1 : -1
|
||||
const nextIdx = (idx + step + order.length) % order.length
|
||||
return order[nextIdx]
|
||||
}
|
||||
|
||||
/** Returns the toggled state variant (on↔off), or null if no state variant exists. */
|
||||
export function getToggledType(currentType: string): string | null {
|
||||
return stateGroups.get(currentType) ?? null
|
||||
}
|
||||
|
||||
/** Returns the "on" variant if this type has one, otherwise returns the type unchanged. */
|
||||
export function getOnStateType(currentType: string): string {
|
||||
return offToOn.get(currentType) ?? currentType
|
||||
}
|
||||
|
||||
/** Returns the "off" variant if this type has one, otherwise returns the type unchanged. */
|
||||
export function getOffStateType(currentType: string): string {
|
||||
return onToOff.get(currentType) ?? currentType
|
||||
}
|
||||
|
||||
/** Returns true if the given furniture type is part of a rotation group. */
|
||||
export function isRotatable(type: string): boolean {
|
||||
return rotationGroups.has(type)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { TileType, FurnitureType, DEFAULT_COLS, DEFAULT_ROWS, TILE_SIZE, Direction } from '../types'
|
||||
import type { TileType as TileTypeVal, OfficeLayout, PlacedFurniture, Seat, FurnitureInstance, FloorColor } from '../types'
|
||||
import { getCatalogEntry } from './furnitureCatalog'
|
||||
import { getColorizedSprite } from '../colorize'
|
||||
|
||||
/** Convert flat tile array from layout into 2D grid */
|
||||
export function layoutToTileMap(layout: OfficeLayout): TileTypeVal[][] {
|
||||
const map: TileTypeVal[][] = []
|
||||
for (let r = 0; r < layout.rows; r++) {
|
||||
const row: TileTypeVal[] = []
|
||||
for (let c = 0; c < layout.cols; c++) {
|
||||
row.push(layout.tiles[r * layout.cols + c])
|
||||
}
|
||||
map.push(row)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Convert placed furniture into renderable FurnitureInstance[] */
|
||||
export function layoutToFurnitureInstances(furniture: PlacedFurniture[]): FurnitureInstance[] {
|
||||
// Pre-compute desk zY per tile so surface items can sort in front of desks
|
||||
const deskZByTile = new Map<string, number>()
|
||||
for (const item of furniture) {
|
||||
const entry = getCatalogEntry(item.type)
|
||||
if (!entry || !entry.isDesk) continue
|
||||
const deskZY = item.row * TILE_SIZE + entry.sprite.length
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
const key = `${item.col + dc},${item.row + dr}`
|
||||
const prev = deskZByTile.get(key)
|
||||
if (prev === undefined || deskZY > prev) deskZByTile.set(key, deskZY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instances: FurnitureInstance[] = []
|
||||
for (const item of furniture) {
|
||||
const entry = getCatalogEntry(item.type)
|
||||
if (!entry) continue
|
||||
const x = item.col * TILE_SIZE
|
||||
const y = item.row * TILE_SIZE
|
||||
const spriteH = entry.sprite.length
|
||||
let zY = y + spriteH
|
||||
|
||||
// Chair z-sorting: ensure characters sitting on chairs render correctly
|
||||
if (entry.category === 'chairs') {
|
||||
if (entry.orientation === 'back') {
|
||||
// Back-facing chairs render IN FRONT of the seated character
|
||||
// (the chair back visually occludes the character behind it)
|
||||
zY = (item.row + 1) * TILE_SIZE + 1
|
||||
} else {
|
||||
// All other chairs: cap zY to first row bottom so characters
|
||||
// at any seat tile render in front of the chair
|
||||
zY = (item.row + 1) * TILE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
// Surface items render in front of the desk they sit on
|
||||
if (entry.canPlaceOnSurfaces) {
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
const deskZ = deskZByTile.get(`${item.col + dc},${item.row + dr}`)
|
||||
if (deskZ !== undefined && deskZ + 0.5 > zY) zY = deskZ + 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Colorize sprite if this furniture has a color override
|
||||
let sprite = entry.sprite
|
||||
if (item.color) {
|
||||
const { h, s, b: bv, c: cv } = item.color
|
||||
sprite = getColorizedSprite(`furn-${item.type}-${h}-${s}-${bv}-${cv}-${item.color.colorize ? 1 : 0}`, entry.sprite, item.color)
|
||||
}
|
||||
|
||||
instances.push({ sprite, x, y, zY })
|
||||
}
|
||||
return instances
|
||||
}
|
||||
|
||||
/** Get all tiles blocked by furniture footprints, optionally excluding a set of tiles.
|
||||
* Skips top backgroundTiles rows so characters can walk through them. */
|
||||
export function getBlockedTiles(furniture: PlacedFurniture[], excludeTiles?: Set<string>): Set<string> {
|
||||
const tiles = new Set<string>()
|
||||
for (const item of furniture) {
|
||||
const entry = getCatalogEntry(item.type)
|
||||
if (!entry) continue
|
||||
const bgRows = entry.backgroundTiles || 0
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
if (dr < bgRows) continue // skip background rows — characters can walk through
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
const key = `${item.col + dc},${item.row + dr}`
|
||||
if (excludeTiles && excludeTiles.has(key)) continue
|
||||
tiles.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
/** Get tiles blocked for placement purposes — skips top backgroundTiles rows per item */
|
||||
export function getPlacementBlockedTiles(furniture: PlacedFurniture[], excludeUid?: string): Set<string> {
|
||||
const tiles = new Set<string>()
|
||||
for (const item of furniture) {
|
||||
if (item.uid === excludeUid) continue
|
||||
const entry = getCatalogEntry(item.type)
|
||||
if (!entry) continue
|
||||
const bgRows = entry.backgroundTiles || 0
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
if (dr < bgRows) continue // skip background rows
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
tiles.add(`${item.col + dc},${item.row + dr}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
/** Map chair orientation to character facing direction */
|
||||
function orientationToFacing(orientation: string): Direction {
|
||||
switch (orientation) {
|
||||
case 'front': return Direction.DOWN
|
||||
case 'back': return Direction.UP
|
||||
case 'left': return Direction.LEFT
|
||||
case 'right': return Direction.RIGHT
|
||||
default: return Direction.DOWN
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate seats from chair furniture.
|
||||
* Facing priority: 1) chair orientation, 2) adjacent desk, 3) forward (DOWN). */
|
||||
export function layoutToSeats(furniture: PlacedFurniture[]): Map<string, Seat> {
|
||||
const seats = new Map<string, Seat>()
|
||||
|
||||
// Build set of all desk tiles
|
||||
const deskTiles = new Set<string>()
|
||||
for (const item of furniture) {
|
||||
const entry = getCatalogEntry(item.type)
|
||||
if (!entry || !entry.isDesk) continue
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
deskTiles.add(`${item.col + dc},${item.row + dr}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dirs: Array<{ dc: number; dr: number; facing: Direction }> = [
|
||||
{ dc: 0, dr: -1, facing: Direction.UP }, // desk is above chair → face UP
|
||||
{ dc: 0, dr: 1, facing: Direction.DOWN }, // desk is below chair → face DOWN
|
||||
{ dc: -1, dr: 0, facing: Direction.LEFT }, // desk is left of chair → face LEFT
|
||||
{ dc: 1, dr: 0, facing: Direction.RIGHT }, // desk is right of chair → face RIGHT
|
||||
]
|
||||
|
||||
// For each chair, every footprint tile becomes a seat.
|
||||
// Multi-tile chairs (e.g. 2-tile couches) produce multiple seats.
|
||||
for (const item of furniture) {
|
||||
const entry = getCatalogEntry(item.type)
|
||||
if (!entry || entry.category !== 'chairs') continue
|
||||
|
||||
let seatCount = 0
|
||||
for (let dr = 0; dr < entry.footprintH; dr++) {
|
||||
for (let dc = 0; dc < entry.footprintW; dc++) {
|
||||
const tileCol = item.col + dc
|
||||
const tileRow = item.row + dr
|
||||
|
||||
// Determine facing direction:
|
||||
// 1) Chair orientation takes priority
|
||||
// 2) Adjacent desk direction
|
||||
// 3) Default forward (DOWN)
|
||||
let facingDir: Direction = Direction.DOWN
|
||||
if (entry.orientation) {
|
||||
facingDir = orientationToFacing(entry.orientation)
|
||||
} else {
|
||||
for (const d of dirs) {
|
||||
if (deskTiles.has(`${tileCol + d.dc},${tileRow + d.dr}`)) {
|
||||
facingDir = d.facing
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First seat uses chair uid (backward compat), subsequent use uid:N
|
||||
const seatUid = seatCount === 0 ? item.uid : `${item.uid}:${seatCount}`
|
||||
seats.set(seatUid, {
|
||||
uid: seatUid,
|
||||
seatCol: tileCol,
|
||||
seatRow: tileRow,
|
||||
facingDir,
|
||||
assigned: false,
|
||||
})
|
||||
seatCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return seats
|
||||
}
|
||||
|
||||
/** Get the set of tiles occupied by seats (so they can be excluded from blocked tiles) */
|
||||
export function getSeatTiles(seats: Map<string, Seat>): Set<string> {
|
||||
const tiles = new Set<string>()
|
||||
for (const seat of seats.values()) {
|
||||
tiles.add(`${seat.seatCol},${seat.seatRow}`)
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
/** Default floor colors for the two rooms */
|
||||
const DEFAULT_LEFT_ROOM_COLOR: FloorColor = { h: 35, s: 30, b: 15, c: 0 } // warm beige
|
||||
const DEFAULT_RIGHT_ROOM_COLOR: FloorColor = { h: 25, s: 45, b: 5, c: 10 } // warm brown
|
||||
const DEFAULT_CARPET_COLOR: FloorColor = { h: 280, s: 40, b: -5, c: 0 } // purple
|
||||
const DEFAULT_DOORWAY_COLOR: FloorColor = { h: 35, s: 25, b: 10, c: 0 } // tan
|
||||
|
||||
/** Create the default office layout matching the current hardcoded office */
|
||||
export function createDefaultLayout(): OfficeLayout {
|
||||
const W = TileType.WALL
|
||||
const F1 = TileType.FLOOR_1
|
||||
const F2 = TileType.FLOOR_2
|
||||
const F3 = TileType.FLOOR_3
|
||||
const F4 = TileType.FLOOR_4
|
||||
|
||||
const tiles: TileTypeVal[] = []
|
||||
const tileColors: Array<FloorColor | null> = []
|
||||
|
||||
for (let r = 0; r < DEFAULT_ROWS; r++) {
|
||||
for (let c = 0; c < DEFAULT_COLS; c++) {
|
||||
if (r === 0 || r === DEFAULT_ROWS - 1) { tiles.push(W); tileColors.push(null); continue }
|
||||
if (c === 0 || c === DEFAULT_COLS - 1) { tiles.push(W); tileColors.push(null); continue }
|
||||
if (c === 10) {
|
||||
if (r >= 4 && r <= 6) {
|
||||
tiles.push(F4); tileColors.push(DEFAULT_DOORWAY_COLOR)
|
||||
} else {
|
||||
tiles.push(W); tileColors.push(null)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (c >= 15 && c <= 18 && r >= 7 && r <= 9) {
|
||||
tiles.push(F3); tileColors.push(DEFAULT_CARPET_COLOR); continue
|
||||
}
|
||||
if (c < 10) {
|
||||
tiles.push(F1); tileColors.push(DEFAULT_LEFT_ROOM_COLOR)
|
||||
} else {
|
||||
tiles.push(F2); tileColors.push(DEFAULT_RIGHT_ROOM_COLOR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const furniture: PlacedFurniture[] = [
|
||||
{ uid: 'desk-left', type: FurnitureType.DESK, col: 4, row: 3 },
|
||||
{ uid: 'desk-right', type: FurnitureType.DESK, col: 13, row: 3 },
|
||||
{ uid: 'bookshelf-1', type: FurnitureType.BOOKSHELF, col: 1, row: 5 },
|
||||
{ uid: 'plant-left', type: FurnitureType.PLANT, col: 1, row: 1 },
|
||||
{ uid: 'cooler-1', type: FurnitureType.COOLER, col: 17, row: 7 },
|
||||
{ uid: 'plant-right', type: FurnitureType.PLANT, col: 18, row: 1 },
|
||||
{ uid: 'whiteboard-1', type: FurnitureType.WHITEBOARD, col: 15, row: 0 },
|
||||
// Left desk chairs
|
||||
{ uid: 'chair-l-top', type: FurnitureType.CHAIR, col: 4, row: 2 },
|
||||
{ uid: 'chair-l-bottom', type: FurnitureType.CHAIR, col: 5, row: 5 },
|
||||
{ uid: 'chair-l-left', type: FurnitureType.CHAIR, col: 3, row: 4 },
|
||||
{ uid: 'chair-l-right', type: FurnitureType.CHAIR, col: 6, row: 3 },
|
||||
// Right desk chairs
|
||||
{ uid: 'chair-r-top', type: FurnitureType.CHAIR, col: 13, row: 2 },
|
||||
{ uid: 'chair-r-bottom', type: FurnitureType.CHAIR, col: 14, row: 5 },
|
||||
{ uid: 'chair-r-left', type: FurnitureType.CHAIR, col: 12, row: 4 },
|
||||
{ uid: 'chair-r-right', type: FurnitureType.CHAIR, col: 15, row: 3 },
|
||||
]
|
||||
|
||||
return { version: 1, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, tiles, tileColors, furniture }
|
||||
}
|
||||
|
||||
/** Serialize layout to JSON string */
|
||||
export function serializeLayout(layout: OfficeLayout): string {
|
||||
return JSON.stringify(layout)
|
||||
}
|
||||
|
||||
/** Deserialize layout from JSON string, migrating old tile types if needed */
|
||||
export function deserializeLayout(json: string): OfficeLayout | null {
|
||||
try {
|
||||
const obj = JSON.parse(json)
|
||||
if (obj && obj.version === 1 && Array.isArray(obj.tiles) && Array.isArray(obj.furniture)) {
|
||||
return migrateLayout(obj as OfficeLayout)
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure layout has tileColors. If missing, generate defaults based on tile types.
|
||||
* Exported for use by message handlers that receive layouts over the wire.
|
||||
*/
|
||||
export function migrateLayoutColors(layout: OfficeLayout): OfficeLayout {
|
||||
return migrateLayout(layout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate old layouts that use legacy tile types (TILE_FLOOR=1, WOOD_FLOOR=2, CARPET=3, DOORWAY=4)
|
||||
* to the new pattern-based system. If tileColors is already present, no migration needed.
|
||||
*/
|
||||
function migrateLayout(layout: OfficeLayout): OfficeLayout {
|
||||
if (layout.tileColors && layout.tileColors.length === layout.tiles.length) {
|
||||
return layout // Already migrated
|
||||
}
|
||||
|
||||
// Check if any tiles use old values (1-4) — these map directly to FLOOR_1-4
|
||||
// but need color assignments
|
||||
const tileColors: Array<FloorColor | null> = []
|
||||
for (const tile of layout.tiles) {
|
||||
switch (tile) {
|
||||
case 0: // WALL
|
||||
tileColors.push(null)
|
||||
break
|
||||
case 1: // was TILE_FLOOR → FLOOR_1 beige
|
||||
tileColors.push(DEFAULT_LEFT_ROOM_COLOR)
|
||||
break
|
||||
case 2: // was WOOD_FLOOR → FLOOR_2 brown
|
||||
tileColors.push(DEFAULT_RIGHT_ROOM_COLOR)
|
||||
break
|
||||
case 3: // was CARPET → FLOOR_3 purple
|
||||
tileColors.push(DEFAULT_CARPET_COLOR)
|
||||
break
|
||||
case 4: // was DOORWAY → FLOOR_4 tan
|
||||
tileColors.push(DEFAULT_DOORWAY_COLOR)
|
||||
break
|
||||
default:
|
||||
// New tile types (5-7) without colors — use neutral gray
|
||||
tileColors.push(tile > 0 ? { h: 0, s: 0, b: 0, c: 0 } : null)
|
||||
}
|
||||
}
|
||||
|
||||
return { ...layout, tileColors }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { TileType } from '../types'
|
||||
|
||||
/** Check if a tile is walkable (floor, carpet, or doorway, and not blocked by furniture) */
|
||||
export function isWalkable(
|
||||
col: number,
|
||||
row: number,
|
||||
tileMap: TileType[][],
|
||||
blockedTiles: Set<string>,
|
||||
): boolean {
|
||||
const rows = tileMap.length
|
||||
const cols = rows > 0 ? tileMap[0].length : 0
|
||||
if (row < 0 || row >= rows || col < 0 || col >= cols) return false
|
||||
const t = tileMap[row][col]
|
||||
if (t === TileType.WALL || t === TileType.VOID) return false
|
||||
if (blockedTiles.has(`${col},${row}`)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Get walkable tile positions (grid coords) for wandering */
|
||||
export function getWalkableTiles(
|
||||
tileMap: TileType[][],
|
||||
blockedTiles: Set<string>,
|
||||
): Array<{ col: number; row: number }> {
|
||||
const rows = tileMap.length
|
||||
const cols = rows > 0 ? tileMap[0].length : 0
|
||||
const tiles: Array<{ col: number; row: number }> = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (isWalkable(c, r, tileMap, blockedTiles)) {
|
||||
tiles.push({ col: c, row: r })
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
/** BFS pathfinding on 4-connected grid (no diagonals). Returns path excluding start, including end. */
|
||||
export function findPath(
|
||||
startCol: number,
|
||||
startRow: number,
|
||||
endCol: number,
|
||||
endRow: number,
|
||||
tileMap: TileType[][],
|
||||
blockedTiles: Set<string>,
|
||||
): Array<{ col: number; row: number }> {
|
||||
if (startCol === endCol && startRow === endRow) return []
|
||||
|
||||
const key = (c: number, r: number) => `${c},${r}`
|
||||
const startKey = key(startCol, startRow)
|
||||
const endKey = key(endCol, endRow)
|
||||
|
||||
// End must be walkable (or be a chair tile which may be adjacent to desk)
|
||||
// We allow the end tile even if it's not strictly walkable for chair positions
|
||||
const endWalkable = isWalkable(endCol, endRow, tileMap, blockedTiles)
|
||||
if (!endWalkable) {
|
||||
// If the end is a desk tile, we still can't path there
|
||||
return []
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
visited.add(startKey)
|
||||
|
||||
const parent = new Map<string, string>()
|
||||
const queue: Array<{ col: number; row: number }> = [{ col: startCol, row: startRow }]
|
||||
|
||||
const dirs = [
|
||||
{ dc: 0, dr: -1 }, // up
|
||||
{ dc: 0, dr: 1 }, // down
|
||||
{ dc: -1, dr: 0 }, // left
|
||||
{ dc: 1, dr: 0 }, // right
|
||||
]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.shift()!
|
||||
const currKey = key(curr.col, curr.row)
|
||||
|
||||
if (currKey === endKey) {
|
||||
// Reconstruct path
|
||||
const path: Array<{ col: number; row: number }> = []
|
||||
let k = endKey
|
||||
while (k !== startKey) {
|
||||
const [c, r] = k.split(',').map(Number)
|
||||
path.unshift({ col: c, row: r })
|
||||
k = parent.get(k)!
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
for (const d of dirs) {
|
||||
const nc = curr.col + d.dc
|
||||
const nr = curr.row + d.dr
|
||||
const nk = key(nc, nr)
|
||||
|
||||
if (visited.has(nk)) continue
|
||||
if (!isWalkable(nc, nr, tileMap, blockedTiles)) continue
|
||||
|
||||
visited.add(nk)
|
||||
parent.set(nk, currKey)
|
||||
queue.push({ col: nc, row: nr })
|
||||
}
|
||||
}
|
||||
|
||||
// No path found
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
NOTIFICATION_NOTE_1_HZ,
|
||||
NOTIFICATION_NOTE_2_HZ,
|
||||
NOTIFICATION_NOTE_1_START_SEC,
|
||||
NOTIFICATION_NOTE_2_START_SEC,
|
||||
NOTIFICATION_NOTE_DURATION_SEC,
|
||||
NOTIFICATION_VOLUME,
|
||||
} from './constants'
|
||||
|
||||
let soundEnabled = true
|
||||
let audioCtx: AudioContext | null = null
|
||||
|
||||
export function setSoundEnabled(enabled: boolean): void {
|
||||
soundEnabled = enabled
|
||||
}
|
||||
|
||||
export function isSoundEnabled(): boolean {
|
||||
return soundEnabled
|
||||
}
|
||||
|
||||
function playNote(ctx: AudioContext, freq: number, startOffset: number): void {
|
||||
const t = ctx.currentTime + startOffset
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(freq, t)
|
||||
|
||||
gain.gain.setValueAtTime(NOTIFICATION_VOLUME, t)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + NOTIFICATION_NOTE_DURATION_SEC)
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
|
||||
osc.start(t)
|
||||
osc.stop(t + NOTIFICATION_NOTE_DURATION_SEC)
|
||||
}
|
||||
|
||||
export async function playDoneSound(): Promise<void> {
|
||||
if (!soundEnabled) return
|
||||
try {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new AudioContext()
|
||||
}
|
||||
if (audioCtx.state === 'suspended') {
|
||||
await audioCtx.resume()
|
||||
}
|
||||
playNote(audioCtx, NOTIFICATION_NOTE_1_HZ, NOTIFICATION_NOTE_1_START_SEC)
|
||||
playNote(audioCtx, NOTIFICATION_NOTE_2_HZ, NOTIFICATION_NOTE_2_START_SEC)
|
||||
} catch {
|
||||
// Audio may not be available
|
||||
}
|
||||
}
|
||||
|
||||
export function unlockAudio(): void {
|
||||
try {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new AudioContext()
|
||||
}
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { SpriteData } from '../types'
|
||||
|
||||
const zoomCaches = new Map<number, WeakMap<SpriteData, HTMLCanvasElement>>()
|
||||
|
||||
const outlineCache = new WeakMap<SpriteData, SpriteData>()
|
||||
|
||||
/** Generate a 1px white outline SpriteData (2px larger in each dimension) */
|
||||
export function getOutlineSprite(sprite: SpriteData): SpriteData {
|
||||
const cached = outlineCache.get(sprite)
|
||||
if (cached) return cached
|
||||
|
||||
const rows = sprite.length
|
||||
const cols = sprite[0].length
|
||||
const outline: string[][] = []
|
||||
for (let r = 0; r < rows + 2; r++) {
|
||||
outline.push(new Array<string>(cols + 2).fill(''))
|
||||
}
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (sprite[r][c] === '') continue
|
||||
const er = r + 1
|
||||
const ec = c + 1
|
||||
if (outline[er - 1][ec] === '') outline[er - 1][ec] = '#FFFFFF'
|
||||
if (outline[er + 1][ec] === '') outline[er + 1][ec] = '#FFFFFF'
|
||||
if (outline[er][ec - 1] === '') outline[er][ec - 1] = '#FFFFFF'
|
||||
if (outline[er][ec + 1] === '') outline[er][ec + 1] = '#FFFFFF'
|
||||
}
|
||||
}
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (sprite[r][c] !== '') {
|
||||
outline[r + 1][c + 1] = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outlineCache.set(sprite, outline)
|
||||
return outline
|
||||
}
|
||||
|
||||
export function getCachedSprite(sprite: SpriteData, zoom: number): HTMLCanvasElement {
|
||||
let cache = zoomCaches.get(zoom)
|
||||
if (!cache) {
|
||||
cache = new WeakMap()
|
||||
zoomCaches.set(zoom, cache)
|
||||
}
|
||||
|
||||
const cached = cache.get(sprite)
|
||||
if (cached) return cached
|
||||
|
||||
const rows = sprite.length
|
||||
const cols = sprite[0].length
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = cols * zoom
|
||||
canvas.height = rows * zoom
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.imageSmoothingEnabled = false
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const color = sprite[r][c]
|
||||
if (color === '') continue
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(c * zoom, r * zoom, zoom, zoom)
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(sprite, canvas)
|
||||
return canvas
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
// Inlined constants (no longer imported from constants)
|
||||
export const TILE_SIZE = 16
|
||||
export const DEFAULT_COLS = 20
|
||||
export const DEFAULT_ROWS = 11
|
||||
export const MAX_COLS = 64
|
||||
export const MAX_ROWS = 64
|
||||
export const MATRIX_EFFECT_DURATION = 0.3
|
||||
|
||||
export const TileType = {
|
||||
WALL: 0,
|
||||
FLOOR_1: 1,
|
||||
FLOOR_2: 2,
|
||||
FLOOR_3: 3,
|
||||
FLOOR_4: 4,
|
||||
FLOOR_5: 5,
|
||||
FLOOR_6: 6,
|
||||
FLOOR_7: 7,
|
||||
VOID: 8,
|
||||
} as const
|
||||
export type TileType = (typeof TileType)[keyof typeof TileType]
|
||||
|
||||
/** Per-tile color settings for floor pattern colorization */
|
||||
export interface FloorColor {
|
||||
/** Hue: 0-360 in colorize mode, -180 to +180 in adjust mode */
|
||||
h: number
|
||||
/** Saturation: 0-100 in colorize mode, -100 to +100 in adjust mode */
|
||||
s: number
|
||||
/** Brightness -100 to 100 */
|
||||
b: number
|
||||
/** Contrast -100 to 100 */
|
||||
c: number
|
||||
/** When true, use Photoshop-style Colorize (grayscale → fixed HSL). Default: adjust mode. */
|
||||
colorize?: boolean
|
||||
}
|
||||
|
||||
export const CharacterState = {
|
||||
IDLE: 'idle',
|
||||
WALK: 'walk',
|
||||
TYPE: 'type',
|
||||
} as const
|
||||
export type CharacterState = (typeof CharacterState)[keyof typeof CharacterState]
|
||||
|
||||
export const Direction = {
|
||||
DOWN: 0,
|
||||
LEFT: 1,
|
||||
RIGHT: 2,
|
||||
UP: 3,
|
||||
} as const
|
||||
export type Direction = (typeof Direction)[keyof typeof Direction]
|
||||
|
||||
/** 2D array of hex color strings (or '' for transparent). [row][col] */
|
||||
export type SpriteData = string[][]
|
||||
|
||||
export interface Seat {
|
||||
/** Chair furniture uid */
|
||||
uid: string
|
||||
/** Tile col where agent sits */
|
||||
seatCol: number
|
||||
/** Tile row where agent sits */
|
||||
seatRow: number
|
||||
/** Direction character faces when sitting (toward adjacent desk) */
|
||||
facingDir: Direction
|
||||
assigned: boolean
|
||||
}
|
||||
|
||||
export interface FurnitureInstance {
|
||||
sprite: SpriteData
|
||||
/** Pixel x (top-left) */
|
||||
x: number
|
||||
/** Pixel y (top-left) */
|
||||
y: number
|
||||
/** Y value used for depth sorting (typically bottom edge) */
|
||||
zY: number
|
||||
}
|
||||
|
||||
export interface ToolActivity {
|
||||
toolId: string
|
||||
status: string
|
||||
done: boolean
|
||||
permissionWait?: boolean
|
||||
}
|
||||
|
||||
export const FurnitureType = {
|
||||
DESK: 'desk',
|
||||
BOOKSHELF: 'bookshelf',
|
||||
PLANT: 'plant',
|
||||
COOLER: 'cooler',
|
||||
WHITEBOARD: 'whiteboard',
|
||||
CHAIR: 'chair',
|
||||
PC: 'pc',
|
||||
LAMP: 'lamp',
|
||||
} as const
|
||||
export type FurnitureType = (typeof FurnitureType)[keyof typeof FurnitureType]
|
||||
|
||||
export const EditTool = {
|
||||
TILE_PAINT: 'tile_paint',
|
||||
WALL_PAINT: 'wall_paint',
|
||||
FURNITURE_PLACE: 'furniture_place',
|
||||
FURNITURE_PICK: 'furniture_pick',
|
||||
SELECT: 'select',
|
||||
EYEDROPPER: 'eyedropper',
|
||||
ERASE: 'erase',
|
||||
} as const
|
||||
export type EditTool = (typeof EditTool)[keyof typeof EditTool]
|
||||
|
||||
export interface FurnitureCatalogEntry {
|
||||
type: string
|
||||
label: string
|
||||
footprintW: number
|
||||
footprintH: number
|
||||
sprite: SpriteData
|
||||
isDesk: boolean
|
||||
category?: string
|
||||
orientation?: string
|
||||
canPlaceOnSurfaces?: boolean
|
||||
backgroundTiles?: number
|
||||
canPlaceOnWalls?: boolean
|
||||
}
|
||||
|
||||
export interface PlacedFurniture {
|
||||
uid: string
|
||||
type: string
|
||||
col: number
|
||||
row: number
|
||||
color?: FloorColor
|
||||
}
|
||||
|
||||
export interface OfficeLayout {
|
||||
version: 1
|
||||
cols: number
|
||||
rows: number
|
||||
tiles: TileType[]
|
||||
furniture: PlacedFurniture[]
|
||||
tileColors?: Array<FloorColor | null>
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: number
|
||||
state: CharacterState
|
||||
dir: Direction
|
||||
x: number
|
||||
y: number
|
||||
tileCol: number
|
||||
tileRow: number
|
||||
path: Array<{ col: number; row: number }>
|
||||
moveProgress: number
|
||||
currentTool: string | null
|
||||
palette: number
|
||||
hueShift: number
|
||||
frame: number
|
||||
frameTimer: number
|
||||
wanderTimer: number
|
||||
wanderCount: number
|
||||
wanderLimit: number
|
||||
isActive: boolean
|
||||
seatId: string | null
|
||||
bubbleType: 'permission' | 'waiting' | null
|
||||
bubbleTimer: number
|
||||
seatTimer: number
|
||||
isSubagent: boolean
|
||||
parentAgentId: number | null
|
||||
label: string
|
||||
matrixEffect: 'spawn' | 'despawn' | null
|
||||
matrixEffectTimer: number
|
||||
matrixEffectSeeds: number[]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Wall tile auto-tiling: sprite storage and bitmask-based piece selection.
|
||||
*
|
||||
* Stores 16 wall sprites (one per 4-bit bitmask) loaded from walls.png.
|
||||
* At render time, each wall tile's 4 cardinal neighbors are checked to build
|
||||
* a bitmask, and the corresponding sprite is drawn directly.
|
||||
* No changes to the layout model — auto-tiling is purely visual.
|
||||
*
|
||||
* Bitmask convention: N=1, E=2, S=4, W=8. Out-of-bounds = NOT wall.
|
||||
*/
|
||||
|
||||
import type { SpriteData, TileType as TileTypeVal, FloorColor, FurnitureInstance } from './types'
|
||||
import { TileType, TILE_SIZE } from './types'
|
||||
import { getColorizedSprite } from './colorize'
|
||||
|
||||
/** 16 wall sprites indexed by bitmask (0-15) */
|
||||
let wallSprites: SpriteData[] | null = null
|
||||
|
||||
/** Set wall sprites (called once when extension sends wallTilesLoaded) */
|
||||
export function setWallSprites(sprites: SpriteData[]): void {
|
||||
wallSprites = sprites
|
||||
}
|
||||
|
||||
/** Check if wall sprites have been loaded */
|
||||
export function hasWallSprites(): boolean {
|
||||
return wallSprites !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wall sprite for a tile based on its cardinal neighbors.
|
||||
* Returns the sprite + Y offset, or null to fall back to solid WALL_COLOR.
|
||||
*/
|
||||
export function getWallSprite(
|
||||
col: number,
|
||||
row: number,
|
||||
tileMap: TileTypeVal[][],
|
||||
): { sprite: SpriteData; offsetY: number } | null {
|
||||
if (!wallSprites) return null
|
||||
|
||||
const tmRows = tileMap.length
|
||||
const tmCols = tmRows > 0 ? tileMap[0].length : 0
|
||||
|
||||
// Build 4-bit neighbor bitmask
|
||||
let mask = 0
|
||||
if (row > 0 && tileMap[row - 1][col] === TileType.WALL) mask |= 1 // N
|
||||
if (col < tmCols - 1 && tileMap[row][col + 1] === TileType.WALL) mask |= 2 // E
|
||||
if (row < tmRows - 1 && tileMap[row + 1][col] === TileType.WALL) mask |= 4 // S
|
||||
if (col > 0 && tileMap[row][col - 1] === TileType.WALL) mask |= 8 // W
|
||||
|
||||
const sprite = wallSprites[mask]
|
||||
if (!sprite) return null
|
||||
|
||||
// Anchor sprite at bottom of tile — tall sprites extend upward
|
||||
return { sprite, offsetY: TILE_SIZE - sprite.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a colorized wall sprite for a tile based on its cardinal neighbors.
|
||||
* Uses Colorize mode (grayscale → HSL) like floor tiles.
|
||||
* Returns the colorized sprite + Y offset, or null if no wall sprites loaded.
|
||||
*/
|
||||
export function getColorizedWallSprite(
|
||||
col: number,
|
||||
row: number,
|
||||
tileMap: TileTypeVal[][],
|
||||
color: FloorColor,
|
||||
): { sprite: SpriteData; offsetY: number } | null {
|
||||
if (!wallSprites) return null
|
||||
|
||||
const tmRows = tileMap.length
|
||||
const tmCols = tmRows > 0 ? tileMap[0].length : 0
|
||||
|
||||
// Build 4-bit neighbor bitmask (same as getWallSprite)
|
||||
let mask = 0
|
||||
if (row > 0 && tileMap[row - 1][col] === TileType.WALL) mask |= 1 // N
|
||||
if (col < tmCols - 1 && tileMap[row][col + 1] === TileType.WALL) mask |= 2 // E
|
||||
if (row < tmRows - 1 && tileMap[row + 1][col] === TileType.WALL) mask |= 4 // S
|
||||
if (col > 0 && tileMap[row][col - 1] === TileType.WALL) mask |= 8 // W
|
||||
|
||||
const sprite = wallSprites[mask]
|
||||
if (!sprite) return null
|
||||
|
||||
const cacheKey = `wall-${mask}-${color.h}-${color.s}-${color.b}-${color.c}`
|
||||
const colorized = getColorizedSprite(cacheKey, sprite, { ...color, colorize: true })
|
||||
|
||||
return { sprite: colorized, offsetY: TILE_SIZE - sprite.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FurnitureInstance-like objects for all wall tiles so they can participate
|
||||
* in z-sorting with furniture and characters.
|
||||
*/
|
||||
export function getWallInstances(
|
||||
tileMap: TileTypeVal[][],
|
||||
tileColors?: Array<FloorColor | null>,
|
||||
cols?: number,
|
||||
): FurnitureInstance[] {
|
||||
if (!wallSprites) return []
|
||||
const tmRows = tileMap.length
|
||||
const tmCols = tmRows > 0 ? tileMap[0].length : 0
|
||||
const layoutCols = cols ?? tmCols
|
||||
const instances: FurnitureInstance[] = []
|
||||
for (let r = 0; r < tmRows; r++) {
|
||||
for (let c = 0; c < tmCols; c++) {
|
||||
if (tileMap[r][c] !== TileType.WALL) continue
|
||||
const colorIdx = r * layoutCols + c
|
||||
const wallColor = tileColors?.[colorIdx]
|
||||
const wallInfo = wallColor
|
||||
? getColorizedWallSprite(c, r, tileMap, wallColor)
|
||||
: getWallSprite(c, r, tileMap)
|
||||
if (!wallInfo) continue
|
||||
instances.push({
|
||||
sprite: wallInfo.sprite,
|
||||
x: c * TILE_SIZE,
|
||||
y: r * TILE_SIZE + wallInfo.offsetY,
|
||||
zY: (r + 1) * TILE_SIZE,
|
||||
})
|
||||
}
|
||||
}
|
||||
return instances
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the flat fill hex color for a wall tile with a given FloorColor.
|
||||
* Uses same Colorize algorithm as floor tiles: 50% gray → HSL.
|
||||
*/
|
||||
export function wallColorToHex(color: FloorColor): string {
|
||||
const { h, s, b, c } = color
|
||||
// Start with 50% gray (wall base)
|
||||
let lightness = 0.5
|
||||
|
||||
// Apply contrast
|
||||
if (c !== 0) {
|
||||
const factor = (100 + c) / 100
|
||||
lightness = 0.5 + (lightness - 0.5) * factor
|
||||
}
|
||||
|
||||
// Apply brightness
|
||||
if (b !== 0) {
|
||||
lightness = lightness + b / 200
|
||||
}
|
||||
|
||||
lightness = Math.max(0, Math.min(1, lightness))
|
||||
|
||||
// HSL to hex (same as colorize.ts hslToHex)
|
||||
const satFrac = s / 100
|
||||
const ch = (1 - Math.abs(2 * lightness - 1)) * satFrac
|
||||
const hp = h / 60
|
||||
const x = ch * (1 - Math.abs(hp % 2 - 1))
|
||||
let r1 = 0, g1 = 0, b1 = 0
|
||||
|
||||
if (hp < 1) { r1 = ch; g1 = x; b1 = 0 }
|
||||
else if (hp < 2) { r1 = x; g1 = ch; b1 = 0 }
|
||||
else if (hp < 3) { r1 = 0; g1 = ch; b1 = x }
|
||||
else if (hp < 4) { r1 = 0; g1 = x; b1 = ch }
|
||||
else if (hp < 5) { r1 = x; g1 = 0; b1 = ch }
|
||||
else { r1 = ch; g1 = 0; b1 = x }
|
||||
|
||||
const m = lightness - ch / 2
|
||||
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round((v + m) * 255)))
|
||||
|
||||
return `#${clamp(r1).toString(16).padStart(2, '0')}${clamp(g1).toString(16).padStart(2, '0')}${clamp(b1).toString(16).padStart(2, '0')}`
|
||||
}
|
||||
Reference in New Issue
Block a user