Files
OpenOPC/opc/plugins/office_ui/frontend_src/game/scenes/OfficeScene.ts
T
LZH-YS1998 ee79331d48 fix(office-ui): resolve blank/frozen office canvas and add sidebar collapse
Office canvas fixes:
- Lazy-create the Phaser game via ResizeObserver on the first non-zero
  layout instead of creating it inside the display:none office page with
  inline px fallback sizes. Phaser's RESIZE-mode 500ms parent poll has no
  zero guard, so the old path shrank the canvas to 0x0 (blue-black screen)
  and later restored it to a stale wrong size (clipped office).
- On unhide, re-measure with scale.getParentBounds() before scale.refresh();
  plain scale.resize() is clobbered by the stale cached parentSize in
  RESIZE mode.
- Fix whole-game freeze when clicking an office card: camera effects
  resolve ease names via EaseMap, which has no 'Cubic.Out' key, leaving
  effect.ease undefined and killing the RAF loop with a per-frame
  TypeError. Use 'Cubic.easeOut' for cam.pan in panToOffice/resetCameraView.
- Bound GameBridge queues (latest snapshot supersedes, event queue capped)
  since game creation is now deferred until the Office page is first opened.

Sidebar:
- Add a collapse/expand handle on the canvas/sidebar boundary with a 220ms
  grid transition; state persists in localStorage. The canvas follows the
  column change automatically through the ResizeObserver path. Stacked
  (<=1024px) layout collapses the bottom panel and moves the handle to the
  bottom edge.

README:
- Add Simplified Chinese translation (README.zh-CN.md) with a language
  switcher in both files; fix stale TOC entries in the English README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:47:04 +08:00

591 lines
20 KiB
TypeScript

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.easeOut',
})
cam.pan(targetX, targetY, 260, 'Cubic.easeOut')
}
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.easeOut',
})
cam.pan(cx, cy, 380, 'Cubic.easeOut')
}
// ── 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)
}
}