From e820df7eb657e894d622f8c8fd49d388498109ba Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 13 Jul 2026 03:57:43 +0800 Subject: [PATCH] fix: refresh Pascal scene interactions live --- .../components/ai-design-agent-panel.tsx | 8 + .../apps/editor/components/scene-loader.tsx | 87 +++++++++-- .../apps/editor/components/viewer-toolbar.tsx | 17 +++ .../editor/lib/pascal-agent-browser-events.ts | 15 ++ .../editor/custom-camera-controls.tsx | 25 ++-- .../editor/first-person-controls.tsx | 140 ++++++++++++------ .../editor/first-person-input.test.ts | 23 +++ .../components/editor/first-person-input.ts | 48 ++++++ .../editor/src/components/editor/index.tsx | 14 +- 9 files changed, 295 insertions(+), 82 deletions(-) create mode 100644 services/pascal-editor/apps/editor/lib/pascal-agent-browser-events.ts create mode 100644 services/pascal-editor/packages/editor/src/components/editor/first-person-input.test.ts create mode 100644 services/pascal-editor/packages/editor/src/components/editor/first-person-input.ts diff --git a/services/pascal-editor/apps/editor/components/ai-design-agent-panel.tsx b/services/pascal-editor/apps/editor/components/ai-design-agent-panel.tsx index 46ba08def..9c1ce2c8c 100644 --- a/services/pascal-editor/apps/editor/components/ai-design-agent-panel.tsx +++ b/services/pascal-editor/apps/editor/components/ai-design-agent-panel.tsx @@ -15,6 +15,7 @@ import { Wrench, } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' +import { announcePascalAgentActivity } from '@/lib/pascal-agent-browser-events' import { withPascalBasePath } from '@/lib/pascal-base-path' interface AiDesignAgentPanelProps { @@ -184,6 +185,13 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) { .join('|') }, [messages]) + useEffect(() => { + announcePascalAgentActivity(sceneId, isRunning) + return () => { + if (isRunning) announcePascalAgentActivity(sceneId, false) + } + }, [isRunning, sceneId]) + useEffect(() => { let active = true fetch(withPascalBasePath('/api/agent/chat')) diff --git a/services/pascal-editor/apps/editor/components/scene-loader.tsx b/services/pascal-editor/apps/editor/components/scene-loader.tsx index dbd9edbe1..b10ea6053 100644 --- a/services/pascal-editor/apps/editor/components/scene-loader.tsx +++ b/services/pascal-editor/apps/editor/components/scene-loader.tsx @@ -11,9 +11,12 @@ import { } from '@pascal-app/editor' import { Bot, Hammer, Layers } from 'lucide-react' import Image from 'next/image' -import Link from 'next/link' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + PASCAL_AGENT_ACTIVITY_EVENT, + type PascalAgentActivityDetail, +} from '@/lib/pascal-agent-browser-events' import { withPascalBasePath } from '@/lib/pascal-base-path' import { AiDesignAgentPanel } from './ai-design-agent-panel' import { BuildTab } from './build-tab' @@ -85,6 +88,12 @@ interface LiveSceneEvent { graph: SceneGraphWithCollections } +interface RemoteSceneSnapshot extends SceneMeta { + graph: SceneGraphWithCollections +} + +const AGENT_SCENE_POLL_MS = 700 + function sceneGraphSignature(graph: SceneGraphWithCollections): string { return JSON.stringify({ nodes: graph.nodes, @@ -120,6 +129,27 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const handleLoad = useCallback(async () => initialScene, [initialScene]) + const applyRemoteSnapshot = useCallback((snapshot: RemoteSceneSnapshot | LiveSceneEvent) => { + if (snapshot.version <= versionRef.current) return false + + versionRef.current = snapshot.version + lastRemoteGraphJsonRef.current = sceneGraphSignature(snapshot.graph) + suppressRemoteSaveUntilRef.current = Date.now() + 2500 + applySceneGraphToEditor(snapshot.graph) + setConflict(false) + setSaveError(null) + return true + }, []) + + const fetchLatestScene = useCallback(async () => { + const response = await fetch(withPascalBasePath(`/api/scenes/${meta.id}`), { + cache: 'no-store', + }) + if (!response.ok) return false + const snapshot = (await response.json()) as RemoteSceneSnapshot + return applyRemoteSnapshot(snapshot) + }, [applyRemoteSnapshot, meta.id]) + const handleSave = useCallback( async (graph: SceneGraph, options?: { keepalive?: boolean }) => { const graphJson = sceneGraphSignature(graph) @@ -179,22 +209,55 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { if (payload.sceneId !== meta.id) return if (payload.version <= versionRef.current) return - versionRef.current = payload.version - lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph) - suppressRemoteSaveUntilRef.current = Date.now() + 2500 - applySceneGraphToEditor(payload.graph) - setConflict(false) - setSaveError(null) + applyRemoteSnapshot(payload) }) source.addEventListener('error', () => { + void fetchLatestScene().catch(() => undefined) if (source.readyState === EventSource.CLOSED) { setSaveError('场景实时连接已断开') } }) return () => source.close() - }, [meta.id]) + }, [applyRemoteSnapshot, fetchLatestScene, meta.id]) + + useEffect(() => { + let disposed = false + let polling = false + let timer: ReturnType | null = null + + const clearTimer = () => { + if (timer) clearTimeout(timer) + timer = null + } + + const poll = async () => { + clearTimer() + try { + await fetchLatestScene() + } catch { + // SSE remains active; the next short poll can recover from a transient request failure. + } + if (!disposed && polling) timer = setTimeout(poll, AGENT_SCENE_POLL_MS) + } + + const handleAgentActivity = (event: Event) => { + const detail = (event as CustomEvent).detail + if (!detail || detail.sceneId !== meta.id) return + polling = detail.running + clearTimer() + void poll() + } + + window.addEventListener(PASCAL_AGENT_ACTIVITY_EVENT, handleAgentActivity) + return () => { + disposed = true + polling = false + clearTimer() + window.removeEventListener(PASCAL_AGENT_ACTIVITY_EVENT, handleAgentActivity) + } + }, [fetchLatestScene, meta.id]) const handleThumb = useCallback( async (_blob: Blob) => { @@ -241,14 +304,6 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {

