mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-08-14 00:47:49 +00:00
2475 lines
93 KiB
TypeScript
2475 lines
93 KiB
TypeScript
import { TILE_SIZE, MATRIX_EFFECT_DURATION, CharacterState, Direction } from '../types'
|
|
import {
|
|
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 { FurnitureType } from '../types'
|
|
import { createCharacter, updateCharacter } from './characters'
|
|
import { CHARACTER_PALETTES, getAvailableCharacterVariantCount } from '../sprites/spriteData'
|
|
import { matrixEffectSeeds } from './matrixEffect'
|
|
import { isWalkable, getWalkableTiles, findPath } from '../layout/tileMap'
|
|
import {
|
|
createDefaultLayout,
|
|
layoutToTileMap,
|
|
layoutToFurnitureInstances,
|
|
layoutToSeats,
|
|
getBlockedTiles,
|
|
getInteractionPoints,
|
|
getDoorwayTiles,
|
|
} from '../layout/layoutSerializer'
|
|
import type { InteractionPoint } from '../layout/layoutSerializer'
|
|
import { getCatalogEntry, getOnStateType } from '../layout/furnitureCatalog'
|
|
import { BugSystem } from '../bugs/bugSystem'
|
|
import { BUG_DEFAULT_COUNT } from '../bugs/config'
|
|
import type { BugEntity } from '../bugs/types'
|
|
|
|
const CODE_SNIPPET_LIFETIME = 5.5 // seconds
|
|
const CODE_SNIPPET_SPAWN_RATE = 0.6 // per second
|
|
const SRE_BLACKWORD_SPAWN_RATE = 3.2 // per second
|
|
const PHOTO_COMMENT_LIFETIME = 4.0 // seconds
|
|
const PHOTO_COMMENT_SPAWN_RATE = 2.5 // per second
|
|
const LOBSTER_RAGE_DURATION_SEC = 10
|
|
const LOBSTER_BUBBLE_LIFETIME_SEC = 0.8
|
|
const LOBSTER_BUBBLE_SPAWN_RATE = 12
|
|
const LOBSTER_HIT_RADIUS_PX = 6
|
|
// ── Locale-aware text pools ─────────────────────────────────────────────────
|
|
type OfficeLocale = 'zh-TW' | 'zh' | 'en'
|
|
|
|
const CODE_SNIPPETS_ZH_TW = [
|
|
// JS/TS
|
|
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
|
|
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
|
|
// Python
|
|
'def fn():', 'self.', 'yield', 'lambda x:', '@decorator', '**kwargs', 'except:', 'raise',
|
|
// LLM / AI
|
|
'prompt:', 'tokens++', 'model.chat()', 'agent.run()', 'stream()', 'tool_use', 'thinking...',
|
|
'chat.completions', 'role: "user"', 'max_tokens=', 'temperature=0.7', 'messages.append()',
|
|
'stop_reason', 'tool_calls[]', 'system_prompt', 'embedding()', 'rag.search()', 'context[]',
|
|
// Prompt snippets
|
|
'You are a...', 'Step by step', 'Let me think', 'In summary:', 'For example:',
|
|
'Please fix...', 'Refactor this', 'Add tests for', 'Explain why', 'Review this PR',
|
|
'Write a func', 'Debug this', 'Optimize the', 'How to impl', 'Best practice',
|
|
'Chain of thought', 'Few-shot:', 'Zero-shot:', 'As an expert', 'Given context:',
|
|
// Dev slang
|
|
'LGTM', 'RTFM', 'WIP', 'FIXME:', 'TODO:', 'HACK:', 'nit:', '// why?!',
|
|
'git push -f', 'npm i npm i', 'works on my💻', 'ship it!',
|
|
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
|
|
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
|
|
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
|
|
'rm /tmp/*',
|
|
// 台灣工程師行話
|
|
'// 別刪這行', '// 能動就好', '// 不知道為啥能動', '// 下次再說', '// 祖傳程式碼',
|
|
'在嗎?', '已讀不回', '先這樣吧', '回滾!', '又掛了',
|
|
'誰動了我的分支', '這不是bug', '需求又改了', '上線吧', '別碰那個檔案',
|
|
'跑不起來啊', '靠腰,重啟試試', '這是誰寫的垃圾!!!', '誰刪我程式碼了去你的', '又500了', '刪庫跑路再說!!!',
|
|
'這什麼三小需求', '怎麼又改規格', '去你的,為什麼又改', '靠腰,測試又炸了', '噢不,production掛了',
|
|
// OpenClaw CLI
|
|
'openclaw status', 'openclaw gateway start', 'openclaw gateway restart',
|
|
'openclaw logs', 'openclaw doctor', 'openclaw config get',
|
|
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
|
|
]
|
|
|
|
const SRE_BLACKWORDS_ZH_TW = [
|
|
'先調整!',
|
|
'先看監控',
|
|
'先看P99',
|
|
'先查閘道日誌',
|
|
'先查日誌紀錄',
|
|
'先重現',
|
|
'限流先開',
|
|
'檢查gateway',
|
|
'降載執行',
|
|
'先做降級',
|
|
'先擴容',
|
|
'先擴容頂住',
|
|
'找找文提',
|
|
'先重啟試試',
|
|
'先重啟閘道',
|
|
'先保SLA',
|
|
'先止血',
|
|
'核心鏈路優先',
|
|
'非核心先降級',
|
|
'觀察流量',
|
|
'開灰度觀察',
|
|
'先斷開故障節點',
|
|
'先摘流壞實例',
|
|
'熔斷值再收緊',
|
|
'重試風暴了',
|
|
'依賴超時傳染了',
|
|
'執行緒池爆了',
|
|
'連線池見底了',
|
|
'快取擊穿了',
|
|
'快取雪崩了',
|
|
'DB抖了',
|
|
'MQ堆積了',
|
|
'閘道扛不住了',
|
|
'上游在抖',
|
|
'下游撐不住了',
|
|
'觀察錯誤率',
|
|
'觀察超時率',
|
|
'抓出回應慢的',
|
|
'RT飆了',
|
|
'error budget快打穿了',
|
|
'先叫值班同學',
|
|
'先通知業務端',
|
|
'先發故障通告',
|
|
'記錄時間線,後面盤點',
|
|
'警報風暴來了',
|
|
'開戰情室',
|
|
'恢復中...',
|
|
'SLA 要頂住',
|
|
'MTTR 壓下來',
|
|
'這波別炸',
|
|
]
|
|
|
|
const PHOTO_COMMENTS_ZH_TW = [
|
|
'這張讚!', '構圖超棒', '光線好美', '色調很舒服', '學習了',
|
|
'出片了!', '桌布等級', '大片既視感', '這質感絕了', '散景好美',
|
|
'焦段選得好', '拍出了情緒', '太有電影感了', '超有氛圍',
|
|
'這光太絕了!!!', '這光太絕了!!!', '這光太絕了!!!',
|
|
'這光太絕了!!!', '這光太絕了!!!',
|
|
'不愧是台灣之光', '這是決定性瞬間!!!', '這個構圖絕了!',
|
|
]
|
|
|
|
const CODE_SNIPPETS_ZH = [
|
|
// JS/TS
|
|
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
|
|
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
|
|
// Python
|
|
'def fn():', 'self.', 'yield', 'lambda x:', '@decorator', '**kwargs', 'except:', 'raise',
|
|
// LLM / AI
|
|
'prompt:', 'tokens++', 'model.chat()', 'agent.run()', 'stream()', 'tool_use', 'thinking...',
|
|
'chat.completions', 'role: "user"', 'max_tokens=', 'temperature=0.7', 'messages.append()',
|
|
'stop_reason', 'tool_calls[]', 'system_prompt', 'embedding()', 'rag.search()', 'context[]',
|
|
// Prompt snippets
|
|
'You are a...', 'Step by step', 'Let me think', 'In summary:', 'For example:',
|
|
'Please fix...', 'Refactor this', 'Add tests for', 'Explain why', 'Review this PR',
|
|
'Write a func', 'Debug this', 'Optimize the', 'How to impl', 'Best practice',
|
|
'Chain of thought', 'Few-shot:', 'Zero-shot:', 'As an expert', 'Given context:',
|
|
// Dev slang
|
|
'LGTM', 'RTFM', 'WIP', 'FIXME:', 'TODO:', 'HACK:', 'nit:', '// why?!',
|
|
'git push -f', 'rm -rf node_', 'npm i npm i', 'works on my💻', 'ship it!',
|
|
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
|
|
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
|
|
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
|
|
'rm /tmp/*',
|
|
// 中文程序员黑话
|
|
'// 别删这行', '// 能跑就行', '// 不知道为啥能跑', '// 下次再改', '// 祖传代码',
|
|
'在吗?', '已读不回', '先这样吧', '回滚!', '又挂了',
|
|
'谁动了我的分支', '这不是bug', '需求又变了', '上线吧', '别碰那个文件',
|
|
'跑不起来啊', '重启试试', '这是谁写的屎山!!!', '谁删我代码了???tmd', '又500了', '删库跑路再说!!!',
|
|
// OpenClaw CLI
|
|
'openclaw status', 'openclaw gateway start', 'openclaw gateway restart',
|
|
'openclaw logs', 'openclaw doctor', 'openclaw config get',
|
|
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
|
|
]
|
|
|
|
const SRE_BLACKWORDS_ZH = [
|
|
'先止血!',
|
|
'先看监控',
|
|
'先看P99',
|
|
'先查网关日志',
|
|
'先查日志',
|
|
'先复现',
|
|
'限流先开',
|
|
'熔断先上',
|
|
'先熔断',
|
|
'降级保命',
|
|
'做降级保核心链路',
|
|
'先扩容',
|
|
'先扩容顶住',
|
|
'先回滚',
|
|
'先重启试试',
|
|
'先重启网关',
|
|
'先保SLA',
|
|
'先兜底',
|
|
'核心链路优先',
|
|
'非核心先降级',
|
|
'流量削峰',
|
|
'开灰度观察',
|
|
'先旁路故障节点',
|
|
'先摘流坏实例',
|
|
'熔断阈值再收紧',
|
|
'重试风暴了',
|
|
'依赖超时传染了',
|
|
'线程池爆了',
|
|
'连接池见底了',
|
|
'缓存击穿了',
|
|
'缓存雪崩了',
|
|
'DB抖了',
|
|
'MQ堆积了',
|
|
'网关扛不住了',
|
|
'上游在抖',
|
|
'下游背压了',
|
|
'观察错误率',
|
|
'观察超时率',
|
|
'抓慢请求',
|
|
'RT飙了',
|
|
'error budget快打穿了',
|
|
'先拉值班同学',
|
|
'先同步业务方预期',
|
|
'先发故障通告',
|
|
'记录时间线,后面复盘',
|
|
'告警风暴来了',
|
|
'开战情室',
|
|
'恢复中...',
|
|
'SLA 要顶住',
|
|
'MTTR 压下来',
|
|
'这波别炸',
|
|
]
|
|
|
|
const PHOTO_COMMENTS_ZH = [
|
|
'毒!', '毒德大学', '德味!', '刀锐奶化', '空气切割感',
|
|
'氛围感拉满', '这光太绝了', '构图教科书', '色彩太舒服了', '学习了',
|
|
'出片了!', '壁纸级', '大片既视感', '这质感绝了', '奶油焦外',
|
|
'焦段选得好', '拍出了情绪', '太有电影感了',
|
|
'德味、毒、大师、学习了!!!', '德味、毒、大师、学习了!!!', '德味、毒、大师、学习了!!!',
|
|
'德味、毒、大师、学习了!!!', '德味、毒、大师、学习了!!!',
|
|
'不愧是中国布列松', '这是决定性瞬间!!!', '这个复杂构图绝了!',
|
|
]
|
|
|
|
const CODE_SNIPPETS_EN = [
|
|
// JS/TS
|
|
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
|
|
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
|
|
// Python
|
|
'def fn():', 'self.', 'yield', 'lambda x:', '@decorator', '**kwargs', 'except:', 'raise',
|
|
// LLM / AI
|
|
'prompt:', 'tokens++', 'model.chat()', 'agent.run()', 'stream()', 'tool_use', 'thinking...',
|
|
'chat.completions', 'role: "user"', 'max_tokens=', 'temperature=0.7', 'messages.append()',
|
|
'stop_reason', 'tool_calls[]', 'system_prompt', 'embedding()', 'rag.search()', 'context[]',
|
|
// Prompt snippets
|
|
'You are a...', 'Step by step', 'Let me think', 'In summary:', 'For example:',
|
|
'Please fix...', 'Refactor this', 'Add tests for', 'Explain why', 'Review this PR',
|
|
'Write a func', 'Debug this', 'Optimize the', 'How to impl', 'Best practice',
|
|
'Chain of thought', 'Few-shot:', 'Zero-shot:', 'As an expert', 'Given context:',
|
|
// Dev slang (EN flavor)
|
|
'LGTM', 'RTFM', 'WIP', 'FIXME:', 'TODO:', 'HACK:', 'nit:', '// why?!',
|
|
'git push -f', 'rm -rf node_', 'npm i npm i', 'works on my💻', 'ship it!',
|
|
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
|
|
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
|
|
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
|
|
'have you tried turning it off?', 'undefined is not a function',
|
|
'it was like this when I got here', 'not my code, not my problem',
|
|
'just needs a quick fix', 'we can refactor later', 'deadline is tomorrow',
|
|
'the tests pass locally', 'works in staging!', 'blame git log',
|
|
'rm /tmp/*',
|
|
// OpenClaw CLI
|
|
'openclaw status', 'openclaw gateway start', 'openclaw gateway restart',
|
|
'openclaw logs', 'openclaw doctor', 'openclaw config get',
|
|
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
|
|
]
|
|
|
|
const SRE_BLACKWORDS_EN = [
|
|
'Stop the bleeding!',
|
|
'Check the dashboards',
|
|
'Check P99 latency',
|
|
'Check gateway logs',
|
|
'Check the logs',
|
|
'Reproduce it first',
|
|
'Enable rate limiting',
|
|
'Circuit break it',
|
|
'Initiate circuit break',
|
|
'Degrade gracefully',
|
|
'Protect the critical path',
|
|
'Scale up!',
|
|
'Scale up to hold the load',
|
|
'Roll back now',
|
|
'Restart and see',
|
|
'Restart the gateway',
|
|
'Protect the SLA',
|
|
'Fallback first',
|
|
'Critical path first',
|
|
'Degrade non-critical',
|
|
'Shed the load',
|
|
'Canary deploy',
|
|
'Bypass the bad node',
|
|
'Drain the bad instance',
|
|
'Tighten circuit threshold',
|
|
'Retry storm detected',
|
|
'Timeout is cascading',
|
|
'Thread pool exhausted',
|
|
'Connection pool empty',
|
|
'Cache penetration!',
|
|
'Cache avalanche!',
|
|
'DB is flapping',
|
|
'MQ backlog growing',
|
|
'Gateway overloaded',
|
|
'Upstream is flapping',
|
|
'Downstream back-pressure',
|
|
'Watch error rate',
|
|
'Watch timeout rate',
|
|
'Catch slow requests',
|
|
'RT is spiking',
|
|
'Error budget almost gone',
|
|
'Page the on-call',
|
|
'Sync with stakeholders',
|
|
'Send incident notice',
|
|
'Log the timeline',
|
|
'Alert storm incoming',
|
|
'Open the war room',
|
|
'Recovering...',
|
|
'Hold the SLA',
|
|
'Drive MTTR down',
|
|
"Don\'t let it blow up",
|
|
]
|
|
|
|
const PHOTO_COMMENTS_EN = [
|
|
'Stunning shot!', 'Love the bokeh', 'Perfect lighting', 'Great composition', 'Amazing colors',
|
|
'Wallpaper worthy!', 'Cinematic feel', 'Such atmosphere', 'Beautiful tones', 'Shot of the day!',
|
|
'Incredible depth', 'Mood is perfect', 'Frame it!', 'So filmic', 'Golden hour magic',
|
|
'Rule of thirds nailed', 'Negative space done right', 'The decisive moment!',
|
|
'This is art!!!', 'This is art!!!', 'This is art!!!', 'This is art!!!', 'This is art!!!',
|
|
'Not a photo, a painting!', 'The decisive moment!!!', 'Complex comp, nailed it!',
|
|
]
|
|
|
|
function getCodeSnippets(locale: OfficeLocale): string[] {
|
|
if (locale === 'zh-TW') return CODE_SNIPPETS_ZH_TW
|
|
if (locale === 'en') return CODE_SNIPPETS_EN
|
|
return CODE_SNIPPETS_ZH
|
|
}
|
|
function getSreBlackwords(locale: OfficeLocale): string[] {
|
|
if (locale === 'zh-TW') return SRE_BLACKWORDS_ZH_TW
|
|
if (locale === 'en') return SRE_BLACKWORDS_EN
|
|
return SRE_BLACKWORDS_ZH
|
|
}
|
|
function getPhotoComments(locale: OfficeLocale): string[] {
|
|
if (locale === 'zh-TW') return PHOTO_COMMENTS_ZH_TW
|
|
if (locale === 'en') return PHOTO_COMMENTS_EN
|
|
return PHOTO_COMMENTS_ZH
|
|
}
|
|
function getTempWorkerLabel(locale: OfficeLocale): string {
|
|
if (locale === 'zh-TW') return '臨時工'
|
|
if (locale === 'en') return 'Temp'
|
|
return '临时工'
|
|
}
|
|
function getGatewaySreLabel(locale: OfficeLocale): string {
|
|
if (locale === 'zh-TW') return '值班工程師'
|
|
if (locale === 'en') return 'On-Call SRE'
|
|
return '值班SRE'
|
|
}
|
|
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
|
|
const GATEWAY_SRE_LABEL = '值班SRE'
|
|
const GATEWAY_SRE_STANDBY_COL = 2
|
|
const GATEWAY_SRE_STANDBY_ROW = 14
|
|
const GATEWAY_SRE_RESCUE_CANDIDATES = [
|
|
// Rescue point: right wall of lounge area.
|
|
{ col: 18, row: 14 },
|
|
{ col: 18, row: 13 },
|
|
{ col: 17, row: 14 },
|
|
{ col: 17, row: 13 },
|
|
] as const
|
|
|
|
export type GatewaySreState = 'unknown' | 'healthy' | 'degraded' | 'down'
|
|
|
|
export interface GatewaySreInfo {
|
|
id: number
|
|
status: GatewaySreState
|
|
error: string | null
|
|
responseMs: number | null
|
|
checkedAt: number | null
|
|
}
|
|
|
|
interface GreetingSequence {
|
|
childId: number
|
|
parentId: number
|
|
childTarget: { col: number; row: number } | null
|
|
/** Where the parent walks to when they are NOT at their seat (midpoint meeting) */
|
|
parentTarget: { col: number; row: number } | null
|
|
/** Tile to wait at when MainAgent is busy with another greeter */
|
|
waitTarget: { col: number; row: number } | null
|
|
phase: 'waiting' | 'walk' | 'pause' | 'parent_smile' | 'child_smile' | 'final_pause' | 'complete'
|
|
timer: number
|
|
isExit: boolean
|
|
/** Tile to walk back to after farewell, before despawning */
|
|
exitReturnPos: { col: number; row: number } | null
|
|
/** True when child arrived at greeting tile but MainAgent wasn't physically present */
|
|
parentAbsent?: boolean
|
|
}
|
|
|
|
export class OfficeState {
|
|
layout: OfficeLayout
|
|
tileMap: TileTypeVal[][]
|
|
seats: Map<string, Seat>
|
|
blockedTiles: Set<string>
|
|
furniture: FurnitureInstance[]
|
|
walkableTiles: Array<{ col: number; row: number }>
|
|
interactionPoints: InteractionPoint[]
|
|
doorwayTiles: Array<{ col: number; row: number }>
|
|
characters: Map<number, Character> = new Map()
|
|
selectedAgentId: number | null = null
|
|
cameraFollowId: number | null = null
|
|
hoveredAgentId: number | null = null
|
|
hoveredTile: { col: number; row: number } | null = null
|
|
/** Maps "parentId:toolId" → sub-agent character ID (negative) */
|
|
subagentIdMap: Map<string, number> = new Map()
|
|
/** Reverse lookup: sub-agent character ID → parent info */
|
|
subagentMeta: Map<number, { parentAgentId: number; parentToolId: string }> = new Map()
|
|
private nextSubagentId = -1
|
|
private static CAT_ID = -9999
|
|
private static LOBSTER_ID = -9998
|
|
private static HUNTER_LOBSTER_ID = -9997
|
|
private static GATEWAY_SRE_ID = -9996
|
|
private bugSystem: BugSystem
|
|
private bugWorldWidth: number
|
|
private bugWorldHeight: number
|
|
private gatewaySreStatus: GatewaySreState = 'unknown'
|
|
private gatewaySreError: string | null = null
|
|
private gatewaySreResponseMs: number | null = null
|
|
private gatewaySreCheckedAt: number | null = null
|
|
private locale: OfficeLocale = 'zh'
|
|
private activeGreetings: Map<number, GreetingSequence> = new Map()
|
|
/** The first regular agent added — all other agents greet this one on entry */
|
|
private mainAgentId: number | null = null
|
|
/** FIFO queue of agent IDs waiting to greet MainAgent. Only queue head walks over; others stay at seat. */
|
|
private greetQueue: number[] = []
|
|
/** Subagents lingering at seat before farewell. key=charId, value=remaining seconds */
|
|
private lingerSubagents: Map<number, number> = new Map()
|
|
/** Subagent IDs queued for farewell — when processGreetQueue starts their greeting, mark isExit=true */
|
|
private exitOnGreetComplete: Set<number> = new Set()
|
|
/** Agent IDs that were explicitly set to idle (下班) — only these should greet on next activation */
|
|
private explicitlyIdledAgents: Set<number> = new Set()
|
|
/** Stashed exit return positions for subagents waiting in greetQueue. Cleared when greeting starts. */
|
|
private exitReturnStash: Map<number, { col: number; row: number }> = new Map()
|
|
|
|
getTempWorkerLabel(): string {
|
|
return getTempWorkerLabel(this.locale)
|
|
}
|
|
|
|
setLocale(locale: OfficeLocale) {
|
|
this.locale = locale
|
|
// Re-label the SRE character if it exists
|
|
const sre = this.characters.get(OfficeState.GATEWAY_SRE_ID)
|
|
if (sre) sre.label = getGatewaySreLabel(locale)
|
|
// Re-label any temp workers
|
|
for (const [, ch] of this.characters) {
|
|
if (ch.label === '临时工' || ch.label === '臨時工' || ch.label === 'Temp') {
|
|
ch.label = getTempWorkerLabel(locale)
|
|
}
|
|
}
|
|
}
|
|
|
|
private buildRuntimeBlockedTiles(furniture: PlacedFurniture[]): Set<string> {
|
|
// Keep right-office stool seats walkable so adding subagent stools
|
|
// does not shrink idle wandering range.
|
|
const nonBlockingSeatTiles = new Set<string>()
|
|
for (const [seatId, seat] of this.seats.entries()) {
|
|
if (!seatId.startsWith('stool-r')) continue
|
|
nonBlockingSeatTiles.add(`${seat.seatCol},${seat.seatRow}`)
|
|
}
|
|
return getBlockedTiles(furniture, nonBlockingSeatTiles)
|
|
}
|
|
|
|
constructor(layout?: OfficeLayout, locale: OfficeLocale = 'zh') {
|
|
this.locale = locale
|
|
this.layout = layout || createDefaultLayout()
|
|
this.tileMap = layoutToTileMap(this.layout)
|
|
this.seats = layoutToSeats(this.layout.furniture)
|
|
this.blockedTiles = this.buildRuntimeBlockedTiles(this.layout.furniture)
|
|
this.furniture = layoutToFurnitureInstances(this.layout.furniture)
|
|
this.walkableTiles = getWalkableTiles(this.tileMap, this.blockedTiles)
|
|
this.interactionPoints = getInteractionPoints(this.layout.furniture, this.tileMap, this.blockedTiles)
|
|
this.doorwayTiles = getDoorwayTiles(this.layout)
|
|
this.spawnCat()
|
|
this.spawnLobster()
|
|
this.spawnHunterLobster()
|
|
this.ensureGatewaySre()
|
|
this.bugWorldWidth = this.layout.cols * TILE_SIZE
|
|
this.bugWorldHeight = this.layout.rows * TILE_SIZE
|
|
this.bugSystem = new BugSystem(this.bugWorldWidth, this.bugWorldHeight, BUG_DEFAULT_COUNT)
|
|
}
|
|
|
|
/** 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 = this.buildRuntimeBlockedTiles(layout.furniture)
|
|
this.rebuildFurnitureInstances()
|
|
this.walkableTiles = getWalkableTiles(this.tileMap, this.blockedTiles)
|
|
this.interactionPoints = getInteractionPoints(layout.furniture, this.tileMap, this.blockedTiles)
|
|
this.doorwayTiles = getDoorwayTiles(layout)
|
|
|
|
// 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.isCat || ch.isLobster || ch.isSystemRole) continue
|
|
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 (tileCol/Row rounded for pathfinding, x/y fractional for visual)
|
|
ch.tileCol = Math.round(seat.seatCol)
|
|
ch.tileRow = Math.round(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.isCat || ch.isLobster || ch.isSystemRole) continue
|
|
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 = Math.round(seat.seatCol)
|
|
ch.tileRow = Math.round(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.isSystemRole) continue
|
|
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 `${Math.round(seat.seatCol)},${Math.round(seat.seatRow)}`
|
|
}
|
|
|
|
/** Temporarily unblock a character's own seat, run fn, then re-block */
|
|
private withOwnSeatUnblocked<T>(ch: Character, fn: () => T): T {
|
|
const key = this.ownSeatKey(ch)
|
|
if (key) this.blockedTiles.delete(key)
|
|
const result = fn()
|
|
if (key) this.blockedTiles.add(key)
|
|
return result
|
|
}
|
|
|
|
private findFreeSeat(): string | null {
|
|
for (const [uid, seat] of this.seats) {
|
|
if (!seat.assigned) return uid
|
|
}
|
|
return null
|
|
}
|
|
|
|
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<string>()
|
|
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 round uses each available character variant once (random order).
|
|
* Beyond that, variants
|
|
* repeat in balanced rounds with a random hue shift (≥45°).
|
|
*/
|
|
private pickDiversePalette(): { palette: number; hueShift: number } {
|
|
const paletteCount = getAvailableCharacterVariantCount()
|
|
const counts = new Array(paletteCount).fill(0) as number[]
|
|
for (const ch of this.characters.values()) {
|
|
if (ch.isSubagent) continue
|
|
counts[ch.palette % paletteCount]++
|
|
}
|
|
const minCount = Math.min(...counts)
|
|
// Available = variants at the minimum count (least used)
|
|
const available: number[] = []
|
|
for (let i = 0; i < paletteCount; i++) {
|
|
if (counts[i] === minCount) available.push(i)
|
|
}
|
|
const extraVariants = available.filter((index) => index >= CHARACTER_PALETTES.length)
|
|
const preferred = minCount === 0 && extraVariants.length > 0 ? extraVariants : available
|
|
const palette = preferred[Math.floor(Math.random() * preferred.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, spawnAtDoor?: 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
|
|
|
|
// Spawn at doorway and walk to seat
|
|
if (spawnAtDoor && this.doorwayTiles.length > 0 && seatId) {
|
|
const door = this.doorwayTiles[Math.floor(Math.random() * this.doorwayTiles.length)]
|
|
const seat = this.seats.get(seatId)!
|
|
seat.assigned = true
|
|
ch = createCharacter(id, palette, seatId, null, hueShift)
|
|
ch.tileCol = door.col
|
|
ch.tileRow = door.row
|
|
ch.x = door.col * TILE_SIZE + TILE_SIZE / 2
|
|
ch.y = door.row * TILE_SIZE + TILE_SIZE / 2
|
|
ch.dir = Direction.DOWN
|
|
// Pathfind from door to seat
|
|
const path = findPath(door.col, door.row, Math.round(seat.seatCol), Math.round(seat.seatRow), this.tileMap, this.blockedTiles)
|
|
if (path.length > 0) {
|
|
ch.path = path
|
|
ch.state = CharacterState.WALK
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
} else {
|
|
// No path — just sit at seat directly
|
|
ch.tileCol = Math.round(seat.seatCol)
|
|
ch.tileRow = Math.round(seat.seatRow)
|
|
ch.x = seat.seatCol * TILE_SIZE + TILE_SIZE / 2
|
|
ch.y = seat.seatRow * TILE_SIZE + TILE_SIZE / 2
|
|
ch.dir = seat.facingDir
|
|
ch.state = CharacterState.TYPE
|
|
}
|
|
} else 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)
|
|
|
|
// Track first agent as MainAgent; others greet on entry
|
|
if (this.mainAgentId === null) {
|
|
this.mainAgentId = id
|
|
} else if (spawnAtDoor) {
|
|
this.tryStartGreeting(ch)
|
|
}
|
|
}
|
|
|
|
/** Spawn the office cat at a random walkable tile */
|
|
spawnCat(): void {
|
|
const id = OfficeState.CAT_ID
|
|
if (this.characters.has(id)) return
|
|
const spawn = this.walkableTiles.length > 0
|
|
? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
|
|
: { col: 1, row: 1 }
|
|
const ch = createCharacter(id, 0, null, null, 0)
|
|
ch.isCat = true
|
|
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.state = CharacterState.IDLE
|
|
ch.wanderTimer = 1 + Math.random() * 3
|
|
this.characters.set(id, ch)
|
|
}
|
|
|
|
/** Spawn the office lobster at a random walkable tile */
|
|
spawnLobster(): void {
|
|
const id = OfficeState.LOBSTER_ID
|
|
if (this.characters.has(id)) return
|
|
const spawn = this.walkableTiles.length > 0
|
|
? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
|
|
: { col: 1, row: 1 }
|
|
const ch = createCharacter(id, 0, null, null, 0)
|
|
ch.isLobster = true
|
|
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.state = CharacterState.IDLE
|
|
ch.wanderTimer = 1 + Math.random() * 3
|
|
this.characters.set(id, ch)
|
|
}
|
|
|
|
toggleFirstLobsterRage(): boolean {
|
|
const lobster = this.characters.get(OfficeState.LOBSTER_ID)
|
|
if (!lobster || !lobster.isLobster) return false
|
|
if (lobster.lobsterRageTimer > 0) {
|
|
lobster.lobsterRageTimer = 0
|
|
lobster.lobsterBubbles = []
|
|
return false
|
|
}
|
|
lobster.lobsterRageTimer = LOBSTER_RAGE_DURATION_SEC
|
|
return true
|
|
}
|
|
|
|
getFirstLobsterAt(worldX: number, worldY: number): number | null {
|
|
const lobster = this.characters.get(OfficeState.LOBSTER_ID)
|
|
if (!lobster || lobster.matrixEffect === 'despawn') return null
|
|
const cx = lobster.x
|
|
const cy = lobster.y + 2
|
|
const dx = worldX - cx
|
|
const dy = worldY - cy
|
|
const hitR = LOBSTER_HIT_RADIUS_PX
|
|
if (dx * dx + dy * dy <= hitR * hitR) return lobster.id
|
|
return null
|
|
}
|
|
|
|
/** Spawn the hunter lobster at a random walkable tile */
|
|
spawnHunterLobster(): void {
|
|
const id = OfficeState.HUNTER_LOBSTER_ID
|
|
if (this.characters.has(id)) return
|
|
const spawn = this.walkableTiles.length > 0
|
|
? this.walkableTiles[Math.floor(Math.random() * this.walkableTiles.length)]
|
|
: { col: 1, row: 1 }
|
|
const ch = createCharacter(id, 0, null, null, 0)
|
|
ch.isLobster = true
|
|
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.state = CharacterState.IDLE
|
|
ch.wanderTimer = 1 + Math.random() * 3
|
|
this.characters.set(id, ch)
|
|
}
|
|
|
|
ensureGatewaySre(): void {
|
|
const id = OfficeState.GATEWAY_SRE_ID
|
|
if (this.characters.has(id)) return
|
|
const spawn = this.findClosestWalkable(GATEWAY_SRE_STANDBY_COL, GATEWAY_SRE_STANDBY_ROW)
|
|
const ch = createCharacter(id, 2, null, null, 0)
|
|
ch.isSystemRole = true
|
|
ch.systemRoleType = 'gateway_sre'
|
|
ch.systemStatus = this.gatewaySreStatus
|
|
ch.label = getGatewaySreLabel(this.locale)
|
|
ch.isActive = false
|
|
ch.state = CharacterState.IDLE
|
|
ch.seatId = null
|
|
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.wanderTimer = 60
|
|
ch.matrixEffect = 'spawn'
|
|
ch.matrixEffectTimer = 0
|
|
ch.matrixEffectSeeds = matrixEffectSeeds()
|
|
this.characters.set(id, ch)
|
|
}
|
|
|
|
updateGatewaySreState(info: {
|
|
status: GatewaySreState
|
|
error?: string | null
|
|
responseMs?: number | null
|
|
checkedAt?: number | null
|
|
}): void {
|
|
const prevStatus = this.gatewaySreStatus
|
|
this.gatewaySreStatus = info.status
|
|
this.gatewaySreError = info.error ?? null
|
|
this.gatewaySreResponseMs = info.responseMs ?? null
|
|
this.gatewaySreCheckedAt = info.checkedAt ?? null
|
|
const ch = this.characters.get(OfficeState.GATEWAY_SRE_ID)
|
|
if (!ch) return
|
|
ch.systemStatus = this.gatewaySreStatus
|
|
if (prevStatus !== info.status) {
|
|
// React immediately when gateway status changes.
|
|
ch.wanderTimer = 0
|
|
if (info.status === 'down') {
|
|
ch.path = []
|
|
ch.moveProgress = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
getGatewaySreInfo(): GatewaySreInfo | null {
|
|
const ch = this.characters.get(OfficeState.GATEWAY_SRE_ID)
|
|
if (!ch) return null
|
|
return {
|
|
id: ch.id,
|
|
status: this.gatewaySreStatus,
|
|
error: this.gatewaySreError,
|
|
responseMs: this.gatewaySreResponseMs,
|
|
checkedAt: this.gatewaySreCheckedAt,
|
|
}
|
|
}
|
|
|
|
/** Find a walkable tile adjacent to a character */
|
|
private findAdjacentWalkable(ch: Character): { col: number; row: number } | null {
|
|
const adjacents = [
|
|
{ col: ch.tileCol - 1, row: ch.tileRow },
|
|
{ col: ch.tileCol + 1, row: ch.tileRow },
|
|
{ col: ch.tileCol, row: ch.tileRow - 1 },
|
|
{ col: ch.tileCol, row: ch.tileRow + 1 },
|
|
]
|
|
for (const t of adjacents) {
|
|
if (isWalkable(t.col, t.row, this.tileMap, this.blockedTiles)) return t
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Find a midpoint meeting location when MainAgent is not at their seat.
|
|
* Returns childTarget (where child walks) and parentTarget (where parent walks),
|
|
* adjacent to each other near the midpoint between the two characters.
|
|
*/
|
|
private findMeetingTiles(
|
|
child: Character,
|
|
parent: Character,
|
|
): { childTarget: { col: number; row: number }; parentTarget: { col: number; row: number } } | null {
|
|
const midCol = Math.round((child.tileCol + parent.tileCol) / 2)
|
|
const midRow = Math.round((child.tileRow + parent.tileRow) / 2)
|
|
|
|
// Find walkable tile closest to midpoint for the parent to walk to
|
|
const parentTile = this.findClosestWalkable(midCol, midRow)
|
|
if (!parentTile) return null
|
|
|
|
// Verify parent can reach that tile
|
|
const parentPath = findPath(parent.tileCol, parent.tileRow, parentTile.col, parentTile.row, this.tileMap, this.blockedTiles)
|
|
if (parentPath.length === 0 && !(parent.tileCol === parentTile.col && parent.tileRow === parentTile.row)) return null
|
|
|
|
// Child walks to a tile adjacent to parent's meeting tile
|
|
const adjacents = [
|
|
{ col: parentTile.col - 1, row: parentTile.row },
|
|
{ col: parentTile.col + 1, row: parentTile.row },
|
|
{ col: parentTile.col, row: parentTile.row - 1 },
|
|
{ col: parentTile.col, row: parentTile.row + 1 },
|
|
]
|
|
for (const adj of adjacents) {
|
|
if (!isWalkable(adj.col, adj.row, this.tileMap, this.blockedTiles)) continue
|
|
const childPath = findPath(child.tileCol, child.tileRow, adj.col, adj.row, this.tileMap, this.blockedTiles)
|
|
if (childPath.length > 0) return { childTarget: adj, parentTarget: parentTile }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Returns true if MainAgent is currently in an active greeting sequence */
|
|
private isMainAgentBusy(): boolean {
|
|
if (this.mainAgentId === null) return false
|
|
for (const g of this.activeGreetings.values()) {
|
|
if (g.parentId === this.mainAgentId) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Queue `ch` to greet MainAgent. If MainAgent is free and queue is empty,
|
|
* starts the greeting immediately. Otherwise, adds to FIFO queue and the agent
|
|
* stays at their seat until it's their turn.
|
|
* No-op if: ch IS MainAgent, already in queue/greeting, MainAgent not found.
|
|
*/
|
|
private tryStartGreeting(ch: Character): void {
|
|
if (this.mainAgentId === null || ch.id === this.mainAgentId || ch.isSystemRole) return
|
|
if (this.activeGreetings.has(ch.id)) return
|
|
if (this.greetQueue.includes(ch.id)) return
|
|
const mainCh = this.characters.get(this.mainAgentId)
|
|
if (!mainCh || mainCh.matrixEffect === 'despawn') return
|
|
|
|
this.greetQueue.push(ch.id)
|
|
this.processGreetQueue()
|
|
}
|
|
|
|
/** Dequeue the next agent and start their greeting walk if MainAgent is free. */
|
|
private processGreetQueue(): void {
|
|
if (this.isMainAgentBusy()) return
|
|
// Find first queued agent that still exists and isn't already greeting
|
|
while (this.greetQueue.length > 0) {
|
|
const nextId = this.greetQueue[0]
|
|
const ch = this.characters.get(nextId)
|
|
if (!ch || this.activeGreetings.has(nextId)) {
|
|
this.greetQueue.shift()
|
|
continue
|
|
}
|
|
const mainCh = this.characters.get(this.mainAgentId!)
|
|
if (!mainCh || mainCh.matrixEffect === 'despawn') {
|
|
this.greetQueue.length = 0
|
|
return
|
|
}
|
|
// Determine meeting point based on whether MainAgent is at their seat
|
|
let greetTile: { col: number; row: number } | null
|
|
let parentTarget: { col: number; row: number } | null = null
|
|
|
|
if (mainCh.state === CharacterState.TYPE) {
|
|
// MainAgent is at seat — child walks to adjacent tile, parent stays
|
|
greetTile = this.findAdjacentWalkable(mainCh)
|
|
} else {
|
|
// MainAgent is not at seat — meet halfway
|
|
const meeting = this.findMeetingTiles(ch, mainCh)
|
|
if (meeting) {
|
|
greetTile = meeting.childTarget
|
|
parentTarget = meeting.parentTarget
|
|
} else {
|
|
// Fallback: walk to wherever MainAgent currently is
|
|
greetTile = this.findAdjacentWalkable(mainCh)
|
|
}
|
|
}
|
|
|
|
if (!greetTile) { this.greetQueue.shift(); continue }
|
|
const greetPath = findPath(ch.tileCol, ch.tileRow, greetTile.col, greetTile.row, this.tileMap, this.blockedTiles)
|
|
if (greetPath.length === 0) { this.greetQueue.shift(); continue }
|
|
this.greetQueue.shift()
|
|
ch.path = greetPath
|
|
ch.state = CharacterState.WALK
|
|
ch.moveProgress = 0
|
|
ch.greetLocked = true
|
|
|
|
// If parent needs to walk to meeting point, lock and start them moving now
|
|
if (parentTarget) {
|
|
const parentPath = findPath(mainCh.tileCol, mainCh.tileRow, parentTarget.col, parentTarget.row, this.tileMap, this.blockedTiles)
|
|
if (parentPath.length > 0) {
|
|
mainCh.greetLocked = true
|
|
mainCh.path = parentPath
|
|
mainCh.state = CharacterState.WALK
|
|
mainCh.moveProgress = 0
|
|
} else {
|
|
parentTarget = null // parent already there or unreachable
|
|
}
|
|
}
|
|
|
|
const isExitGreeting = this.exitOnGreetComplete.has(ch.id)
|
|
if (isExitGreeting) this.exitOnGreetComplete.delete(ch.id)
|
|
// Recover the stashed return position for this exit greeting
|
|
const exitReturnPos = isExitGreeting ? (this.exitReturnStash.get(ch.id) ?? null) : null
|
|
if (isExitGreeting) this.exitReturnStash.delete(ch.id)
|
|
this.activeGreetings.set(ch.id, {
|
|
childId: ch.id,
|
|
parentId: this.mainAgentId!,
|
|
childTarget: greetTile,
|
|
parentTarget,
|
|
waitTarget: null,
|
|
phase: 'walk',
|
|
timer: 0,
|
|
isExit: isExitGreeting,
|
|
exitReturnPos,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
/** Approximate facing direction from one tile toward another */
|
|
private directionToward(fromCol: number, fromRow: number, toCol: number, toRow: number): Direction {
|
|
const dc = toCol - fromCol
|
|
const dr = toRow - fromRow
|
|
if (Math.abs(dc) >= Math.abs(dr)) return dc >= 0 ? Direction.RIGHT : Direction.LEFT
|
|
return dr >= 0 ? Direction.DOWN : Direction.UP
|
|
}
|
|
|
|
private updateGreetings(dt: number): void {
|
|
const completed: number[] = []
|
|
|
|
for (const [childId, seq] of this.activeGreetings) {
|
|
const child = this.characters.get(childId)
|
|
const parent = this.characters.get(seq.parentId)
|
|
|
|
if (!child || child.matrixEffect === 'despawn') {
|
|
completed.push(childId)
|
|
if (parent) parent.greetLocked = false
|
|
continue
|
|
}
|
|
|
|
switch (seq.phase) {
|
|
case 'walk': {
|
|
const target = seq.childTarget
|
|
if (!target) { seq.phase = 'complete'; break }
|
|
|
|
// If parent has a meeting target, keep them walking toward it
|
|
if (seq.parentTarget && parent) {
|
|
const pt = seq.parentTarget
|
|
const parentArrived = parent.tileCol === pt.col && parent.tileRow === pt.row
|
|
if (!parentArrived && parent.path.length === 0 && parent.state !== CharacterState.WALK) {
|
|
const repath = findPath(parent.tileCol, parent.tileRow, pt.col, pt.row, this.tileMap, this.blockedTiles)
|
|
if (repath.length > 0) {
|
|
parent.path = repath
|
|
parent.state = CharacterState.WALK
|
|
parent.moveProgress = 0
|
|
parent.greetLocked = true
|
|
}
|
|
}
|
|
}
|
|
|
|
const childArrived = child.tileCol === target.col && child.tileRow === target.row
|
|
const parentArrived = !seq.parentTarget || !parent ||
|
|
(parent.tileCol === seq.parentTarget.col && parent.tileRow === seq.parentTarget.row)
|
|
|
|
// Both have arrived at their respective meeting tiles
|
|
if (childArrived && parentArrived) {
|
|
child.path = []
|
|
child.state = CharacterState.IDLE
|
|
child.greetLocked = true
|
|
if (parent) {
|
|
parent.greetLocked = true
|
|
parent.path = []
|
|
parent.state = CharacterState.IDLE
|
|
child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow)
|
|
parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow)
|
|
// Detect if parent is not physically nearby (wandered away from greeting tile)
|
|
const distToParent = Math.abs(parent.tileCol - child.tileCol) + Math.abs(parent.tileRow - child.tileRow)
|
|
if (seq.isExit && distToParent > 3) {
|
|
seq.parentAbsent = true
|
|
}
|
|
} else {
|
|
// Parent character doesn't exist
|
|
if (seq.isExit) seq.parentAbsent = true
|
|
}
|
|
seq.phase = 'pause'
|
|
seq.timer = 1.0
|
|
} else if (child.path.length === 0 && child.state !== CharacterState.WALK) {
|
|
// Child lost path mid-walk — try to re-path to target
|
|
const path = findPath(child.tileCol, child.tileRow, target.col, target.row, this.tileMap, this.blockedTiles)
|
|
if (path.length > 0) {
|
|
child.path = path
|
|
child.state = CharacterState.WALK
|
|
child.moveProgress = 0
|
|
} else {
|
|
seq.phase = 'complete'
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'pause': {
|
|
child.path = []
|
|
child.state = CharacterState.IDLE
|
|
if (parent) {
|
|
parent.path = []
|
|
parent.state = CharacterState.IDLE
|
|
child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow)
|
|
parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow)
|
|
}
|
|
seq.timer -= dt
|
|
if (seq.timer <= 0) {
|
|
const emoji = seq.isExit ? '❤️' : '😊'
|
|
if (parent) this.pushCodeSnippet(parent.id, emoji)
|
|
seq.phase = 'parent_smile'
|
|
seq.timer = 1.0
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'parent_smile': {
|
|
child.path = []
|
|
child.state = CharacterState.IDLE
|
|
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
|
|
seq.timer -= dt
|
|
if (seq.timer <= 0) {
|
|
this.pushCodeSnippet(child.id, seq.isExit ? '❤️' : '😊')
|
|
seq.phase = 'child_smile'
|
|
seq.timer = 1.0
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'child_smile': {
|
|
child.path = []
|
|
child.state = CharacterState.IDLE
|
|
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
|
|
seq.timer -= dt
|
|
if (seq.timer <= 0) {
|
|
seq.phase = 'final_pause'
|
|
seq.timer = 1.0
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'final_pause': {
|
|
child.path = []
|
|
child.state = CharacterState.IDLE
|
|
if (parent) { parent.path = []; parent.state = CharacterState.IDLE }
|
|
seq.timer -= dt
|
|
if (seq.timer <= 0) {
|
|
seq.phase = 'complete'
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'complete': {
|
|
child.greetLocked = false
|
|
if (parent) parent.greetLocked = false
|
|
|
|
if (seq.isExit) {
|
|
child.bubbleType = null
|
|
// After farewell (whether MainAgent was present or not), walk to bottom-right corner
|
|
if (!this.walkToBottomRightThenDespawn(child)) {
|
|
child.matrixEffect = 'despawn'
|
|
child.matrixEffectTimer = 0
|
|
child.matrixEffectSeeds = matrixEffectSeeds()
|
|
}
|
|
} else {
|
|
// Walk to assigned seat
|
|
if (child.seatId) {
|
|
const seat = this.seats.get(child.seatId)
|
|
if (seat) {
|
|
const path = this.withOwnSeatUnblocked(child, () =>
|
|
findPath(child.tileCol, child.tileRow, Math.round(seat.seatCol), Math.round(seat.seatRow), this.tileMap, this.blockedTiles)
|
|
)
|
|
if (path.length > 0) {
|
|
child.path = path
|
|
child.state = CharacterState.WALK
|
|
child.moveProgress = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
completed.push(childId)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const id of completed) this.activeGreetings.delete(id)
|
|
// After any greeting completes, let the next queued agent proceed
|
|
if (completed.length > 0) this.processGreetQueue()
|
|
}
|
|
|
|
private findClosestWalkable(targetCol: number, targetRow: number): { col: number; row: number } {
|
|
if (this.walkableTiles.length === 0) return { col: 1, row: 1 }
|
|
let best = this.walkableTiles[0]
|
|
let bestDist = Math.abs(best.col - targetCol) + Math.abs(best.row - targetRow)
|
|
for (let i = 1; i < this.walkableTiles.length; i++) {
|
|
const t = this.walkableTiles[i]
|
|
const d = Math.abs(t.col - targetCol) + Math.abs(t.row - targetRow)
|
|
if (d < bestDist) {
|
|
best = t
|
|
bestDist = d
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
/** Find a walkable tile near the sofa/lounge area, for temp workers with no assigned seat */
|
|
private findSofaAreaTile(): { col: number; row: number } | null {
|
|
const sofa = this.layout.furniture.find(
|
|
(f) => f.type === FurnitureType.SOFA || f.type === FurnitureType.BENCH,
|
|
)
|
|
if (!sofa) return null
|
|
return this.findClosestWalkable(sofa.col, sofa.row)
|
|
}
|
|
|
|
/** Find the walkable tile closest to the bottom-right corner of the map */
|
|
private findBottomRightCornerTile(): { col: number; row: number } | null {
|
|
if (this.walkableTiles.length === 0) return null
|
|
// Bottom-right in tile space = max col + max row
|
|
const maxCol = Math.max(...this.walkableTiles.map((t) => t.col))
|
|
const maxRow = Math.max(...this.walkableTiles.map((t) => t.row))
|
|
return this.findClosestWalkable(maxCol, maxRow)
|
|
}
|
|
|
|
/**
|
|
* Walk character to the bottom-right corner then despawn.
|
|
* Unlike findExitWalkPath, accepts any path length >= 1 so nearby agents still move.
|
|
*/
|
|
private walkToBottomRightThenDespawn(ch: Character): boolean {
|
|
const corner = this.findBottomRightCornerTile()
|
|
if (!corner) return false
|
|
if (corner.col === ch.tileCol && corner.row === ch.tileRow) {
|
|
// Already there — despawn directly
|
|
ch.matrixEffect = 'despawn'
|
|
ch.matrixEffectTimer = 0
|
|
ch.matrixEffectSeeds = matrixEffectSeeds()
|
|
return true
|
|
}
|
|
const path = findPath(ch.tileCol, ch.tileRow, corner.col, corner.row, this.tileMap, this.blockedTiles)
|
|
if (path.length === 0) return false
|
|
ch.path = path
|
|
ch.state = CharacterState.WALK
|
|
ch.moveProgress = 0
|
|
ch.pendingDespawn = corner
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Find a walk-back path for a departing temp worker.
|
|
* Ensures the path is long enough that the worker visibly walks away from MainAgent.
|
|
* Falls back to the sofa area or any distant tile if the primary target is unreachable.
|
|
*/
|
|
private findExitWalkPath(
|
|
child: Character,
|
|
preferredTarget: { col: number; row: number } | null,
|
|
): { path: Array<{ col: number; row: number }>; target: { col: number; row: number } } | null {
|
|
const MIN_WALK_TILES = 5 // must walk at least this many steps
|
|
|
|
const tryTarget = (target: { col: number; row: number }) => {
|
|
if (target.col === child.tileCol && target.row === child.tileRow) return null
|
|
const path = findPath(child.tileCol, child.tileRow, target.col, target.row, this.tileMap, this.blockedTiles)
|
|
if (path.length >= MIN_WALK_TILES) return { path, target }
|
|
return null
|
|
}
|
|
|
|
// 1. Try preferred target (seat area)
|
|
if (preferredTarget) {
|
|
const result = tryTarget(preferredTarget)
|
|
if (result) return result
|
|
}
|
|
|
|
// 2. Try sofa area
|
|
const sofaTile = this.findSofaAreaTile()
|
|
if (sofaTile) {
|
|
const result = tryTarget(sofaTile)
|
|
if (result) return result
|
|
}
|
|
|
|
// 3. Find any walkable tile sufficiently far from current position
|
|
const { tileCol: cx, tileRow: cy } = child
|
|
const farTile = this.walkableTiles
|
|
.filter((t) => Math.abs(t.col - child.tileCol) + Math.abs(t.row - child.tileRow) >= MIN_WALK_TILES)
|
|
.sort((a, b) => {
|
|
// Prefer tiles far from child but in direction away from center of map
|
|
const da = Math.abs(a.col - cx) + Math.abs(a.row - cy)
|
|
const db = Math.abs(b.col - cx) + Math.abs(b.row - cy)
|
|
return db - da
|
|
})
|
|
.find((t) => {
|
|
const path = findPath(child.tileCol, child.tileRow, t.col, t.row, this.tileMap, this.blockedTiles)
|
|
return path.length >= MIN_WALK_TILES
|
|
})
|
|
|
|
if (farTile) {
|
|
const path = findPath(child.tileCol, child.tileRow, farTile.col, farTile.row, this.tileMap, this.blockedTiles)
|
|
return { path, target: farTile }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
private getGatewaySrePatrolTiles(): Array<{ col: number; row: number }> {
|
|
// Mostly patrol in lounge (break area), occasionally stroll in office areas.
|
|
const loungeTiles = this.walkableTiles.filter((t) => t.row >= 9)
|
|
const officeTiles = this.walkableTiles.filter((t) =>
|
|
t.row <= 8 && t.col >= 1 && t.col <= 19
|
|
)
|
|
const lounge = loungeTiles.length > 0 ? loungeTiles : this.walkableTiles
|
|
if (officeTiles.length === 0) return lounge
|
|
// Weighted sampling pool: ~86% lounge, ~14% office.
|
|
return [...lounge, ...lounge, ...lounge, ...lounge, ...lounge, ...lounge, ...officeTiles]
|
|
}
|
|
|
|
private getGatewaySreRescuePoint(patrolTiles: Array<{ col: number; row: number }>): { col: number; row: number } {
|
|
for (const candidate of GATEWAY_SRE_RESCUE_CANDIDATES) {
|
|
if (patrolTiles.some((t) => t.col === candidate.col && t.row === candidate.row)) {
|
|
return { col: candidate.col, row: candidate.row }
|
|
}
|
|
}
|
|
return this.findClosestWalkable(18, 14)
|
|
}
|
|
|
|
private getGatewaySreDegradedTiles(
|
|
patrolTiles: Array<{ col: number; row: number }>,
|
|
rescuePoint: { col: number; row: number },
|
|
): Array<{ col: number; row: number }> {
|
|
const nearby = patrolTiles.filter((t) =>
|
|
Math.abs(t.col - rescuePoint.col) + Math.abs(t.row - rescuePoint.row) <= 4
|
|
)
|
|
return nearby.length > 0 ? nearby : patrolTiles
|
|
}
|
|
|
|
private forceWalkTo(ch: Character, col: number, row: number): void {
|
|
const path = findPath(ch.tileCol, ch.tileRow, col, row, this.tileMap, this.blockedTiles)
|
|
if (path.length === 0) return
|
|
ch.path = path
|
|
ch.moveProgress = 0
|
|
ch.state = CharacterState.WALK
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
}
|
|
|
|
private updateGatewaySreCharacter(ch: Character, dt: number): void {
|
|
ch.isActive = false
|
|
ch.seatId = null
|
|
ch.systemRoleType = 'gateway_sre'
|
|
ch.systemStatus = this.gatewaySreStatus
|
|
const patrolTiles = this.getGatewaySrePatrolTiles()
|
|
const rescuePoint = this.getGatewaySreRescuePoint(patrolTiles)
|
|
const degradedTiles = this.getGatewaySreDegradedTiles(patrolTiles, rescuePoint)
|
|
|
|
if (this.gatewaySreStatus === 'unknown') {
|
|
ch.moveSpeedMultiplier = 1
|
|
ch.path = []
|
|
ch.moveProgress = 0
|
|
ch.state = CharacterState.IDLE
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
ch.wanderTimer = 5
|
|
return
|
|
}
|
|
|
|
if (this.gatewaySreStatus === 'down') {
|
|
ch.moveSpeedMultiplier = 2.2
|
|
const atRescue = ch.tileCol === rescuePoint.col && ch.tileRow === rescuePoint.row
|
|
const last = ch.path[ch.path.length - 1]
|
|
if (!atRescue && (!last || last.col !== rescuePoint.col || last.row !== rescuePoint.row)) {
|
|
this.withOwnSeatUnblocked(ch, () => this.forceWalkTo(ch, rescuePoint.col, rescuePoint.row))
|
|
}
|
|
this.withOwnSeatUnblocked(ch, () =>
|
|
updateCharacter(ch, dt, [rescuePoint], this.seats, this.tileMap, this.blockedTiles, [])
|
|
)
|
|
// Hold at server front and face the server.
|
|
if (ch.tileCol === rescuePoint.col && ch.tileRow === rescuePoint.row) {
|
|
ch.path = []
|
|
ch.moveProgress = 0
|
|
ch.state = CharacterState.IDLE
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
ch.wanderTimer = 2
|
|
ch.dir = Direction.UP
|
|
}
|
|
return
|
|
}
|
|
|
|
ch.moveSpeedMultiplier = this.gatewaySreStatus === 'degraded' ? 1.4 : 0.9
|
|
const scope = this.gatewaySreStatus === 'degraded' ? degradedTiles : patrolTiles
|
|
this.withOwnSeatUnblocked(ch, () =>
|
|
updateCharacter(ch, dt, scope, this.seats, this.tileMap, this.blockedTiles, [])
|
|
)
|
|
}
|
|
|
|
private getFirstIdleHumanoid(): Character | null {
|
|
const list = Array.from(this.characters.values())
|
|
.filter(ch => !ch.isCat && !ch.isLobster && !ch.isSystemRole && ch.state === CharacterState.IDLE && ch.matrixEffect !== 'despawn')
|
|
.sort((a, b) => a.id - b.id)
|
|
return list[0] || null
|
|
}
|
|
|
|
private faceToward(ch: Character, target: Character): void {
|
|
const dx = target.tileCol - ch.tileCol
|
|
const dy = target.tileRow - ch.tileRow
|
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
ch.dir = dx >= 0 ? Direction.RIGHT : Direction.LEFT
|
|
} else {
|
|
ch.dir = dy >= 0 ? Direction.DOWN : Direction.UP
|
|
}
|
|
}
|
|
|
|
private spawnLobsterBubble(ch: Character): void {
|
|
let tailX = 0
|
|
let tailY = 0
|
|
if (ch.dir === Direction.RIGHT) tailX = -6
|
|
else if (ch.dir === Direction.LEFT) tailX = 6
|
|
else if (ch.dir === Direction.UP) tailY = 6
|
|
else tailY = -6
|
|
ch.lobsterBubbles.push({
|
|
age: 0,
|
|
x: tailX + (Math.random() - 0.5) * 2.5,
|
|
y: tailY + (Math.random() - 0.5) * 2.5,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Hunter lobster behavior:
|
|
* - No idle humanoid -> default wandering (same as normal lobster)
|
|
* - Has idle humanoid -> follow while staying around half a tile away
|
|
*/
|
|
private updateHunterLobster(ch: Character, dt: number, idleTarget: Character | null): void {
|
|
if (!idleTarget) {
|
|
this.withOwnSeatUnblocked(ch, () =>
|
|
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
|
|
)
|
|
return
|
|
}
|
|
|
|
const targetDistancePx = TILE_SIZE * 0.5
|
|
const stopDistancePx = TILE_SIZE * 0.45
|
|
const dx = idleTarget.x - ch.x
|
|
const dy = idleTarget.y - ch.y
|
|
const distPx = Math.hypot(dx, dy)
|
|
|
|
// Close enough: hold position and face the target.
|
|
if (distPx <= stopDistancePx) {
|
|
ch.path = []
|
|
ch.moveProgress = 0
|
|
ch.state = CharacterState.IDLE
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
this.faceToward(ch, idleTarget)
|
|
ch.wanderTimer = 60
|
|
this.withOwnSeatUnblocked(ch, () =>
|
|
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
|
|
)
|
|
return
|
|
}
|
|
|
|
// If we are already near half-tile distance, hold and face.
|
|
if (Math.abs(distPx - targetDistancePx) <= TILE_SIZE * 0.2) {
|
|
ch.path = []
|
|
ch.moveProgress = 0
|
|
ch.state = CharacterState.IDLE
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
this.faceToward(ch, idleTarget)
|
|
} else {
|
|
// Repath to target's tile so motion keeps following; we'll stop early at half-tile distance.
|
|
const destination = { col: idleTarget.tileCol, row: idleTarget.tileRow }
|
|
const last = ch.path[ch.path.length - 1]
|
|
const keepsCurrentPath = last && last.col === destination.col && last.row === destination.row
|
|
if (!keepsCurrentPath) {
|
|
const newPath = findPath(ch.tileCol, ch.tileRow, destination.col, destination.row, this.tileMap, this.blockedTiles)
|
|
if (newPath.length > 0) {
|
|
ch.path = newPath
|
|
ch.moveProgress = 0
|
|
ch.state = CharacterState.WALK
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep timer high so cat/lobster FSM won't pick a random wander target while tracking.
|
|
ch.wanderTimer = 60
|
|
this.withOwnSeatUnblocked(ch, () =>
|
|
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
|
|
)
|
|
|
|
// Clamp after movement so it never catches the idle humanoid.
|
|
const dxAfter = idleTarget.x - ch.x
|
|
const dyAfter = idleTarget.y - ch.y
|
|
const distAfterPx = Math.hypot(dxAfter, dyAfter)
|
|
if (distAfterPx <= stopDistancePx) {
|
|
ch.path = []
|
|
ch.moveProgress = 0
|
|
ch.state = CharacterState.IDLE
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
this.faceToward(ch, idleTarget)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
removeAgentImmediately(id: number): void {
|
|
const ch = this.characters.get(id)
|
|
if (!ch) return
|
|
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
|
|
this.characters.delete(id)
|
|
}
|
|
|
|
/** 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 (Math.round(seat.seatCol) === col && Math.round(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, Math.round(seat.seatCol), Math.round(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, Math.round(seat.seatCol), Math.round(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
|
|
const availablePrioritySeats = SUBAGENT_PRIORITY_SEAT_IDS.filter((seatId) => {
|
|
const seat = this.seats.get(seatId)
|
|
return !!seat && !seat.assigned
|
|
})
|
|
if (availablePrioritySeats.length > 0) {
|
|
const randomIdx = Math.floor(Math.random() * availablePrioritySeats.length)
|
|
bestSeatId = availablePrioritySeats[randomIdx]
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let ch: Character
|
|
if (bestSeatId) {
|
|
const seat = this.seats.get(bestSeatId)!
|
|
seat.assigned = true
|
|
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 }
|
|
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.moveSpeedMultiplier = SUBAGENT_RUN_SPEED_MULTIPLIER
|
|
}
|
|
ch.isSubagent = true
|
|
ch.parentAgentId = parentAgentId
|
|
ch.label = getTempWorkerLabel(this.locale)
|
|
ch.matrixEffect = 'spawn'
|
|
ch.matrixEffectTimer = 0
|
|
ch.matrixEffectSeeds = matrixEffectSeeds()
|
|
this.characters.set(id, ch)
|
|
|
|
// Join MainAgent greeting queue (waits in place if MainAgent is busy)
|
|
this.tryStartGreeting(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') {
|
|
this.subagentIdMap.delete(key)
|
|
this.subagentMeta.delete(id)
|
|
this.lingerSubagents.delete(id)
|
|
return
|
|
}
|
|
// If already lingering, do nothing — let the timer run
|
|
if (this.lingerSubagents.has(id)) return
|
|
// Start linger: stay at seat for 60s, then do farewell
|
|
this.lingerSubagents.set(id, 60)
|
|
this.subagentIdMap.delete(key)
|
|
this.subagentMeta.delete(id)
|
|
if (this.selectedAgentId === id) this.selectedAgentId = null
|
|
if (this.cameraFollowId === id) this.cameraFollowId = null
|
|
return
|
|
}
|
|
this.subagentIdMap.delete(key)
|
|
this.subagentMeta.delete(id)
|
|
}
|
|
|
|
/** Internal: execute the actual farewell sequence for a subagent (called after linger) */
|
|
private startSubagentFarewell(id: number): void {
|
|
const ch = this.characters.get(id)
|
|
if (!ch || ch.matrixEffect === 'despawn') {
|
|
this.characters.delete(id)
|
|
return
|
|
}
|
|
// Save a walkable return position before freeing seat.
|
|
// Seat tile itself is often blocked by chair furniture, so use nearest walkable floor tile.
|
|
let exitReturnPos: { col: number; row: number } | null = null
|
|
if (ch.seatId) {
|
|
const seat = this.seats.get(ch.seatId)
|
|
if (seat) {
|
|
exitReturnPos = this.findClosestWalkable(Math.round(seat.seatCol), Math.round(seat.seatRow))
|
|
seat.assigned = false
|
|
}
|
|
ch.seatId = null
|
|
}
|
|
// No seat — fall back to sofa/lounge area
|
|
if (!exitReturnPos) exitReturnPos = this.findSofaAreaTile()
|
|
|
|
ch.bubbleType = null
|
|
// Queue farewell greeting with MainAgent (isExit=true marks it as a departure)
|
|
this.tryStartGreeting(ch)
|
|
if (this.activeGreetings.has(id)) {
|
|
// Already started greeting immediately — mark as exit
|
|
const seq = this.activeGreetings.get(id)!
|
|
seq.isExit = true
|
|
seq.exitReturnPos = exitReturnPos
|
|
} else if (this.greetQueue.includes(id)) {
|
|
// In queue — when processed, processGreetQueue will set isExit=true
|
|
this.exitOnGreetComplete.add(id)
|
|
// Stash the return position until the greeting actually starts
|
|
if (exitReturnPos) this.exitReturnStash.set(id, exitReturnPos)
|
|
} else {
|
|
// No MainAgent reachable — walk to bottom-right corner then despawn
|
|
if (!this.walkToBottomRightThenDespawn(ch)) {
|
|
ch.matrixEffect = 'despawn'
|
|
ch.matrixEffectTimer = 0
|
|
ch.matrixEffectSeeds = matrixEffectSeeds()
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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') {
|
|
this.subagentMeta.delete(id)
|
|
toRemove.push(key)
|
|
continue
|
|
}
|
|
if (this.activeGreetings.has(id)) {
|
|
const seq = this.activeGreetings.get(id)!
|
|
seq.isExit = true
|
|
if (ch.seatId) {
|
|
const seat = this.seats.get(ch.seatId)
|
|
if (seat) seat.assigned = false
|
|
ch.seatId = null
|
|
}
|
|
this.subagentMeta.delete(id)
|
|
if (this.selectedAgentId === id) this.selectedAgentId = null
|
|
if (this.cameraFollowId === id) this.cameraFollowId = null
|
|
toRemove.push(key)
|
|
continue
|
|
}
|
|
if (ch.seatId) {
|
|
const seat = this.seats.get(ch.seatId)
|
|
if (seat) seat.assigned = false
|
|
ch.seatId = null
|
|
}
|
|
ch.bubbleType = null
|
|
const parentCh = this.characters.get(parentAgentId)
|
|
const greetTile = parentCh ? this.findAdjacentWalkable(parentCh) : null
|
|
if (greetTile && parentCh) {
|
|
const path = findPath(ch.tileCol, ch.tileRow, greetTile.col, greetTile.row, this.tileMap, this.blockedTiles)
|
|
if (path.length > 0) {
|
|
ch.path = path
|
|
ch.state = CharacterState.WALK
|
|
ch.moveProgress = 0
|
|
this.activeGreetings.set(id, {
|
|
childId: id,
|
|
parentId: parentAgentId,
|
|
childTarget: greetTile,
|
|
parentTarget: null,
|
|
waitTarget: null,
|
|
phase: 'walk',
|
|
timer: 0,
|
|
isExit: true,
|
|
exitReturnPos: ch.pendingDespawn && ch.pendingDespawn !== true ? ch.pendingDespawn : null,
|
|
})
|
|
this.subagentMeta.delete(id)
|
|
if (this.selectedAgentId === id) this.selectedAgentId = null
|
|
if (this.cameraFollowId === id) this.cameraFollowId = null
|
|
toRemove.push(key)
|
|
continue
|
|
}
|
|
}
|
|
// No greeting path — walk to bottom-right corner then despawn
|
|
if (!this.walkToBottomRightThenDespawn(ch)) {
|
|
ch.matrixEffect = 'despawn'
|
|
ch.matrixEffectTimer = 0
|
|
ch.matrixEffectSeeds = matrixEffectSeeds()
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
removeAllSubagentsImmediately(parentAgentId: number): void {
|
|
const toRemove: string[] = []
|
|
const toDeleteIds: number[] = []
|
|
for (const [key, id] of this.subagentIdMap) {
|
|
const meta = this.subagentMeta.get(id)
|
|
if (!meta || meta.parentAgentId !== parentAgentId) continue
|
|
const ch = this.characters.get(id)
|
|
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
|
|
this.subagentMeta.delete(id)
|
|
toRemove.push(key)
|
|
toDeleteIds.push(id)
|
|
}
|
|
for (const key of toRemove) {
|
|
this.subagentIdMap.delete(key)
|
|
}
|
|
for (const id of toDeleteIds) {
|
|
this.characters.delete(id)
|
|
}
|
|
}
|
|
|
|
/** 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) {
|
|
if (ch.isActive === active) return
|
|
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
|
|
}
|
|
// Greet MainAgent on start-work and stop-work transitions
|
|
if (!ch.isSubagent && !ch.isSystemRole) {
|
|
this.tryStartGreeting(ch)
|
|
}
|
|
this.rebuildFurnitureInstances()
|
|
}
|
|
}
|
|
|
|
/** Rebuild furniture instances with auto-state applied (active agents turn electronics ON) */
|
|
private rebuildFurnitureInstances(): void {
|
|
// Collect tiles where active agents face desks
|
|
const autoOnTiles = new Set<string>()
|
|
for (const ch of this.characters.values()) {
|
|
if (!ch.isActive || !ch.seatId) continue
|
|
const seat = this.seats.get(ch.seatId)
|
|
if (!seat) continue
|
|
// Find the desk tile(s) the agent faces from their seat
|
|
const dCol = seat.facingDir === Direction.RIGHT ? 1 : seat.facingDir === Direction.LEFT ? -1 : 0
|
|
const dRow = seat.facingDir === Direction.DOWN ? 1 : seat.facingDir === Direction.UP ? -1 : 0
|
|
// Check tiles in the facing direction (desk could be 1-3 tiles deep)
|
|
for (let d = 1; d <= AUTO_ON_FACING_DEPTH; d++) {
|
|
const tileCol = seat.seatCol + dCol * d
|
|
const tileRow = seat.seatRow + dRow * d
|
|
autoOnTiles.add(`${tileCol},${tileRow}`)
|
|
}
|
|
// Also check tiles to the sides of the facing direction (desks can be wide)
|
|
for (let d = 1; d <= AUTO_ON_SIDE_DEPTH; d++) {
|
|
const baseCol = seat.seatCol + dCol * d
|
|
const baseRow = seat.seatRow + dRow * d
|
|
if (dCol !== 0) {
|
|
// Facing left/right: check tiles above and below
|
|
autoOnTiles.add(`${baseCol},${baseRow - 1}`)
|
|
autoOnTiles.add(`${baseCol},${baseRow + 1}`)
|
|
} else {
|
|
// Facing up/down: check tiles left and right
|
|
autoOnTiles.add(`${baseCol - 1},${baseRow}`)
|
|
autoOnTiles.add(`${baseCol + 1},${baseRow}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (autoOnTiles.size === 0) {
|
|
this.furniture = layoutToFurnitureInstances(this.layout.furniture)
|
|
return
|
|
}
|
|
|
|
// Build modified furniture list with auto-state applied
|
|
const modifiedFurniture: PlacedFurniture[] = this.layout.furniture.map((item) => {
|
|
const entry = getCatalogEntry(item.type)
|
|
if (!entry) return item
|
|
// Check if any tile of this furniture overlaps an auto-on tile
|
|
for (let dr = 0; dr < entry.footprintH; dr++) {
|
|
for (let dc = 0; dc < entry.footprintW; dc++) {
|
|
if (autoOnTiles.has(`${item.col + dc},${item.row + dr}`)) {
|
|
const onType = getOnStateType(item.type)
|
|
if (onType !== item.type) {
|
|
return { ...item, type: onType }
|
|
}
|
|
return item
|
|
}
|
|
}
|
|
}
|
|
return item
|
|
})
|
|
|
|
this.furniture = layoutToFurnitureInstances(modifiedFurniture)
|
|
}
|
|
|
|
setAgentTool(id: number, tool: string | null): void {
|
|
const ch = this.characters.get(id)
|
|
if (ch) {
|
|
ch.currentTool = tool
|
|
}
|
|
}
|
|
|
|
setAgentTaskText(id: number, text: string | undefined): void {
|
|
const ch = this.characters.get(id)
|
|
if (ch) {
|
|
ch.taskText = text
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
/** Push an explicit snippet bubble (e.g. subagent session updates). */
|
|
pushCodeSnippet(id: number, text: string): void {
|
|
const ch = this.characters.get(id)
|
|
if (!ch || ch.isCat || ch.isLobster) return
|
|
const compact = text.replace(/\s+/g, ' ').trim()
|
|
if (!compact) return
|
|
ch.codeSnippets.push({
|
|
text: compact.length > 90 ? `${compact.slice(0, 89)}…` : compact,
|
|
age: 0,
|
|
x: (Math.random() - 0.5) * 20,
|
|
y: 0,
|
|
})
|
|
if (ch.codeSnippets.length > 4) {
|
|
ch.codeSnippets = ch.codeSnippets.slice(-4)
|
|
}
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collision avoidance: when two walking characters contest the same next tile,
|
|
* the one farther from its destination steps aside (sideways first, then backwards)
|
|
* and waits for the closer one to pass before resuming.
|
|
*/
|
|
/** Find a free adjacent tile for a character to dodge to, or null if none found */
|
|
private findDodgeTile(
|
|
ch: Character,
|
|
dc: number, dr: number,
|
|
walkableSet: Set<string>,
|
|
occupiedKeys: Set<string>,
|
|
claimedNext: Map<string, number>,
|
|
): { col: number; row: number } | null {
|
|
const candidates: Array<{ col: number; row: number }> = dc !== 0 || dr !== 0
|
|
? [
|
|
{ col: ch.tileCol + dr, row: ch.tileRow + dc }, // side A (perpendicular)
|
|
{ col: ch.tileCol - dr, row: ch.tileRow - dc }, // side B (perpendicular)
|
|
{ col: ch.tileCol - dc, row: ch.tileRow - dr }, // backwards
|
|
]
|
|
: [
|
|
// No direction info — try all 4 neighbours
|
|
{ col: ch.tileCol + 1, row: ch.tileRow },
|
|
{ col: ch.tileCol - 1, row: ch.tileRow },
|
|
{ col: ch.tileCol, row: ch.tileRow + 1 },
|
|
{ col: ch.tileCol, row: ch.tileRow - 1 },
|
|
]
|
|
|
|
for (const cand of candidates) {
|
|
const key = `${cand.col},${cand.row}`
|
|
if (!walkableSet.has(key)) continue
|
|
if (this.blockedTiles.has(key)) continue
|
|
if (occupiedKeys.has(key)) continue
|
|
if (claimedNext.has(key)) continue
|
|
return cand
|
|
}
|
|
return null
|
|
}
|
|
|
|
private resolveWalkConflicts(): void {
|
|
const allHumanoids: Character[] = []
|
|
for (const ch of this.characters.values()) {
|
|
if (ch.matrixEffect || ch.isCat || ch.isLobster || ch.greetLocked) continue
|
|
if (ch.yieldTimer > 0) continue
|
|
allHumanoids.push(ch)
|
|
}
|
|
if (allHumanoids.length < 2) return
|
|
|
|
// Current tile positions of every character (for dodge-target exclusion)
|
|
const occupiedKeys = new Set<string>()
|
|
for (const ch of this.characters.values()) {
|
|
occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`)
|
|
}
|
|
|
|
const walkableSet = new Set<string>(this.walkableTiles.map(t => `${t.col},${t.row}`))
|
|
|
|
// claimedNext: tiles that are "taken" — no other character may step into them.
|
|
// Pre-populate with standing characters' current tiles only.
|
|
// Walking characters claim their next tile dynamically in Pass 1 (sorted by priority).
|
|
const claimedNext = new Map<string, number>()
|
|
for (const ch of allHumanoids) {
|
|
if (ch.state !== CharacterState.WALK) {
|
|
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
|
|
}
|
|
}
|
|
|
|
// ── Pass 1: next-tile conflicts ───────────────────────────────────────────
|
|
// All walkers (including mid-step), sorted by remaining path length.
|
|
// Shorter path = closer to destination = higher priority = claims the tile.
|
|
// Loser is snapped back to current tile and must dodge.
|
|
const walkers = allHumanoids.filter(
|
|
ch => ch.state === CharacterState.WALK && !ch.yieldDestination && ch.path.length > 0,
|
|
)
|
|
walkers.sort((a, b) => a.path.length - b.path.length)
|
|
|
|
for (const ch of walkers) {
|
|
const next = ch.path[0]
|
|
const key = `${next.col},${next.row}`
|
|
if (!claimedNext.has(key)) {
|
|
// Also claim current tile so no one walks into us from behind
|
|
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
|
|
claimedNext.set(key, ch.id)
|
|
continue
|
|
}
|
|
// Tile is taken — snap back to current tile and dodge
|
|
if (ch.moveProgress > 0) {
|
|
// Abort mid-step: snap back to the tile we came from
|
|
ch.x = ch.tileCol * TILE_SIZE + TILE_SIZE / 2
|
|
ch.y = ch.tileRow * TILE_SIZE + TILE_SIZE / 2
|
|
ch.moveProgress = 0
|
|
}
|
|
const dest = ch.path[ch.path.length - 1]
|
|
const dc = next.col - ch.tileCol
|
|
const dr = next.row - ch.tileRow
|
|
const dodgeTile = this.findDodgeTile(ch, dc, dr, walkableSet, occupiedKeys, claimedNext)
|
|
if (dodgeTile) {
|
|
ch.path = [dodgeTile]
|
|
ch.moveProgress = 0
|
|
// Face the dodge direction immediately so the character doesn't appear to
|
|
// move backward while still facing forward.
|
|
ch.dir = this.directionToward(ch.tileCol, ch.tileRow, dodgeTile.col, dodgeTile.row)
|
|
ch.yieldDestination = { col: dest.col, row: dest.row }
|
|
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
|
|
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, ch.id)
|
|
} else {
|
|
ch.path = []
|
|
ch.yieldTimer = 0.5 + Math.random() * 0.4
|
|
ch.yieldDestination = { col: dest.col, row: dest.row }
|
|
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
|
|
}
|
|
}
|
|
|
|
// ── Pass 2: hard overlap — same tileCol/tileRow right now ────────────────
|
|
const tileGroups = new Map<string, Character[]>()
|
|
for (const ch of allHumanoids) {
|
|
const key = `${ch.tileCol},${ch.tileRow}`
|
|
const g = tileGroups.get(key)
|
|
if (g) g.push(ch)
|
|
else tileGroups.set(key, [ch])
|
|
}
|
|
|
|
for (const group of tileGroups.values()) {
|
|
if (group.length < 2) continue
|
|
|
|
// Highest priority (index 0) stays; others dodge
|
|
group.sort((a, b) => {
|
|
const aScore = a.state === CharacterState.WALK ? a.path.length : 9999
|
|
const bScore = b.state === CharacterState.WALK ? b.path.length : 9999
|
|
return aScore - bScore
|
|
})
|
|
|
|
for (let i = 1; i < group.length; i++) {
|
|
const dodger = group[i]
|
|
if (dodger.yieldTimer > 0 || dodger.yieldDestination) continue
|
|
|
|
const dc = dodger.dir === Direction.RIGHT ? 1 : dodger.dir === Direction.LEFT ? -1 : 0
|
|
const dr = dodger.dir === Direction.DOWN ? 1 : dodger.dir === Direction.UP ? -1 : 0
|
|
|
|
const dodgeTile = this.findDodgeTile(dodger, dc, dr, walkableSet, occupiedKeys, claimedNext)
|
|
if (dodgeTile) {
|
|
if (dodger.state === CharacterState.WALK && dodger.path.length > 0) {
|
|
const orig = dodger.path[dodger.path.length - 1]
|
|
dodger.yieldDestination = { col: orig.col, row: orig.row }
|
|
}
|
|
dodger.path = [dodgeTile]
|
|
dodger.moveProgress = 0
|
|
dodger.state = CharacterState.WALK
|
|
dodger.frame = 0
|
|
dodger.frameTimer = 0
|
|
// Face the dodge direction immediately
|
|
dodger.dir = this.directionToward(dodger.tileCol, dodger.tileRow, dodgeTile.col, dodgeTile.row)
|
|
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, dodger.id)
|
|
occupiedKeys.delete(`${dodger.tileCol},${dodger.tileRow}`)
|
|
occupiedKeys.add(`${dodgeTile.col},${dodgeTile.row}`)
|
|
} else {
|
|
dodger.yieldTimer = 0.3 + Math.random() * 0.3
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
update(dt: number): void {
|
|
this.ensureGatewaySre()
|
|
this.bugSystem.update(dt, this.bugWorldWidth, this.bugWorldHeight)
|
|
this.resolveWalkConflicts()
|
|
const toDelete: number[] = []
|
|
const firstIdleHumanoid = this.getFirstIdleHumanoid()
|
|
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
|
|
}
|
|
|
|
if (ch.id === OfficeState.HUNTER_LOBSTER_ID) {
|
|
this.updateHunterLobster(ch, dt, firstIdleHumanoid)
|
|
continue
|
|
}
|
|
|
|
// Yield timer: character is waiting at dodge tile before resuming
|
|
if (ch.yieldTimer > 0) {
|
|
ch.yieldTimer = Math.max(0, ch.yieldTimer - dt)
|
|
if (ch.yieldTimer === 0 && ch.yieldDestination) {
|
|
const dest = ch.yieldDestination
|
|
ch.yieldDestination = null
|
|
const resumePath = findPath(ch.tileCol, ch.tileRow, dest.col, dest.row, this.tileMap, this.blockedTiles)
|
|
if (resumePath.length > 0) {
|
|
ch.path = resumePath
|
|
ch.moveProgress = 0
|
|
ch.state = CharacterState.WALK
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
}
|
|
}
|
|
continue // frozen while waiting
|
|
}
|
|
|
|
if (ch.systemRoleType === 'gateway_sre' && !ch.greetLocked) {
|
|
this.updateGatewaySreCharacter(ch, dt)
|
|
} else {
|
|
// Temporarily unblock own seat so character can pathfind to it
|
|
// (greetLocked guards inside updateCharacter prevent repath-to-seat during greeting)
|
|
this.withOwnSeatUnblocked(ch, () =>
|
|
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
|
|
)
|
|
}
|
|
|
|
// If character just finished walking to dodge tile, start wait timer
|
|
if (ch.yieldDestination && ch.state !== CharacterState.WALK && ch.path.length === 0) {
|
|
ch.yieldTimer = 0.6 + Math.random() * 0.5
|
|
ch.state = CharacterState.IDLE
|
|
ch.frame = 0
|
|
ch.frameTimer = 0
|
|
}
|
|
|
|
if (ch.isLobster) {
|
|
if (ch.lobsterRageTimer > 0) {
|
|
ch.lobsterRageTimer = Math.max(0, ch.lobsterRageTimer - dt)
|
|
if (Math.random() < dt * LOBSTER_BUBBLE_SPAWN_RATE) {
|
|
this.spawnLobsterBubble(ch)
|
|
}
|
|
}
|
|
for (const b of ch.lobsterBubbles) b.age += dt
|
|
ch.lobsterBubbles = ch.lobsterBubbles.filter(b => b.age < LOBSTER_BUBBLE_LIFETIME_SEC)
|
|
}
|
|
|
|
// Tick bubble timer for waiting bubbles
|
|
if (ch.bubbleType === 'waiting') {
|
|
ch.bubbleTimer -= dt
|
|
if (ch.bubbleTimer <= 0) {
|
|
ch.bubbleType = null
|
|
ch.bubbleTimer = 0
|
|
}
|
|
}
|
|
|
|
// Photo comment particles for characters viewing the photograph
|
|
if (ch.isViewingPhoto && ch.state === CharacterState.IDLE && !ch.isCat && !ch.isLobster) {
|
|
for (const pc of ch.photoComments) pc.age += dt
|
|
ch.photoComments = ch.photoComments.filter(pc => pc.age < PHOTO_COMMENT_LIFETIME)
|
|
if (ch.photoComments.length < 2 && Math.random() < dt * PHOTO_COMMENT_SPAWN_RATE) {
|
|
ch.photoComments.push({
|
|
text: getPhotoComments(this.locale)[Math.floor(Math.random() * getPhotoComments(this.locale).length)],
|
|
age: 0,
|
|
x: (Math.random() - 0.5) * 16,
|
|
})
|
|
}
|
|
// Spawn first one immediately
|
|
if (ch.photoComments.length === 0) {
|
|
ch.photoComments.push({
|
|
text: getPhotoComments(this.locale)[Math.floor(Math.random() * getPhotoComments(this.locale).length)],
|
|
age: 0,
|
|
x: (Math.random() - 0.5) * 16,
|
|
})
|
|
}
|
|
} else {
|
|
ch.isViewingPhoto = false
|
|
ch.photoComments = []
|
|
}
|
|
|
|
const isSreFirefighting =
|
|
ch.systemRoleType === 'gateway_sre' &&
|
|
ch.systemStatus === 'down' &&
|
|
ch.state === CharacterState.IDLE
|
|
|
|
// Code snippet particles:
|
|
// - Working agents: regular coding snippets
|
|
// - Gateway SRE in down state: ops slang ("运维黑话")
|
|
// - Subagent/session-driven snippets can be injected externally via pushCodeSnippet().
|
|
if (!ch.isCat && !ch.isLobster) {
|
|
for (const s of ch.codeSnippets) s.age += dt
|
|
ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME)
|
|
}
|
|
|
|
if (isSreFirefighting && !ch.isCat && !ch.isLobster) {
|
|
if (ch.codeSnippets.length < 3 && Math.random() < dt * SRE_BLACKWORD_SPAWN_RATE) {
|
|
ch.codeSnippets.push({
|
|
text: getSreBlackwords(this.locale)[Math.floor(Math.random() * getSreBlackwords(this.locale).length)],
|
|
age: 0,
|
|
x: (Math.random() - 0.5) * 14,
|
|
y: 0,
|
|
})
|
|
}
|
|
} else if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster && !ch.isSubagent) {
|
|
// Spawn new snippet randomly
|
|
if (ch.codeSnippets.length < 2 && Math.random() < dt * CODE_SNIPPET_SPAWN_RATE) {
|
|
ch.codeSnippets.push({
|
|
text: getCodeSnippets(this.locale)[Math.floor(Math.random() * getCodeSnippets(this.locale).length)],
|
|
age: 0,
|
|
x: (Math.random() - 0.5) * 20,
|
|
y: 0,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
// Tick linger timers — only count down while character is actually sitting at seat
|
|
if (this.lingerSubagents.size > 0) {
|
|
const lingerExpired: number[] = []
|
|
for (const [lingerId, timer] of this.lingerSubagents) {
|
|
const ch = this.characters.get(lingerId)
|
|
if (!ch) { lingerExpired.push(lingerId); continue }
|
|
// Only count down while character is idle/typing at their seat (not walking or greeting)
|
|
const isRestingAtSeat =
|
|
ch.seatId !== null &&
|
|
ch.state !== CharacterState.WALK &&
|
|
!this.activeGreetings.has(lingerId) &&
|
|
!this.greetQueue.includes(lingerId)
|
|
if (!isRestingAtSeat) continue
|
|
const remaining = timer - dt
|
|
if (remaining <= 0) {
|
|
lingerExpired.push(lingerId)
|
|
} else {
|
|
this.lingerSubagents.set(lingerId, remaining)
|
|
}
|
|
}
|
|
for (const lingerId of lingerExpired) {
|
|
this.lingerSubagents.delete(lingerId)
|
|
this.startSubagentFarewell(lingerId)
|
|
}
|
|
}
|
|
this.updateGreetings(dt)
|
|
// Remove characters that finished despawn
|
|
for (const id of toDelete) {
|
|
this.characters.delete(id)
|
|
}
|
|
}
|
|
|
|
getCharacters(): Character[] {
|
|
return Array.from(this.characters.values())
|
|
}
|
|
|
|
getBugs(): BugEntity[] {
|
|
return this.bugSystem.getBugs()
|
|
}
|
|
|
|
isBugEnabled(): boolean {
|
|
return this.bugSystem.isEnabled()
|
|
}
|
|
|
|
setBugEnabled(enabled: boolean): void {
|
|
this.bugSystem.setEnabled(enabled)
|
|
}
|
|
|
|
getBugTargetCount(): number {
|
|
return this.bugSystem.getTargetCount()
|
|
}
|
|
|
|
setBugTargetCount(count: number): void {
|
|
this.bugSystem.setTargetCount(count, this.bugWorldWidth, this.bugWorldHeight)
|
|
}
|
|
|
|
setBugWorldSize(width: number, height: number): void {
|
|
this.bugWorldWidth = Math.max(1, width)
|
|
this.bugWorldHeight = Math.max(1, height)
|
|
}
|
|
|
|
setBugCursor(x: number, y: number, active: boolean): void {
|
|
this.bugSystem.setCursor(x, y, active)
|
|
}
|
|
|
|
/** 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 or pets
|
|
if (ch.matrixEffect === 'despawn') continue
|
|
if (ch.isCat || ch.isLobster) 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
|
|
}
|
|
}
|