diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts new file mode 100644 index 0000000..0c4a6d9 --- /dev/null +++ b/app/api/agent-activity/route.ts @@ -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 { + 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() + 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 }) +} diff --git a/app/api/pixel-office/layout/route.ts b/app/api/pixel-office/layout/route.ts new file mode 100644 index 0000000..41a424d --- /dev/null +++ b/app/api/pixel-office/layout/route.ts @@ -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 }) + } +} diff --git a/app/pixel-office/components/EditActionBar.tsx b/app/pixel-office/components/EditActionBar.tsx new file mode 100644 index 0000000..33ce04c --- /dev/null +++ b/app/pixel-office/components/EditActionBar.tsx @@ -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 ( +
+ + + {isDirty && ( + <> + + + + )} +
+ ) +} diff --git a/app/pixel-office/components/EditorToolbar.tsx b/app/pixel-office/components/EditorToolbar.tsx new file mode 100644 index 0000000..65b560f --- /dev/null +++ b/app/pixel-office/components/EditorToolbar.tsx @@ -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(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 ( + + ) +} + +function ColorSlider({ label, value, min, max, onChange }: { + label: string; value: number; min: number; max: number; onChange: (v: number) => void +}) { + return ( +
+ {label} + onChange(Number(e.target.value))} + style={{ flex: 1, height: 12, accentColor: 'rgba(90, 140, 255, 0.8)' }} /> + {value} +
+ ) +} + +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('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 ( +
+ {/* Tool row */} +
+ + + + +
+ + {/* Floor sub-panel */} + {isFloorActive && ( +
+
+ + +
+ {showColor && ( +
+ handleColorChange('h', v)} /> + handleColorChange('s', v)} /> + handleColorChange('b', v)} /> + handleColorChange('c', v)} /> +
+ )} +
+ {floorPatterns.map(patIdx => ( + onTileTypeChange(patIdx as TileTypeVal)} /> + ))} +
+
+ )} + + {/* Wall sub-panel */} + {isWallActive && ( +
+
+ +
+ {showWallColor && ( +
+ handleWallColorChange('h', v)} /> + handleWallColorChange('s', v)} /> + handleWallColorChange('b', v)} /> + handleWallColorChange('c', v)} /> +
+ )} +
+ )} + + {/* Furniture sub-panel */} + {isFurnitureActive && ( +
+
+ {getActiveCategories().map(cat => ( + + ))} +
+ +
+
+ {categoryItems.map(entry => { + const cached = getCachedSprite(entry.sprite, 2) + const isSelected = selectedFurnitureType === entry.type + return ( + + ) + })} +
+
+ )} + + {/* Selected furniture color panel */} + {selectedFurnitureUid && ( +
+
+ + {selectedFurnitureColor && ( + + )} +
+ {showFurnitureColor && ( +
+ {effectiveColor.colorize ? ( + <> + handleSelFurnColorChange('h', v)} /> + handleSelFurnColorChange('s', v)} /> + + ) : ( + <> + handleSelFurnColorChange('h', v)} /> + handleSelFurnColorChange('s', v)} /> + + )} + handleSelFurnColorChange('b', v)} /> + handleSelFurnColorChange('c', v)} /> + +
+ )} +
+ )} +
+ ) +} diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx new file mode 100644 index 0000000..5767f74 --- /dev/null +++ b/app/pixel-office/page.tsx @@ -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(null) + const containerRef = useRef(null) + const officeRef = useRef(null) + const editorRef = useRef(new EditorState()) + const agentIdMapRef = useRef>(new Map()) + const nextIdRef = useRef<{ current: number }>({ current: 1 }) + const zoomRef = useRef(4) + const panRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + const savedLayoutRef = useRef(null) + const animationFrameIdRef = useRef(null) + const prevAgentStatesRef = useRef>(new Map()) + + const [agents, setAgents] = useState([]) + const [hoveredAgentId, setHoveredAgentId] = useState(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() + 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 ( +
+ {/* Top bar: agent tags + controls */} +
+
+ {agents.map(agent => ( +
+ {agent.emoji} + {agent.name} + {agent.state === 'working' && working} + {agent.state === 'idle' && idle} +
+ ))} + {agents.length === 0 && ( +
{t('common.noData')}
+ )} +
+
+ + +
+
+ + {/* Canvas */} +
+ + + {/* Editor overlays */} + {isEditMode && ( + <> + 0} + canRedo={editor.redoStack.length > 0} + onUndo={handleUndo} onRedo={handleRedo} + onSave={handleSave} onReset={handleReset} /> + + + )} +
+
+ ) +} diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 04a8260..c32b0f3 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -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" }, ], }, { diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 4ce4637..f8b16f4 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -17,6 +17,7 @@ const translations: Record> = { "nav.monitor": "็›‘ๆŽง", "nav.sessions": "ไผš่ฏๅˆ—่กจ", "nav.stats": "ๆถˆๆฏ็ปŸ่ฎก", + "nav.pixelOffice": "ๅƒ็ด ๅŠžๅ…ฌๅฎค", "nav.config": "้…็ฝฎ", "nav.skills": "ๆŠ€่ƒฝ็ฎก็†", "nav.alerts": "ๅ‘Š่ญฆไธญๅฟƒ", @@ -201,6 +202,15 @@ const translations: Record> = { "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> = { "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> = { "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", }, }; diff --git a/lib/pixel-office/agentBridge.ts b/lib/pixel-office/agentBridge.ts new file mode 100644 index 0000000..68033be --- /dev/null +++ b/lib/pixel-office/agentBridge.ts @@ -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>() + +export function syncAgentsToOffice( + activities: AgentActivity[], + office: OfficeState, + agentIdMap: Map, + 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() + 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) + } +} diff --git a/lib/pixel-office/colorize.ts b/lib/pixel-office/colorize.ts new file mode 100644 index 0000000..0c27078 --- /dev/null +++ b/lib/pixel-office/colorize.ts @@ -0,0 +1,103 @@ +/** + * Shared sprite colorization module. + */ + +import type { SpriteData, FloorColor } from './types' + +const colorizeCache = new Map() + +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 +} diff --git a/lib/pixel-office/constants.ts b/lib/pixel-office/constants.ts new file mode 100644 index 0000000..1a3f912 --- /dev/null +++ b/lib/pixel-office/constants.ts @@ -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 diff --git a/lib/pixel-office/editor/editorActions.ts b/lib/pixel-office/editor/editorActions.ts new file mode 100644 index 0000000..ce3af0b --- /dev/null +++ b/lib/pixel-office/editor/editorActions.ts @@ -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 | null = null + if (entry.canPlaceOnSurfaces) { + deskTiles = new Set() + 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 = 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 }, + } +} diff --git a/lib/pixel-office/editor/editorState.ts b/lib/pixel-office/editor/editorState.ts new file mode 100644 index 0000000..cca0d2e --- /dev/null +++ b/lib/pixel-office/editor/editorState.ts @@ -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 + } +} diff --git a/lib/pixel-office/engine/characters.ts b/lib/pixel-office/engine/characters.ts new file mode 100644 index 0000000..e3ff36a --- /dev/null +++ b/lib/pixel-office/engine/characters.ts @@ -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, + tileMap: TileTypeVal[][], + blockedTiles: Set, +): 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)) +} diff --git a/lib/pixel-office/engine/gameLoop.ts b/lib/pixel-office/engine/gameLoop.ts new file mode 100644 index 0000000..5568d92 --- /dev/null +++ b/lib/pixel-office/engine/gameLoop.ts @@ -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) + } +} diff --git a/lib/pixel-office/engine/matrixEffect.ts b/lib/pixel-office/engine/matrixEffect.ts new file mode 100644 index 0000000..c2c2a08 --- /dev/null +++ b/lib/pixel-office/engine/matrixEffect.ts @@ -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) + } + } + } +} diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts new file mode 100644 index 0000000..f1b9852 --- /dev/null +++ b/lib/pixel-office/engine/officeState.ts @@ -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 + blockedTiles: Set + furniture: FurnitureInstance[] + walkableTiles: Array<{ col: number; row: number }> + characters: Map = 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 = new Map() + /** Reverse lookup: sub-agent character ID โ†’ parent info */ + subagentMeta: Map = 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(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() + 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 + } +} diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts new file mode 100644 index 0000000..14b7372 --- /dev/null +++ b/lib/pixel-office/engine/renderer.ts @@ -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, + 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, + characters: Map, + 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 + characters: Map +} + +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, + 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 } +} diff --git a/lib/pixel-office/floorTiles.ts b/lib/pixel-office/floorTiles.ts new file mode 100644 index 0000000..c8b9e0c --- /dev/null +++ b/lib/pixel-office/floorTiles.ts @@ -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 }) +} diff --git a/lib/pixel-office/layout/furnitureCatalog.ts b/lib/pixel-office/layout/furnitureCatalog.ts new file mode 100644 index 0000000..b2f8db3 --- /dev/null +++ b/lib/pixel-office/layout/furnitureCatalog.ts @@ -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 +} + +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 +} + +// Maps any member asset ID โ†’ its rotation group +const rotationGroups = new Map() + +// โ”€โ”€ State groups โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Maps asset ID โ†’ its on/off counterpart (symmetric for toggle) +const stateGroups = new Map() +// Directional maps for getOnStateType / getOffStateType +const offToOn = new Map() // off asset โ†’ on asset +const onToOff = new Map() // 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>() // 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() + 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 = {} + 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>() // "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 = {} + 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() + 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) +} diff --git a/lib/pixel-office/layout/layoutSerializer.ts b/lib/pixel-office/layout/layoutSerializer.ts new file mode 100644 index 0000000..0ceb425 --- /dev/null +++ b/lib/pixel-office/layout/layoutSerializer.ts @@ -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() + 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): Set { + const tiles = new Set() + 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 { + const tiles = new Set() + 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 { + const seats = new Map() + + // Build set of all desk tiles + const deskTiles = new Set() + 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): Set { + const tiles = new Set() + 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 = [] + + 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 = [] + 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 } +} diff --git a/lib/pixel-office/layout/tileMap.ts b/lib/pixel-office/layout/tileMap.ts new file mode 100644 index 0000000..2a7bc8b --- /dev/null +++ b/lib/pixel-office/layout/tileMap.ts @@ -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, +): 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, +): 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, +): 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() + visited.add(startKey) + + const parent = new Map() + 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 [] +} diff --git a/lib/pixel-office/notificationSound.ts b/lib/pixel-office/notificationSound.ts new file mode 100644 index 0000000..b75e2ad --- /dev/null +++ b/lib/pixel-office/notificationSound.ts @@ -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 { + 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 + } +} diff --git a/lib/pixel-office/sprites/spriteCache.ts b/lib/pixel-office/sprites/spriteCache.ts new file mode 100644 index 0000000..6d8c1bd --- /dev/null +++ b/lib/pixel-office/sprites/spriteCache.ts @@ -0,0 +1,72 @@ +import type { SpriteData } from '../types' + +const zoomCaches = new Map>() + +const outlineCache = new WeakMap() + +/** 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(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 +} diff --git a/lib/pixel-office/sprites/spriteData.ts b/lib/pixel-office/sprites/spriteData.ts new file mode 100644 index 0000000..cb84d03 --- /dev/null +++ b/lib/pixel-office/sprites/spriteData.ts @@ -0,0 +1,1122 @@ +import type { Direction, SpriteData, FloorColor } from '../types' +import { Direction as Dir } from '../types' +import { adjustSprite } from '../colorize' + +// โ”€โ”€ Color Palettes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const _ = '' // transparent + +// โ”€โ”€ Furniture Sprites โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Square desk: 32x32 pixels (2x2 tiles) โ€” top-down wood surface */ +export const DESK_SQUARE_SPRITE: SpriteData = (() => { + const W = '#8B6914' // wood edge + const L = '#A07828' // lighter wood + const S = '#B8922E' // surface + const D = '#6B4E0A' // dark edge + const rows: string[][] = [] + // Row 0: empty + rows.push(new Array(32).fill(_)) + // Row 1: top edge + rows.push([_, ...new Array(30).fill(W), _]) + // Rows 2-5: top surface + for (let r = 0; r < 4; r++) { + rows.push([_, W, ...new Array(28).fill(r < 1 ? L : S), W, _]) + } + // Row 6: horizontal divider + rows.push([_, D, ...new Array(28).fill(W), D, _]) + // Rows 7-12: middle surface area + for (let r = 0; r < 6; r++) { + rows.push([_, W, ...new Array(28).fill(S), W, _]) + } + // Row 13: center line + rows.push([_, W, ...new Array(28).fill(L), W, _]) + // Rows 14-19: lower surface + for (let r = 0; r < 6; r++) { + rows.push([_, W, ...new Array(28).fill(S), W, _]) + } + // Row 20: horizontal divider + rows.push([_, D, ...new Array(28).fill(W), D, _]) + // Rows 21-24: bottom surface + for (let r = 0; r < 4; r++) { + rows.push([_, W, ...new Array(28).fill(r > 2 ? L : S), W, _]) + } + // Row 25: bottom edge + rows.push([_, ...new Array(30).fill(W), _]) + // Rows 26-31: legs/shadow + for (let r = 0; r < 4; r++) { + const row = new Array(32).fill(_) as string[] + row[1] = D; row[2] = D; row[29] = D; row[30] = D + rows.push(row) + } + rows.push(new Array(32).fill(_)) + rows.push(new Array(32).fill(_)) + return rows +})() + +/** Plant in pot: 16x24 */ +export const PLANT_SPRITE: SpriteData = (() => { + const G = '#3D8B37' + const D = '#2D6B27' + const T = '#6B4E0A' + const P = '#B85C3A' + const R = '#8B4422' + return [ + [_, _, _, _, _, _, G, G, _, _, _, _, _, _, _, _], + [_, _, _, _, _, G, G, G, G, _, _, _, _, _, _, _], + [_, _, _, _, G, G, D, G, G, G, _, _, _, _, _, _], + [_, _, _, G, G, D, G, G, D, G, G, _, _, _, _, _], + [_, _, G, G, G, G, G, G, G, G, G, G, _, _, _, _], + [_, G, G, D, G, G, G, G, G, G, D, G, G, _, _, _], + [_, G, G, G, G, D, G, G, D, G, G, G, G, _, _, _], + [_, _, G, G, G, G, G, G, G, G, G, G, _, _, _, _], + [_, _, _, G, G, G, D, G, G, G, G, _, _, _, _, _], + [_, _, _, _, G, G, G, G, G, G, _, _, _, _, _, _], + [_, _, _, _, _, G, G, G, G, _, _, _, _, _, _, _], + [_, _, _, _, _, _, T, T, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, T, T, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, T, T, _, _, _, _, _, _, _, _], + [_, _, _, _, _, R, R, R, R, R, _, _, _, _, _, _], + [_, _, _, _, R, P, P, P, P, P, R, _, _, _, _, _], + [_, _, _, _, R, P, P, P, P, P, R, _, _, _, _, _], + [_, _, _, _, R, P, P, P, P, P, R, _, _, _, _, _], + [_, _, _, _, R, P, P, P, P, P, R, _, _, _, _, _], + [_, _, _, _, R, P, P, P, P, P, R, _, _, _, _, _], + [_, _, _, _, R, P, P, P, P, P, R, _, _, _, _, _], + [_, _, _, _, _, R, P, P, P, R, _, _, _, _, _, _], + [_, _, _, _, _, _, R, R, R, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + ] +})() + +/** Bookshelf: 16x32 (1 tile wide, 2 tiles tall) */ +export const BOOKSHELF_SPRITE: SpriteData = (() => { + const W = '#8B6914' + const D = '#6B4E0A' + const R = '#CC4444' + const B = '#4477AA' + const G = '#44AA66' + const Y = '#CCAA33' + const P = '#9955AA' + return [ + [_, W, W, W, W, W, W, W, W, W, W, W, W, W, W, _], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, D, R, R, B, B, G, G, Y, Y, R, R, B, B, D, W], + [W, D, R, R, B, B, G, G, Y, Y, R, R, B, B, D, W], + [W, D, R, R, B, B, G, G, Y, Y, R, R, B, B, D, W], + [W, D, R, R, B, B, G, G, Y, Y, R, R, B, B, D, W], + [W, D, R, R, B, B, G, G, Y, Y, R, R, B, B, D, W], + [W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, D, P, P, Y, Y, B, B, G, G, P, P, R, R, D, W], + [W, D, P, P, Y, Y, B, B, G, G, P, P, R, R, D, W], + [W, D, P, P, Y, Y, B, B, G, G, P, P, R, R, D, W], + [W, D, P, P, Y, Y, B, B, G, G, P, P, R, R, D, W], + [W, D, P, P, Y, Y, B, B, G, G, P, P, R, R, D, W], + [W, D, P, P, Y, Y, B, B, G, G, P, P, R, R, D, W], + [W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, D, G, G, R, R, P, P, B, B, Y, Y, G, G, D, W], + [W, D, G, G, R, R, P, P, B, B, Y, Y, G, G, D, W], + [W, D, G, G, R, R, P, P, B, B, Y, Y, G, G, D, W], + [W, D, G, G, R, R, P, P, B, B, Y, Y, G, G, D, W], + [W, D, G, G, R, R, P, P, B, B, Y, Y, G, G, D, W], + [W, D, G, G, R, R, P, P, B, B, Y, Y, G, G, D, W], + [W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, D, D, D, D, D, D, D, D, D, D, D, D, D, D, W], + [W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W], + [_, W, W, W, W, W, W, W, W, W, W, W, W, W, W, _], + ] +})() + +/** Water cooler: 16x24 */ +export const COOLER_SPRITE: SpriteData = (() => { + const W = '#CCDDEE' + const L = '#88BBDD' + const D = '#999999' + const B = '#666666' + return [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, D, D, D, D, D, D, _, _, _, _, _], + [_, _, _, _, D, L, L, L, L, L, L, D, _, _, _, _], + [_, _, _, _, D, L, L, L, L, L, L, D, _, _, _, _], + [_, _, _, _, D, L, L, L, L, L, L, D, _, _, _, _], + [_, _, _, _, D, L, L, L, L, L, L, D, _, _, _, _], + [_, _, _, _, D, L, L, L, L, L, L, D, _, _, _, _], + [_, _, _, _, _, D, D, D, D, D, D, _, _, _, _, _], + [_, _, _, _, _, D, W, W, W, W, D, _, _, _, _, _], + [_, _, _, _, _, D, W, W, W, W, D, _, _, _, _, _], + [_, _, _, _, _, D, W, W, W, W, D, _, _, _, _, _], + [_, _, _, _, _, D, W, W, W, W, D, _, _, _, _, _], + [_, _, _, _, _, D, W, W, W, W, D, _, _, _, _, _], + [_, _, _, _, D, D, W, W, W, W, D, D, _, _, _, _], + [_, _, _, _, D, W, W, W, W, W, W, D, _, _, _, _], + [_, _, _, _, D, W, W, W, W, W, W, D, _, _, _, _], + [_, _, _, _, D, D, D, D, D, D, D, D, _, _, _, _], + [_, _, _, _, _, D, B, B, B, B, D, _, _, _, _, _], + [_, _, _, _, _, D, B, B, B, B, D, _, _, _, _, _], + [_, _, _, _, _, D, B, B, B, B, D, _, _, _, _, _], + [_, _, _, _, D, D, B, B, B, B, D, D, _, _, _, _], + [_, _, _, _, D, B, B, B, B, B, B, D, _, _, _, _], + [_, _, _, _, D, D, D, D, D, D, D, D, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + ] +})() + +/** Whiteboard: 32x16 (2 tiles wide, 1 tile tall) โ€” hangs on wall */ +export const WHITEBOARD_SPRITE: SpriteData = (() => { + const F = '#AAAAAA' + const W = '#EEEEFF' + const M = '#CC4444' + const B = '#4477AA' + return [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, _], + [_, F, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, F, _], + [_, F, W, W, M, M, M, W, W, W, W, W, B, B, B, B, W, W, W, W, W, W, W, M, W, W, W, W, W, W, F, _], + [_, F, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, B, B, W, W, M, W, W, W, W, W, W, F, _], + [_, F, W, W, W, W, M, M, M, M, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, B, B, W, W, F, _], + [_, F, W, W, W, W, W, W, W, W, W, W, W, B, B, B, W, W, W, W, W, W, W, W, W, W, W, W, W, W, F, _], + [_, F, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, M, M, M, W, W, W, W, W, W, W, F, _], + [_, F, W, M, M, W, W, W, W, W, W, W, W, W, W, W, B, B, W, W, W, W, W, W, W, W, W, W, W, W, F, _], + [_, F, W, W, W, W, W, W, B, B, B, W, W, W, W, W, W, W, W, W, W, W, W, W, M, M, M, M, W, W, F, _], + [_, F, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, F, _], + [_, F, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, F, _], + [_, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, F, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + ] +})() + +/** Chair: 16x16 โ€” top-down desk chair */ +export const CHAIR_SPRITE: SpriteData = (() => { + const W = '#8B6914' + const D = '#6B4E0A' + const B = '#5C3D0A' + const S = '#A07828' + return [ + [_, _, _, _, _, D, D, D, D, D, D, _, _, _, _, _], + [_, _, _, _, D, B, B, B, B, B, B, D, _, _, _, _], + [_, _, _, _, D, B, S, S, S, S, B, D, _, _, _, _], + [_, _, _, _, D, B, S, S, S, S, B, D, _, _, _, _], + [_, _, _, _, D, B, S, S, S, S, B, D, _, _, _, _], + [_, _, _, _, D, B, S, S, S, S, B, D, _, _, _, _], + [_, _, _, _, D, B, S, S, S, S, B, D, _, _, _, _], + [_, _, _, _, D, B, S, S, S, S, B, D, _, _, _, _], + [_, _, _, _, D, B, S, S, S, S, B, D, _, _, _, _], + [_, _, _, _, D, B, B, B, B, B, B, D, _, _, _, _], + [_, _, _, _, _, D, D, D, D, D, D, _, _, _, _, _], + [_, _, _, _, _, _, D, W, W, D, _, _, _, _, _, _], + [_, _, _, _, _, _, D, W, W, D, _, _, _, _, _, _], + [_, _, _, _, _, D, D, D, D, D, D, _, _, _, _, _], + [_, _, _, _, _, D, _, _, _, _, D, _, _, _, _, _], + [_, _, _, _, _, D, _, _, _, _, D, _, _, _, _, _], + ] +})() + +/** PC monitor: 16x16 โ€” top-down monitor on stand */ +export const PC_SPRITE: SpriteData = (() => { + const F = '#555555' + const S = '#3A3A5C' + const B = '#6688CC' + const D = '#444444' + return [ + [_, _, _, F, F, F, F, F, F, F, F, F, F, _, _, _], + [_, _, _, F, S, S, S, S, S, S, S, S, F, _, _, _], + [_, _, _, F, S, B, B, B, B, B, B, S, F, _, _, _], + [_, _, _, F, S, B, B, B, B, B, B, S, F, _, _, _], + [_, _, _, F, S, B, B, B, B, B, B, S, F, _, _, _], + [_, _, _, F, S, B, B, B, B, B, B, S, F, _, _, _], + [_, _, _, F, S, B, B, B, B, B, B, S, F, _, _, _], + [_, _, _, F, S, B, B, B, B, B, B, S, F, _, _, _], + [_, _, _, F, S, S, S, S, S, S, S, S, F, _, _, _], + [_, _, _, F, F, F, F, F, F, F, F, F, F, _, _, _], + [_, _, _, _, _, _, _, D, D, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, D, D, _, _, _, _, _, _, _], + [_, _, _, _, _, _, D, D, D, D, _, _, _, _, _, _], + [_, _, _, _, _, D, D, D, D, D, D, _, _, _, _, _], + [_, _, _, _, _, D, D, D, D, D, D, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + ] +})() + +/** Desk lamp: 16x16 โ€” top-down lamp with light cone */ +export const LAMP_SPRITE: SpriteData = (() => { + const Y = '#FFDD55' + const L = '#FFEE88' + const D = '#888888' + const B = '#555555' + const G = '#FFFFCC' + return [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, G, G, G, G, _, _, _, _, _, _], + [_, _, _, _, _, G, Y, Y, Y, Y, G, _, _, _, _, _], + [_, _, _, _, G, Y, Y, L, L, Y, Y, G, _, _, _, _], + [_, _, _, _, Y, Y, L, L, L, L, Y, Y, _, _, _, _], + [_, _, _, _, Y, Y, L, L, L, L, Y, Y, _, _, _, _], + [_, _, _, _, _, Y, Y, Y, Y, Y, Y, _, _, _, _, _], + [_, _, _, _, _, _, D, D, D, D, _, _, _, _, _, _], + [_, _, _, _, _, _, _, D, D, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, D, D, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, D, D, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, D, D, _, _, _, _, _, _, _], + [_, _, _, _, _, _, D, D, D, D, _, _, _, _, _, _], + [_, _, _, _, _, B, B, B, B, B, B, _, _, _, _, _], + [_, _, _, _, _, B, B, B, B, B, B, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + ] +})() + +// โ”€โ”€ Speech Bubble Sprites โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Permission bubble: white square with "..." in amber, and a tail pointer (11x13) */ +export const BUBBLE_PERMISSION_SPRITE: SpriteData = (() => { + const B = '#555566' // border + const F = '#EEEEFF' // fill + const A = '#CCA700' // amber dots + return [ + [B, B, B, B, B, B, B, B, B, B, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, A, F, A, F, A, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, B, B, B, B, B, B, B, B, B, B], + [_, _, _, _, B, B, B, _, _, _, _], + [_, _, _, _, _, B, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _], + ] +})() + +/** Waiting bubble: white square with green checkmark, and a tail pointer (11x13) */ +export const BUBBLE_WAITING_SPRITE: SpriteData = (() => { + const B = '#555566' // border + const F = '#EEEEFF' // fill + const G = '#44BB66' // green check + return [ + [_, B, B, B, B, B, B, B, B, B, _], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, F, F, F, F, F, G, F, B], + [B, F, F, F, F, F, F, G, F, F, B], + [B, F, F, G, F, F, G, F, F, F, B], + [B, F, F, F, G, G, F, F, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [B, F, F, F, F, F, F, F, F, F, B], + [_, B, B, B, B, B, B, B, B, B, _], + [_, _, _, _, B, B, B, _, _, _, _], + [_, _, _, _, _, B, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _], + ] +})() + +// โ”€โ”€ Character Sprites โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// 16x24 characters with palette substitution + +/** Palette colors for 6 distinct agent characters */ +export const CHARACTER_PALETTES = [ + { skin: '#FFCC99', shirt: '#4488CC', pants: '#334466', hair: '#553322', shoes: '#222222' }, + { skin: '#FFCC99', shirt: '#CC4444', pants: '#333333', hair: '#FFD700', shoes: '#222222' }, + { skin: '#DEB887', shirt: '#44AA66', pants: '#334444', hair: '#222222', shoes: '#333333' }, + { skin: '#FFCC99', shirt: '#AA55CC', pants: '#443355', hair: '#AA4422', shoes: '#222222' }, + { skin: '#DEB887', shirt: '#CCAA33', pants: '#444433', hair: '#553322', shoes: '#333333' }, + { skin: '#FFCC99', shirt: '#FF8844', pants: '#443322', hair: '#111111', shoes: '#222222' }, +] as const + +interface CharPalette { + skin: string + shirt: string + pants: string + hair: string + shoes: string +} + +// Template keys for character pixel data +const H = 'hair' +const K = 'skin' +const S = 'shirt' +const P = 'pants' +const O = 'shoes' +const E = '#FFFFFF' // eyes + +type TemplateCell = typeof H | typeof K | typeof S | typeof P | typeof O | typeof E | typeof _ + +/** Resolve a template to SpriteData using a palette */ +function resolveTemplate(template: TemplateCell[][], palette: CharPalette): SpriteData { + return template.map((row) => + row.map((cell) => { + if (cell === _) return '' + if (cell === E) return E + if (cell === H) return palette.hair + if (cell === K) return palette.skin + if (cell === S) return palette.shirt + if (cell === P) return palette.pants + if (cell === O) return palette.shoes + return cell + }), + ) +} + +/** Flip a template horizontally (for generating left sprites from right) */ +function flipHorizontal(template: TemplateCell[][]): TemplateCell[][] { + return template.map((row) => [...row].reverse()) +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// DOWN-FACING SPRITES +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +// Walk down: 4 frames (1, 2=standing, 3=mirror legs, 2 again) +const CHAR_WALK_DOWN_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, E, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, P, P, _, _, _, _, P, P, _, _, _, _], + [_, _, _, _, P, P, _, _, _, _, P, P, _, _, _, _], + [_, _, _, _, O, O, _, _, _, _, _, O, O, _, _, _], + [_, _, _, _, O, O, _, _, _, _, _, O, O, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_WALK_DOWN_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, E, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_WALK_DOWN_3: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, E, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, O, O, _, _, _, _, _, _, P, P, _, _, _], + [_, _, _, O, O, _, _, _, _, _, _, P, P, _, _, _], + [_, _, _, _, _, _, _, _, _, _, O, O, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, O, O, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// Down typing: front-facing sitting, arms on keyboard +const CHAR_DOWN_TYPE_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, E, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, K, K, S, S, S, S, S, S, K, K, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_DOWN_TYPE_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, E, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, K, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, _, K, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// Down reading: front-facing sitting, arms at sides, looking at screen +const CHAR_DOWN_READ_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, E, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_DOWN_READ_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, E, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// UP-FACING SPRITES (back of head, no face) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +// Walk up: back view, legs alternate +const CHAR_WALK_UP_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, P, P, _, _, _, _, P, P, _, _, _, _], + [_, _, _, _, P, P, _, _, _, _, P, P, _, _, _, _], + [_, _, _, O, O, _, _, _, _, _, _, O, O, _, _, _], + [_, _, _, O, O, _, _, _, _, _, _, O, O, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_WALK_UP_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_WALK_UP_3: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, O, O, _, _, _, _, _, _, P, P, _, _, _], + [_, _, _, O, O, _, _, _, _, _, _, P, P, _, _, _], + [_, _, _, _, _, _, _, _, _, _, O, O, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, O, O, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// Up typing: back view, arms out to keyboard +const CHAR_UP_TYPE_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, K, K, S, S, S, S, S, S, K, K, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_UP_TYPE_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, K, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, _, K, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// Up reading: back view, arms at sides +const CHAR_UP_READ_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_UP_READ_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, _, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, H, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, K, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, S, S, S, S, S, S, S, S, _, _, _, _], + [_, _, _, _, K, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, P, P, P, P, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// RIGHT-FACING SPRITES (side profile, one eye visible) +// Left sprites are generated by flipHorizontal() +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +// Right walk: side view, legs step +const CHAR_WALK_RIGHT_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, _, _, _, _, _, _], + [_, _, _, _, _, _, _, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, K, S, S, S, S, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, _, P, P, _, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, P, P, _, _, _, P, P, _, _, _, _], + [_, _, _, _, _, P, P, _, _, _, P, P, _, _, _, _], + [_, _, _, _, _, O, O, _, _, _, _, O, O, _, _, _], + [_, _, _, _, _, O, O, _, _, _, _, O, O, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_WALK_RIGHT_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, _, _, _, _, _, _], + [_, _, _, _, _, _, _, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, K, S, S, S, S, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, _, P, P, _, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, _, P, P, _, _, _, _, _], + [_, _, _, _, _, _, P, P, _, P, P, _, _, _, _, _], + [_, _, _, _, _, _, P, P, _, P, P, _, _, _, _, _], + [_, _, _, _, _, _, O, O, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, O, O, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_WALK_RIGHT_3: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, _, _, _, _, _, _], + [_, _, _, _, _, _, _, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, K, S, S, S, S, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, _, P, P, _, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, P, P, P, _, _, _, _, _], + [_, _, _, _, _, _, _, _, P, P, P, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, O, O, _, _, _, _, _], + [_, _, _, _, _, O, O, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// Right typing: side profile sitting, one arm on keyboard +const CHAR_RIGHT_TYPE_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, _, _, _, _, _, _], + [_, _, _, _, _, _, _, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, K, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, _, P, P, _, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, _, P, P, _, _, _, _, _], + [_, _, _, _, _, _, O, O, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_RIGHT_TYPE_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, _, _, _, _, _, _], + [_, _, _, _, _, _, _, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, K, _, _, _], + [_, _, _, _, _, S, S, S, S, S, _, _, K, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, _, P, P, _, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, _, P, P, _, _, _, _, _], + [_, _, _, _, _, _, O, O, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// Right reading: side sitting, arms at side +const CHAR_RIGHT_READ_1: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, _, _, _, _, _, _], + [_, _, _, _, _, _, _, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, K, S, S, S, S, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, _, P, P, _, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, _, P, P, _, _, _, _, _], + [_, _, _, _, _, _, O, O, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +const CHAR_RIGHT_READ_2: TemplateCell[][] = [ + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, H, H, H, H, H, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, E, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, K, _, _, _, _, _], + [_, _, _, _, _, _, K, K, K, K, _, _, _, _, _, _], + [_, _, _, _, _, _, _, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, S, S, S, S, S, S, _, _, _, _, _], + [_, _, _, _, _, K, S, S, S, S, K, _, _, _, _, _], + [_, _, _, _, _, _, S, S, S, S, _, _, _, _, _, _], + [_, _, _, _, _, _, _, P, P, _, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, P, P, _, _, _, _, _, _], + [_, _, _, _, _, _, P, P, _, P, P, _, _, _, _, _], + [_, _, _, _, _, _, O, O, _, O, O, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], + [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], +] + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Template export (for export-characters script) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +/** All character templates grouped by direction, for use by the export script. + * Frame order per direction: walk1, walk2, walk3, type1, type2, read1, read2 */ +export const CHARACTER_TEMPLATES = { + down: [ + CHAR_WALK_DOWN_1, CHAR_WALK_DOWN_2, CHAR_WALK_DOWN_3, + CHAR_DOWN_TYPE_1, CHAR_DOWN_TYPE_2, + CHAR_DOWN_READ_1, CHAR_DOWN_READ_2, + ], + up: [ + CHAR_WALK_UP_1, CHAR_WALK_UP_2, CHAR_WALK_UP_3, + CHAR_UP_TYPE_1, CHAR_UP_TYPE_2, + CHAR_UP_READ_1, CHAR_UP_READ_2, + ], + right: [ + CHAR_WALK_RIGHT_1, CHAR_WALK_RIGHT_2, CHAR_WALK_RIGHT_3, + CHAR_RIGHT_TYPE_1, CHAR_RIGHT_TYPE_2, + CHAR_RIGHT_READ_1, CHAR_RIGHT_READ_2, + ], +} as const + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Loaded character sprites (from PNG assets) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +interface LoadedCharacterData { + down: SpriteData[] + up: SpriteData[] + right: SpriteData[] +} + +let loadedCharacters: LoadedCharacterData[] | null = null + +/** Set pre-colored character sprites loaded from PNG assets. Call this when characterSpritesLoaded message arrives. */ +export function setCharacterTemplates(data: LoadedCharacterData[]): void { + loadedCharacters = data + // Clear cache so sprites are rebuilt from loaded data + spriteCache.clear() +} + +/** Flip a SpriteData horizontally (for generating left sprites from right) */ +function flipSpriteHorizontal(sprite: SpriteData): SpriteData { + return sprite.map((row) => [...row].reverse()) +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Sprite resolution + caching +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +export interface CharacterSprites { + walk: Record + typing: Record + reading: Record +} + +const spriteCache = new Map() + +/** Apply hue shift to every sprite in a CharacterSprites set */ +function hueShiftSprites(sprites: CharacterSprites, hueShift: number): CharacterSprites { + const color: FloorColor = { h: hueShift, s: 0, b: 0, c: 0 } + const shift = (s: SpriteData) => adjustSprite(s, color) + const shiftWalk = (arr: [SpriteData, SpriteData, SpriteData, SpriteData]): [SpriteData, SpriteData, SpriteData, SpriteData] => + [shift(arr[0]), shift(arr[1]), shift(arr[2]), shift(arr[3])] + const shiftPair = (arr: [SpriteData, SpriteData]): [SpriteData, SpriteData] => + [shift(arr[0]), shift(arr[1])] + return { + walk: { + [Dir.DOWN]: shiftWalk(sprites.walk[Dir.DOWN]), + [Dir.UP]: shiftWalk(sprites.walk[Dir.UP]), + [Dir.RIGHT]: shiftWalk(sprites.walk[Dir.RIGHT]), + [Dir.LEFT]: shiftWalk(sprites.walk[Dir.LEFT]), + } as Record, + typing: { + [Dir.DOWN]: shiftPair(sprites.typing[Dir.DOWN]), + [Dir.UP]: shiftPair(sprites.typing[Dir.UP]), + [Dir.RIGHT]: shiftPair(sprites.typing[Dir.RIGHT]), + [Dir.LEFT]: shiftPair(sprites.typing[Dir.LEFT]), + } as Record, + reading: { + [Dir.DOWN]: shiftPair(sprites.reading[Dir.DOWN]), + [Dir.UP]: shiftPair(sprites.reading[Dir.UP]), + [Dir.RIGHT]: shiftPair(sprites.reading[Dir.RIGHT]), + [Dir.LEFT]: shiftPair(sprites.reading[Dir.LEFT]), + } as Record, + } +} + +export function getCharacterSprites(paletteIndex: number, hueShift = 0): CharacterSprites { + const cacheKey = `${paletteIndex}:${hueShift}` + const cached = spriteCache.get(cacheKey) + if (cached) return cached + + let sprites: CharacterSprites + + if (loadedCharacters) { + // Use pre-colored character sprites directly (no palette swapping) + const char = loadedCharacters[paletteIndex % loadedCharacters.length] + const d = char.down + const u = char.up + const rt = char.right + const flip = flipSpriteHorizontal + + sprites = { + walk: { + [Dir.DOWN]: [d[0], d[1], d[2], d[1]], + [Dir.UP]: [u[0], u[1], u[2], u[1]], + [Dir.RIGHT]: [rt[0], rt[1], rt[2], rt[1]], + [Dir.LEFT]: [flip(rt[0]), flip(rt[1]), flip(rt[2]), flip(rt[1])], + }, + typing: { + [Dir.DOWN]: [d[3], d[4]], + [Dir.UP]: [u[3], u[4]], + [Dir.RIGHT]: [rt[3], rt[4]], + [Dir.LEFT]: [flip(rt[3]), flip(rt[4])], + }, + reading: { + [Dir.DOWN]: [d[5], d[6]], + [Dir.UP]: [u[5], u[6]], + [Dir.RIGHT]: [rt[5], rt[6]], + [Dir.LEFT]: [flip(rt[5]), flip(rt[6])], + }, + } + } else { + // Fallback: use hardcoded templates with palette swapping + const pal = CHARACTER_PALETTES[paletteIndex % CHARACTER_PALETTES.length] + const r = (t: TemplateCell[][]) => resolveTemplate(t, pal) + const rf = (t: TemplateCell[][]) => resolveTemplate(flipHorizontal(t), pal) + + sprites = { + walk: { + [Dir.DOWN]: [r(CHAR_WALK_DOWN_1), r(CHAR_WALK_DOWN_2), r(CHAR_WALK_DOWN_3), r(CHAR_WALK_DOWN_2)], + [Dir.UP]: [r(CHAR_WALK_UP_1), r(CHAR_WALK_UP_2), r(CHAR_WALK_UP_3), r(CHAR_WALK_UP_2)], + [Dir.RIGHT]: [r(CHAR_WALK_RIGHT_1), r(CHAR_WALK_RIGHT_2), r(CHAR_WALK_RIGHT_3), r(CHAR_WALK_RIGHT_2)], + [Dir.LEFT]: [rf(CHAR_WALK_RIGHT_1), rf(CHAR_WALK_RIGHT_2), rf(CHAR_WALK_RIGHT_3), rf(CHAR_WALK_RIGHT_2)], + }, + typing: { + [Dir.DOWN]: [r(CHAR_DOWN_TYPE_1), r(CHAR_DOWN_TYPE_2)], + [Dir.UP]: [r(CHAR_UP_TYPE_1), r(CHAR_UP_TYPE_2)], + [Dir.RIGHT]: [r(CHAR_RIGHT_TYPE_1), r(CHAR_RIGHT_TYPE_2)], + [Dir.LEFT]: [rf(CHAR_RIGHT_TYPE_1), rf(CHAR_RIGHT_TYPE_2)], + }, + reading: { + [Dir.DOWN]: [r(CHAR_DOWN_READ_1), r(CHAR_DOWN_READ_2)], + [Dir.UP]: [r(CHAR_UP_READ_1), r(CHAR_UP_READ_2)], + [Dir.RIGHT]: [r(CHAR_RIGHT_READ_1), r(CHAR_RIGHT_READ_2)], + [Dir.LEFT]: [rf(CHAR_RIGHT_READ_1), rf(CHAR_RIGHT_READ_2)], + }, + } + } + + // Apply hue shift if non-zero + if (hueShift !== 0) { + sprites = hueShiftSprites(sprites, hueShift) + } + + spriteCache.set(cacheKey, sprites) + return sprites +} diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts new file mode 100644 index 0000000..428bde6 --- /dev/null +++ b/lib/pixel-office/types.ts @@ -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 +} + +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[] +} diff --git a/lib/pixel-office/wallTiles.ts b/lib/pixel-office/wallTiles.ts new file mode 100644 index 0000000..e8ec8b1 --- /dev/null +++ b/lib/pixel-office/wallTiles.ts @@ -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, + 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')}` +}