{saveError}

)} -
- - 全部场景 - -
+ + + + + + + ) +} + function LevelModeToggle() { const levelMode = useViewer((state) => state.levelMode) const setLevelMode = useViewer((state) => state.setLevelMode) @@ -493,6 +507,8 @@ function WalkthroughButton() { return ( {!hasPlacedSpawn && (
- Place a Spawn Point from the Build tab to control where walkthrough starts. + 可在“搭建”中放置出生点,用于设定漫游起点。
)} - {isLocked && ( + {isLocked && isHintVisible && (
- +
- - - - + + + +
- Click to look around + 移动鼠标观察 · H 隐藏提示
diff --git a/services/pascal-editor/packages/editor/src/components/editor/first-person-input.test.ts b/services/pascal-editor/packages/editor/src/components/editor/first-person-input.test.ts new file mode 100644 index 000000000..772c9e589 --- /dev/null +++ b/services/pascal-editor/packages/editor/src/components/editor/first-person-input.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { getFirstPersonMovementInput } from './first-person-input' + +describe('first-person movement input', () => { + test('maps physical WASD and arrow key codes', () => { + expect(getFirstPersonMovementInput('KeyW', 'w', true)).toEqual({ forward: true }) + expect(getFirstPersonMovementInput('ArrowLeft', 'ArrowLeft', true)).toEqual({ leftward: true }) + expect(getFirstPersonMovementInput('ArrowDown', 'ArrowDown', false)).toEqual({ + backward: false, + }) + }) + + test('falls back to event.key for embedded browsers with missing codes', () => { + expect(getFirstPersonMovementInput('', 'D', true)).toEqual({ rightward: true }) + expect(getFirstPersonMovementInput('', 'ArrowUp', true)).toEqual({ forward: true }) + }) + + test('supports jump and sprint while ignoring unrelated keys', () => { + expect(getFirstPersonMovementInput('Space', ' ', true)).toEqual({ jump: true }) + expect(getFirstPersonMovementInput('', 'Shift', true)).toEqual({ run: true }) + expect(getFirstPersonMovementInput('KeyH', 'h', true)).toBeNull() + }) +}) diff --git a/services/pascal-editor/packages/editor/src/components/editor/first-person-input.ts b/services/pascal-editor/packages/editor/src/components/editor/first-person-input.ts new file mode 100644 index 000000000..8998760e6 --- /dev/null +++ b/services/pascal-editor/packages/editor/src/components/editor/first-person-input.ts @@ -0,0 +1,48 @@ +import type { MovementInput } from '@pascal-app/viewer' + +type MovementKeyName = Exclude + +export const firstPersonKeyboardMap: Array<{ name: MovementKeyName; keys: string[] }> = [ + { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, + { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, + { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, + { name: 'rightward', keys: ['ArrowRight', 'KeyD'] }, + { name: 'jump', keys: ['Space'] }, + { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, +] + +export const inactiveFirstPersonMovement: MovementInput = { + backward: false, + forward: false, + jump: false, + leftward: false, + rightward: false, + run: false, +} + +const movementCodeToName = new Map( + firstPersonKeyboardMap.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)), +) + +const movementKeyAliases = new Map([ + ['arrowup', 'forward'], + ['w', 'forward'], + ['arrowdown', 'backward'], + ['s', 'backward'], + ['arrowleft', 'leftward'], + ['a', 'leftward'], + ['arrowright', 'rightward'], + ['d', 'rightward'], + [' ', 'jump'], + ['spacebar', 'jump'], + ['shift', 'run'], +]) + +export function getFirstPersonMovementInput( + code: string, + key: string, + active: boolean, +): MovementInput | null { + const name = movementCodeToName.get(code) ?? movementKeyAliases.get(key.toLocaleLowerCase()) + return name ? ({ [name]: active } as MovementInput) : null +} diff --git a/services/pascal-editor/packages/editor/src/components/editor/index.tsx b/services/pascal-editor/packages/editor/src/components/editor/index.tsx index c951fcec5..022fefe91 100644 --- a/services/pascal-editor/packages/editor/src/components/editor/index.tsx +++ b/services/pascal-editor/packages/editor/src/components/editor/index.tsx @@ -362,13 +362,21 @@ const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ keys: [{ value: 'Space' }, { value: 'Left click' }], alternativeKeys: [{ value: 'Middle click' }], }, - { action: '旋转', keys: [{ value: 'Right click' }] }, + { + action: '旋转', + keys: [{ value: 'Left click' }], + alternativeKeys: [{ value: 'Right click' }], + }, { action: '缩放', keys: [{ value: 'Scroll' }] }, ] const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ - { action: '平移', keys: [{ value: 'Left click' }] }, - { action: '旋转', keys: [{ value: 'Right click' }] }, + { action: '平移', keys: [{ value: 'Middle click' }] }, + { + action: '旋转', + keys: [{ value: 'Left click' }], + alternativeKeys: [{ value: 'Right click' }], + }, { action: '缩放', keys: [{ value: 'Scroll' }] }, ]