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:
+1
-1
File diff suppressed because one or more lines are too long
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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user