fix: refresh Pascal scene interactions live

This commit is contained in:
134 2026-07-13 03:57:43 +08:00
parent 64dec38ff3
commit e820df7eb6
9 changed files with 295 additions and 82 deletions

View File

@ -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'))

View File

@ -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<typeof setTimeout> | 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<PascalAgentActivityDetail>).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) {
<p className="font-medium text-destructive text-xs">{saveError}</p>
</div>
)}
<div className="pointer-events-none absolute top-4 right-4 z-40 flex items-center gap-2">
<Link
className="pointer-events-auto rounded-md border border-border bg-background/90 px-3 py-1.5 font-medium text-xs shadow-sm backdrop-blur hover:bg-accent/40"
href="/scenes"
>
全部场景
</Link>
</div>
<Editor
layoutVersion="v2"
onLoad={handleLoad}

View File

@ -32,6 +32,7 @@ import {
EyeOff,
Footprints,
Grid2X2,
LayoutGrid,
Magnet,
PenLine,
SlidersHorizontal,
@ -39,6 +40,7 @@ import {
SwatchBook,
} from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { type ReactNode, useCallback } from 'react'
import { flushSync } from 'react-dom'
import { withPascalBasePath } from '@/lib/pascal-base-path'
@ -193,6 +195,18 @@ function CollapseSidebarButton() {
)
}
function AllScenesButton() {
return (
<div className={TOOLBAR_CONTAINER}>
<ToolbarTooltip label="全部场景">
<Link aria-label="全部场景" className={TOOLBAR_BTN} href="/scenes">
<LayoutGrid className="h-4 w-4" />
</Link>
</ToolbarTooltip>
</div>
)
}
function LevelModeToggle() {
const levelMode = useViewer((state) => state.levelMode)
const setLevelMode = useViewer((state) => state.setLevelMode)
@ -493,6 +507,8 @@ function WalkthroughButton() {
return (
<ToolbarTooltip label="漫游模式">
<button
aria-label="漫游模式"
aria-pressed={isFirstPersonMode}
className={cn(
TOOLBAR_BTN,
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
@ -526,6 +542,7 @@ export function CommunityViewerToolbarLeft() {
<>
<CollapseSidebarButton />
<ViewModeControl />
<AllScenesButton />
</>
)
}

View File

@ -0,0 +1,15 @@
export const PASCAL_AGENT_ACTIVITY_EVENT = 'pascal-agent-activity'
export interface PascalAgentActivityDetail {
running: boolean
sceneId: string
}
export function announcePascalAgentActivity(sceneId: string, running: boolean): void {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent<PascalAgentActivityDetail>(PASCAL_AGENT_ACTIVITY_EVENT, {
detail: { running, sceneId },
}),
)
}

View File

@ -599,12 +599,12 @@ export const CustomCameraControls = () => {
: CameraControlsImpl.ACTION.DOLLY
return {
left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE,
left: CameraControlsImpl.ACTION.ROTATE,
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction,
}
}, [cameraMode, isPreviewMode])
}, [cameraMode])
// Touch gestures (mobile / trackpad).
// - One finger drag → rotate by default (much easier on a phone), but
@ -636,18 +636,16 @@ export const CustomCameraControls = () => {
? CameraControlsImpl.ACTION.TOUCH_ZOOM_TRUCK
: CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK
const oneFingerAction = isPreviewMode
? CameraControlsImpl.ACTION.TOUCH_TRUCK
: isInteracting
? CameraControlsImpl.ACTION.NONE
: CameraControlsImpl.ACTION.TOUCH_ROTATE
const oneFingerAction = isInteracting
? CameraControlsImpl.ACTION.NONE
: CameraControlsImpl.ACTION.TOUCH_ROTATE
return {
one: oneFingerAction,
two: twoFingerAction,
three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
}
}, [cameraMode, isPreviewMode, isInteracting])
}, [cameraMode, isInteracting])
useEffect(() => {
if (isFirstPersonMode) return
@ -720,13 +718,12 @@ export const CustomCameraControls = () => {
controls.current.mouseButtons.wheel = wheelAction
controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
if (isPreviewMode) {
// In preview mode, left-click is always pan (viewer-style)
if (space) {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
} else if (space) {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
} else {
} else if (isInteracting) {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
} else {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.ROTATE
}
}
@ -851,7 +848,7 @@ export const CustomCameraControls = () => {
clearKeyboardPanKeys()
clearNavigationCursor()
}
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])
}, [cameraMode, gl, isFirstPersonMode, isInteracting, clearPendingFloorplanNavigationPose])
// Cancel any in-progress 2D-origin navigation pose when the user starts
// dragging (right-click orbit, middle-click pan, touch). `controlstart`

