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 = new Map() seats: SeatDef[] = [] walkableTiles: { x: number; y: number }[] = [] private walkableTilesByOffice: Map = 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) } }