Initial commit
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import Phaser from 'phaser'
|
||||
import type { OfficeScene } from './scenes/OfficeScene'
|
||||
import type { VisualEvent, VisualSnapshot } from '../types/visual'
|
||||
import { getOffices, type OfficeConfig } from './map/OfficeStore'
|
||||
import { AgentState } from './types'
|
||||
|
||||
export class GameBridge extends Phaser.Events.EventEmitter {
|
||||
private scene: OfficeScene | null = null
|
||||
private eventQueue: VisualEvent[] = []
|
||||
private snapshotQueue: VisualSnapshot[] = []
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
setScene(scene: OfficeScene) {
|
||||
this.scene = scene
|
||||
|
||||
for (const snap of this.snapshotQueue) {
|
||||
this.applySnapshot(snap)
|
||||
}
|
||||
this.snapshotQueue = []
|
||||
|
||||
for (const evt of this.eventQueue) {
|
||||
this.applyEvent(evt)
|
||||
}
|
||||
this.eventQueue = []
|
||||
}
|
||||
|
||||
getScene(): OfficeScene | null {
|
||||
return this.scene
|
||||
}
|
||||
|
||||
// ── Called from React side ────────────────────────────
|
||||
|
||||
pushEvent(evt: VisualEvent) {
|
||||
if (!this.scene) {
|
||||
this.eventQueue.push(evt)
|
||||
return
|
||||
}
|
||||
this.applyEvent(evt)
|
||||
}
|
||||
|
||||
pushSnapshot(snapshot: VisualSnapshot) {
|
||||
const agentCount = Object.keys(snapshot.agents ?? {}).length
|
||||
if (!this.scene) {
|
||||
console.log(`[GameBridge] pushSnapshot queued (scene not ready) — ${agentCount} agents`)
|
||||
this.snapshotQueue.push(snapshot)
|
||||
return
|
||||
}
|
||||
console.log(`[GameBridge] pushSnapshot applying now — ${agentCount} agents`)
|
||||
this.applySnapshot(snapshot)
|
||||
}
|
||||
|
||||
sendToSeat(agentId: string) {
|
||||
if (!this.scene) return
|
||||
this.scene.behavior.sendToSeat(
|
||||
this.scene.ensureAgent(agentId),
|
||||
)
|
||||
}
|
||||
|
||||
setAgentActive(agentId: string, active: boolean) {
|
||||
if (!this.scene) return
|
||||
const agent = this.scene.getAgent(agentId)
|
||||
if (agent) agent.isActive = active
|
||||
}
|
||||
|
||||
setAgentBubble(agentId: string, text: string | null) {
|
||||
if (!this.scene) return
|
||||
const agent = this.scene.getAgent(agentId)
|
||||
if (!agent) return
|
||||
if (text) agent.showBubble(text)
|
||||
else agent.clearBubble()
|
||||
}
|
||||
|
||||
ensureAgent(agentId: string, displayName?: string, officeId?: string, palette?: number, deskId?: string) {
|
||||
if (!this.scene) return
|
||||
this.scene.ensureAgent(agentId, displayName, false, null, officeId, palette, deskId)
|
||||
}
|
||||
|
||||
getCharacterCards() {
|
||||
if (!this.scene) return []
|
||||
return this.scene.getCharacterCards()
|
||||
}
|
||||
|
||||
// ── Office management API ─────────────────────────────
|
||||
|
||||
getOffices(): OfficeConfig[] {
|
||||
return getOffices()
|
||||
}
|
||||
|
||||
renameOffice(officeId: string, newName: string) {
|
||||
if (!this.scene) return
|
||||
this.scene.renameOffice(officeId, newName)
|
||||
this.emit('officeChanged')
|
||||
}
|
||||
|
||||
assignAgentToOffice(agentId: string, officeId: string) {
|
||||
if (!this.scene) return
|
||||
this.scene.reassignAgent(agentId, officeId)
|
||||
this.emit('officeChanged')
|
||||
}
|
||||
|
||||
panToOffice(officeId: string) {
|
||||
if (!this.scene) return
|
||||
this.scene.panToOffice(officeId)
|
||||
}
|
||||
|
||||
resetCamera() {
|
||||
if (!this.scene) return
|
||||
this.scene.resetCameraView()
|
||||
}
|
||||
|
||||
/** Re-read `isLocalDaytime()` (URL + localStorage) and refresh skyline / grass if it changed. */
|
||||
syncOutdoorLighting() {
|
||||
if (!this.scene) return
|
||||
this.scene.syncOutdoorLighting()
|
||||
}
|
||||
|
||||
rebuildOfficeCollision(officeId: string, mapStr: string[], seats: [number, number][]) {
|
||||
if (!this.scene) return
|
||||
this.scene.rebuildOfficeCollision(officeId, mapStr, seats)
|
||||
}
|
||||
|
||||
getSeatsForOffice(officeId: string): Array<{ id: string; assigned: boolean; assignedTo: string | null }> {
|
||||
if (!this.scene) return []
|
||||
return this.scene.seats
|
||||
.filter(s => s.id.startsWith(`${officeId}-desk-`) || s.id.startsWith(`${officeId}-leader-`))
|
||||
.map(s => ({ id: s.id, assigned: s.assigned, assignedTo: s.assignedTo }))
|
||||
}
|
||||
|
||||
changeAgentSeat(agentId: string, seatId: string) {
|
||||
if (!this.scene) return
|
||||
this.scene.changeAgentSeat(agentId, seatId)
|
||||
this.emit('officeChanged')
|
||||
}
|
||||
|
||||
// ── Chat + Kanban game integration ──────────────────────
|
||||
|
||||
notifyChannelMessage(agentIds: string[], text: string) {
|
||||
if (!this.scene) return
|
||||
for (const id of agentIds) {
|
||||
const agent = this.scene.getAgent(id)
|
||||
if (agent) {
|
||||
agent.showBubble(text.slice(0, 30))
|
||||
setTimeout(() => agent.clearBubble(), 4000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerCrossOfficeMeeting(agentIds: string[]) {
|
||||
if (!this.scene) return
|
||||
for (const id of agentIds) {
|
||||
const agent = this.scene.getAgent(id)
|
||||
if (agent) {
|
||||
this.scene.behavior.moveToZone(agent, 'meetingRoom')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerCelebration(agentId: string) {
|
||||
if (!this.scene) return
|
||||
const agent = this.scene.getAgent(agentId)
|
||||
if (agent) {
|
||||
agent.showBubble('🎉 Done!')
|
||||
setTimeout(() => agent.clearBubble(), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────
|
||||
|
||||
private applyEvent(evt: VisualEvent) {
|
||||
if (!this.scene) return
|
||||
this.scene.behavior.applyEvent(evt)
|
||||
this.emit('eventApplied', evt)
|
||||
}
|
||||
|
||||
private applySnapshot(snapshot: VisualSnapshot) {
|
||||
if (!this.scene) {
|
||||
console.warn('[GameBridge] applySnapshot called but scene is null')
|
||||
return
|
||||
}
|
||||
|
||||
// Snapshot old agents to array first (avoid mutating Map during iteration)
|
||||
const oldIds = Array.from(this.scene.agents.keys())
|
||||
console.log('[GameBridge] applySnapshot — clearing', oldIds.length, 'old agents:', oldIds)
|
||||
for (const id of oldIds) {
|
||||
this.scene.removeAgent(id)
|
||||
}
|
||||
|
||||
const timeline = snapshot.timeline ?? []
|
||||
for (const evt of timeline) {
|
||||
this.scene.behavior.applyEvent(evt)
|
||||
}
|
||||
|
||||
const agentEntries = Object.entries(snapshot.agents ?? {})
|
||||
console.log('[GameBridge] applySnapshot — adding', agentEntries.length, 'agents:', agentEntries.map(([id]) => id))
|
||||
for (const [id, info] of agentEntries) {
|
||||
const agentData = info as {
|
||||
name?: string; role_name?: string; office_id?: string
|
||||
status?: string; runtime_status?: string; current_tool?: string | null
|
||||
appearance?: { palette?: number; hue_shift?: number; seat_zone?: string; desk_id?: string }
|
||||
}
|
||||
const name = agentData.name || agentData.role_name || id
|
||||
const officeId = agentData.office_id
|
||||
const palette = agentData.appearance?.palette
|
||||
const deskId = agentData.appearance?.desk_id
|
||||
try {
|
||||
const agent = this.scene.ensureAgent(id, name, false, null, officeId, palette, deskId)
|
||||
const runtimeStatus = agentData.runtime_status || agentData.status
|
||||
if (runtimeStatus === 'tool_active') {
|
||||
agent.currentTool = agentData.current_tool ?? null
|
||||
agent.isActive = true
|
||||
agent.setAgentState(AgentState.TYPE)
|
||||
} else if (runtimeStatus === 'reflecting') {
|
||||
agent.currentTool = agentData.current_tool ?? 'Reflect'
|
||||
agent.isActive = false
|
||||
agent.setAgentState(AgentState.REFLECT)
|
||||
}
|
||||
console.log(`[GameBridge] ✓ ensured ${id} in ${officeId} palette=${palette}`)
|
||||
} catch (err) {
|
||||
console.error(`[GameBridge] ✗ ensureAgent failed for ${id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (timeline.length === 0 && agentEntries.length === 0) {
|
||||
console.warn('[GameBridge] applySnapshot — empty snapshot, creating fallback agent')
|
||||
this.scene.ensureAgent('openopc-main', 'OpenOPC')
|
||||
}
|
||||
|
||||
console.log('[GameBridge] applySnapshot done — total agents:', this.scene.agents.size)
|
||||
this.emit('snapshotApplied', snapshot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Phaser from 'phaser'
|
||||
import { createGameConfig } from './config'
|
||||
import type { GameBridge } from './GameBridge'
|
||||
import { BootScene } from './scenes/BootScene'
|
||||
import { OfficeScene } from './scenes/OfficeScene'
|
||||
|
||||
interface Props {
|
||||
bridge: GameBridge
|
||||
}
|
||||
|
||||
export function PhaserGame({ bridge }: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const gameRef = useRef<Phaser.Game | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapperRef.current || !containerRef.current || gameRef.current) return
|
||||
|
||||
// Measure the wrapper (which has definite CSS dimensions from the grid layout).
|
||||
// The inner container div is initially empty so has 0 dimensions.
|
||||
const wrapper = wrapperRef.current
|
||||
const container = containerRef.current
|
||||
|
||||
// Force container to fill wrapper so clientWidth/Height are non-zero
|
||||
container.style.width = `${wrapper.clientWidth}px`
|
||||
container.style.height = `${wrapper.clientHeight}px`
|
||||
|
||||
// Safety: never create a 0×0 game
|
||||
const w = container.clientWidth || window.innerWidth - 400
|
||||
const h = container.clientHeight || window.innerHeight - 48
|
||||
|
||||
if (w < 50 || h < 50) {
|
||||
console.warn('[PhaserGame] Container too small:', w, h, '— using fallback size')
|
||||
container.style.width = `${window.innerWidth - 400}px`
|
||||
container.style.height = `${window.innerHeight - 48}px`
|
||||
}
|
||||
|
||||
console.log('[PhaserGame] Creating Phaser game', container.clientWidth, '×', container.clientHeight)
|
||||
|
||||
const config = createGameConfig(container)
|
||||
config.scene = [BootScene, OfficeScene]
|
||||
const game = new Phaser.Game(config)
|
||||
game.registry.set('bridge', bridge)
|
||||
gameRef.current = game
|
||||
|
||||
// Keep canvas sized to wrapper on window resize
|
||||
const onResize = () => {
|
||||
if (!wrapper || !game) return
|
||||
container.style.width = `${wrapper.clientWidth}px`
|
||||
container.style.height = `${wrapper.clientHeight}px`
|
||||
game.scale.resize(wrapper.clientWidth, wrapper.clientHeight)
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
game.destroy(true)
|
||||
gameRef.current = null
|
||||
}
|
||||
}, [bridge]) // bridge is a stable ref, effect runs once
|
||||
|
||||
return (
|
||||
// Wrapper fills the CSS grid cell
|
||||
<div ref={wrapperRef} style={{ width: '100%', height: '100%' }}>
|
||||
{/* Phaser mounts its canvas inside this div */}
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Phaser from 'phaser'
|
||||
|
||||
export const TILE_SIZE = 32
|
||||
export const OFFICE_COLS = 20
|
||||
export const OFFICE_ROWS = 25
|
||||
export const GAP_COLS = 2
|
||||
export const OFFICE_COUNT = 3
|
||||
export const WORLD_COLS = OFFICE_COLS * OFFICE_COUNT + GAP_COLS * (OFFICE_COUNT - 1) // 64
|
||||
export const WORLD_ROWS = OFFICE_ROWS // 25
|
||||
|
||||
export const MAP_COLS = WORLD_COLS
|
||||
export const MAP_ROWS = WORLD_ROWS
|
||||
export const OUTDOOR_MARGIN_X = TILE_SIZE * 8
|
||||
export const OUTDOOR_MARGIN_TOP = TILE_SIZE * 4
|
||||
export const OUTDOOR_MARGIN_BOTTOM = TILE_SIZE * 20
|
||||
export const CHAR_SCALE = 1.8
|
||||
export const CHAR_SCALE_X = CHAR_SCALE
|
||||
export const CHAR_SCALE_Y = CHAR_SCALE
|
||||
export const CHAR_SHADOW_WIDTH = 30
|
||||
export const CHAR_SHADOW_HEIGHT = 8
|
||||
export const CHAR_SHADOW_Y = -2
|
||||
export const CHAR_SHADOW_ALPHA = 0.22
|
||||
|
||||
/** Daytime if local hour is in [DAYTIME_START_HOUR, DAYTIME_END_HOUR] inclusive. */
|
||||
export const DAYTIME_START_HOUR = 5
|
||||
/** 23 → day through 23:59; 0:00–4:59 is night when mode is Auto (no URL/storage override). */
|
||||
export const DAYTIME_END_HOUR = 23
|
||||
|
||||
/** Parse `?day=1` / `#?day=1` / `#/path?day=1` for outdoor preview. */
|
||||
function readOutdoorOverrideFromUrl(): 'day' | 'night' | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const parse = (raw: string): 'day' | 'night' | null => {
|
||||
const q = new URLSearchParams(raw)
|
||||
if (q.get('day') === '1' || q.get('daytime') === '1') return 'day'
|
||||
if (q.get('night') === '1') return 'night'
|
||||
return null
|
||||
}
|
||||
let o = parse(window.location.search || '')
|
||||
if (o) return o
|
||||
const hash = window.location.hash
|
||||
if (!hash) return null
|
||||
const qm = hash.indexOf('?')
|
||||
if (qm >= 0) {
|
||||
o = parse(hash.slice(qm + 1))
|
||||
if (o) return o
|
||||
}
|
||||
const h = hash.replace(/^#/, '')
|
||||
if (h.includes('=')) {
|
||||
o = parse(h)
|
||||
if (o) return o
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Day vs night for the outdoor skyline. Clock: local 5:00–23:59 = day, 0:00–4:59 = night (Auto mode).
|
||||
* Override (browser): URL `?day=1` / `?daytime=1` (also after `#…?`), or `localStorage opc_outdoor_override` = `day`|`night`.
|
||||
* Legacy: `opc_outdoor_day` / `opc_outdoor_night` = `1`.
|
||||
*/
|
||||
export function isLocalDaytime(now = new Date()): boolean {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const url = readOutdoorOverrideFromUrl()
|
||||
if (url === 'day') return true
|
||||
if (url === 'night') return false
|
||||
const om = window.localStorage?.getItem('opc_outdoor_override')
|
||||
if (om === 'day') return true
|
||||
if (om === 'night') return false
|
||||
if (window.localStorage?.getItem('opc_outdoor_day') === '1') return true
|
||||
if (window.localStorage?.getItem('opc_outdoor_night') === '1') return false
|
||||
} catch {
|
||||
/* private mode / SSR */
|
||||
}
|
||||
}
|
||||
const h = now.getHours()
|
||||
return h >= DAYTIME_START_HOUR && h <= DAYTIME_END_HOUR
|
||||
}
|
||||
|
||||
/** Phaser camera clear color to match sky / lawn edge. */
|
||||
export const SCENE_CLEAR_DAY = 0xa8d4ec
|
||||
export const SCENE_CLEAR_NIGHT = 0x31453a
|
||||
|
||||
export const WALK_SPEED_URGENT = 200
|
||||
export const WALK_SPEED_NORMAL = 100
|
||||
export const WALK_SPEED_RELAXED = 60
|
||||
|
||||
export const WANDER_PAUSE_MIN = 2.0
|
||||
export const WANDER_PAUSE_MAX = 20.0
|
||||
export const WANDER_MOVES_BEFORE_REST_MIN = 3
|
||||
export const WANDER_MOVES_BEFORE_REST_MAX = 6
|
||||
export const SEAT_REST_MIN = 120.0
|
||||
export const SEAT_REST_MAX = 240.0
|
||||
export const CELEBRATE_DURATION = 2.5
|
||||
export const COFFEE_DURATION_MIN = 8.0
|
||||
export const COFFEE_DURATION_MAX = 15.0
|
||||
export const CHAT_DURATION_MIN = 5.0
|
||||
export const CHAT_DURATION_MAX = 10.0
|
||||
export const STATUS_BUBBLE_DURATION = 5.0
|
||||
export const INACTIVE_SEAT_TIMER_MIN = 3.0
|
||||
export const INACTIVE_SEAT_TIMER_RANGE = 2.0
|
||||
|
||||
export function createGameConfig(parent: HTMLElement): Phaser.Types.Core.GameConfig {
|
||||
const w = parent.clientWidth || window.innerWidth - 380
|
||||
const h = parent.clientHeight || window.innerHeight - 48
|
||||
const skyHex = isLocalDaytime() ? '#a8d4ec' : '#31453a'
|
||||
return {
|
||||
type: Phaser.CANVAS,
|
||||
parent,
|
||||
width: w,
|
||||
height: h,
|
||||
pixelArt: true,
|
||||
backgroundColor: skyHex,
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
gravity: { x: 0, y: 0 },
|
||||
debug: false,
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
mode: Phaser.Scale.RESIZE,
|
||||
autoCenter: Phaser.Scale.NONE,
|
||||
parent,
|
||||
},
|
||||
render: {
|
||||
antialias: false,
|
||||
pixelArt: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import Phaser from 'phaser'
|
||||
import {
|
||||
TILE_SIZE,
|
||||
CHAR_SCALE_X, CHAR_SCALE_Y,
|
||||
CHAR_SHADOW_ALPHA, CHAR_SHADOW_HEIGHT, CHAR_SHADOW_WIDTH, CHAR_SHADOW_Y,
|
||||
WALK_SPEED_URGENT, WALK_SPEED_NORMAL, WALK_SPEED_RELAXED,
|
||||
CELEBRATE_DURATION, STATUS_BUBBLE_DURATION,
|
||||
WANDER_PAUSE_MIN, WANDER_PAUSE_MAX,
|
||||
WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX,
|
||||
} from '../config'
|
||||
import { AgentState, Direction } from '../types'
|
||||
import type { PathfindingManager } from '../systems/PathfindingManager'
|
||||
|
||||
function randomRange(min: number, max: number) {
|
||||
return min + Math.random() * (max - min)
|
||||
}
|
||||
function randomInt(min: number, max: number) {
|
||||
return Math.floor(randomRange(min, max + 1))
|
||||
}
|
||||
|
||||
export class Agent extends Phaser.GameObjects.Container {
|
||||
declare body: Phaser.Physics.Arcade.Body
|
||||
|
||||
agentId: string
|
||||
displayName: string
|
||||
officeId = 'office-0'
|
||||
agentState: AgentState = AgentState.IDLE
|
||||
dir: Direction = Direction.DOWN
|
||||
palette: number
|
||||
isActive = false
|
||||
currentTool: string | null = null
|
||||
seatId: string | null = null
|
||||
urgency: 'urgent' | 'normal' | 'relaxed' = 'relaxed'
|
||||
isSubagent = false
|
||||
parentAgentId: string | null = null
|
||||
taskSummary?: string
|
||||
lastEventAt = 0
|
||||
stateTimer = 0
|
||||
seatTimer = 0
|
||||
wanderTimer: number
|
||||
wanderCount = 0
|
||||
wanderLimit: number
|
||||
hueShift = 0
|
||||
|
||||
bubbleText: string | null = null
|
||||
bubbleTimer = 0
|
||||
|
||||
myceliumEffect: string | null = null
|
||||
myceliumEffectTimer = 0
|
||||
myceliumSession: string | null = null
|
||||
|
||||
private sprite: Phaser.GameObjects.Sprite
|
||||
private shadow: Phaser.GameObjects.Ellipse
|
||||
private bubbleObj: Phaser.GameObjects.Container | null = null
|
||||
private currentPath: { x: number; y: number }[] = []
|
||||
private pathIndex = 0
|
||||
private pathfinder: PathfindingManager | null = null
|
||||
private arrivalCallback: (() => void) | null = null
|
||||
|
||||
constructor(
|
||||
scene: Phaser.Scene,
|
||||
agentId: string,
|
||||
displayName: string,
|
||||
palette: number,
|
||||
tileX: number,
|
||||
tileY: number,
|
||||
) {
|
||||
const px = tileX * TILE_SIZE + TILE_SIZE / 2
|
||||
const py = tileY * TILE_SIZE + TILE_SIZE / 2
|
||||
|
||||
super(scene, px, py)
|
||||
|
||||
this.agentId = agentId
|
||||
this.displayName = displayName
|
||||
this.palette = palette % 6
|
||||
this.wanderTimer = randomRange(0.5, 2.5)
|
||||
this.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
|
||||
|
||||
const spriteKey = `char_${this.palette}`
|
||||
this.shadow = scene.add.ellipse(
|
||||
0,
|
||||
CHAR_SHADOW_Y,
|
||||
CHAR_SHADOW_WIDTH,
|
||||
CHAR_SHADOW_HEIGHT,
|
||||
0x151820,
|
||||
CHAR_SHADOW_ALPHA,
|
||||
)
|
||||
this.shadow.setOrigin(0.5, 0.5)
|
||||
this.add(this.shadow)
|
||||
|
||||
this.sprite = scene.add.sprite(0, 0, spriteKey)
|
||||
this.sprite.setScale(CHAR_SCALE_X, CHAR_SCALE_Y)
|
||||
this.sprite.setOrigin(0.5, 1)
|
||||
this.add(this.sprite)
|
||||
|
||||
scene.add.existing(this)
|
||||
scene.physics.world.enable(this)
|
||||
|
||||
const bodyW = Math.min(12, TILE_SIZE - 4)
|
||||
const bodyH = Math.min(6, TILE_SIZE / 2)
|
||||
this.body.setSize(bodyW, bodyH)
|
||||
this.body.setOffset(-bodyW / 2, -bodyH)
|
||||
this.body.setCollideWorldBounds(true)
|
||||
|
||||
this.setDepth(py)
|
||||
this.playAnimForState()
|
||||
}
|
||||
|
||||
setPathfinder(pf: PathfindingManager) {
|
||||
this.pathfinder = pf
|
||||
}
|
||||
|
||||
getState(): AgentState { return this.agentState }
|
||||
|
||||
getTilePos(): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.floor(this.x / TILE_SIZE),
|
||||
y: Math.floor(this.y / TILE_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
// ── State management ──────────────────────────────────────
|
||||
|
||||
setAgentState(newState: AgentState) {
|
||||
if (this.agentState === newState) return
|
||||
this.agentState = newState
|
||||
this.playAnimForState()
|
||||
}
|
||||
|
||||
setDirection(dir: Direction) {
|
||||
if (this.dir === dir) return
|
||||
this.dir = dir
|
||||
this.playAnimForState()
|
||||
}
|
||||
|
||||
private playAnimForState() {
|
||||
if (!this.sprite?.anims) return
|
||||
|
||||
const key = `char_${this.palette}`
|
||||
const dir = this.dir
|
||||
|
||||
const isLeft = dir === Direction.LEFT
|
||||
this.sprite.setFlipX(isLeft)
|
||||
const animDir = isLeft ? 'right' : dir
|
||||
|
||||
switch (this.agentState) {
|
||||
case AgentState.WALK:
|
||||
this.sprite.play(`${key}_walk_${animDir}`, true)
|
||||
break
|
||||
case AgentState.CELEBRATE:
|
||||
this.sprite.play(`${key}_celebrate_${animDir}`, true)
|
||||
break
|
||||
case AgentState.TYPE:
|
||||
case AgentState.PRESENT:
|
||||
case AgentState.PRACTICE:
|
||||
this.sprite.play(`${key}_type_${animDir}`, true)
|
||||
break
|
||||
case AgentState.THINK:
|
||||
case AgentState.REFLECT:
|
||||
this.sprite.play(`${key}_read_${animDir}`, true)
|
||||
break
|
||||
case AgentState.COFFEE:
|
||||
case AgentState.CHAT:
|
||||
this.sprite.play(`${key}_coffee_${animDir}`, true)
|
||||
break
|
||||
case AgentState.SLEEP:
|
||||
case AgentState.IDLE:
|
||||
default:
|
||||
this.sprite.play(`${key}_idle_${animDir}`, true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Movement ──────────────────────────────────────────────
|
||||
|
||||
async walkTo(tileX: number, tileY: number, onArrival?: () => void): Promise<boolean> {
|
||||
if (!this.pathfinder || !this.sprite?.anims) return false
|
||||
|
||||
const from = this.getTilePos()
|
||||
const path = await this.pathfinder.findPath(from, { x: tileX, y: tileY })
|
||||
|
||||
if (path.length === 0) return false
|
||||
|
||||
this.currentPath = path
|
||||
this.pathIndex = 0
|
||||
this.arrivalCallback = onArrival ?? null
|
||||
this.setAgentState(AgentState.WALK)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
stopMovement() {
|
||||
this.currentPath = []
|
||||
this.pathIndex = 0
|
||||
this.arrivalCallback = null
|
||||
this.body?.setVelocity(0, 0)
|
||||
}
|
||||
|
||||
get isMoving(): boolean {
|
||||
return this.agentState === AgentState.WALK && this.currentPath.length > 0
|
||||
}
|
||||
|
||||
private getWalkSpeed(): number {
|
||||
switch (this.urgency) {
|
||||
case 'urgent': return WALK_SPEED_URGENT
|
||||
case 'normal': return WALK_SPEED_NORMAL
|
||||
default: return WALK_SPEED_RELAXED
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bubble ────────────────────────────────────────────────
|
||||
|
||||
showBubble(text: string, duration = STATUS_BUBBLE_DURATION) {
|
||||
this.bubbleText = text
|
||||
this.bubbleTimer = duration
|
||||
this.updateBubbleDisplay()
|
||||
}
|
||||
|
||||
clearBubble() {
|
||||
this.bubbleText = null
|
||||
this.bubbleTimer = 0
|
||||
if (this.bubbleObj) {
|
||||
this.bubbleObj.destroy()
|
||||
this.bubbleObj = null
|
||||
}
|
||||
}
|
||||
|
||||
private updateBubbleDisplay() {
|
||||
if (this.bubbleObj) {
|
||||
this.bubbleObj.destroy()
|
||||
this.bubbleObj = null
|
||||
}
|
||||
if (!this.bubbleText) return
|
||||
|
||||
const container = this.scene.add.container(this.x, this.y - 70)
|
||||
|
||||
const textObj = this.scene.add.text(0, 0, this.bubbleText, {
|
||||
fontSize: '10px',
|
||||
fontFamily: 'monospace',
|
||||
color: '#1a1a2e',
|
||||
backgroundColor: '#ffffff',
|
||||
padding: { x: 4, y: 2 },
|
||||
resolution: 2,
|
||||
})
|
||||
textObj.setOrigin(0.5, 1)
|
||||
|
||||
const bg = this.scene.add.graphics()
|
||||
const w = textObj.width + 8
|
||||
const h = textObj.height + 4
|
||||
bg.fillStyle(0xffffff, 0.95)
|
||||
bg.fillRoundedRect(-w / 2, -h, w, h, 4)
|
||||
bg.lineStyle(1, 0x666666, 0.5)
|
||||
bg.strokeRoundedRect(-w / 2, -h, w, h, 4)
|
||||
|
||||
container.add([bg, textObj])
|
||||
container.setDepth(100000)
|
||||
this.bubbleObj = container
|
||||
}
|
||||
|
||||
// ── Per-frame update ──────────────────────────────────────
|
||||
|
||||
update(dt: number) {
|
||||
// Depth sorting
|
||||
this.setDepth(this.y)
|
||||
|
||||
// Bubble position tracking + timer
|
||||
if (this.bubbleObj) {
|
||||
this.bubbleObj.setPosition(this.x, this.y - 70)
|
||||
if (this.bubbleTimer > 0) {
|
||||
this.bubbleTimer -= dt
|
||||
if (this.bubbleTimer <= 0) {
|
||||
this.clearBubble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// State timer
|
||||
if (this.stateTimer > 0) {
|
||||
this.stateTimer -= dt
|
||||
if (this.stateTimer <= 0) {
|
||||
this.stateTimer = 0
|
||||
if (this.agentState === AgentState.CELEBRATE) {
|
||||
this.setAgentState(AgentState.IDLE)
|
||||
}
|
||||
if (this.agentState === AgentState.COFFEE) {
|
||||
this.setAgentState(AgentState.IDLE)
|
||||
this.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
|
||||
}
|
||||
if (this.agentState === AgentState.CHAT) {
|
||||
this.setAgentState(AgentState.IDLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path following
|
||||
if (this.agentState === AgentState.WALK && this.currentPath.length > 0) {
|
||||
this.followPath(dt)
|
||||
}
|
||||
}
|
||||
|
||||
private followPath(dt: number) {
|
||||
this.body.setVelocity(0, 0)
|
||||
|
||||
if (this.pathIndex >= this.currentPath.length) {
|
||||
this.arriveAtDestination()
|
||||
return
|
||||
}
|
||||
|
||||
const target = this.currentPath[this.pathIndex]
|
||||
const targetPx = target.x * TILE_SIZE + TILE_SIZE / 2
|
||||
const targetPy = target.y * TILE_SIZE + TILE_SIZE / 2
|
||||
|
||||
const dx = targetPx - this.x
|
||||
const dy = targetPy - this.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
const speed = this.getWalkSpeed()
|
||||
const step = speed * dt
|
||||
|
||||
if (dist <= step + 0.5) {
|
||||
this.x = targetPx
|
||||
this.y = targetPy
|
||||
this.pathIndex++
|
||||
|
||||
if (this.pathIndex >= this.currentPath.length) {
|
||||
this.arriveAtDestination()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.x += (dx / dist) * step
|
||||
this.y += (dy / dist) * step
|
||||
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
this.setDirection(dx > 0 ? Direction.RIGHT : Direction.LEFT)
|
||||
} else {
|
||||
this.setDirection(dy > 0 ? Direction.DOWN : Direction.UP)
|
||||
}
|
||||
}
|
||||
|
||||
private arriveAtDestination() {
|
||||
this.currentPath = []
|
||||
this.pathIndex = 0
|
||||
this.body.setVelocity(0, 0)
|
||||
|
||||
const cb = this.arrivalCallback
|
||||
this.arrivalCallback = null
|
||||
|
||||
if (cb) {
|
||||
cb()
|
||||
} else if (this.agentState === AgentState.WALK) {
|
||||
this.setAgentState(AgentState.IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
destroy(fromScene?: boolean) {
|
||||
this.clearBubble()
|
||||
super.destroy(fromScene)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Direction, SeatDef, InteractableDef } from '../types'
|
||||
import { OFFICE_COLS, OFFICE_ROWS } from '../config'
|
||||
import { getOffices, parseOfficeMapStr, type OfficeConfig } from './OfficeStore'
|
||||
|
||||
export interface ZoneDef {
|
||||
name: string
|
||||
bounds: { x: number; y: number; w: number; h: number }
|
||||
seats: SeatDef[]
|
||||
interactables: InteractableDef[]
|
||||
doorways: { id: string; tileX: number; tileY: number }[]
|
||||
}
|
||||
|
||||
function seat(id: string, tileX: number, tileY: number, facing: Direction): SeatDef {
|
||||
return { id, tileX, tileY, facing, assigned: false, assignedTo: null }
|
||||
}
|
||||
|
||||
function interactable(id: string, tileX: number, tileY: number, type: string): InteractableDef {
|
||||
return { id, tileX, tileY, type }
|
||||
}
|
||||
|
||||
interface ZoneTemplate {
|
||||
name: string
|
||||
localBounds: { x: number; y: number; w: number; h: number }
|
||||
interactables: { id: string; localX: number; localY: number; type: string }[]
|
||||
doorways: { id: string; localX: number; localY: number }[]
|
||||
}
|
||||
|
||||
const ZONE_TEMPLATES: Record<string, ZoneTemplate> = {
|
||||
meetingRoom: {
|
||||
name: 'Meeting Room',
|
||||
localBounds: { x: 7, y: 3, w: 6, h: 5 },
|
||||
interactables: [{ id: 'whiteboard', localX: 9, localY: 1, type: 'whiteboard' }],
|
||||
doorways: [{ id: 'door-meeting', localX: 9, localY: 9 }],
|
||||
},
|
||||
workspace: {
|
||||
name: 'Workspace',
|
||||
localBounds: { x: 1, y: 10, w: 11, h: 7 },
|
||||
interactables: [{ id: 'printer', localX: 10, localY: 10, type: 'printer' }],
|
||||
doorways: [{ id: 'door-ws', localX: 9, localY: 10 }],
|
||||
},
|
||||
breakRoom: {
|
||||
name: 'Break Room',
|
||||
localBounds: { x: 13, y: 10, w: 6, h: 8 },
|
||||
interactables: [
|
||||
{ id: 'coffee-machine', localX: 18, localY: 10, type: 'coffee_machine' },
|
||||
{ id: 'fridge', localX: 18, localY: 11, type: 'fridge' },
|
||||
],
|
||||
doorways: [{ id: 'door-break', localX: 13, localY: 10 }],
|
||||
},
|
||||
leaderOffice: {
|
||||
name: 'Leader Office',
|
||||
localBounds: { x: 13, y: 18, w: 6, h: 6 },
|
||||
interactables: [],
|
||||
doorways: [{ id: 'door-leader', localX: 16, localY: 17 }],
|
||||
},
|
||||
lobby: {
|
||||
name: 'Lobby',
|
||||
localBounds: { x: 1, y: 19, w: 12, h: 5 },
|
||||
interactables: [],
|
||||
doorways: [{ id: 'entrance', localX: 10, localY: 18 }],
|
||||
},
|
||||
}
|
||||
|
||||
function inferFacing(col: number, row: number, zoneName: string): Direction {
|
||||
switch (zoneName) {
|
||||
case 'workspace':
|
||||
return 'up'
|
||||
case 'meetingRoom': {
|
||||
const tableCenterX = 9.5
|
||||
return col < tableCenterX ? 'right' : 'left'
|
||||
}
|
||||
case 'breakRoom': {
|
||||
const tableCenterX = 15.5
|
||||
return col < tableCenterX ? 'right' : 'left'
|
||||
}
|
||||
case 'leaderOffice':
|
||||
return 'up'
|
||||
default:
|
||||
return 'down'
|
||||
}
|
||||
}
|
||||
|
||||
function classifyLocalSeat(col: number, row: number): string {
|
||||
for (const [name, z] of Object.entries(ZONE_TEMPLATES)) {
|
||||
const { x, y, w, h } = z.localBounds
|
||||
if (col >= x && col < x + w && row >= y && row < y + h) return name
|
||||
}
|
||||
return 'lobby'
|
||||
}
|
||||
|
||||
export function buildZonesForOffice(office: OfficeConfig): Record<string, ZoneDef> {
|
||||
const off = office.offsetCol
|
||||
const zones: Record<string, ZoneDef> = {}
|
||||
|
||||
for (const [zoneKey, tmpl] of Object.entries(ZONE_TEMPLATES)) {
|
||||
const globalKey = `${office.id}-${zoneKey}`
|
||||
zones[globalKey] = {
|
||||
name: `${tmpl.name} (${office.name})`,
|
||||
bounds: {
|
||||
x: tmpl.localBounds.x + off,
|
||||
y: tmpl.localBounds.y,
|
||||
w: tmpl.localBounds.w,
|
||||
h: tmpl.localBounds.h,
|
||||
},
|
||||
seats: [],
|
||||
interactables: tmpl.interactables.map(i =>
|
||||
interactable(`${office.id}-${i.id}`, i.localX + off, i.localY, i.type),
|
||||
),
|
||||
doorways: tmpl.doorways.map(d => ({
|
||||
id: `${office.id}-${d.id}`,
|
||||
tileX: d.localX + off,
|
||||
tileY: d.localY,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const counters: Record<string, number> = {}
|
||||
for (const [col, row] of office.seats) {
|
||||
const localZone = classifyLocalSeat(col, row)
|
||||
const globalKey = `${office.id}-${localZone}`
|
||||
if (!zones[globalKey]) continue
|
||||
counters[globalKey] = (counters[globalKey] ?? 0) + 1
|
||||
const idx = counters[globalKey]
|
||||
const prefix = localZone === 'workspace' ? 'desk' : localZone === 'meetingRoom' ? 'meeting' : localZone === 'breakRoom' ? 'break' : localZone === 'leaderOffice' ? 'leader' : 'lobby'
|
||||
const facing = inferFacing(col, row, localZone)
|
||||
zones[globalKey].seats.push(seat(`${office.id}-${prefix}-${idx}`, col + off, row, facing))
|
||||
}
|
||||
|
||||
return zones
|
||||
}
|
||||
|
||||
export function buildAllZones(offices?: OfficeConfig[]): Record<string, ZoneDef> {
|
||||
const all = offices ?? getOffices()
|
||||
const zones: Record<string, ZoneDef> = {}
|
||||
for (const office of all) {
|
||||
Object.assign(zones, buildZonesForOffice(office))
|
||||
}
|
||||
return zones
|
||||
}
|
||||
|
||||
export let ZONES: Record<string, ZoneDef> = buildAllZones()
|
||||
|
||||
export function reloadZones(offices?: OfficeConfig[]) {
|
||||
ZONES = buildAllZones(offices)
|
||||
}
|
||||
|
||||
export function getOfficeZoneKey(officeId: string, zoneName: string): string {
|
||||
return `${officeId}-${zoneName}`
|
||||
}
|
||||
|
||||
export function getOfficeDeskSeats(officeId: string): SeatDef[] {
|
||||
const desks = ZONES[`${officeId}-workspace`]?.seats ?? []
|
||||
const leaders = ZONES[`${officeId}-leaderOffice`]?.seats ?? []
|
||||
return [...desks, ...leaders]
|
||||
}
|
||||
|
||||
export function getOfficeAllSeats(officeId: string): SeatDef[] {
|
||||
return Object.entries(ZONES)
|
||||
.filter(([k]) => k.startsWith(`${officeId}-`))
|
||||
.flatMap(([, z]) => z.seats)
|
||||
}
|
||||
|
||||
export function getAllDeskSeats(): SeatDef[] {
|
||||
return Object.entries(ZONES)
|
||||
.filter(([k]) => k.endsWith('-workspace'))
|
||||
.flatMap(([, z]) => z.seats)
|
||||
}
|
||||
|
||||
export function getMeetingSeats(officeId?: string): SeatDef[] {
|
||||
if (officeId) return ZONES[`${officeId}-meetingRoom`]?.seats ?? []
|
||||
return Object.entries(ZONES)
|
||||
.filter(([k]) => k.endsWith('-meetingRoom'))
|
||||
.flatMap(([, z]) => z.seats)
|
||||
}
|
||||
|
||||
export function getAllSeats(): SeatDef[] {
|
||||
return Object.values(ZONES).flatMap(z => z.seats)
|
||||
}
|
||||
|
||||
export function randomTileInZone(zoneKey: string): { x: number; y: number } | null {
|
||||
const zone = ZONES[zoneKey]
|
||||
if (!zone) return null
|
||||
const { x, y, w, h } = zone.bounds
|
||||
|
||||
const officeId = zoneKey.split('-').slice(0, 2).join('-')
|
||||
const offices = getOffices()
|
||||
const office = offices.find(o => o.id === officeId)
|
||||
if (!office) return { x: x + 1, y: y + 1 }
|
||||
|
||||
const grid = parseOfficeMapStr(office.mapStr)
|
||||
const off = office.offsetCol
|
||||
const walkable: { x: number; y: number }[] = []
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
const localCol = col - off
|
||||
if (localCol >= 0 && localCol < OFFICE_COLS && row < OFFICE_ROWS && grid[row]?.[localCol] === 0) {
|
||||
walkable.push({ x: col, y: row })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (walkable.length === 0) return null
|
||||
return walkable[Math.floor(Math.random() * walkable.length)]
|
||||
}
|
||||
|
||||
export function getDoorwayTargets(zoneKey: string): { x: number; y: number }[] {
|
||||
const zone = ZONES[zoneKey]
|
||||
if (!zone) return []
|
||||
return zone.doorways.map(d => ({ x: d.tileX, y: d.tileY }))
|
||||
}
|
||||
|
||||
export function getOfficeLobbyDoorways(officeId: string): { x: number; y: number }[] {
|
||||
const zone = ZONES[`${officeId}-lobby`]
|
||||
if (!zone) return []
|
||||
return zone.doorways.map(d => ({ x: d.tileX, y: d.tileY }))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
import { OFFICE_COLS, OFFICE_ROWS, GAP_COLS } from '../config'
|
||||
|
||||
export const DEFAULT_MAP_STR: string[] = [
|
||||
'####################',
|
||||
'####################',
|
||||
'####################',
|
||||
'#######......#######',
|
||||
'#######..##..#######',
|
||||
'#######..##..#######',
|
||||
'#######..##..#######',
|
||||
'#######......#######',
|
||||
'#########.##########',
|
||||
'#########.##########',
|
||||
'#.............#....#',
|
||||
'#..................#',
|
||||
'#.#########....##..#',
|
||||
'#...........#......#',
|
||||
'#...........#..##..#',
|
||||
'#.#########.#......#',
|
||||
'#...........#......#',
|
||||
'#...........####.###',
|
||||
'##########..####.###',
|
||||
'##########..#......#',
|
||||
'#...........#..#...#',
|
||||
'#...####....#.###..#',
|
||||
'#...........#......#',
|
||||
'#.##....#####....###',
|
||||
'####################',
|
||||
]
|
||||
|
||||
export const DEFAULT_SEATS: [number, number][] = [
|
||||
[8, 4], [8, 5], [8, 6], [11, 4], [11, 5], [11, 6],
|
||||
[3, 13], [6, 13], [9, 13], [3, 16], [6, 16], [9, 16],
|
||||
[14, 12], [17, 12], [14, 14], [17, 14],
|
||||
[15, 22],
|
||||
]
|
||||
|
||||
export interface OfficeConfig {
|
||||
id: string
|
||||
name: string
|
||||
offsetCol: number
|
||||
mapStr: string[]
|
||||
seats: [number, number][]
|
||||
assignedAgents: string[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'office-multi-config'
|
||||
|
||||
function makeDefaultOffices(): OfficeConfig[] {
|
||||
const step = OFFICE_COLS + GAP_COLS
|
||||
return [
|
||||
{ id: 'office-0', name: 'Office A', offsetCol: 0, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
|
||||
{ id: 'office-1', name: 'Office B', offsetCol: step, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
|
||||
{ id: 'office-2', name: 'Office C', offsetCol: step * 2, mapStr: [...DEFAULT_MAP_STR], seats: [...DEFAULT_SEATS], assignedAgents: [] },
|
||||
]
|
||||
}
|
||||
|
||||
export const DEFAULT_OFFICES = makeDefaultOffices()
|
||||
|
||||
export function getOffices(): OfficeConfig[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as OfficeConfig[]
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return makeDefaultOffices()
|
||||
}
|
||||
|
||||
export function saveOffices(offices: OfficeConfig[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(offices))
|
||||
}
|
||||
|
||||
export function getOfficeById(id: string): OfficeConfig | undefined {
|
||||
return getOffices().find(o => o.id === id)
|
||||
}
|
||||
|
||||
export function renameOffice(id: string, name: string) {
|
||||
const offices = getOffices()
|
||||
const office = offices.find(o => o.id === id)
|
||||
if (office) {
|
||||
office.name = name
|
||||
saveOffices(offices)
|
||||
}
|
||||
}
|
||||
|
||||
export function assignAgent(officeId: string, agentId: string) {
|
||||
const offices = getOffices()
|
||||
for (const o of offices) {
|
||||
o.assignedAgents = o.assignedAgents.filter(a => a !== agentId)
|
||||
}
|
||||
const target = offices.find(o => o.id === officeId)
|
||||
if (target) target.assignedAgents.push(agentId)
|
||||
saveOffices(offices)
|
||||
}
|
||||
|
||||
export function unassignAgent(agentId: string) {
|
||||
const offices = getOffices()
|
||||
for (const o of offices) {
|
||||
o.assignedAgents = o.assignedAgents.filter(a => a !== agentId)
|
||||
}
|
||||
saveOffices(offices)
|
||||
}
|
||||
|
||||
export function getAgentOffice(agentId: string): OfficeConfig | undefined {
|
||||
return getOffices().find(o => o.assignedAgents.includes(agentId))
|
||||
}
|
||||
|
||||
export function updateOfficeMap(officeId: string, mapStr: string[], seats: [number, number][]) {
|
||||
const offices = getOffices()
|
||||
const office = offices.find(o => o.id === officeId)
|
||||
if (office) {
|
||||
office.mapStr = mapStr
|
||||
office.seats = seats
|
||||
saveOffices(offices)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOfficeMapStr(mapStr: string[]): number[][] {
|
||||
const grid: number[][] = []
|
||||
for (let r = 0; r < OFFICE_ROWS; r++) {
|
||||
const row: number[] = []
|
||||
const line = r < mapStr.length ? mapStr[r] : '#'.repeat(OFFICE_COLS)
|
||||
for (let c = 0; c < OFFICE_COLS; c++) {
|
||||
row.push(c < line.length && line[c] === '.' ? 0 : 1)
|
||||
}
|
||||
grid.push(row)
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
export function buildCompositeGrid(offices: OfficeConfig[]): number[][] {
|
||||
const worldCols = offices.length > 0
|
||||
? offices[offices.length - 1].offsetCol + OFFICE_COLS
|
||||
: OFFICE_COLS
|
||||
const grid: number[][] = []
|
||||
for (let r = 0; r < OFFICE_ROWS; r++) {
|
||||
grid.push(new Array(worldCols).fill(1))
|
||||
}
|
||||
for (const office of offices) {
|
||||
const officeGrid = parseOfficeMapStr(office.mapStr)
|
||||
for (let r = 0; r < OFFICE_ROWS; r++) {
|
||||
for (let c = 0; c < OFFICE_COLS; c++) {
|
||||
grid[r][office.offsetCol + c] = officeGrid[r][c]
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
export function getWorkspaceSeatCount(office: OfficeConfig): number {
|
||||
let count = 0
|
||||
for (const [col, row] of office.seats) {
|
||||
if (row >= 10 && row <= 17 && col < 12) count++
|
||||
else if (row >= 18 && row <= 23 && col >= 13 && col <= 18) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Phaser from 'phaser'
|
||||
|
||||
const CHAR_FRAME_W = 16
|
||||
const CHAR_FRAME_H = 32
|
||||
const CHAR_COUNT = 6
|
||||
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Boot')
|
||||
}
|
||||
|
||||
preload() {
|
||||
this.load.image('office-bg', 'assets/office-bg.png')
|
||||
this.load.spritesheet('office-tileset-32', 'assets/office-tileset-32.png', {
|
||||
frameWidth: 32,
|
||||
frameHeight: 32,
|
||||
})
|
||||
|
||||
for (let i = 0; i < CHAR_COUNT; i++) {
|
||||
this.load.spritesheet(`char_${i}`, `assets/characters/char_${i}.png`, {
|
||||
frameWidth: CHAR_FRAME_W,
|
||||
frameHeight: CHAR_FRAME_H,
|
||||
})
|
||||
}
|
||||
|
||||
const bar = this.add.graphics()
|
||||
this.load.on('progress', (v: number) => {
|
||||
bar.clear()
|
||||
bar.fillStyle(0x4a90d9, 1)
|
||||
bar.fillRect(this.scale.width / 2 - 100, this.scale.height / 2 - 8, 200 * v, 16)
|
||||
})
|
||||
this.load.on('complete', () => bar.destroy())
|
||||
}
|
||||
|
||||
create() {
|
||||
this.createCharacterAnimations()
|
||||
console.log('[BootScene] All assets loaded')
|
||||
this.scene.start('Office')
|
||||
}
|
||||
|
||||
private createCharacterAnimations() {
|
||||
const COLS = 7
|
||||
const dirs = [
|
||||
{ name: 'down', row: 0 },
|
||||
{ name: 'up', row: 1 },
|
||||
{ name: 'right', row: 2 },
|
||||
{ name: 'left', row: 2 },
|
||||
]
|
||||
for (let p = 0; p < CHAR_COUNT; p++) {
|
||||
const key = `char_${p}`
|
||||
for (const dir of dirs) {
|
||||
const base = dir.row * COLS
|
||||
this.anims.create({ key: `${key}_walk_${dir.name}`, frames: [{ key, frame: base }, { key, frame: base + 1 }, { key, frame: base + 2 }, { key, frame: base + 1 }], frameRate: 8, repeat: -1 })
|
||||
this.anims.create({ key: `${key}_idle_${dir.name}`, frames: [{ key, frame: base + 1 }], frameRate: 1 })
|
||||
this.anims.create({ key: `${key}_type_${dir.name}`, frames: [{ key, frame: base + 3 }, { key, frame: base + 4 }], frameRate: 3, repeat: -1 })
|
||||
this.anims.create({ key: `${key}_read_${dir.name}`, frames: [{ key, frame: base + 5 }, { key, frame: base + 6 }], frameRate: 2, repeat: -1 })
|
||||
this.anims.create({ key: `${key}_coffee_${dir.name}`, frames: [{ key, frame: base + 1 }, { key, frame: base }], frameRate: 2, repeat: -1 })
|
||||
this.anims.create({ key: `${key}_celebrate_${dir.name}`, frames: [{ key, frame: base }, { key, frame: base + 1 }, { key, frame: base + 2 }, { key, frame: base + 1 }], frameRate: 6, repeat: -1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import Phaser from 'phaser'
|
||||
import {
|
||||
TILE_SIZE, MAP_COLS, MAP_ROWS, OFFICE_COLS, OUTDOOR_MARGIN_X, OUTDOOR_MARGIN_TOP, OUTDOOR_MARGIN_BOTTOM,
|
||||
isLocalDaytime, SCENE_CLEAR_DAY, SCENE_CLEAR_NIGHT,
|
||||
} from '../config'
|
||||
import { OfficeMapBuilder, type MapData } from '../map/OfficeMapBuilder'
|
||||
import { Agent } from '../entities/Agent'
|
||||
import { PathfindingManager } from '../systems/PathfindingManager'
|
||||
import { BehaviorController } from '../systems/BehaviorController'
|
||||
import type { GameBridge } from '../GameBridge'
|
||||
import { getAllSeats, getOfficeDeskSeats, reloadZones } from '../map/InteractionZones'
|
||||
import {
|
||||
getOffices, assignAgent as storeAssignAgent,
|
||||
updateOfficeMap, buildCompositeGrid, renameOffice as storeRenameOffice,
|
||||
getAgentOffice,
|
||||
type OfficeConfig,
|
||||
} from '../map/OfficeStore'
|
||||
import { AgentState, type SeatDef } from '../types'
|
||||
|
||||
export class OfficeScene extends Phaser.Scene {
|
||||
mapData!: MapData
|
||||
pathfinder!: PathfindingManager
|
||||
behavior!: BehaviorController
|
||||
bridge!: GameBridge
|
||||
mapBuilder!: OfficeMapBuilder
|
||||
private yachtLoopStarted = false
|
||||
private outdoorIsDay: boolean | null = null
|
||||
|
||||
agents: Map<string, Agent> = new Map()
|
||||
seats: SeatDef[] = []
|
||||
walkableTiles: { x: number; y: number }[] = []
|
||||
private walkableTilesByOffice: Map<string, { x: number; y: number }[]> = new Map()
|
||||
|
||||
private dragState: {
|
||||
pointerId: number | null
|
||||
lastX: number
|
||||
lastY: number
|
||||
moved: boolean
|
||||
} = {
|
||||
pointerId: null,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
moved: false,
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super('Office')
|
||||
}
|
||||
|
||||
create() {
|
||||
this.bridge = this.registry.get('bridge') as GameBridge
|
||||
|
||||
const isDay = isLocalDaytime()
|
||||
this.outdoorIsDay = isDay
|
||||
this.cameras.main.setBackgroundColor(isDay ? SCENE_CLEAR_DAY : SCENE_CLEAR_NIGHT)
|
||||
|
||||
this.mapBuilder = new OfficeMapBuilder()
|
||||
this.mapData = this.mapBuilder.buildMap(this, isDay)
|
||||
|
||||
this.pathfinder = new PathfindingManager(this.mapData.collisionGrid)
|
||||
this.walkableTiles = this.pathfinder.getWalkableTiles()
|
||||
this.buildWalkableTilesByOffice()
|
||||
|
||||
this.seats = getAllSeats().map(s => ({ ...s }))
|
||||
|
||||
this.behavior = new BehaviorController(this)
|
||||
|
||||
const mapW = MAP_COLS * TILE_SIZE
|
||||
const mapH = MAP_ROWS * TILE_SIZE
|
||||
const worldX = -OUTDOOR_MARGIN_X
|
||||
const worldY = -OUTDOOR_MARGIN_TOP
|
||||
const worldW = mapW + OUTDOOR_MARGIN_X * 2
|
||||
const worldH = mapH + OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM
|
||||
this.physics.world.setBounds(worldX, worldY, worldW, worldH)
|
||||
this.cameras.main.setBounds(worldX, worldY, worldW, worldH)
|
||||
|
||||
this.resetCameraView(false)
|
||||
this.startWaterfrontLoop()
|
||||
|
||||
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
||||
if (!pointer.leftButtonDown()) return
|
||||
this.dragState.pointerId = pointer.id
|
||||
this.dragState.lastX = pointer.x
|
||||
this.dragState.lastY = pointer.y
|
||||
this.dragState.moved = false
|
||||
})
|
||||
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
|
||||
if (!pointer.isDown || this.dragState.pointerId !== pointer.id) return
|
||||
const dx = pointer.x - this.dragState.lastX
|
||||
const dy = pointer.y - this.dragState.lastY
|
||||
if (!this.dragState.moved && Math.abs(pointer.downX - pointer.x) + Math.abs(pointer.downY - pointer.y) < 6) {
|
||||
return
|
||||
}
|
||||
this.dragState.moved = true
|
||||
const cam = this.cameras.main
|
||||
cam.stopFollow()
|
||||
cam.scrollX -= dx / cam.zoom
|
||||
cam.scrollY -= dy / cam.zoom
|
||||
this.dragState.lastX = pointer.x
|
||||
this.dragState.lastY = pointer.y
|
||||
})
|
||||
this.input.on('wheel', (_p: Phaser.Input.Pointer, _g: Phaser.GameObjects.GameObject[], _dx: number, dy: number) => {
|
||||
const cam = this.cameras.main
|
||||
const pointer = this.input.activePointer
|
||||
const before = cam.getWorldPoint(pointer.x, pointer.y)
|
||||
const minZ = this.getMinCameraZoom(cam)
|
||||
const nextZoom = Phaser.Math.Clamp(cam.zoom - dy * 0.0015, minZ, 3)
|
||||
cam.setZoom(nextZoom)
|
||||
const after = cam.getWorldPoint(pointer.x, pointer.y)
|
||||
cam.scrollX += before.x - after.x
|
||||
cam.scrollY += before.y - after.y
|
||||
cam.scrollX = cam.clampX(cam.scrollX)
|
||||
cam.scrollY = cam.clampY(cam.scrollY)
|
||||
})
|
||||
|
||||
this.scale.on('resize', () => {
|
||||
const cam = this.cameras.main
|
||||
const minZ = this.getMinCameraZoom(cam)
|
||||
if (cam.zoom < minZ) cam.setZoom(minZ)
|
||||
cam.scrollX = cam.clampX(cam.scrollX)
|
||||
cam.scrollY = cam.clampY(cam.scrollY)
|
||||
})
|
||||
|
||||
this.time.addEvent({
|
||||
delay: 45000,
|
||||
loop: true,
|
||||
callback: this.checkOutdoorDayNight,
|
||||
callbackScope: this,
|
||||
})
|
||||
|
||||
this.input.on('pointerup', (pointer: Phaser.Input.Pointer) => {
|
||||
if (this.dragState.pointerId !== pointer.id) return
|
||||
const dragged = this.dragState.pointerId === pointer.id && this.dragState.moved
|
||||
this.dragState.pointerId = null
|
||||
this.dragState.moved = false
|
||||
if (dragged) return
|
||||
const wp = this.cameras.main.getWorldPoint(pointer.x, pointer.y)
|
||||
let closest: Agent | null = null
|
||||
let closestDist = Infinity
|
||||
for (const agent of this.agents.values()) {
|
||||
const d = Phaser.Math.Distance.Between(wp.x, wp.y, agent.x, agent.y)
|
||||
if (d < 24 && d < closestDist) { closest = agent; closestDist = d }
|
||||
}
|
||||
if (closest) {
|
||||
this.bridge.emit('agentSelected', closest.agentId)
|
||||
this.cameras.main.startFollow(closest, true, 0.1, 0.1)
|
||||
}
|
||||
})
|
||||
|
||||
if (this.bridge) this.bridge.setScene(this)
|
||||
|
||||
console.log('[OfficeScene] Ready — walkable tiles:', this.walkableTiles.length)
|
||||
}
|
||||
|
||||
update(_time: number, delta: number) {
|
||||
const dt = delta / 1000
|
||||
for (const agent of this.agents.values()) agent.update(dt)
|
||||
this.behavior.updateIdle(dt)
|
||||
}
|
||||
|
||||
private startWaterfrontLoop() {
|
||||
if (this.yachtLoopStarted) return
|
||||
this.yachtLoopStarted = true
|
||||
|
||||
const { dockCenterX, dockY, waterTopY, waterBottomY } = this.mapData.waterfront
|
||||
const yachtScale = 3
|
||||
const boatY = Phaser.Math.Clamp(dockY + TILE_SIZE * 5.0, waterTopY + TILE_SIZE * 3.8, waterBottomY - TILE_SIZE * 4.6)
|
||||
const startX = dockCenterX - TILE_SIZE * 20
|
||||
const dockX = dockCenterX + TILE_SIZE * 1.65
|
||||
const exitX = dockCenterX + TILE_SIZE * 19
|
||||
|
||||
const shadow = this.add.ellipse(0, 13, 122, 24, 0x173443, 0.28)
|
||||
const wakeA = this.add.ellipse(-78, 8, 24, 7, 0xeaf8ff, 0.42)
|
||||
const wakeB = this.add.ellipse(-92, 9, 16, 5, 0xd7eef7, 0.3)
|
||||
const wakeC = this.add.ellipse(-106, 10, 10, 4, 0xbfdceb, 0.18)
|
||||
const hullMain = this.add.rectangle(-2, 6, 94, 16, 0xf6f7f8, 1)
|
||||
const sternBlock = this.add.rectangle(-49, 5, 12, 14, 0xe5e8ec, 1)
|
||||
const bowMid = this.add.rectangle(44, 4, 12, 12, 0xf6f7f8, 1)
|
||||
const bowTip = this.add.triangle(56, 4, 0, -6, 13, 0, 0, 6, 0xf6f7f8, 1)
|
||||
const waterline = this.add.rectangle(-1, 11, 66, 4, 0xb9c3cb, 0.95)
|
||||
const hullStripe = this.add.rectangle(6, 3, 74, 3, 0x8dbfd8, 0.95)
|
||||
const lowerCabin = this.add.rectangle(-6, -3, 52, 10, 0xffffff, 1)
|
||||
const lowerCabinAft = this.add.rectangle(-28, -2, 18, 8, 0xecf0f2, 1)
|
||||
const bridgeBase = this.add.rectangle(18, -5, 18, 8, 0xffffff, 1)
|
||||
const upperDeck = this.add.rectangle(6, -13, 40, 8, 0xf8fafb, 1)
|
||||
const upperDeckAft = this.add.rectangle(-18, -12, 18, 6, 0xf0f4f6, 1)
|
||||
const bridgeGlass = this.add.rectangle(19, -5, 14, 4, 0x97d3f7, 0.92)
|
||||
const deckGlassBand = this.add.rectangle(0, -3, 42, 3, 0xcfe9f7, 0.88)
|
||||
const rail = this.add.rectangle(4, -16, 44, 2, 0xd9e1e6, 0.96)
|
||||
const mast = this.add.rectangle(7, -25, 2, 9, 0xe9edf1, 0.95)
|
||||
const radar = this.add.rectangle(11, -28, 9, 2, 0xe9edf1, 0.95)
|
||||
const flag = this.add.triangle(17, -27, 0, -3, 7, 0, 0, 3, 0x9fc5dc, 0.95)
|
||||
const yacht = this.add.container(startX, boatY, [
|
||||
shadow,
|
||||
wakeC,
|
||||
wakeB,
|
||||
wakeA,
|
||||
sternBlock,
|
||||
hullMain,
|
||||
bowMid,
|
||||
bowTip,
|
||||
hullStripe,
|
||||
waterline,
|
||||
lowerCabinAft,
|
||||
lowerCabin,
|
||||
bridgeBase,
|
||||
upperDeckAft,
|
||||
upperDeck,
|
||||
bridgeGlass,
|
||||
deckGlassBand,
|
||||
rail,
|
||||
mast,
|
||||
radar,
|
||||
flag,
|
||||
])
|
||||
const portholes = [-26, -10, 8, 26].map(px => this.add.circle(px, 5, 2.1, 0xd9f4ff, 0.95))
|
||||
yacht.add(portholes)
|
||||
|
||||
yacht.setDepth(-250)
|
||||
yacht.setAlpha(0)
|
||||
yacht.setRotation(-0.04)
|
||||
yacht.setScale(yachtScale)
|
||||
this.tweens.add({
|
||||
targets: [wakeA, wakeB, wakeC],
|
||||
scaleX: { from: 0.85, to: 1.2 },
|
||||
alpha: { from: 0.5, to: 0.2 },
|
||||
duration: 900,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
})
|
||||
|
||||
const runCycle = () => {
|
||||
yacht.setPosition(startX, boatY)
|
||||
yacht.setAlpha(0)
|
||||
yacht.setRotation(-0.04)
|
||||
wakeA.setAlpha(0.55)
|
||||
wakeB.setAlpha(0.42)
|
||||
wakeC.setAlpha(0.24)
|
||||
|
||||
this.tweens.add({
|
||||
targets: yacht,
|
||||
x: dockX,
|
||||
alpha: 1,
|
||||
rotation: 0.015,
|
||||
duration: 7200,
|
||||
ease: 'Sine.InOut',
|
||||
onComplete: () => {
|
||||
wakeA.setAlpha(0.22)
|
||||
wakeB.setAlpha(0.14)
|
||||
wakeC.setAlpha(0.08)
|
||||
this.tweens.add({
|
||||
targets: yacht,
|
||||
x: dockX + 4,
|
||||
y: boatY + TILE_SIZE * 0.12,
|
||||
duration: 1400,
|
||||
yoyo: true,
|
||||
repeat: 2,
|
||||
ease: 'Sine.InOut',
|
||||
})
|
||||
|
||||
this.time.delayedCall(3600, () => {
|
||||
wakeA.setAlpha(0.48)
|
||||
wakeB.setAlpha(0.32)
|
||||
wakeC.setAlpha(0.18)
|
||||
this.tweens.add({
|
||||
targets: yacht,
|
||||
x: exitX,
|
||||
alpha: 0,
|
||||
rotation: -0.055,
|
||||
duration: 7600,
|
||||
ease: 'Sine.InOut',
|
||||
onComplete: () => {
|
||||
this.time.delayedCall(2400, runCycle)
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
runCycle()
|
||||
}
|
||||
|
||||
// ── Office helpers ──────────────────────────────────
|
||||
|
||||
private buildWalkableTilesByOffice() {
|
||||
this.walkableTilesByOffice.clear()
|
||||
const offices = getOffices()
|
||||
for (const office of offices) {
|
||||
const tiles: { x: number; y: number }[] = []
|
||||
for (const t of this.walkableTiles) {
|
||||
if (t.x >= office.offsetCol && t.x < office.offsetCol + OFFICE_COLS) {
|
||||
tiles.push(t)
|
||||
}
|
||||
}
|
||||
this.walkableTilesByOffice.set(office.id, tiles)
|
||||
}
|
||||
}
|
||||
|
||||
getWalkableTilesForOffice(officeId: string): { x: number; y: number }[] {
|
||||
return this.walkableTilesByOffice.get(officeId) ?? []
|
||||
}
|
||||
|
||||
resetCameraView(animate = true) {
|
||||
const cam = this.cameras.main
|
||||
const { worldW, worldH } = this.getWorldSize()
|
||||
const minZ = this.getMinCameraZoom(cam)
|
||||
const fitZoom = Math.min(this.scale.width / worldW, this.scale.height / worldH)
|
||||
const maxReset = Math.max(1.22, minZ)
|
||||
const targetZoom = Phaser.Math.Clamp(Math.max(fitZoom * 1.18, 0.62), minZ, maxReset)
|
||||
const targetX = MAP_COLS * TILE_SIZE / 2
|
||||
const targetY = (MAP_ROWS * TILE_SIZE - OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM) / 2 + TILE_SIZE * 2.6
|
||||
cam.stopFollow()
|
||||
if (!animate) {
|
||||
cam.setZoom(targetZoom)
|
||||
cam.centerOn(targetX, targetY)
|
||||
return
|
||||
}
|
||||
this.tweens.add({
|
||||
targets: cam,
|
||||
zoom: targetZoom,
|
||||
duration: 260,
|
||||
ease: 'Cubic.Out',
|
||||
})
|
||||
cam.pan(targetX, targetY, 260, 'Cubic.Out')
|
||||
}
|
||||
|
||||
panToOffice(officeId: string) {
|
||||
const offices = getOffices()
|
||||
const office = offices.find(o => o.id === officeId)
|
||||
if (!office) return
|
||||
const cx = (office.offsetCol + OFFICE_COLS / 2) * TILE_SIZE
|
||||
const cy = (MAP_ROWS / 2) * TILE_SIZE
|
||||
const cam = this.cameras.main
|
||||
const minZ = this.getMinCameraZoom(cam)
|
||||
const targetZoom = Phaser.Math.Clamp(
|
||||
Math.min(this.scale.width / ((OFFICE_COLS + 4) * TILE_SIZE), this.scale.height / ((MAP_ROWS - 4) * TILE_SIZE)),
|
||||
minZ,
|
||||
Math.max(1.5, minZ),
|
||||
)
|
||||
cam.stopFollow()
|
||||
this.tweens.add({
|
||||
targets: cam,
|
||||
zoom: targetZoom,
|
||||
duration: 380,
|
||||
ease: 'Cubic.Out',
|
||||
})
|
||||
cam.pan(cx, cy, 380, 'Cubic.Out')
|
||||
}
|
||||
|
||||
// ── Agent management ──────────────────────────────────
|
||||
|
||||
resolveOfficeForAgent(agentId: string): string {
|
||||
const stored = getAgentOffice(agentId)
|
||||
if (stored) return stored.id
|
||||
const offices = getOffices()
|
||||
let best: OfficeConfig | null = null
|
||||
let bestFree = -1
|
||||
for (const o of offices) {
|
||||
const deskSeats = getOfficeDeskSeats(o.id)
|
||||
const assignedCount = o.assignedAgents.length
|
||||
const free = deskSeats.length - assignedCount
|
||||
if (free > bestFree) { best = o; bestFree = free }
|
||||
}
|
||||
const officeId = best?.id ?? offices[0]?.id ?? 'office-0'
|
||||
storeAssignAgent(officeId, agentId)
|
||||
return officeId
|
||||
}
|
||||
|
||||
addAgent(agentId: string, displayName?: string, isSubagent = false, parentAgentId: string | null = null, backendOfficeId?: string, backendPalette?: number, backendDeskId?: string): Agent {
|
||||
if (this.agents.has(agentId)) return this.agents.get(agentId)!
|
||||
|
||||
const name = displayName ?? agentId
|
||||
const palette = backendPalette ?? (this.agents.size % 6)
|
||||
|
||||
// Use backend office_id if provided, otherwise fall back to local resolution
|
||||
const officeId = backendOfficeId
|
||||
? (() => { storeAssignAgent(backendOfficeId, agentId); return backendOfficeId })()
|
||||
: this.resolveOfficeForAgent(agentId)
|
||||
|
||||
// Use backend desk_id for precise seat, otherwise find free seat
|
||||
const seat = backendDeskId
|
||||
? (this.seats.find(s => s.id === backendDeskId && !s.assigned) ?? this.findFreeSeatInOffice(officeId))
|
||||
: this.findFreeSeatInOffice(officeId)
|
||||
|
||||
const offices = getOffices()
|
||||
const office = offices.find(o => o.id === officeId)
|
||||
const fallbackX = (office?.offsetCol ?? 0) + 5
|
||||
const fallbackY = 20
|
||||
|
||||
let startX = fallbackX
|
||||
let startY = fallbackY
|
||||
if (seat) {
|
||||
startX = seat.tileX
|
||||
startY = seat.tileY
|
||||
seat.assigned = true
|
||||
seat.assignedTo = agentId
|
||||
}
|
||||
|
||||
const agent = new Agent(this, agentId, name, palette, startX, startY)
|
||||
agent.officeId = officeId
|
||||
agent.setPathfinder(this.pathfinder)
|
||||
agent.seatId = seat?.id ?? null
|
||||
agent.isSubagent = isSubagent
|
||||
agent.parentAgentId = parentAgentId
|
||||
|
||||
if (seat) {
|
||||
agent.setAgentState(AgentState.TYPE)
|
||||
agent.setDirection(seat.facing)
|
||||
agent.seatTimer = 10
|
||||
}
|
||||
|
||||
this.agents.set(agentId, agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
removeAgent(agentId: string) {
|
||||
const agent = this.agents.get(agentId)
|
||||
if (!agent) return
|
||||
if (agent.seatId) {
|
||||
const seat = this.seats.find(s => s.id === agent.seatId)
|
||||
if (seat) { seat.assigned = false; seat.assignedTo = null }
|
||||
}
|
||||
agent.destroy()
|
||||
this.agents.delete(agentId)
|
||||
try {
|
||||
const { unassignAgent } = require('../map/OfficeStore')
|
||||
unassignAgent(agentId)
|
||||
} catch { /* OfficeStore may not be available */ }
|
||||
}
|
||||
|
||||
getAgent(agentId: string): Agent | undefined { return this.agents.get(agentId) }
|
||||
|
||||
ensureAgent(agentId: string, displayName?: string, isSubagent = false, parentAgentId: string | null = null, backendOfficeId?: string, backendPalette?: number, backendDeskId?: string): Agent {
|
||||
const existing = this.agents.get(agentId)
|
||||
if (existing) {
|
||||
// If backend specifies a different office, reassign
|
||||
if (backendOfficeId && existing.officeId !== backendOfficeId) {
|
||||
this.reassignAgent(agentId, backendOfficeId)
|
||||
}
|
||||
// If backend specifies a specific desk, move to it
|
||||
if (backendDeskId && existing.seatId !== backendDeskId) {
|
||||
this.changeAgentSeat(agentId, backendDeskId)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
return this.addAgent(agentId, displayName, isSubagent, parentAgentId, backendOfficeId, backendPalette, backendDeskId)
|
||||
}
|
||||
|
||||
findFreeSeatInOffice(officeId: string): SeatDef | null {
|
||||
const deskSeat = this.seats.filter(s => s.id.startsWith(`${officeId}-desk-`)).find(s => !s.assigned)
|
||||
if (deskSeat) return deskSeat
|
||||
return this.seats.filter(s => s.id.startsWith(`${officeId}-leader-`)).find(s => !s.assigned) ?? null
|
||||
}
|
||||
|
||||
getSeatById(id: string): SeatDef | undefined { return this.seats.find(s => s.id === id) }
|
||||
|
||||
reassignAgent(agentId: string, newOfficeId: string) {
|
||||
const agent = this.agents.get(agentId)
|
||||
if (!agent) return
|
||||
|
||||
if (agent.seatId) {
|
||||
const oldSeat = this.seats.find(s => s.id === agent.seatId)
|
||||
if (oldSeat) { oldSeat.assigned = false; oldSeat.assignedTo = null }
|
||||
}
|
||||
agent.stopMovement()
|
||||
|
||||
storeAssignAgent(newOfficeId, agentId)
|
||||
agent.officeId = newOfficeId
|
||||
|
||||
const newSeat = this.findFreeSeatInOffice(newOfficeId)
|
||||
const offices = getOffices()
|
||||
const office = offices.find(o => o.id === newOfficeId)
|
||||
const fallbackX = (office?.offsetCol ?? 0) + 5
|
||||
const fallbackY = 20
|
||||
|
||||
if (newSeat) {
|
||||
newSeat.assigned = true
|
||||
newSeat.assignedTo = agentId
|
||||
agent.seatId = newSeat.id
|
||||
agent.setPosition(newSeat.tileX * TILE_SIZE + TILE_SIZE / 2, newSeat.tileY * TILE_SIZE + TILE_SIZE / 2)
|
||||
agent.setAgentState(AgentState.TYPE)
|
||||
agent.setDirection(newSeat.facing)
|
||||
agent.seatTimer = 10
|
||||
} else {
|
||||
agent.seatId = null
|
||||
agent.setPosition(fallbackX * TILE_SIZE + TILE_SIZE / 2, fallbackY * TILE_SIZE + TILE_SIZE / 2)
|
||||
}
|
||||
}
|
||||
|
||||
getCharacterCards() {
|
||||
const cards: Array<{
|
||||
id: string; displayName: string; state: string; currentTool: string | null
|
||||
isSubagent: boolean; parentAgentId: string | null; taskSummary?: string
|
||||
lastEventAt: number; officeId: string; seatId: string | null
|
||||
}> = []
|
||||
for (const agent of this.agents.values()) {
|
||||
cards.push({
|
||||
id: agent.agentId, displayName: agent.displayName, state: agent.agentState,
|
||||
currentTool: agent.currentTool, isSubagent: agent.isSubagent,
|
||||
parentAgentId: agent.parentAgentId, taskSummary: agent.taskSummary,
|
||||
lastEventAt: agent.lastEventAt, officeId: agent.officeId, seatId: agent.seatId,
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
changeAgentSeat(agentId: string, newSeatId: string) {
|
||||
const agent = this.agents.get(agentId)
|
||||
if (!agent) return
|
||||
if (agent.seatId) {
|
||||
const oldSeat = this.seats.find(s => s.id === agent.seatId)
|
||||
if (oldSeat) { oldSeat.assigned = false; oldSeat.assignedTo = null }
|
||||
}
|
||||
const newSeat = this.seats.find(s => s.id === newSeatId)
|
||||
if (!newSeat || (newSeat.assigned && newSeat.assignedTo !== agentId)) return
|
||||
newSeat.assigned = true
|
||||
newSeat.assignedTo = agentId
|
||||
agent.seatId = newSeatId
|
||||
agent.stopMovement()
|
||||
agent.setPosition(newSeat.tileX * TILE_SIZE + TILE_SIZE / 2, newSeat.tileY * TILE_SIZE + TILE_SIZE / 2)
|
||||
agent.setAgentState(AgentState.TYPE)
|
||||
agent.setDirection(newSeat.facing)
|
||||
agent.seatTimer = 10
|
||||
}
|
||||
|
||||
rebuildOfficeCollision(officeId: string, mapStr: string[], seatCoords: [number, number][]) {
|
||||
updateOfficeMap(officeId, mapStr, seatCoords)
|
||||
|
||||
if (this.mapData.wallBodies) {
|
||||
this.mapData.wallBodies.clear(true, true)
|
||||
}
|
||||
|
||||
const offices = getOffices()
|
||||
const grid = buildCompositeGrid(offices)
|
||||
const wallBodies = OfficeMapBuilder.buildWallBodies(this, grid)
|
||||
this.mapData = { ...this.mapData, wallBodies, collisionGrid: grid }
|
||||
|
||||
this.pathfinder = new PathfindingManager(grid)
|
||||
this.walkableTiles = this.pathfinder.getWalkableTiles()
|
||||
this.buildWalkableTilesByOffice()
|
||||
|
||||
reloadZones(offices)
|
||||
this.seats = getAllSeats().map(s => ({ ...s }))
|
||||
|
||||
for (const agent of this.agents.values()) {
|
||||
agent.setPathfinder(this.pathfinder)
|
||||
agent.stopMovement()
|
||||
}
|
||||
|
||||
console.log('[OfficeScene] Office collision rebuilt —', officeId, 'walkable:', this.walkableTiles.length)
|
||||
}
|
||||
|
||||
renameOffice(officeId: string, newName: string) {
|
||||
storeRenameOffice(officeId, newName)
|
||||
this.mapBuilder.updateLabel(officeId, newName)
|
||||
}
|
||||
|
||||
/** Matches `setBounds` in create(); used for zoom floor so the viewport never extends past the world. */
|
||||
private checkOutdoorDayNight() {
|
||||
this.applyOutdoorLighting(isLocalDaytime())
|
||||
}
|
||||
|
||||
/** Call after changing URL or `opc_outdoor_override` in localStorage (via GameBridge). */
|
||||
syncOutdoorLighting() {
|
||||
this.applyOutdoorLighting(isLocalDaytime())
|
||||
}
|
||||
|
||||
private applyOutdoorLighting(next: boolean) {
|
||||
if (next === this.outdoorIsDay) return
|
||||
this.outdoorIsDay = next
|
||||
this.cameras.main.setBackgroundColor(next ? SCENE_CLEAR_DAY : SCENE_CLEAR_NIGHT)
|
||||
this.mapBuilder.refreshOutdoorDayNight(this, next)
|
||||
}
|
||||
|
||||
private getWorldSize() {
|
||||
const mapW = MAP_COLS * TILE_SIZE
|
||||
const mapH = MAP_ROWS * TILE_SIZE
|
||||
return {
|
||||
worldW: mapW + OUTDOOR_MARGIN_X * 2,
|
||||
worldH: mapH + OUTDOOR_MARGIN_TOP + OUTDOOR_MARGIN_BOTTOM,
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimum zoom so `displayWidth/Height` never exceeds world bounds (avoids empty margin past the map). */
|
||||
private getMinCameraZoom(cam: Phaser.Cameras.Scene2D.Camera) {
|
||||
const { worldW, worldH } = this.getWorldSize()
|
||||
return Math.max(cam.width / worldW, cam.height / worldH)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import { AgentState, Direction } from '../types'
|
||||
import {
|
||||
CELEBRATE_DURATION, COFFEE_DURATION_MIN, COFFEE_DURATION_MAX,
|
||||
CHAT_DURATION_MIN, CHAT_DURATION_MAX,
|
||||
WANDER_PAUSE_MIN, WANDER_PAUSE_MAX,
|
||||
WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX,
|
||||
SEAT_REST_MIN, SEAT_REST_MAX,
|
||||
INACTIVE_SEAT_TIMER_MIN, INACTIVE_SEAT_TIMER_RANGE,
|
||||
TILE_SIZE,
|
||||
} from '../config'
|
||||
import type { OfficeScene } from '../scenes/OfficeScene'
|
||||
import type { Agent } from '../entities/Agent'
|
||||
import { ZONES, randomTileInZone, getOfficeLobbyDoorways } from '../map/InteractionZones'
|
||||
import type { VisualEvent } from '../../types/visual'
|
||||
|
||||
function randomRange(min: number, max: number) { return min + Math.random() * (max - min) }
|
||||
function randomInt(min: number, max: number) { return Math.floor(randomRange(min, max + 1)) }
|
||||
function trimPreview(s: string, maxLen: number) { return s.length > maxLen ? s.slice(0, maxLen) + '...' : s }
|
||||
|
||||
const READING_TOOLS = new Set([
|
||||
'read', 'read_file', 'browse', 'list', 'list_dir',
|
||||
'glob', 'grep', 'search', 'fetch', 'web_fetch',
|
||||
'webfetch', 'web_search', 'websearch',
|
||||
])
|
||||
|
||||
function mapToolToState(toolName: string | null): AgentState {
|
||||
if (!toolName) return AgentState.TYPE
|
||||
const t = toolName.toLowerCase().trim()
|
||||
if (t === 'reflect') return AgentState.REFLECT
|
||||
if (t === 'practice') return AgentState.PRACTICE
|
||||
if (t === 'synthesize') return AgentState.PRESENT
|
||||
if (READING_TOOLS.has(t)) return AgentState.THINK
|
||||
return AgentState.TYPE
|
||||
}
|
||||
|
||||
function mapToolDisplay(toolName: string | null): string {
|
||||
if (!toolName) return 'Tool'
|
||||
const t = toolName.toLowerCase().trim()
|
||||
if (t === 'shell') return 'Shell'
|
||||
if (t === 'write_file' || t === 'write') return 'Write'
|
||||
if (t === 'edit_file' || t === 'edit') return 'Edit'
|
||||
if (t === 'read_file' || t === 'read') return 'Read'
|
||||
if (t === 'grep' || t === 'search') return 'Search'
|
||||
if (t === 'web_search' || t === 'websearch') return 'WebSearch'
|
||||
if (t === 'web_fetch' || t === 'webfetch') return 'Fetch'
|
||||
return toolName.slice(0, 12)
|
||||
}
|
||||
|
||||
export class BehaviorController {
|
||||
private scene: OfficeScene
|
||||
private pendingDespawn = new Set<string>()
|
||||
|
||||
constructor(scene: OfficeScene) {
|
||||
this.scene = scene
|
||||
}
|
||||
|
||||
// ── Event dispatch ────────────────────────────────────
|
||||
|
||||
applyEvent(evt: VisualEvent) {
|
||||
const data = evt.data ?? {}
|
||||
const agentId = evt.agent_id || 'openopc-main'
|
||||
|
||||
if (agentId === 'user') return
|
||||
|
||||
const isSub = agentId.startsWith('subagent-')
|
||||
const parentId = isSub
|
||||
? (typeof data.parent_agent_id === 'string' ? data.parent_agent_id : 'openopc-main')
|
||||
: null
|
||||
const displayName = isSub ? `Sub ${agentId.slice(-4)}` : undefined
|
||||
|
||||
const agent = this.scene.ensureAgent(agentId, displayName, isSub, parentId)
|
||||
agent.lastEventAt = Date.now()
|
||||
if (typeof data.task_preview === 'string') agent.taskSummary = data.task_preview
|
||||
if (typeof data.result_preview === 'string') agent.taskSummary = data.result_preview
|
||||
|
||||
switch (evt.type) {
|
||||
case 'tool_start': this.onToolStart(agent, data); break
|
||||
case 'tool_done': this.onToolDone(agent, data); break
|
||||
case 'agent_active': this.onAgentActive(agent); break
|
||||
case 'waiting': this.onWaiting(agent); break
|
||||
case 'reflect_start': this.onReflectStart(agent); break
|
||||
case 'reflect_done': this.onReflectDone(agent); break
|
||||
case 'skill_synthesized': this.onSkillSynthesized(agent, data); break
|
||||
case 'subagent_spawn': this.onSubagentSpawn(agent, agentId, parentId); break
|
||||
case 'subagent_done': this.onSubagentDone(agent, agentId); break
|
||||
case 'message_in': this.onMessageIn(agent, data); break
|
||||
case 'message_out': this.onMessageOut(agent, data); break
|
||||
case 'practice_start': this.onPracticeStart(agent, data); break
|
||||
case 'practice_done': this.onPracticeDone(agent); break
|
||||
case 'task_routed': this.onTaskRouted(agent, data); break
|
||||
case 'task_delegated': this.onTaskDelegated(agent, agentId, data); break
|
||||
case 'delegation_done': this.onDelegationDone(agent, data); break
|
||||
case 'agent_spawned': this.onAgentSpawned(agent, agentId, data); break
|
||||
case 'agent_removed': this.onAgentRemoved(agent, agentId); break
|
||||
case 'collab_started': this.onCollabStarted(agent); break
|
||||
case 'collab_ended': this.onCollabEnded(agent); break
|
||||
case 'skill_published': this.onSkillPublished(agent, data); break
|
||||
case 'skill_adopted': this.onSkillAdopted(agent, data); break
|
||||
case 'mycelium_transport': this.onMyceliumTransport(agent, agentId, data); break
|
||||
case 'mycelium_crystallize': this.onMyceliumCrystallize(agent, agentId, data); break
|
||||
case 'mycelium_spore': this.onMyceliumSpore(agent, agentId, data); break
|
||||
case 'mycelium_decompose': this.onMyceliumDecompose(agent, agentId, data); break
|
||||
case 'mycelium_unit_created': this.onMyceliumUnitCreated(agent, data); break
|
||||
case 'mycelium_germinate': this.onMyceliumGerminate(agent, agentId, data); break
|
||||
case 'hyphal_strengthen': this.onHyphalStrengthen(agentId, data); break
|
||||
case 'hyphal_weaken': this.onHyphalWeaken(agentId, data); break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Individual event handlers ─────────────────────────
|
||||
|
||||
private onToolStart(agent: Agent, data: Record<string, unknown>) {
|
||||
const toolName = typeof data.tool_name === 'string' ? data.tool_name : 'tool'
|
||||
const label = mapToolDisplay(toolName)
|
||||
agent.urgency = 'urgent'
|
||||
agent.currentTool = toolName
|
||||
agent.isActive = true
|
||||
agent.showBubble(`${label}...`)
|
||||
this.sendToSeat(agent)
|
||||
}
|
||||
|
||||
private onToolDone(agent: Agent, data: Record<string, unknown>) {
|
||||
const toolName = typeof data.tool_name === 'string' ? data.tool_name : agent.currentTool
|
||||
const label = mapToolDisplay(toolName)
|
||||
agent.currentTool = null
|
||||
agent.setAgentState(AgentState.CELEBRATE)
|
||||
agent.stateTimer = CELEBRATE_DURATION
|
||||
agent.isActive = false
|
||||
agent.showBubble(`${label} done`)
|
||||
}
|
||||
|
||||
private onAgentActive(agent: Agent) {
|
||||
agent.urgency = 'urgent'
|
||||
agent.isActive = true
|
||||
if (!agent.currentTool) {
|
||||
this.sendToSeat(agent)
|
||||
}
|
||||
}
|
||||
|
||||
private onWaiting(agent: Agent) {
|
||||
agent.urgency = 'relaxed'
|
||||
agent.isActive = false
|
||||
agent.currentTool = null
|
||||
agent.showBubble('Waiting')
|
||||
const moved = this.moveToZone(agent, 'breakRoom', AgentState.COFFEE)
|
||||
if (!moved) agent.setAgentState(AgentState.IDLE)
|
||||
if (moved) agent.stateTimer = randomRange(COFFEE_DURATION_MIN, COFFEE_DURATION_MAX)
|
||||
}
|
||||
|
||||
private onReflectStart(agent: Agent) {
|
||||
agent.urgency = 'normal'
|
||||
agent.isActive = false
|
||||
agent.currentTool = 'Reflect'
|
||||
const moved = this.moveToZone(agent, 'meetingRoom', AgentState.REFLECT)
|
||||
if (!moved) agent.setAgentState(AgentState.REFLECT)
|
||||
agent.stateTimer = 30.0
|
||||
agent.showBubble('Reflecting...')
|
||||
}
|
||||
|
||||
private onReflectDone(agent: Agent) {
|
||||
agent.currentTool = null
|
||||
agent.setAgentState(AgentState.CELEBRATE)
|
||||
agent.stateTimer = CELEBRATE_DURATION
|
||||
agent.showBubble('Insight!')
|
||||
}
|
||||
|
||||
private onSkillSynthesized(agent: Agent, data: Record<string, unknown>) {
|
||||
const name = trimPreview(String(data.skill_name ?? 'new'), 20)
|
||||
agent.setAgentState(AgentState.PRESENT)
|
||||
agent.showBubble(`New Skill: ${name}`)
|
||||
}
|
||||
|
||||
private onSubagentSpawn(agent: Agent, agentId: string, parentId: string | null) {
|
||||
this.placeAtDoorway(agent)
|
||||
agent.urgency = 'urgent'
|
||||
agent.isActive = true
|
||||
agent.showBubble('Spawned')
|
||||
this.sendToSeat(agent)
|
||||
}
|
||||
|
||||
private onSubagentDone(agent: Agent, agentId: string) {
|
||||
agent.urgency = 'relaxed'
|
||||
agent.currentTool = null
|
||||
agent.isActive = false
|
||||
agent.showBubble('Finished')
|
||||
const moved = this.moveToDoorway(agent)
|
||||
if (moved) {
|
||||
this.pendingDespawn.add(agentId)
|
||||
} else {
|
||||
this.scene.removeAgent(agentId)
|
||||
}
|
||||
}
|
||||
|
||||
private onMessageIn(agent: Agent, data: Record<string, unknown>) {
|
||||
agent.urgency = 'urgent'
|
||||
const content = typeof data.content_preview === 'string' ? data.content_preview : ''
|
||||
if (content) agent.taskSummary = content
|
||||
const preview = content ? trimPreview(content, 30) : 'New task'
|
||||
agent.showBubble(preview)
|
||||
agent.isActive = true
|
||||
this.sendToSeat(agent)
|
||||
}
|
||||
|
||||
private onMessageOut(agent: Agent, data: Record<string, unknown>) {
|
||||
const content = typeof data.content_preview === 'string' ? data.content_preview : ''
|
||||
if (content) agent.taskSummary = content
|
||||
const preview = content ? trimPreview(content, 30) : 'Reply sent'
|
||||
agent.showBubble(`Reply: ${preview}`)
|
||||
}
|
||||
|
||||
private onPracticeStart(agent: Agent, data: Record<string, unknown>) {
|
||||
agent.urgency = 'normal'
|
||||
const domain = typeof data.target_domain === 'string' ? data.target_domain : 'Practice'
|
||||
agent.showBubble(`Practicing: ${trimPreview(domain, 20)}`)
|
||||
agent.isActive = false
|
||||
agent.currentTool = 'Practice'
|
||||
const moved = this.moveToZone(agent, 'meetingRoom', AgentState.PRACTICE)
|
||||
if (!moved) agent.setAgentState(AgentState.PRACTICE)
|
||||
}
|
||||
|
||||
private onPracticeDone(agent: Agent) {
|
||||
agent.currentTool = null
|
||||
agent.setAgentState(AgentState.CELEBRATE)
|
||||
agent.stateTimer = CELEBRATE_DURATION
|
||||
agent.showBubble('Practice done!')
|
||||
}
|
||||
|
||||
private onTaskRouted(agent: Agent, data: Record<string, unknown>) {
|
||||
agent.urgency = 'urgent'
|
||||
const method = typeof data.method === 'string' ? data.method : 'auto'
|
||||
agent.isActive = true
|
||||
this.sendToSeat(agent)
|
||||
agent.showBubble(`Assigned (${method})`)
|
||||
}
|
||||
|
||||
private onTaskDelegated(agent: Agent, agentId: string, data: Record<string, unknown>) {
|
||||
agent.urgency = 'normal'
|
||||
agent.stateTimer = randomRange(CHAT_DURATION_MIN, CHAT_DURATION_MAX)
|
||||
const target = typeof data.target === 'string' ? data.target : '?'
|
||||
agent.setAgentState(AgentState.CHAT)
|
||||
agent.showBubble(`Delegating to ${target}...`)
|
||||
const targetAgent = this.scene.getAgent(target)
|
||||
if (targetAgent) {
|
||||
targetAgent.parentAgentId = agentId
|
||||
targetAgent.showBubble('Receiving task...')
|
||||
}
|
||||
}
|
||||
|
||||
private onDelegationDone(agent: Agent, data: Record<string, unknown>) {
|
||||
const target = typeof data.target === 'string' ? data.target : ''
|
||||
agent.setAgentState(AgentState.IDLE)
|
||||
agent.showBubble('Delegation complete')
|
||||
if (target) {
|
||||
const targetAgent = this.scene.getAgent(target)
|
||||
if (targetAgent) targetAgent.parentAgentId = null
|
||||
}
|
||||
}
|
||||
|
||||
private onAgentSpawned(agent: Agent, agentId: string, data: Record<string, unknown>) {
|
||||
const roleName = typeof data.role_name === 'string' ? data.role_name : agentId
|
||||
agent.displayName = roleName
|
||||
this.placeAtDoorway(agent)
|
||||
this.sendToSeat(agent)
|
||||
agent.showBubble(`${roleName} joined`)
|
||||
}
|
||||
|
||||
private onAgentRemoved(agent: Agent, agentId: string) {
|
||||
agent.showBubble('Leaving...')
|
||||
const moved = this.moveToDoorway(agent)
|
||||
if (!moved) {
|
||||
this.scene.removeAgent(agentId)
|
||||
} else {
|
||||
this.pendingDespawn.add(agentId)
|
||||
}
|
||||
}
|
||||
|
||||
private onCollabStarted(agent: Agent) {
|
||||
agent.urgency = 'normal'
|
||||
agent.isActive = false
|
||||
agent.stateTimer = randomRange(CHAT_DURATION_MIN, CHAT_DURATION_MAX)
|
||||
this.moveToZone(agent, 'meetingRoom', AgentState.CHAT)
|
||||
agent.showBubble('Collaborating...')
|
||||
}
|
||||
|
||||
private onCollabEnded(agent: Agent) {
|
||||
agent.stateTimer = CELEBRATE_DURATION
|
||||
agent.setAgentState(AgentState.CELEBRATE)
|
||||
agent.showBubble('Collaboration done!')
|
||||
}
|
||||
|
||||
private onSkillPublished(agent: Agent, data: Record<string, unknown>) {
|
||||
const name = trimPreview(String(data.skill_name ?? 'skill'), 20)
|
||||
agent.setAgentState(AgentState.CELEBRATE)
|
||||
agent.stateTimer = CELEBRATE_DURATION
|
||||
agent.showBubble(`Published: ${name}`)
|
||||
}
|
||||
|
||||
private onSkillAdopted(agent: Agent, data: Record<string, unknown>) {
|
||||
const name = trimPreview(String(data.skill_name ?? 'skill'), 20)
|
||||
agent.showBubble(`Adopted: ${name}`)
|
||||
}
|
||||
|
||||
// ── Mycelium events ───────────────────────────────────
|
||||
|
||||
private onMyceliumTransport(agent: Agent, agentId: string, data: Record<string, unknown>) {
|
||||
const source = typeof data.source === 'string' ? data.source : agentId
|
||||
const target = typeof data.target === 'string' ? data.target : null
|
||||
const domain = typeof data.domain === 'string' ? trimPreview(data.domain, 15) : '?'
|
||||
|
||||
const srcAgent = this.scene.ensureAgent(source)
|
||||
srcAgent.myceliumEffect = 'transport_send'
|
||||
srcAgent.myceliumEffectTimer = 3.0
|
||||
srcAgent.showBubble(`-> [${domain}]`)
|
||||
|
||||
if (target) {
|
||||
const tgtAgent = this.scene.ensureAgent(target)
|
||||
tgtAgent.myceliumEffect = 'transport_recv'
|
||||
tgtAgent.myceliumEffectTimer = 2.0
|
||||
tgtAgent.showBubble('Receiving...')
|
||||
const tgtPos = tgtAgent.getTilePos()
|
||||
srcAgent.walkTo(tgtPos.x, tgtPos.y + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private onMyceliumCrystallize(agent: Agent, agentId: string, data: Record<string, unknown>) {
|
||||
const corrobAgents: string[] = Array.isArray(data.corroborating_agents)
|
||||
? data.corroborating_agents : []
|
||||
const contentPreview = trimPreview(String(data.content_preview ?? 'Knowledge'), 22)
|
||||
const allParticipants = Array.from(new Set([agentId, ...corrobAgents]))
|
||||
|
||||
const meetingZoneKey = `${agent.officeId}-meetingRoom`
|
||||
const meetingSeats = ZONES[meetingZoneKey]?.seats ?? []
|
||||
for (let i = 0; i < allParticipants.length; i++) {
|
||||
const pid = allParticipants[i]
|
||||
const pAgent = this.scene.ensureAgent(pid)
|
||||
pAgent.myceliumEffect = 'crystal'
|
||||
pAgent.myceliumEffectTimer = 15.0
|
||||
pAgent.isActive = false
|
||||
|
||||
const seatIdx = i % meetingSeats.length
|
||||
const seat = meetingSeats[seatIdx]
|
||||
pAgent.walkTo(seat.tileX, seat.tileY, () => {
|
||||
pAgent.setDirection(seat.facing)
|
||||
pAgent.setAgentState(AgentState.CHAT)
|
||||
})
|
||||
|
||||
pAgent.showBubble(pid === agentId ? `Crystal: ${contentPreview}` : 'Crystal!')
|
||||
}
|
||||
}
|
||||
|
||||
private onMyceliumSpore(agent: Agent, agentId: string, data: Record<string, unknown>) {
|
||||
const preview = trimPreview(String(data.content_preview ?? 'Breakthrough'), 20)
|
||||
agent.myceliumEffect = 'spore_send'
|
||||
agent.myceliumEffectTimer = 4.0
|
||||
agent.setAgentState(AgentState.PRESENT)
|
||||
agent.showBubble(`Breakthrough! ${preview}`)
|
||||
// Move to break room for broadcast
|
||||
this.moveToZone(agent, 'breakRoom')
|
||||
}
|
||||
|
||||
private onMyceliumDecompose(agent: Agent, agentId: string, data: Record<string, unknown>) {
|
||||
const preview = trimPreview(String(data.humus_preview ?? 'Lesson learned'), 22)
|
||||
agent.myceliumEffect = 'decompose'
|
||||
agent.myceliumEffectTimer = 4.0
|
||||
agent.setAgentState(AgentState.REFLECT)
|
||||
agent.showBubble(`Learning: ${preview}`)
|
||||
this.moveToZone(agent, 'meetingRoom')
|
||||
}
|
||||
|
||||
private onMyceliumUnitCreated(agent: Agent, data: Record<string, unknown>) {
|
||||
const nutrientType = String(data.nutrient_type ?? 'insight').slice(0, 1).toUpperCase()
|
||||
agent.showBubble(`[${nutrientType}]...`, 2.0)
|
||||
}
|
||||
|
||||
private onMyceliumGerminate(agent: Agent, agentId: string, data: Record<string, unknown>) {
|
||||
const targetAgent = typeof data.target_agent === 'string' ? data.target_agent : agentId
|
||||
const tgt = this.scene.ensureAgent(targetAgent)
|
||||
tgt.myceliumEffect = null
|
||||
tgt.setAgentState(AgentState.CELEBRATE)
|
||||
tgt.stateTimer = CELEBRATE_DURATION
|
||||
tgt.showBubble('Insight took root!')
|
||||
}
|
||||
|
||||
private onHyphalStrengthen(_agentId: string, _data: Record<string, unknown>) {
|
||||
// Visual connection tracking could be added here
|
||||
}
|
||||
|
||||
private onHyphalWeaken(_agentId: string, _data: Record<string, unknown>) {
|
||||
// Visual connection tracking could be added here
|
||||
}
|
||||
|
||||
// ── Idle behavior (called each frame) ─────────────────
|
||||
|
||||
updateIdle(dt: number) {
|
||||
for (const agent of this.scene.agents.values()) {
|
||||
// Check pending despawn
|
||||
if (this.pendingDespawn.has(agent.agentId) && !agent.isMoving) {
|
||||
this.pendingDespawn.delete(agent.agentId)
|
||||
this.scene.removeAgent(agent.agentId)
|
||||
continue
|
||||
}
|
||||
|
||||
// Mycelium effect timer
|
||||
if (agent.myceliumEffectTimer > 0) {
|
||||
agent.myceliumEffectTimer -= dt
|
||||
if (agent.myceliumEffectTimer <= 0) {
|
||||
agent.myceliumEffect = null
|
||||
agent.myceliumEffectTimer = 0
|
||||
agent.myceliumSession = null
|
||||
}
|
||||
}
|
||||
|
||||
// Non-active agents sitting at desk: count down seatTimer then transition to IDLE
|
||||
if (agent.agentState === AgentState.TYPE && !agent.isActive) {
|
||||
if (agent.seatTimer > 0) {
|
||||
agent.seatTimer -= dt
|
||||
if (agent.seatTimer <= 0) {
|
||||
agent.seatTimer = 0
|
||||
agent.setAgentState(AgentState.IDLE)
|
||||
agent.wanderCount = 0
|
||||
agent.wanderLimit = randomInt(WANDER_MOVES_BEFORE_REST_MIN, WANDER_MOVES_BEFORE_REST_MAX)
|
||||
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
|
||||
}
|
||||
} else {
|
||||
agent.setAgentState(AgentState.IDLE)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (agent.agentState !== AgentState.IDLE) continue
|
||||
|
||||
// Active agents should go to seat
|
||||
if (agent.isActive) {
|
||||
agent.urgency = 'urgent'
|
||||
if (!agent.seatId) {
|
||||
agent.setAgentState(AgentState.TYPE)
|
||||
continue
|
||||
}
|
||||
this.sendToSeat(agent)
|
||||
continue
|
||||
}
|
||||
|
||||
// Idle wander logic
|
||||
agent.wanderTimer -= dt
|
||||
if (agent.wanderTimer <= 0) {
|
||||
// Wander limit reached => go back to seat and rest
|
||||
if (agent.wanderCount >= agent.wanderLimit && agent.seatId) {
|
||||
agent.urgency = 'relaxed'
|
||||
this.sendToSeat(agent)
|
||||
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
|
||||
continue
|
||||
}
|
||||
|
||||
// Random wander (confined to agent's office)
|
||||
const tiles = this.scene.getWalkableTilesForOffice(agent.officeId)
|
||||
if (tiles.length > 0) {
|
||||
agent.urgency = 'relaxed'
|
||||
const target = tiles[Math.floor(Math.random() * tiles.length)]
|
||||
agent.walkTo(target.x, target.y)
|
||||
agent.wanderCount++
|
||||
}
|
||||
agent.wanderTimer = randomRange(WANDER_PAUSE_MIN, WANDER_PAUSE_MAX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Movement helpers ──────────────────────────────────
|
||||
|
||||
sendToSeat(agent: Agent) {
|
||||
if (!agent.seatId) return
|
||||
const seat = this.scene.getSeatById(agent.seatId)
|
||||
if (!seat) return
|
||||
|
||||
agent.walkTo(seat.tileX, seat.tileY, () => {
|
||||
agent.setAgentState(AgentState.TYPE)
|
||||
agent.setDirection(seat.facing)
|
||||
if (!agent.isActive) {
|
||||
agent.seatTimer = INACTIVE_SEAT_TIMER_MIN + Math.random() * INACTIVE_SEAT_TIMER_RANGE
|
||||
}
|
||||
}).then(moved => {
|
||||
if (!moved) {
|
||||
agent.setAgentState(AgentState.TYPE)
|
||||
agent.setDirection(seat.facing)
|
||||
if (!agent.isActive) {
|
||||
agent.seatTimer = INACTIVE_SEAT_TIMER_MIN + Math.random() * INACTIVE_SEAT_TIMER_RANGE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
moveToZone(agent: Agent, zoneName: string, arrivalState?: string): boolean {
|
||||
const zoneKey = `${agent.officeId}-${zoneName}`
|
||||
const zone = ZONES[zoneKey]
|
||||
if (!zone) return false
|
||||
|
||||
if (zone.seats.length > 0) {
|
||||
const occupiedTiles = new Set<string>()
|
||||
for (const other of this.scene.agents.values()) {
|
||||
if (other === agent) continue
|
||||
if (other.isMoving) continue
|
||||
const pos = other.getTilePos()
|
||||
occupiedTiles.add(`${pos.x},${pos.y}`)
|
||||
}
|
||||
for (const other of this.scene.agents.values()) {
|
||||
if (other === agent) continue
|
||||
if (!other.isMoving) continue
|
||||
const path = (other as any).currentPath as { x: number; y: number }[]
|
||||
if (path?.length) {
|
||||
const dest = path[path.length - 1]
|
||||
occupiedTiles.add(`${dest.x},${dest.y}`)
|
||||
}
|
||||
}
|
||||
const freeSeat = zone.seats.find(s => !occupiedTiles.has(`${s.tileX},${s.tileY}`))
|
||||
if (freeSeat) {
|
||||
agent.walkTo(freeSeat.tileX, freeSeat.tileY, () => {
|
||||
agent.setDirection(freeSeat.facing)
|
||||
if (arrivalState) agent.setAgentState(arrivalState as any)
|
||||
else agent.setAgentState(AgentState.IDLE)
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const pos = randomTileInZone(zoneKey)
|
||||
if (!pos) return false
|
||||
agent.walkTo(pos.x, pos.y, () => {
|
||||
if (arrivalState) agent.setAgentState(arrivalState as any)
|
||||
else agent.setAgentState(AgentState.IDLE)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
moveToDoorway(agent: Agent): boolean {
|
||||
const doorways = getOfficeLobbyDoorways(agent.officeId)
|
||||
if (doorways.length === 0) return false
|
||||
const target = doorways[Math.floor(Math.random() * doorways.length)]
|
||||
agent.walkTo(target.x, target.y)
|
||||
return true
|
||||
}
|
||||
|
||||
placeAtDoorway(agent: Agent) {
|
||||
const doorways = getOfficeLobbyDoorways(agent.officeId)
|
||||
if (doorways.length > 0) {
|
||||
const d = doorways[Math.floor(Math.random() * doorways.length)]
|
||||
agent.setPosition(d.x * TILE_SIZE + TILE_SIZE / 2, d.y * TILE_SIZE + TILE_SIZE / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as EasyStar from 'easystarjs'
|
||||
|
||||
export class PathfindingManager {
|
||||
private easystar: EasyStar.js
|
||||
private grid: number[][]
|
||||
private gridCols: number
|
||||
private gridRows: number
|
||||
|
||||
constructor(collisionGrid: number[][]) {
|
||||
this.grid = collisionGrid.map(row => [...row])
|
||||
this.gridRows = this.grid.length
|
||||
this.gridCols = this.gridRows > 0 ? this.grid[0].length : 0
|
||||
this.easystar = new EasyStar.js()
|
||||
this.easystar.setGrid(this.grid)
|
||||
this.easystar.setAcceptableTiles([0])
|
||||
this.easystar.setIterationsPerCalculation(800)
|
||||
}
|
||||
|
||||
findPath(
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
): Promise<{ x: number; y: number }[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (
|
||||
from.x < 0 || from.x >= this.gridCols ||
|
||||
from.y < 0 || from.y >= this.gridRows ||
|
||||
to.x < 0 || to.x >= this.gridCols ||
|
||||
to.y < 0 || to.y >= this.gridRows
|
||||
) {
|
||||
resolve([])
|
||||
return
|
||||
}
|
||||
|
||||
if (this.grid[to.y]?.[to.x] === 1) {
|
||||
const alt = this.findNearestWalkable(to.x, to.y)
|
||||
if (!alt) { resolve([]); return }
|
||||
to = alt
|
||||
}
|
||||
|
||||
if (this.grid[from.y]?.[from.x] === 1) {
|
||||
const alt = this.findNearestWalkable(from.x, from.y)
|
||||
if (!alt) { resolve([]); return }
|
||||
from = alt
|
||||
}
|
||||
|
||||
this.easystar.findPath(from.x, from.y, to.x, to.y, (path) => {
|
||||
resolve(path ?? [])
|
||||
})
|
||||
this.easystar.calculate()
|
||||
})
|
||||
}
|
||||
|
||||
blockTile(x: number, y: number) {
|
||||
if (y >= 0 && y < this.grid.length && x >= 0 && x < this.grid[0].length) {
|
||||
this.grid[y][x] = 1
|
||||
this.easystar.setGrid(this.grid)
|
||||
}
|
||||
}
|
||||
|
||||
unblockTile(x: number, y: number) {
|
||||
if (y >= 0 && y < this.grid.length && x >= 0 && x < this.grid[0].length) {
|
||||
this.grid[y][x] = 0
|
||||
this.easystar.setGrid(this.grid)
|
||||
}
|
||||
}
|
||||
|
||||
isWalkable(x: number, y: number): boolean {
|
||||
if (y < 0 || y >= this.grid.length || x < 0 || x < 0 || x >= this.grid[0].length) return false
|
||||
return this.grid[y][x] === 0
|
||||
}
|
||||
|
||||
getWalkableTiles(): { x: number; y: number }[] {
|
||||
const tiles: { x: number; y: number }[] = []
|
||||
for (let r = 0; r < this.grid.length; r++) {
|
||||
for (let c = 0; c < this.grid[r].length; c++) {
|
||||
if (this.grid[r][c] === 0) {
|
||||
tiles.push({ x: c, y: r })
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
private findNearestWalkable(x: number, y: number): { x: number; y: number } | null {
|
||||
for (let radius = 1; radius <= 5; radius++) {
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue
|
||||
const nx = x + dx
|
||||
const ny = y + dy
|
||||
if (this.isWalkable(nx, ny)) return { x: nx, y: ny }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Agent event test runner — multi-office edition.
|
||||
* Attach to window so it can be called from browser console:
|
||||
* window.__runEventTests()
|
||||
*/
|
||||
import type { GameBridge } from '../GameBridge'
|
||||
import type { VisualEvent } from '../../types/visual'
|
||||
import { ZONES } from '../map/InteractionZones'
|
||||
import { getOffices } from '../map/OfficeStore'
|
||||
|
||||
let _eventId = 0
|
||||
function makeEvent(type: string, agentId: string, data: Record<string, unknown> = {}): VisualEvent {
|
||||
return {
|
||||
event_id: `test-${++_eventId}`,
|
||||
type,
|
||||
agent_id: agentId,
|
||||
data,
|
||||
timestamp: Date.now() / 1000,
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string
|
||||
pass: boolean
|
||||
detail: string
|
||||
}
|
||||
|
||||
export async function runAllTests(bridge: GameBridge): Promise<TestResult[]> {
|
||||
const results: TestResult[] = []
|
||||
const scene = bridge.getScene()
|
||||
if (!scene) {
|
||||
results.push({ name: 'scene-ready', pass: false, detail: 'OfficeScene not initialized' })
|
||||
return results
|
||||
}
|
||||
|
||||
console.log('%c[EventTest] Starting full event test suite (multi-office)...', 'color: #6366f1; font-weight: bold')
|
||||
|
||||
for (const id of [...scene.agents.keys()]) scene.removeAgent(id)
|
||||
await sleep(100)
|
||||
|
||||
const offices = getOffices()
|
||||
|
||||
// ── Test 1: Office data ──
|
||||
results.push({
|
||||
name: 'offices-loaded',
|
||||
pass: offices.length >= 3,
|
||||
detail: `${offices.length} offices loaded: ${offices.map(o => o.name).join(', ')}`,
|
||||
})
|
||||
|
||||
// ── Test 2: Agents assigned to different offices ──
|
||||
const agentIds = ['agent-A', 'agent-B', 'agent-C', 'agent-D', 'agent-E', 'agent-F']
|
||||
for (const id of agentIds) {
|
||||
bridge.pushEvent(makeEvent('agent_active', id))
|
||||
}
|
||||
await sleep(300)
|
||||
|
||||
const assignedSeats = new Set<string>()
|
||||
let deskOverlap = false
|
||||
for (const id of agentIds) {
|
||||
const agent = scene.getAgent(id)
|
||||
if (!agent) continue
|
||||
if (agent.seatId) {
|
||||
if (assignedSeats.has(agent.seatId)) deskOverlap = true
|
||||
assignedSeats.add(agent.seatId)
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
name: 'desk-assignment-unique',
|
||||
pass: !deskOverlap && assignedSeats.size === agentIds.length,
|
||||
detail: `Assigned ${assignedSeats.size} unique seats to ${agentIds.length} agents. IDs: [${[...assignedSeats].join(', ')}]`,
|
||||
})
|
||||
|
||||
// ── Test 3: All agents have officeId set ──
|
||||
const allHaveOffice = agentIds.every(id => {
|
||||
const agent = scene.getAgent(id)
|
||||
return agent?.officeId != null
|
||||
})
|
||||
results.push({
|
||||
name: 'agents-have-officeId',
|
||||
pass: allHaveOffice,
|
||||
detail: agentIds.map(id => `${id}=${scene.getAgent(id)?.officeId}`).join(', '),
|
||||
})
|
||||
|
||||
// ── Test 4: tool_start → active ──
|
||||
bridge.pushEvent(makeEvent('tool_start', 'agent-A', { tool_name: 'shell' }))
|
||||
await sleep(200)
|
||||
const agA = scene.getAgent('agent-A')!
|
||||
results.push({
|
||||
name: 'tool_start-state',
|
||||
pass: agA.isActive === true && agA.currentTool === 'shell',
|
||||
detail: `isActive=${agA.isActive}, currentTool=${agA.currentTool}`,
|
||||
})
|
||||
|
||||
// ── Test 5: tool_done → celebrate ──
|
||||
bridge.pushEvent(makeEvent('tool_done', 'agent-A', { tool_name: 'shell' }))
|
||||
await sleep(100)
|
||||
results.push({
|
||||
name: 'tool_done-celebrate',
|
||||
pass: agA.agentState === 'celebrate',
|
||||
detail: `state=${agA.agentState}`,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (agA.agentState !== 'celebrate') break
|
||||
await sleep(500)
|
||||
}
|
||||
results.push({
|
||||
name: 'celebrate-to-idle',
|
||||
pass: agA.agentState !== 'celebrate',
|
||||
detail: `state=${agA.agentState}`,
|
||||
})
|
||||
|
||||
// ── Test 6: waiting → break room ──
|
||||
bridge.pushEvent(makeEvent('waiting', 'agent-B'))
|
||||
await sleep(200)
|
||||
const agB = scene.getAgent('agent-B')!
|
||||
results.push({
|
||||
name: 'waiting-state',
|
||||
pass: agB.isActive === false && (agB.agentState === 'walk' || agB.agentState === 'idle' || agB.agentState === 'coffee'),
|
||||
detail: `isActive=${agB.isActive}, state=${agB.agentState}`,
|
||||
})
|
||||
|
||||
// ── Test 7: reflect_start → meeting room ──
|
||||
bridge.pushEvent(makeEvent('reflect_start', 'agent-C'))
|
||||
await sleep(200)
|
||||
const agC = scene.getAgent('agent-C')!
|
||||
results.push({
|
||||
name: 'reflect_start-state',
|
||||
pass: agC.currentTool === 'Reflect' && (agC.agentState === 'walk' || agC.agentState === 'reflect'),
|
||||
detail: `currentTool=${agC.currentTool}, state=${agC.agentState}`,
|
||||
})
|
||||
|
||||
bridge.pushEvent(makeEvent('reflect_done', 'agent-C'))
|
||||
await sleep(100)
|
||||
results.push({
|
||||
name: 'reflect_done-celebrate',
|
||||
pass: agC.agentState === 'celebrate',
|
||||
detail: `state=${agC.agentState}`,
|
||||
})
|
||||
|
||||
// ── Test 8: collab in meeting room (same office) ──
|
||||
const collabAgents = ['agent-A', 'agent-B', 'agent-C', 'agent-D']
|
||||
for (const id of collabAgents) {
|
||||
bridge.pushEvent(makeEvent('collab_started', id))
|
||||
}
|
||||
await sleep(4000)
|
||||
|
||||
const meetingPositions = new Map<string, string>()
|
||||
let meetingOverlap = false
|
||||
for (const id of collabAgents) {
|
||||
const agent = scene.getAgent(id)
|
||||
if (!agent) continue
|
||||
const pos = agent.getTilePos()
|
||||
const key = `${pos.x},${pos.y}`
|
||||
if (meetingPositions.has(key)) meetingOverlap = true
|
||||
meetingPositions.set(key, id)
|
||||
}
|
||||
results.push({
|
||||
name: 'collab-no-overlap',
|
||||
pass: !meetingOverlap,
|
||||
detail: `${collabAgents.length} agents, ${meetingPositions.size} unique positions`,
|
||||
})
|
||||
|
||||
for (const id of collabAgents) bridge.pushEvent(makeEvent('collab_ended', id))
|
||||
await sleep(100)
|
||||
const allCelebrate = collabAgents.every(id => scene.getAgent(id)?.agentState === 'celebrate')
|
||||
results.push({
|
||||
name: 'collab_ended-celebrate',
|
||||
pass: allCelebrate,
|
||||
detail: collabAgents.map(id => `${id}=${scene.getAgent(id)?.agentState}`).join(', '),
|
||||
})
|
||||
await sleep(3000)
|
||||
|
||||
// ── Test 9: practice ──
|
||||
bridge.pushEvent(makeEvent('practice_start', 'agent-E', { target_domain: 'TypeScript' }))
|
||||
await sleep(200)
|
||||
const agE = scene.getAgent('agent-E')!
|
||||
results.push({
|
||||
name: 'practice_start-state',
|
||||
pass: agE.currentTool === 'Practice' && (agE.agentState === 'walk' || agE.agentState === 'practice'),
|
||||
detail: `currentTool=${agE.currentTool}, state=${agE.agentState}`,
|
||||
})
|
||||
|
||||
bridge.pushEvent(makeEvent('practice_done', 'agent-E'))
|
||||
await sleep(100)
|
||||
results.push({
|
||||
name: 'practice_done-celebrate',
|
||||
pass: agE.agentState === 'celebrate',
|
||||
detail: `state=${agE.agentState}`,
|
||||
})
|
||||
await sleep(3000)
|
||||
|
||||
// ── Test 10: task_delegated ──
|
||||
bridge.pushEvent(makeEvent('task_delegated', 'agent-A', { target: 'agent-B' }))
|
||||
await sleep(100)
|
||||
results.push({
|
||||
name: 'task_delegated-chat',
|
||||
pass: agA.agentState === 'chat',
|
||||
detail: `state=${agA.agentState}`,
|
||||
})
|
||||
|
||||
bridge.pushEvent(makeEvent('delegation_done', 'agent-A', { target: 'agent-B' }))
|
||||
await sleep(300)
|
||||
results.push({
|
||||
name: 'delegation_done-return',
|
||||
pass: ['idle', 'walk', 'type'].includes(agA.agentState),
|
||||
detail: `state=${agA.agentState}`,
|
||||
})
|
||||
|
||||
// ── Test 11: message ──
|
||||
const agF = scene.getAgent('agent-F')!
|
||||
bridge.pushEvent(makeEvent('message_in', 'agent-F', { content_preview: 'Hello test' }))
|
||||
await sleep(200)
|
||||
results.push({
|
||||
name: 'message_in-active',
|
||||
pass: agF.isActive === true,
|
||||
detail: `isActive=${agF.isActive}, state=${agF.agentState}`,
|
||||
})
|
||||
|
||||
bridge.pushEvent(makeEvent('message_out', 'agent-F', { content_preview: 'Reply test' }))
|
||||
await sleep(100)
|
||||
results.push({
|
||||
name: 'message_out-bubble',
|
||||
pass: agF.bubbleText?.includes('Reply') === true,
|
||||
detail: `bubble="${agF.bubbleText}"`,
|
||||
})
|
||||
|
||||
// ── Test 12: subagent ──
|
||||
bridge.pushEvent(makeEvent('subagent_spawn', 'subagent-test-1', { parent_agent_id: 'agent-A' }))
|
||||
await sleep(200)
|
||||
const sub1 = scene.getAgent('subagent-test-1')
|
||||
results.push({
|
||||
name: 'subagent_spawn-created',
|
||||
pass: sub1 != null && sub1.isSubagent === true,
|
||||
detail: `created=${!!sub1}, isSubagent=${sub1?.isSubagent}`,
|
||||
})
|
||||
|
||||
bridge.pushEvent(makeEvent('subagent_done', 'subagent-test-1'))
|
||||
await sleep(5000)
|
||||
results.push({
|
||||
name: 'subagent_done-removed',
|
||||
pass: scene.getAgent('subagent-test-1') == null,
|
||||
detail: `still exists=${!!scene.getAgent('subagent-test-1')}`,
|
||||
})
|
||||
|
||||
// ── Test 13: task_routed ──
|
||||
bridge.pushEvent(makeEvent('task_routed', 'agent-E', { method: 'auto' }))
|
||||
await sleep(200)
|
||||
results.push({
|
||||
name: 'task_routed-active',
|
||||
pass: agE.isActive === true,
|
||||
detail: `isActive=${agE.isActive}, state=${agE.agentState}`,
|
||||
})
|
||||
|
||||
// ── Test 14: agent_removed ──
|
||||
bridge.pushEvent(makeEvent('agent_removed', 'agent-F'))
|
||||
await sleep(5000)
|
||||
results.push({
|
||||
name: 'agent_removed-destroyed',
|
||||
pass: scene.getAgent('agent-F') == null,
|
||||
detail: `still exists=${!!scene.getAgent('agent-F')}`,
|
||||
})
|
||||
|
||||
// ── Test 15: crystallize ──
|
||||
bridge.pushEvent(makeEvent('mycelium_crystallize', 'agent-A', {
|
||||
corroborating_agents: ['agent-B', 'agent-C'],
|
||||
content_preview: 'Shared knowledge',
|
||||
}))
|
||||
await sleep(4000)
|
||||
|
||||
const crystalPositions = new Map<string, string>()
|
||||
let crystalOverlap = false
|
||||
for (const id of ['agent-A', 'agent-B', 'agent-C']) {
|
||||
const agent = scene.getAgent(id)
|
||||
if (!agent) continue
|
||||
const pos = agent.getTilePos()
|
||||
const key = `${pos.x},${pos.y}`
|
||||
if (crystalPositions.has(key)) crystalOverlap = true
|
||||
crystalPositions.set(key, id)
|
||||
}
|
||||
results.push({
|
||||
name: 'crystal-no-overlap',
|
||||
pass: !crystalOverlap,
|
||||
detail: `3 agents, ${crystalPositions.size} unique positions`,
|
||||
})
|
||||
|
||||
// ── Test 16: desk seat uniqueness ──
|
||||
const deskSeatMap = new Map<string, string>()
|
||||
let deskConflict = false
|
||||
for (const agent of scene.agents.values()) {
|
||||
if (!agent.seatId) continue
|
||||
if (deskSeatMap.has(agent.seatId)) deskConflict = true
|
||||
deskSeatMap.set(agent.seatId, agent.agentId)
|
||||
}
|
||||
results.push({
|
||||
name: 'desk-seat-no-conflict',
|
||||
pass: !deskConflict,
|
||||
detail: `${deskSeatMap.size} desk seats assigned, conflict=${deskConflict}`,
|
||||
})
|
||||
|
||||
// ── Test 17: reassign agent across offices ──
|
||||
const agD = scene.getAgent('agent-D')
|
||||
if (agD) {
|
||||
const oldOfficeId = agD.officeId
|
||||
const targetOffice = offices.find(o => o.id !== oldOfficeId) ?? offices[1]
|
||||
bridge.assignAgentToOffice('agent-D', targetOffice.id)
|
||||
await sleep(300)
|
||||
const newOffice = agD.officeId
|
||||
results.push({
|
||||
name: 'reassign-office',
|
||||
pass: newOffice === targetOffice.id && newOffice !== oldOfficeId,
|
||||
detail: `${oldOfficeId} → ${newOffice} (expected ${targetOffice.id})`,
|
||||
})
|
||||
} else {
|
||||
results.push({ name: 'reassign-office', pass: false, detail: 'agent-D not found' })
|
||||
}
|
||||
|
||||
// ── Test 18: cross-office seat uniqueness after reassign ──
|
||||
const seatMap2 = new Map<string, string>()
|
||||
let conflict2 = false
|
||||
for (const agent of scene.agents.values()) {
|
||||
if (!agent.seatId) continue
|
||||
if (seatMap2.has(agent.seatId)) conflict2 = true
|
||||
seatMap2.set(agent.seatId, agent.agentId)
|
||||
}
|
||||
results.push({
|
||||
name: 'cross-office-seat-unique',
|
||||
pass: !conflict2,
|
||||
detail: `${seatMap2.size} seats, conflict=${conflict2}`,
|
||||
})
|
||||
|
||||
// ── Summary ──
|
||||
const passed = results.filter(r => r.pass).length
|
||||
const failed = results.filter(r => !r.pass).length
|
||||
|
||||
console.log('')
|
||||
console.log('%c[EventTest] ═══════════════════════════════════════', 'color: #6366f1; font-weight: bold')
|
||||
console.log(`%c[EventTest] Results: ${passed} passed, ${failed} failed, ${results.length} total`, `color: ${failed > 0 ? '#f87171' : '#34d399'}; font-weight: bold`)
|
||||
console.log('%c[EventTest] ═══════════════════════════════════════', 'color: #6366f1; font-weight: bold')
|
||||
|
||||
for (const r of results) {
|
||||
const icon = r.pass ? '\u2705' : '\u274C'
|
||||
const color = r.pass ? 'color: #34d399' : 'color: #f87171; font-weight: bold'
|
||||
console.log(`%c ${icon} ${r.name}: ${r.detail}`, color)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export function registerTestRunner(bridge: GameBridge) {
|
||||
(window as any).__runEventTests = () => runAllTests(bridge)
|
||||
;(window as any).__bridge = bridge
|
||||
console.log(
|
||||
'%c[EventTest] Test runner ready. Run: window.__runEventTests()',
|
||||
'color: #fbbf24; font-weight: bold',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export const AgentState = {
|
||||
IDLE: 'idle',
|
||||
WALK: 'walk',
|
||||
TYPE: 'type',
|
||||
THINK: 'think',
|
||||
CELEBRATE: 'celebrate',
|
||||
COFFEE: 'coffee',
|
||||
CHAT: 'chat',
|
||||
PRACTICE: 'practice',
|
||||
REFLECT: 'reflect',
|
||||
SLEEP: 'sleep',
|
||||
PRESENT: 'present',
|
||||
} as const
|
||||
export type AgentState = (typeof AgentState)[keyof typeof AgentState]
|
||||
|
||||
export const Direction = {
|
||||
DOWN: 'down',
|
||||
LEFT: 'left',
|
||||
RIGHT: 'right',
|
||||
UP: 'up',
|
||||
} as const
|
||||
export type Direction = (typeof Direction)[keyof typeof Direction]
|
||||
|
||||
export interface SeatDef {
|
||||
id: string
|
||||
tileX: number
|
||||
tileY: number
|
||||
facing: Direction
|
||||
assigned: boolean
|
||||
assignedTo: string | null
|
||||
}
|
||||
|
||||
export interface InteractableDef {
|
||||
id: string
|
||||
tileX: number
|
||||
tileY: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface ZoneDef {
|
||||
bounds: { x: number; y: number; w: number; h: number }
|
||||
seats: SeatDef[]
|
||||
interactables: InteractableDef[]
|
||||
doorways: { id: string; tileX: number; tileY: number }[]
|
||||
}
|
||||
|
||||
export interface AgentInfo {
|
||||
id: string
|
||||
displayName: string
|
||||
state: AgentState
|
||||
isActive: boolean
|
||||
currentTool: string | null
|
||||
seatId: string | null
|
||||
urgency: 'urgent' | 'normal' | 'relaxed'
|
||||
bubble: string | null
|
||||
bubbleTimer: number
|
||||
isSubagent: boolean
|
||||
parentAgentId: string | null
|
||||
palette: number
|
||||
hueShift: number
|
||||
taskSummary?: string
|
||||
lastEventAt: number
|
||||
wanderTimer: number
|
||||
wanderCount: number
|
||||
wanderLimit: number
|
||||
seatTimer: number
|
||||
stateTimer: number
|
||||
myceliumEffect: 'crystal' | 'transport_send' | 'transport_recv' | 'spore_send' | 'decompose' | null
|
||||
myceliumEffectTimer: number
|
||||
myceliumSession: string | null
|
||||
}
|
||||
Reference in New Issue
Block a user