View File

@ -65,6 +65,11 @@ import {
type FirstPersonColliderWorld,
type FirstPersonSpawn,
} from './first-person/build-collider-world'
import {
firstPersonKeyboardMap,
getFirstPersonMovementInput,
inactiveFirstPersonMovement,
} from './first-person-input'
const CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002
@ -78,35 +83,7 @@ const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
const VOID_FALL_RESPAWN_DEPTH = 12
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
const movementKeyboardBindings: 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'] },
]
const keyboardMap = movementKeyboardBindings
const movementKeyToName = new Map<string, MovementKeyName>(
movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
)
const inactiveMovementInput: MovementInput = {
backward: false,
forward: false,
jump: false,
leftward: false,
rightward: false,
run: false,
}
function getMovementInputForKey(code: string, active: boolean): MovementInput | null {
const name = movementKeyToName.get(code)
return name ? ({ [name]: active } as MovementInput) : null
}
const FIRST_PERSON_HINT_AUTO_HIDE_MS = 6500
function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
const activeElement = document.activeElement
@ -566,9 +543,13 @@ const resolvePlacedSpawnNode = (
export const FirstPersonControls = () => {
const { camera, gl } = useThree()
const selectedLevelId = useViewer((state) => state.selection.levelId)
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
const sceneNodes = useScene((state) => state.nodes)
const placedSpawnNode = useMemo(
() => resolvePlacedSpawnNode(sceneNodes, selectedLevelId),
[sceneNodes, selectedLevelId],
)
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
const movementInputRef = useRef<MovementInput>({ ...inactiveFirstPersonMovement })
const hadPointerLockRef = useRef(false)
const yawRef = useRef(0)
const pitchRef = useRef(0)
@ -602,7 +583,10 @@ export const FirstPersonControls = () => {
}, [])
const rebuildColliderWorld = useCallback(() => {
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
const nextWorld = buildFirstPersonColliderWorldFromRegistry()
if (nextWorld) {
replaceColliderWorld(nextWorld)
}
replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
}, [replaceColliderWorld, replaceElevatorColliderMeshes])
@ -922,17 +906,37 @@ export const FirstPersonControls = () => {
}, [placedSpawnNode])
useEffect(() => {
rebuildColliderWorld()
const timeoutIds: number[] = []
const rebuildAfterRender = () => {
if (useScene.getState().nodes !== sceneNodes) return
rebuildColliderWorld()
}
const frameId = window.requestAnimationFrame(rebuildAfterRender)
// Scene nodes can reach the store before their Three.js objects reach the
// registry. Rebuild again as rendering settles so walkthrough never starts
// with an empty collider world after loading or an AI-authored update.
for (const delay of [250, 1000, 2500]) {
timeoutIds.push(window.setTimeout(rebuildAfterRender, delay))
}
return () => {
window.cancelAnimationFrame(frameId)
for (const timeoutId of timeoutIds) {
window.clearTimeout(timeoutId)
}
}
}, [rebuildColliderWorld, sceneNodes])
useEffect(
() => () => {
worldRef.current?.dispose()
worldRef.current = null
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
elevatorColliderMeshesRef.current = []
setElevatorColliderMeshes([])
setWorld(null)
}
}, [rebuildColliderWorld])
},
[],
)
useEffect(() => {
emitter.on('door:animation-completed', rebuildColliderWorld)
@ -1032,10 +1036,11 @@ export const FirstPersonControls = () => {
return false
}
const movement = getMovementInputForKey(event.code, active)
const movement = getFirstPersonMovementInput(event.code, event.key, active)
if (!movement) return false
event.preventDefault()
event.stopPropagation()
Object.assign(movementInputRef.current, movement)
controllerRef.current?.setMovement(movement)
return true
@ -1071,11 +1076,25 @@ export const FirstPersonControls = () => {
applyMovementKey(event, false)
}
const resetMovement = () => {
movementInputRef.current = { ...inactiveFirstPersonMovement }
controllerRef.current?.setMovement(inactiveFirstPersonMovement)
}
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') resetMovement()
}
document.addEventListener('keydown', handleKeyDown, true)
document.addEventListener('keyup', handleKeyUp, true)
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('blur', resetMovement)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('keyup', handleKeyUp, true)
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('blur', resetMovement)
resetMovement()
}
}, [closeInteractableTarget, gl, toggleInteractableTarget])
@ -1368,7 +1387,7 @@ export const FirstPersonControls = () => {
return (
<>
{controllerStart && (
<KeyboardControls map={keyboardMap}>
<KeyboardControls map={firstPersonKeyboardMap}>
<BVHEcctrl
acceleration={26}
airDragFactor={0.3}
@ -1409,13 +1428,16 @@ export const FirstPersonControls = () => {
*/
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
const [isLocked, setIsLocked] = useState(false)
const [isHintVisible, setIsHintVisible] = useState(true)
const hasPlacedSpawn = useScene((state) =>
Object.values(state.nodes).some((node) => node.type === 'spawn'),
)
useEffect(() => {
const handlePointerLockChange = () => {
setIsLocked(document.pointerLockElement != null)
const locked = document.pointerLockElement != null
setIsLocked(locked)
if (locked) setIsHintVisible(true)
}
handlePointerLockChange()
@ -1425,6 +1447,26 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
}
}, [])
useEffect(() => {
if (!(isLocked && isHintVisible)) return
const timer = window.setTimeout(() => setIsHintVisible(false), FIRST_PERSON_HINT_AUTO_HIDE_MS)
return () => window.clearTimeout(timer)
}, [isHintVisible, isLocked])
useEffect(() => {
const handleHintShortcut = (event: KeyboardEvent) => {
if (event.code !== 'KeyH') return
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
event.preventDefault()
event.stopPropagation()
setIsHintVisible((visible) => !visible)
}
document.addEventListener('keydown', handleHintShortcut, true)
return () => document.removeEventListener('keydown', handleHintShortcut, true)
}, [])
const handleExit = useCallback(() => {
if (document.pointerLockElement) {
document.exitPointerLock()
@ -1452,30 +1494,30 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
ESC
</kbd>
Exit Street View
退出漫游
</button>
</div>
{!hasPlacedSpawn && (
<div className="absolute top-4 left-1/2 z-50 -translate-x-1/2">
<div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl">
Place a Spawn Point from the Build tab to control where walkthrough starts.
可在“搭建”中放置出生点,用于设定漫游起点。
</div>
</div>
)}
{isLocked && (
{isLocked && isHintVisible && (
<div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
<ControlHint keys={['W', 'A', 'S', 'D', '↑', '←', '↓', '→']} label="移动" />
<div className="h-px w-full bg-border/30" />
<InlineControlHint keyLabel="Space" label="Jump" />
<InlineControlHint keyLabel="Shift" label="Sprint" />
<InlineControlHint keyLabel="E / R" label="Interact" />
<InlineControlHint keyLabel="T" label="Close" />
<InlineControlHint keyLabel="Space" label="跳跃" />
<InlineControlHint keyLabel="Shift" label="加速" />
<InlineControlHint keyLabel="E / R" label="交互" />
<InlineControlHint keyLabel="T" label="关闭" />
<div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">
Click to look around
移动鼠标观察 · H 隐藏提示
</span>
</div>
</div>

View File

@ -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()
})
})

View File

@ -0,0 +1,48 @@
import type { MovementInput } from '@pascal-app/viewer'
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
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<string, MovementKeyName>(
firstPersonKeyboardMap.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
)
const movementKeyAliases = new Map<string, MovementKeyName>([
['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
}

View File

@ -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' }] },
]