diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index a97b4ca..8ac2426 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -3,9 +3,17 @@ import { promises as fs, existsSync } from 'fs' import path from 'path' import os from 'os' +export const dynamic = 'force-dynamic' +export const revalidate = 0 +const SESSION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000 +const MAX_PARENT_SESSIONS_TO_PARSE = 40 +const ORPHAN_FALLBACK_WINDOW_MS = 15 * 60 * 1000 +const SUBAGENT_MAX_ACTIVE_MS = 30 * 60 * 1000 + export interface SubagentInfo { toolId: string label: string + sessionKey?: string } export interface AgentActivity { @@ -19,70 +27,147 @@ export interface AgentActivity { subagents?: SubagentInfo[] } -/** Parse the last N lines of the most recent session file for subtask patterns */ -async function parseSubagents(agentSessionsDir: string): Promise { +function isSubtaskDescription(desc: string): boolean { + const d = desc.toLowerCase() + return desc.startsWith('Subtask:') || desc.startsWith('子任务') || d.includes('subtask') +} + +function isSpawnTool(name: string): boolean { + return name === 'sessions_spawn' || name === 'session_spawn' +} + +function pickSubagentLabel(raw: unknown): string { + if (!raw || typeof raw !== 'object') return 'Subtask' + const args = raw as Record + if (typeof args.label === 'string' && args.label.trim()) return args.label.trim() + if (typeof args.task === 'string' && args.task.trim()) return args.task.trim() + if (typeof args.description === 'string' && args.description.trim()) return args.description.trim() + return 'Subtask' +} + +function extractCompletedSubagentLabel(text: string): string | null { + if (!text) return null + const patterns = [ + /A subagent task\s+"([^"]+)"\s+just completed/i, + /A subagent task\s+'([^']+)'\s+just completed/i, + /subagent task\s+"([^"]+)"\s+.*completed/i, + /subagent task\s+'([^']+)'\s+.*completed/i, + /子任务[“"]([^”"]+)[”"].{0,12}完成/, + ] + for (const p of patterns) { + const m = text.match(p) + if (m?.[1]?.trim()) return m[1].trim() + } + return null +} + +function parseRecordTimestamp(record: unknown): number { + if (!record || typeof record !== 'object') return 0 + const rec = record as Record + if (typeof rec.timestamp === 'string') { + const t = Date.parse(rec.timestamp) + if (Number.isFinite(t)) return t + } + if (typeof rec.timestamp === 'number' && Number.isFinite(rec.timestamp)) return rec.timestamp + const msg = rec.message + if (msg && typeof msg === 'object') { + const m = msg as Record + if (typeof m.timestamp === 'string') { + const t = Date.parse(m.timestamp) + if (Number.isFinite(t)) return t + } + if (typeof m.timestamp === 'number' && Number.isFinite(m.timestamp)) return m.timestamp + } + return 0 +} + +async function parseSubagentsFromSessionFile(filePath: string, sessionKey: 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 content = await fs.readFile(filePath, 'utf8') const lines = content.split('\n').filter(l => l.trim()) - // Look for active subtask tool_use entries - const activeSubtasks = new Map() + const activeSubtasks = new Map() + const spawnToolIds = new Set() + for (const line of lines) { try { const record = JSON.parse(line) + const eventAt = parseRecordTimestamp(record) + + // Legacy format 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) - } + if (block.type !== 'tool_use' || typeof block.id !== 'string' || !block.id) continue + if (typeof block.name === 'string' && isSpawnTool(block.name)) { + activeSubtasks.set(block.id, { label: pickSubagentLabel(block.input), at: eventAt }) + spawnToolIds.add(block.id) + continue + } + if (typeof block.input?.description === 'string' && isSubtaskDescription(block.input.description)) { + activeSubtasks.set(block.id, { label: block.input.description, at: eventAt }) } } } - // 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) { + if (block.type === 'tool_result' && block.tool_use_id && !spawnToolIds.has(block.tool_use_id)) { activeSubtasks.delete(block.tool_use_id) } } } + + // New format + if (record.type === 'message' && record.message) { + const msg = record.message + const role = typeof msg.role === 'string' ? msg.role : '' + const blocks = Array.isArray(msg.content) ? msg.content : [] + if (role === 'assistant') { + for (const block of blocks) { + if (block?.type === 'toolCall' && typeof block.id === 'string' && block.id) { + if (typeof block.name === 'string' && isSpawnTool(block.name)) { + activeSubtasks.set(block.id, { label: pickSubagentLabel(block.arguments), at: eventAt }) + spawnToolIds.add(block.id) + } else if (typeof block.arguments?.description === 'string' && isSubtaskDescription(block.arguments.description)) { + activeSubtasks.set(block.id, { label: block.arguments.description, at: eventAt }) + } + } else if (block?.type === 'tool_use' && typeof block.id === 'string' && typeof block.input?.description === 'string') { + if (isSubtaskDescription(block.input.description)) activeSubtasks.set(block.id, { label: block.input.description, at: eventAt }) + } + } + } else if (role === 'toolResult') { + const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : '' + const toolName = typeof msg.toolName === 'string' ? msg.toolName : '' + if (toolCallId && !isSpawnTool(toolName) && !spawnToolIds.has(toolCallId)) { + activeSubtasks.delete(toolCallId) + } + } else if (role === 'user') { + const text = blocks + .map((b: { type?: string; text?: string }) => (b?.type === 'text' && typeof b.text === 'string') ? b.text : '') + .join('\n') + const completedLabel = extractCompletedSubagentLabel(text) + if (completedLabel) { + for (const [id, state] of activeSubtasks.entries()) { + if (state.label === completedLabel || state.label.includes(completedLabel) || completedLabel.includes(state.label)) { + activeSubtasks.delete(id) + break + } + } + } + } + } } catch { - // Skip unparseable lines + // Skip bad line } } - for (const [toolId, label] of activeSubtasks) { - subagents.push({ toolId, label }) + const now = Date.now() + for (const [toolId, state] of activeSubtasks.entries()) { + if (state.at > 0 && now - state.at > SUBAGENT_MAX_ACTIVE_MS) continue + const label = state.label + subagents.push({ toolId, label, sessionKey }) } } catch { // Ignore parse errors @@ -90,6 +175,89 @@ async function parseSubagents(agentSessionsDir: string): Promise return subagents } +/** Parse subagents from all parent sessions (main/direct/group/openai/cron etc.), grouped by session */ +async function parseSubagents(agentSessionsDir: string, agentId: string): Promise { + const allSubagents: SubagentInfo[] = [] + try { + const cutoff = Date.now() - SESSION_LOOKBACK_MS + const sessionFiles: Array<{ sessionKey: string; filePath: string; updatedAt: number }> = [] + const knownFilePaths = new Set() + const subagentSessionIds = new Set() + const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json') + if (existsSync(sessionsIndexPath)) { + try { + const sessionsIndexRaw = await fs.readFile(sessionsIndexPath, 'utf8') + const sessionsIndex = JSON.parse(sessionsIndexRaw) as Record + for (const [sessionKey, meta] of Object.entries(sessionsIndex)) { + if (!meta || typeof meta.sessionId !== 'string' || !meta.sessionId) continue + if (sessionKey.includes(':subagent:')) { + subagentSessionIds.add(meta.sessionId) + continue + } + const filePath = path.join(agentSessionsDir, `${meta.sessionId}.jsonl`) + if (!existsSync(filePath)) continue + let updatedAt = 0 + if (typeof meta.updatedAt === 'number' && meta.updatedAt > 0) { + updatedAt = meta.updatedAt + } else { + try { + const stat = await fs.stat(filePath) + updatedAt = stat.mtimeMs + } catch { + updatedAt = 0 + } + } + if (updatedAt > 0 && updatedAt < cutoff) continue + sessionFiles.push({ sessionKey, filePath, updatedAt }) + knownFilePaths.add(filePath) + } + } catch { + // Ignore index parse errors + } + } + + // Fallback: include recent parent session files that are missing in sessions.json mapping. + try { + const orphanCutoff = Date.now() - ORPHAN_FALLBACK_WINDOW_MS + const files = await fs.readdir(agentSessionsDir) + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + if (file.startsWith('probe-')) continue + const filePath = path.join(agentSessionsDir, file) + if (knownFilePaths.has(filePath)) continue + const sessionId = file.slice(0, -'.jsonl'.length) + if (subagentSessionIds.has(sessionId)) continue + const stat = await fs.stat(filePath) + if (stat.mtimeMs < orphanCutoff) continue + if (stat.mtimeMs < cutoff) continue + sessionFiles.push({ + sessionKey: `agent:${agentId}:orphan:${sessionId}`, + filePath, + updatedAt: stat.mtimeMs, + }) + } + } catch { + // Ignore fallback scan errors + } + + sessionFiles.sort((a, b) => b.updatedAt - a.updatedAt) + const candidates = sessionFiles.slice(0, MAX_PARENT_SESSIONS_TO_PARSE) + const nested = await Promise.all(candidates.map((s) => parseSubagentsFromSessionFile(s.filePath, s.sessionKey))) + const dedupe = new Set() + for (const list of nested) { + for (const sub of list) { + const key = `${sub.sessionKey || ''}::${sub.toolId}` + if (dedupe.has(key)) continue + dedupe.add(key) + allSubagents.push(sub) + } + } + } catch { + // Ignore parse errors + } + return allSubagents +} + export async function GET() { const openclawDir = path.join(os.homedir(), '.openclaw') const configPath = path.join(openclawDir, 'openclaw.json') @@ -138,10 +306,10 @@ export async function GET() { state = 'idle' } - // Parse subagents for working agents + // Parse subagents for online agents let subagents: SubagentInfo[] | undefined - if (state === 'working' && agentSessionsDir && existsSync(agentSessionsDir)) { - subagents = await parseSubagents(agentSessionsDir) + if (state !== 'offline' && agentSessionsDir && existsSync(agentSessionsDir)) { + subagents = await parseSubagents(agentSessionsDir, agent.id) if (subagents.length === 0) subagents = undefined } @@ -160,5 +328,8 @@ export async function GET() { console.error('Error reading agent activity:', error) } - return NextResponse.json({ agents }) + return NextResponse.json( + { agents }, + { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0' } }, + ) } diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index c65060f..b1ca16b 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState, useCallback } from 'react' +import { useEffect, useRef, useState, useCallback, useMemo } from 'react' import { OfficeState } from '@/lib/pixel-office/engine/officeState' import { renderFrame } from '@/lib/pixel-office/engine/renderer' import { buildGatewayUrl } from "@/lib/gateway-url" @@ -45,6 +45,16 @@ type ReleaseInfo = { htmlUrl: string } +type AgentStats = { + sessionCount: number + messageCount: number + totalTokens: number + todayAvgResponseMs: number + weeklyResponseMs: number[] + weeklyTokens: number[] + lastActive: number | null +} + function MiniSparkline({ data, width = 120, height = 24, color: fixedColor }: { data: number[]; width?: number; height?: number; color?: string }) { const hasData = data.some(v => v > 0) if (!hasData) return null @@ -162,6 +172,7 @@ const MOBILE_VIEW_NUDGE_Y_PX = -10 const CODE_SNIPPET_LIFETIME_SEC = 5.5 const FLOATING_TICK_INTERVAL_DESKTOP_MS = 48 const FLOATING_TICK_INTERVAL_MOBILE_MS = 32 +const AGENT_ACTIVITY_POLL_INTERVAL_MS = 1000 let cachedOfficeState: OfficeState | null = null let cachedEditorState: EditorState | null = null @@ -191,7 +202,7 @@ export default function PixelOfficePage() { const [agents, setAgents] = useState(cachedAgents) const [hoveredAgentId, setHoveredAgentId] = useState(null) const mousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) - const agentStatsRef = useRef>(new Map()) + const agentStatsRef = useRef>(new Map()) const contributionsRef = useRef(null) const photographRef = useRef(null) const gatewayRef = useRef<{ port: number; token?: string; host?: string }>({ port: 18789 }) @@ -556,7 +567,7 @@ export default function PixelOfficePage() { } const fetchAgents = async () => { try { - const res = await fetch('/api/agent-activity') + const res = await fetch('/api/agent-activity', { cache: 'no-store' }) const data = await res.json() const newAgents: AgentActivity[] = data.agents || [] setAgents(newAgents) @@ -596,7 +607,7 @@ export default function PixelOfficePage() { } } fetchAgents() - const interval = setInterval(fetchAgents, 10000) + const interval = setInterval(fetchAgents, AGENT_ACTIVITY_POLL_INTERVAL_MS) return () => clearInterval(interval) }, []) @@ -1230,10 +1241,27 @@ export default function PixelOfficePage() { for (const [aid, cid] of map.entries()) { if (cid === hoveredAgentId) { agentId = aid; break } } - if (!agentId) return null - const agent = agents.find(a => a.agentId === agentId) - const stats = agentStatsRef.current.get(agentId) - return { agent, stats } + if (agentId) { + const agent = agents.find(a => a.agentId === agentId) + const stats = agentStatsRef.current.get(agentId) + return { agent, stats, isSubagent: false as const, parentAgentId: null as string | null } + } + + const hoveredCharacter = officeRef.current?.characters.get(hoveredAgentId) + if (!hoveredCharacter?.isSubagent || hoveredCharacter.parentAgentId == null) return null + let parentAgentId: string | null = null + for (const [aid, cid] of map.entries()) { + if (cid === hoveredCharacter.parentAgentId) { parentAgentId = aid; break } + } + if (!parentAgentId) return null + const parentAgent = agents.find(a => a.agentId === parentAgentId) + if (!parentAgent) return null + return { + agent: parentAgent, + stats: agentStatsRef.current.get(parentAgentId), + isSubagent: true as const, + parentAgentId, + } }, [hoveredAgentId, agents]) const hoveredInfo = getHoveredAgentInfo() @@ -1248,28 +1276,76 @@ export default function PixelOfficePage() { isMobileViewport ? `w-full ${maxHeight} overflow-y-auto rounded-t-2xl border-x border-t border-[var(--border)] bg-[var(--card)] shadow-2xl p-4 pb-6` : `${desktopWidth} ${maxHeight} overflow-y-auto rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4` + const displayAgents = useMemo(() => { + const expanded: AgentActivity[] = [] + for (const agent of agents) { + expanded.push(agent) + if (!agent.subagents?.length) continue + for (const sub of agent.subagents) { + const subKey = sub.sessionKey ? `${sub.sessionKey}::${sub.toolId}` : sub.toolId + expanded.push({ + agentId: `subagent:${agent.agentId}:${subKey}`, + name: `临时工 ${agent.agentId}`, + emoji: agent.emoji, + state: 'working', + lastActive: agent.lastActive, + }) + } + } + return expanded + }, [agents]) + const mobileAgentPages: AgentActivity[][] = [] - for (let i = 0; i < agents.length; i += 9) { - mobileAgentPages.push(agents.slice(i, i + 9)) + for (let i = 0; i < displayAgents.length; i += 9) { + mobileAgentPages.push(displayAgents.slice(i, i + 9)) + } + const renderAgentChip = (agent: AgentActivity, mobileGrid = false) => { + const isTempWorker = agent.agentId.startsWith('subagent:') + const parentAgentIdFromKey = isTempWorker ? (agent.agentId.split(':')[1] || '') : '' + const tempWorkerOwner = isTempWorker ? (agent.name.replace(/^临时工\s*/, '') || parentAgentIdFromKey) : '' + const chipTooltip = isTempWorker + ? `${tempWorkerOwner} agent创建的subagent` + : `agent id:${agent.agentId}` + const chipToneClass = isTempWorker + ? 'bg-red-900/45 border-red-700/80 text-red-100 animate-pulse' + : ( + agent.state === 'working' ? `pixel-agent-chip-working${isMobileViewport ? '' : ' animate-pulse'}` : + agent.state === 'idle' ? `pixel-agent-chip-idle${isMobileViewport ? '' : ' animate-pulse'}` : + 'pixel-agent-chip-neutral' + ) + return ( +
+
+ {agent.emoji} + {isTempWorker ? ( + + 临时工 + {tempWorkerOwner} + + ) : ( + {agent.name} + )} + {agent.state === 'working' && {t('pixelOffice.state.working')}} + {agent.state === 'idle' && {t('pixelOffice.state.idle')}} + {agent.state === 'offline' && {t('pixelOffice.state.offline')}} + {agent.state === 'waiting' && {t('pixelOffice.state.waiting')}} +
+ {!isMobileViewport && ( +
+ {chipTooltip} +
+ )} +
+ ) } - const renderAgentChip = (agent: AgentActivity, mobileGrid = false) => ( -
- {agent.emoji} - {agent.name} - {agent.state === 'working' && {t('pixelOffice.state.working')}} - {agent.state === 'idle' && {t('pixelOffice.state.idle')}} - {agent.state === 'offline' && {t('pixelOffice.state.offline')}} - {agent.state === 'waiting' && {t('pixelOffice.state.waiting')}} -
- ) return (
@@ -1329,7 +1405,7 @@ export default function PixelOfficePage() {
- {agents.length === 0 ? ( + {displayAgents.length === 0 ? (
{t('common.noData')}
) : (
@@ -1345,8 +1421,8 @@ export default function PixelOfficePage() { )}
- {agents.map((agent) => renderAgentChip(agent))} - {agents.length === 0 && ( + {displayAgents.map((agent) => renderAgentChip(agent))} + {displayAgents.length === 0 && (
{t('common.noData')}
)}
@@ -1401,14 +1477,20 @@ export default function PixelOfficePage() { style={{ left: Math.min(mousePosRef.current.x + 12, (containerRef.current?.clientWidth || 300) - 180), top: mousePosRef.current.y + 12 }}>
{hoveredInfo.agent.emoji} - {hoveredInfo.agent.name} -
-
-
{t('agent.sessionCount')}{hoveredInfo.stats?.sessionCount ?? '--'}
-
{t('agent.messageCount')}{hoveredInfo.stats?.messageCount ?? '--'}
-
{t('agent.tokenUsage')}{hoveredInfo.stats ? formatTokens(hoveredInfo.stats.totalTokens) : '--'}
-
{t('agent.todayAvgResponse')}{hoveredInfo.stats?.todayAvgResponseMs ? `${(hoveredInfo.stats.todayAvgResponseMs / 1000).toFixed(1)}s` : '--'}
+ {hoveredInfo.isSubagent ? '临时工' : hoveredInfo.agent.name}
+ {hoveredInfo.isSubagent ? ( +
+ {(hoveredInfo.parentAgentId || 'unknown')} agent创建的subagent +
+ ) : ( +
+
{t('agent.sessionCount')}{hoveredInfo.stats?.sessionCount ?? '--'}
+
{t('agent.messageCount')}{hoveredInfo.stats?.messageCount ?? '--'}
+
{t('agent.tokenUsage')}{hoveredInfo.stats ? formatTokens(hoveredInfo.stats.totalTokens) : '--'}
+
{t('agent.todayAvgResponse')}{hoveredInfo.stats?.todayAvgResponseMs ? `${(hoveredInfo.stats.todayAvgResponseMs / 1000).toFixed(1)}s` : '--'}
+
+ )}
)} diff --git a/lib/pixel-office/agentBridge.ts b/lib/pixel-office/agentBridge.ts index ac5eec4..0a715ef 100644 --- a/lib/pixel-office/agentBridge.ts +++ b/lib/pixel-office/agentBridge.ts @@ -3,6 +3,7 @@ import { OfficeState } from './engine/officeState' export interface SubagentInfo { toolId: string label: string + sessionKey?: string } export interface AgentActivity { @@ -16,8 +17,9 @@ export interface AgentActivity { subagents?: SubagentInfo[] } -/** Track which subagent toolIds were active last sync, per parent agent */ +/** Track which subagent keys were active last sync, per parent agent */ const prevSubagentKeys = new Map>() +const TEMP_WORKER_LABEL = '临时工' /** Track previous agent states to detect offline→working transitions */ const prevAgentStates = new Map() @@ -88,11 +90,20 @@ export function syncAgentsToOffice( const currentSubKeys = new Set() if (activity.subagents) { for (const sub of activity.subagents) { - currentSubKeys.add(sub.toolId) - const existingSubId = office.getSubagentId(charId, sub.toolId) + const subKey = sub.sessionKey ? `${sub.sessionKey}::${sub.toolId}` : sub.toolId + currentSubKeys.add(subKey) + const existingSubId = office.getSubagentId(charId, subKey) if (existingSubId === null) { - const subId = office.addSubagent(charId, sub.toolId) + const subId = office.addSubagent(charId, subKey) office.setAgentActive(subId, true) + const subCh = office.characters.get(subId) + if (subCh) subCh.label = TEMP_WORKER_LABEL + } else { + const subCh = office.characters.get(existingSubId) + if (subCh) { + subCh.label = TEMP_WORKER_LABEL + office.setAgentActive(existingSubId, true) + } } } } @@ -100,9 +111,9 @@ export function syncAgentsToOffice( // 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) + for (const subKey of prevKeys) { + if (!currentSubKeys.has(subKey)) { + office.removeSubagent(charId, subKey) } } } diff --git a/lib/pixel-office/engine/characters.ts b/lib/pixel-office/engine/characters.ts index 645c1f1..21e4c60 100644 --- a/lib/pixel-office/engine/characters.ts +++ b/lib/pixel-office/engine/characters.ts @@ -78,6 +78,7 @@ export function createCharacter( hueShift, frame: 0, frameTimer: 0, + moveSpeedMultiplier: 1, wanderTimer: 0, wanderCount: 0, wanderLimit: randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX), @@ -310,7 +311,8 @@ export function updateCharacter( 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 speedMultiplier = ch.moveSpeedMultiplier > 0 ? ch.moveSpeedMultiplier : 1 + ch.moveProgress += ((WALK_SPEED_PX_PER_SEC * speedMultiplier) / TILE_SIZE) * dt const fromCenter = tileCenter(ch.tileCol, ch.tileRow) const toCenter = tileCenter(nextTile.col, nextTile.row) diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 1b6100e..02c27fa 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -88,6 +88,15 @@ const PHOTO_COMMENTS = [ '不愧是中国布列松', '这是决定性瞬间!!!', '这个复杂构图绝了!', ] +const TEMP_WORKER_LABEL = '临时工' +const SUBAGENT_PRIORITY_SEAT_IDS = [ + 'stool-r1', 'stool-r2', 'stool-r3', 'stool-r4', + 'stool-r5', 'stool-r6', 'stool-r7', 'stool-r8', +] as const +const SUBAGENT_SPAWN_CENTER_COL = 10 +const SUBAGENT_SPAWN_CENTER_ROW = 14 +const SUBAGENT_RUN_SPEED_MULTIPLIER = 2.8 + export class OfficeState { layout: OfficeLayout tileMap: TileTypeVal[][] @@ -246,6 +255,25 @@ export class OfficeState { return null } + private getSubagentSpawnCandidates(): Array<{ col: number; row: number }> { + if (this.walkableTiles.length === 0) return [{ col: 1, row: 1 }] + const preferred = this.walkableTiles.filter((t) => t.row >= 11) + const candidates = preferred.length > 0 ? preferred : this.walkableTiles + const occupied = new Set() + for (const ch of this.characters.values()) { + occupied.add(`${ch.tileCol},${ch.tileRow}`) + } + const free = candidates.filter((t) => !occupied.has(`${t.col},${t.row}`)) + const source = free.length > 0 ? free : candidates + return source + .slice() + .sort((a, b) => { + const da = Math.abs(a.col - SUBAGENT_SPAWN_CENTER_COL) + Math.abs(a.row - SUBAGENT_SPAWN_CENTER_ROW) + const db = Math.abs(b.col - SUBAGENT_SPAWN_CENTER_COL) + Math.abs(b.row - SUBAGENT_SPAWN_CENTER_ROW) + return da - db + }) + } + /** * 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 @@ -667,13 +695,22 @@ export class OfficeState { 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 + for (const seatId of SUBAGENT_PRIORITY_SEAT_IDS) { + const seat = this.seats.get(seatId) + if (seat && !seat.assigned) { + bestSeatId = seatId + break + } + } + if (!bestSeatId) { + 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 + } } } } @@ -682,7 +719,35 @@ export class OfficeState { if (bestSeatId) { const seat = this.seats.get(bestSeatId)! seat.assigned = true - ch = createCharacter(id, palette, bestSeatId, seat, hueShift) + ch = createCharacter(id, palette, bestSeatId, null, hueShift) + ch.moveSpeedMultiplier = SUBAGENT_RUN_SPEED_MULTIPLIER + const targetCol = Math.round(seat.seatCol) + const targetRow = Math.round(seat.seatRow) + const spawnCandidates = this.getSubagentSpawnCandidates() + let selectedSpawn: { col: number; row: number } | null = null + let selectedPath: Array<{ col: number; row: number }> = [] + for (const candidate of spawnCandidates) { + const path = this.withOwnSeatUnblocked(ch, () => + findPath(candidate.col, candidate.row, targetCol, targetRow, this.tileMap, this.blockedTiles) + ) + if (path.length > 0) { + selectedSpawn = candidate + selectedPath = path + break + } + } + if (selectedSpawn && selectedPath.length > 0) { + ch.tileCol = selectedSpawn.col + ch.tileRow = selectedSpawn.row + ch.x = selectedSpawn.col * TILE_SIZE + TILE_SIZE / 2 + ch.y = selectedSpawn.row * TILE_SIZE + TILE_SIZE / 2 + ch.path = selectedPath + ch.state = CharacterState.WALK + ch.moveProgress = 0 + } else { + // Rare fallback: if no candidate has a path, keep old behavior and place at seat. + ch = createCharacter(id, palette, bestSeatId, seat, hueShift) + } } else { // No seats — spawn at closest walkable tile to parent let spawn = { col: 1, row: 1 } @@ -703,9 +768,11 @@ export class OfficeState { ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2 ch.tileCol = spawn.col ch.tileRow = spawn.row + ch.moveSpeedMultiplier = SUBAGENT_RUN_SPEED_MULTIPLIER } ch.isSubagent = true ch.parentAgentId = parentAgentId + ch.label = TEMP_WORKER_LABEL ch.matrixEffect = 'spawn' ch.matrixEffectTimer = 0 ch.matrixEffectSeeds = matrixEffectSeeds() diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index d9491fd..2676b2d 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -231,6 +231,11 @@ export function renderScene( photograph?: HTMLImageElement, ): void { const drawables: ZDrawable[] = [] + const laptopSizeScale = 0.7 + const laptopXTiltRad = (50 * Math.PI) / 180 + const laptopUpwardSpinRad = -Math.PI / 12 + const laptopTiltScaleY = Math.max(0.22, Math.abs(Math.cos(laptopXTiltRad))) + const laptopTiltSkewX = -Math.sin(laptopXTiltRad) * 0.35 // Wall decorations as z-sorted drawables (zY just above row 0 walls so they render on top of walls but below characters) const wallDecoZY = TILE_SIZE + 0.5 @@ -301,6 +306,52 @@ export function renderScene( for (const ch of characters) { const charZY = ch.y + TILE_SIZE / 2 + CHARACTER_Z_SORT_OFFSET + // Subagent temporary laptop: place it in front of the character using + // live world coordinates, so it stays aligned with seated offsets. + if (ch.isSubagent && ch.state === CharacterState.TYPE && ch.seatId) { + let dx = 0 + let dy = 0 + if (ch.dir === Direction.LEFT) dx = -1 + else if (ch.dir === Direction.RIGHT) dx = 1 + else if (ch.dir === Direction.UP) dy = -1 + else dy = 1 + + const forwardOffsetPx = TILE_SIZE * 0.62 + const sittingOffset = ch.state === CharacterState.TYPE ? CHARACTER_SITTING_OFFSET_PX : 0 + const laptopWorldX = ch.x + dx * forwardOffsetPx + const laptopWorldY = ch.y + sittingOffset + dy * forwardOffsetPx - 5 - TILE_SIZE / 8 + const laptopX = offsetX + laptopWorldX * zoom + const laptopY = offsetY + laptopWorldY * zoom + const laptopFacing = + ch.dir === Direction.LEFT ? Direction.RIGHT : + ch.dir === Direction.RIGHT ? Direction.LEFT : + ch.dir === Direction.UP ? Direction.DOWN : + Direction.UP + const laptopRotation = + laptopFacing === Direction.DOWN ? 0 : + laptopFacing === Direction.LEFT ? 90 : + laptopFacing === Direction.UP ? 180 : 270 + const laptopZY = laptopWorldY + TILE_SIZE * 0.45 + + drawables.push({ + zY: laptopZY, + draw: (c) => { + const emojiSize = TILE_SIZE * zoom * laptopSizeScale + c.save() + c.translate(Math.round(laptopX), Math.round(laptopY)) + c.rotate((laptopRotation * Math.PI) / 180) + // Composite transform: X-axis tilt + extra upward spin around laptop center. + c.rotate(laptopUpwardSpinRad) + c.transform(1, 0, laptopTiltSkewX, laptopTiltScaleY, 0, 0) + c.font = `${emojiSize}px serif` + c.textAlign = 'center' + c.textBaseline = 'middle' + c.fillText('💻', 0, 0) + c.restore() + }, + }) + } + if (ch.isLobster) { const lobsterX = Math.round(offsetX + ch.x * zoom) const lobsterY = Math.round(offsetY + ch.y * zoom + 2 * zoom) @@ -409,7 +460,9 @@ export function renderScene( const isWorking = ch.isActive && ch.state === CharacterState.TYPE // Blink effect for working state: use time-based alpha const labelAlpha = isWorking ? 0.7 + 0.3 * Math.sin(Date.now() / 300) : 1.0 - const labelColor = isWorking ? `rgba(34,197,94,${labelAlpha})` : '#FFD700' + const labelColor = ch.isSubagent + ? (isWorking ? `rgba(220,38,38,${labelAlpha})` : '#991B1B') + : (isWorking ? `rgba(34,197,94,${labelAlpha})` : '#FFD700') drawables.push({ zY: charZY + 0.1, draw: (c) => { diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index ec4923d..37c17b2 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -183,6 +183,7 @@ export interface Character { hueShift: number frame: number frameTimer: number + moveSpeedMultiplier: number wanderTimer: number wanderCount: number wanderLimit: number