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>
This commit is contained in:
LZH-YS1998
2026-07-07 11:45:11 +08:00
parent 137e382097
commit ee79331d48
12 changed files with 1087 additions and 202 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-iXYjKKT7.js"></script>
<script type="module" crossorigin src="./assets/index-DoyFIcGP.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-CyXk-Ux0.css">
<link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
</head>
<body>
<div id="root"></div>
+17 -1
View File
@@ -459,6 +459,14 @@ export default function App() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const [theme, setTheme] = useState<ThemeName>('openopc')
const [showSubagents, setShowSubagents] = useState(true)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
try { return localStorage.getItem('opc_office_sidebar_collapsed') === '1' } catch { return false }
})
const toggleSidebar = () => setSidebarCollapsed(v => {
const next = !v
try { localStorage.setItem('opc_office_sidebar_collapsed', next ? '1' : '0') } catch { /* private mode */ }
return next
})
const [eventTypeFilter, setEventTypeFilter] = useState('all')
const [activePage, setActivePage] = useState<AppPage>('workspace')
const [swarmAgents, setSwarmAgents] = useState<AgentInfo[]>([])
@@ -2408,13 +2416,21 @@ export default function App() {
)}
{/* Main Grid */}
<main className={`main-grid${activePage !== 'office' ? ' hidden' : ''}`}>
<main className={`main-grid${activePage !== 'office' ? ' hidden' : ''}${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
{/* Phaser Game Canvas */}
<section className="canvas-wrap">
<PhaserGame bridge={bridgeRef.current} />
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? 'Hide sub-agents' : 'Show sub-agents'}>
{showSubagents ? '👥' : '👤'}
</button>
<button
className="sidebar-collapse-btn"
onClick={toggleSidebar}
title={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
aria-label={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
>
<span className="collapse-glyph">{sidebarCollapsed ? '' : ''}</span>
</button>
</section>
{/* Sidebar */}
@@ -36,6 +36,7 @@ export class GameBridge extends Phaser.Events.EventEmitter {
pushEvent(evt: VisualEvent) {
if (!this.scene) {
this.eventQueue.push(evt)
if (this.eventQueue.length > 500) this.eventQueue.shift()
return
}
this.applyEvent(evt)
@@ -45,7 +46,11 @@ export class GameBridge extends Phaser.Events.EventEmitter {
const agentCount = Object.keys(snapshot.agents ?? {}).length
if (!this.scene) {
console.log(`[GameBridge] pushSnapshot queued (scene not ready) — ${agentCount} agents`)
this.snapshotQueue.push(snapshot)
// A snapshot fully resets the scene, so it supersedes anything queued
// before it. The game may not be created until the Office page is first
// opened — keep the queues bounded in the meantime.
this.snapshotQueue = [snapshot]
this.eventQueue = []
return
}
console.log(`[GameBridge] pushSnapshot applying now — ${agentCount} agents`)
@@ -15,47 +15,51 @@ export function PhaserGame({ bridge }: Props) {
const gameRef = useRef<Phaser.Game | null>(null)
useEffect(() => {
if (!wrapperRef.current || !containerRef.current || gameRef.current) return
if (!wrapperRef.current || !containerRef.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`
const createGame = (w: number, h: number) => {
console.log('[PhaserGame] Creating Phaser game', w, '×', h)
const config = createGameConfig(container, w, h)
config.scene = [BootScene, OfficeScene]
const game = new Phaser.Game(config)
game.registry.set('bridge', bridge)
gameRef.current = game
}
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)
if (typeof ResizeObserver === 'undefined') {
createGame(wrapper.clientWidth || window.innerWidth - 380, wrapper.clientHeight || window.innerHeight - 48)
return () => {
gameRef.current?.destroy(true)
gameRef.current = null
}
}
window.addEventListener('resize', onResize)
// The office page can start hidden (display:none → 0×0). Never create or
// resize the game at zero size; wait for the first real layout instead.
const observer = new ResizeObserver((entries) => {
const rect = entries[entries.length - 1].contentRect
const w = Math.floor(rect.width)
const h = Math.floor(rect.height)
if (w < 1 || h < 1) return // hidden — keep last known size
if (!gameRef.current) {
createGame(w, h)
} else {
// In RESIZE scale mode the game follows parentSize, which Phaser only
// re-measures on its 500ms poll — and scale.resize() gets clobbered by
// that stale value. Re-measure the parent, then refresh.
const scale = gameRef.current.scale
scale.getParentBounds()
scale.refresh()
}
})
observer.observe(wrapper)
return () => {
window.removeEventListener('resize', onResize)
game.destroy(true)
observer.disconnect()
gameRef.current?.destroy(true)
gameRef.current = null
}
}, [bridge]) // bridge is a stable ref, effect runs once
@@ -63,8 +67,9 @@ export function PhaserGame({ bridge }: Props) {
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} />
{/* Phaser mounts its canvas inside this div; it must track the wrapper
so Phaser's own parent-bounds polling reads the true size. */}
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
</div>
)
}
@@ -99,9 +99,9 @@ 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
export function createGameConfig(parent: HTMLElement, width?: number, height?: number): Phaser.Types.Core.GameConfig {
const w = width || parent.clientWidth || window.innerWidth - 380
const h = height || parent.clientHeight || window.innerHeight - 48
const skyHex = isLocalDaytime() ? '#a8d4ec' : '#31453a'
return {
type: Phaser.CANVAS,
@@ -320,9 +320,9 @@ export class OfficeScene extends Phaser.Scene {
targets: cam,
zoom: targetZoom,
duration: 260,
ease: 'Cubic.Out',
ease: 'Cubic.easeOut',
})
cam.pan(targetX, targetY, 260, 'Cubic.Out')
cam.pan(targetX, targetY, 260, 'Cubic.easeOut')
}
panToOffice(officeId: string) {
@@ -343,9 +343,9 @@ export class OfficeScene extends Phaser.Scene {
targets: cam,
zoom: targetZoom,
duration: 380,
ease: 'Cubic.Out',
ease: 'Cubic.easeOut',
})
cam.pan(cx, cy, 380, 'Cubic.Out')
cam.pan(cx, cy, 380, 'Cubic.easeOut')
}
// ── Agent management ──────────────────────────────────
@@ -478,12 +478,22 @@ html, body, #root {
height: 100%;
min-height: 0;
overflow: hidden;
/* Canvas follows via ResizeObserver in PhaserGame, so the column change animates cleanly */
transition: grid-template-columns 220ms ease;
}
.main-grid.hidden {
display: none;
}
.main-grid.sidebar-collapsed {
grid-template-columns: 1fr 0px;
}
.main-grid.sidebar-collapsed .sidebar {
border-left: none;
}
/* ── Canvas ─────────────────────────────────────────── */
.canvas-wrap {
@@ -535,6 +545,35 @@ html, body, #root {
transform: scale(1.05);
}
/* Handle on the canvas/sidebar boundary that collapses or expands the side panel */
.sidebar-collapse-btn {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 18px;
height: 56px;
padding: 0;
border: 1px solid var(--border);
border-right: none;
border-radius: 8px 0 0 8px;
background: rgba(20, 27, 43, 0.94);
color: var(--text-secondary);
font-size: 10px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 6;
transition: background 150ms, color 150ms;
}
.sidebar-collapse-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
/* ── Sidebar ────────────────────────────────────────── */
.sidebar {
@@ -1314,6 +1353,14 @@ html, body, #root {
.main-grid {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1.2fr) minmax(0, 1fr);
transition: grid-template-rows 220ms ease;
}
.main-grid.sidebar-collapsed {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) 0;
}
.main-grid.sidebar-collapsed .sidebar {
border-top: none;
}
.sidebar {
border-left: none;
@@ -1321,6 +1368,22 @@ html, body, #root {
max-height: 48vh;
min-height: 0;
}
/* Sidebar stacks below the canvas here — move the handle to the bottom edge */
.sidebar-collapse-btn {
top: auto;
bottom: 0;
right: 50%;
transform: translateX(50%);
width: 56px;
height: 18px;
border: 1px solid var(--border);
border-bottom: none;
border-radius: 8px 8px 0 0;
}
.sidebar-collapse-btn .collapse-glyph {
display: inline-block;
transform: rotate(90deg);
}
.stat-chips { display: none; }
}