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:
xmanrui
2026-02-25 01:39:44 +08:00
parent 7b8e5affc3
commit eebe431560
26 changed files with 6051 additions and 0 deletions
+164
View File
@@ -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 })
}
+43
View File
@@ -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>
)
}
+664
View File
@@ -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>
)
}
+1
View File
@@ -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" },
],
},
{
+20
View File
@@ -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",
},
};
+104
View File
@@ -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)
}
}
+103
View File
@@ -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
}
+113
View File
@@ -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
+209
View File
@@ -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 },
}
}
+109
View File
@@ -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
}
}
+304
View File
@@ -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))
}
+38
View File
@@ -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)
}
}
+130
View File
@@ -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)
}
}
}
}
+677
View File
@@ -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
}
}
+629
View File
@@ -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 }
}
+74
View File
@@ -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 })
}
+303
View File
@@ -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)
}
+330
View File
@@ -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 }
}
+105
View File
@@ -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 []
}
+66
View File
@@ -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
}
}
+72
View File
@@ -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
+166
View File
@@ -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[]
}
+163
View File
@@ -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')}`
}