fix: refresh Pascal scene interactions live
This commit is contained in:
parent
64dec38ff3
commit
e820df7eb6
@ -15,6 +15,7 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { announcePascalAgentActivity } from '@/lib/pascal-agent-browser-events'
|
||||||
import { withPascalBasePath } from '@/lib/pascal-base-path'
|
import { withPascalBasePath } from '@/lib/pascal-base-path'
|
||||||
|
|
||||||
interface AiDesignAgentPanelProps {
|
interface AiDesignAgentPanelProps {
|
||||||
@ -184,6 +185,13 @@ export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
|
|||||||
.join('|')
|
.join('|')
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
announcePascalAgentActivity(sceneId, isRunning)
|
||||||
|
return () => {
|
||||||
|
if (isRunning) announcePascalAgentActivity(sceneId, false)
|
||||||
|
}
|
||||||
|
}, [isRunning, sceneId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
fetch(withPascalBasePath('/api/agent/chat'))
|
fetch(withPascalBasePath('/api/agent/chat'))
|
||||||
|
|||||||
@ -11,9 +11,12 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { Bot, Hammer, Layers } from 'lucide-react'
|
import { Bot, Hammer, Layers } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { withPascalBasePath } from '@/lib/pascal-base-path'
|
||||||
import { AiDesignAgentPanel } from './ai-design-agent-panel'
|
import { AiDesignAgentPanel } from './ai-design-agent-panel'
|
||||||
import { BuildTab } from './build-tab'
|
import { BuildTab } from './build-tab'
|
||||||
@ -85,6 +88,12 @@ interface LiveSceneEvent {
|
|||||||
graph: SceneGraphWithCollections
|
graph: SceneGraphWithCollections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RemoteSceneSnapshot extends SceneMeta {
|
||||||
|
graph: SceneGraphWithCollections
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_SCENE_POLL_MS = 700
|
||||||
|
|
||||||
function sceneGraphSignature(graph: SceneGraphWithCollections): string {
|
function sceneGraphSignature(graph: SceneGraphWithCollections): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
nodes: graph.nodes,
|
nodes: graph.nodes,
|
||||||
@ -120,6 +129,27 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
|||||||
|
|
||||||
const handleLoad = useCallback(async () => initialScene, [initialScene])
|
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(
|
const handleSave = useCallback(
|
||||||
async (graph: SceneGraph, options?: { keepalive?: boolean }) => {
|
async (graph: SceneGraph, options?: { keepalive?: boolean }) => {
|
||||||
const graphJson = sceneGraphSignature(graph)
|
const graphJson = sceneGraphSignature(graph)
|
||||||
@ -179,22 +209,55 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
|||||||
if (payload.sceneId !== meta.id) return
|
if (payload.sceneId !== meta.id) return
|
||||||
if (payload.version <= versionRef.current) return
|
if (payload.version <= versionRef.current) return
|
||||||
|
|
||||||
versionRef.current = payload.version
|
applyRemoteSnapshot(payload)
|
||||||
lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
|
|
||||||
suppressRemoteSaveUntilRef.current = Date.now() + 2500
|
|
||||||
applySceneGraphToEditor(payload.graph)
|
|
||||||
setConflict(false)
|
|
||||||
setSaveError(null)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
source.addEventListener('error', () => {
|
source.addEventListener('error', () => {
|
||||||
|
void fetchLatestScene().catch(() => undefined)
|
||||||
if (source.readyState === EventSource.CLOSED) {
|
if (source.readyState === EventSource.CLOSED) {
|
||||||
setSaveError('场景实时连接已断开')
|
setSaveError('场景实时连接已断开')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => source.close()
|
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(
|
const handleThumb = useCallback(
|
||||||
async (_blob: Blob) => {
|
async (_blob: Blob) => {
|
||||||
@ -241,14 +304,6 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
|||||||
<p className="font-medium text-destructive text-xs">{saveError}</p>
|
<p className="font-medium text-destructive text-xs">{saveError}</p>
|
||||||
</div>
|
</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
|
<Editor
|
||||||
layoutVersion="v2"
|
layoutVersion="v2"
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Footprints,
|
Footprints,
|
||||||
Grid2X2,
|
Grid2X2,
|
||||||
|
LayoutGrid,
|
||||||
Magnet,
|
Magnet,
|
||||||
PenLine,
|
PenLine,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
@ -39,6 +40,7 @@ import {
|
|||||||
SwatchBook,
|
SwatchBook,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
import { type ReactNode, useCallback } from 'react'
|
import { type ReactNode, useCallback } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
import { withPascalBasePath } from '@/lib/pascal-base-path'
|
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() {
|
function LevelModeToggle() {
|
||||||
const levelMode = useViewer((state) => state.levelMode)
|
const levelMode = useViewer((state) => state.levelMode)
|
||||||
const setLevelMode = useViewer((state) => state.setLevelMode)
|
const setLevelMode = useViewer((state) => state.setLevelMode)
|
||||||
@ -493,6 +507,8 @@ function WalkthroughButton() {
|
|||||||
return (
|
return (
|
||||||
<ToolbarTooltip label="漫游模式">
|
<ToolbarTooltip label="漫游模式">
|
||||||
<button
|
<button
|
||||||
|
aria-label="漫游模式"
|
||||||
|
aria-pressed={isFirstPersonMode}
|
||||||
className={cn(
|
className={cn(
|
||||||
TOOLBAR_BTN,
|
TOOLBAR_BTN,
|
||||||
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
|
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
|
||||||
@ -526,6 +542,7 @@ export function CommunityViewerToolbarLeft() {
|
|||||||
<>
|
<>
|
||||||
<CollapseSidebarButton />
|
<CollapseSidebarButton />
|
||||||
<ViewModeControl />
|
<ViewModeControl />
|
||||||
|
<AllScenesButton />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -599,12 +599,12 @@ export const CustomCameraControls = () => {
|
|||||||
: CameraControlsImpl.ACTION.DOLLY
|
: CameraControlsImpl.ACTION.DOLLY
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE,
|
left: CameraControlsImpl.ACTION.ROTATE,
|
||||||
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
|
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
|
||||||
right: CameraControlsImpl.ACTION.ROTATE,
|
right: CameraControlsImpl.ACTION.ROTATE,
|
||||||
wheel: wheelAction,
|
wheel: wheelAction,
|
||||||
}
|
}
|
||||||
}, [cameraMode, isPreviewMode])
|
}, [cameraMode])
|
||||||
|
|
||||||
// Touch gestures (mobile / trackpad).
|
// Touch gestures (mobile / trackpad).
|
||||||
// - One finger drag → rotate by default (much easier on a phone), but
|
// - 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_ZOOM_TRUCK
|
||||||
: CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK
|
: CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK
|
||||||
|
|
||||||
const oneFingerAction = isPreviewMode
|
const oneFingerAction = isInteracting
|
||||||
? CameraControlsImpl.ACTION.TOUCH_TRUCK
|
? CameraControlsImpl.ACTION.NONE
|
||||||
: isInteracting
|
: CameraControlsImpl.ACTION.TOUCH_ROTATE
|
||||||
? CameraControlsImpl.ACTION.NONE
|
|
||||||
: CameraControlsImpl.ACTION.TOUCH_ROTATE
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
one: oneFingerAction,
|
one: oneFingerAction,
|
||||||
two: twoFingerAction,
|
two: twoFingerAction,
|
||||||
three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
|
three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
|
||||||
}
|
}
|
||||||
}, [cameraMode, isPreviewMode, isInteracting])
|
}, [cameraMode, isInteracting])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstPersonMode) return
|
if (isFirstPersonMode) return
|
||||||
@ -720,13 +718,12 @@ export const CustomCameraControls = () => {
|
|||||||
controls.current.mouseButtons.wheel = wheelAction
|
controls.current.mouseButtons.wheel = wheelAction
|
||||||
controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
|
controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
|
||||||
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
|
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
|
||||||
if (isPreviewMode) {
|
if (space) {
|
||||||
// In preview mode, left-click is always pan (viewer-style)
|
|
||||||
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
|
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
|
||||||
} else if (space) {
|
} else if (isInteracting) {
|
||||||
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
|
|
||||||
} else {
|
|
||||||
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
|
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
|
||||||
|
} else {
|
||||||
|
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.ROTATE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -851,7 +848,7 @@ export const CustomCameraControls = () => {
|
|||||||
clearKeyboardPanKeys()
|
clearKeyboardPanKeys()
|
||||||
clearNavigationCursor()
|
clearNavigationCursor()
|
||||||
}
|
}
|
||||||
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])
|
}, [cameraMode, gl, isFirstPersonMode, isInteracting, clearPendingFloorplanNavigationPose])
|
||||||
|
|
||||||
// Cancel any in-progress 2D-origin navigation pose when the user starts
|
// Cancel any in-progress 2D-origin navigation pose when the user starts
|
||||||
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
|
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
|
||||||
|
|||||||
@ -65,6 +65,11 @@ import {
|
|||||||
type FirstPersonColliderWorld,
|
type FirstPersonColliderWorld,
|
||||||
type FirstPersonSpawn,
|
type FirstPersonSpawn,
|
||||||
} from './first-person/build-collider-world'
|
} from './first-person/build-collider-world'
|
||||||
|
import {
|
||||||
|
firstPersonKeyboardMap,
|
||||||
|
getFirstPersonMovementInput,
|
||||||
|
inactiveFirstPersonMovement,
|
||||||
|
} from './first-person-input'
|
||||||
|
|
||||||
const CAMERA_EYE_OFFSET = 0.45
|
const CAMERA_EYE_OFFSET = 0.45
|
||||||
const LOOK_SENSITIVITY = 0.002
|
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 ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
|
||||||
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
||||||
const VOID_FALL_RESPAWN_DEPTH = 12
|
const VOID_FALL_RESPAWN_DEPTH = 12
|
||||||
|
const FIRST_PERSON_HINT_AUTO_HIDE_MS = 6500
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
|
function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
|
||||||
const activeElement = document.activeElement
|
const activeElement = document.activeElement
|
||||||
@ -566,9 +543,13 @@ const resolvePlacedSpawnNode = (
|
|||||||
export const FirstPersonControls = () => {
|
export const FirstPersonControls = () => {
|
||||||
const { camera, gl } = useThree()
|
const { camera, gl } = useThree()
|
||||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
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 controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||||
const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
|
const movementInputRef = useRef<MovementInput>({ ...inactiveFirstPersonMovement })
|
||||||
const hadPointerLockRef = useRef(false)
|
const hadPointerLockRef = useRef(false)
|
||||||
const yawRef = useRef(0)
|
const yawRef = useRef(0)
|
||||||
const pitchRef = useRef(0)
|
const pitchRef = useRef(0)
|
||||||
@ -602,7 +583,10 @@ export const FirstPersonControls = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const rebuildColliderWorld = useCallback(() => {
|
const rebuildColliderWorld = useCallback(() => {
|
||||||
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
|
const nextWorld = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
if (nextWorld) {
|
||||||
|
replaceColliderWorld(nextWorld)
|
||||||
|
}
|
||||||
replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
|
replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
|
||||||
}, [replaceColliderWorld, replaceElevatorColliderMeshes])
|
}, [replaceColliderWorld, replaceElevatorColliderMeshes])
|
||||||
|
|
||||||
@ -922,17 +906,37 @@ export const FirstPersonControls = () => {
|
|||||||
}, [placedSpawnNode])
|
}, [placedSpawnNode])
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId)
|
||||||
|
for (const timeoutId of timeoutIds) {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [rebuildColliderWorld, sceneNodes])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
worldRef.current?.dispose()
|
worldRef.current?.dispose()
|
||||||
worldRef.current = null
|
worldRef.current = null
|
||||||
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
|
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
|
||||||
elevatorColliderMeshesRef.current = []
|
elevatorColliderMeshesRef.current = []
|
||||||
setElevatorColliderMeshes([])
|
},
|
||||||
setWorld(null)
|
[],
|
||||||
}
|
)
|
||||||
}, [rebuildColliderWorld])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
emitter.on('door:animation-completed', rebuildColliderWorld)
|
emitter.on('door:animation-completed', rebuildColliderWorld)
|
||||||
@ -1032,10 +1036,11 @@ export const FirstPersonControls = () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const movement = getMovementInputForKey(event.code, active)
|
const movement = getFirstPersonMovementInput(event.code, event.key, active)
|
||||||
if (!movement) return false
|
if (!movement) return false
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
Object.assign(movementInputRef.current, movement)
|
Object.assign(movementInputRef.current, movement)
|
||||||
controllerRef.current?.setMovement(movement)
|
controllerRef.current?.setMovement(movement)
|
||||||
return true
|
return true
|
||||||
@ -1071,11 +1076,25 @@ export const FirstPersonControls = () => {
|
|||||||
applyMovementKey(event, false)
|
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('keydown', handleKeyDown, true)
|
||||||
document.addEventListener('keyup', handleKeyUp, true)
|
document.addEventListener('keyup', handleKeyUp, true)
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
window.addEventListener('blur', resetMovement)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown, true)
|
document.removeEventListener('keydown', handleKeyDown, true)
|
||||||
document.removeEventListener('keyup', handleKeyUp, true)
|
document.removeEventListener('keyup', handleKeyUp, true)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
window.removeEventListener('blur', resetMovement)
|
||||||
|
resetMovement()
|
||||||
}
|
}
|
||||||
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
||||||
|
|
||||||
@ -1368,7 +1387,7 @@ export const FirstPersonControls = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{controllerStart && (
|
{controllerStart && (
|
||||||
<KeyboardControls map={keyboardMap}>
|
<KeyboardControls map={firstPersonKeyboardMap}>
|
||||||
<BVHEcctrl
|
<BVHEcctrl
|
||||||
acceleration={26}
|
acceleration={26}
|
||||||
airDragFactor={0.3}
|
airDragFactor={0.3}
|
||||||
@ -1409,13 +1428,16 @@ export const FirstPersonControls = () => {
|
|||||||
*/
|
*/
|
||||||
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
||||||
const [isLocked, setIsLocked] = useState(false)
|
const [isLocked, setIsLocked] = useState(false)
|
||||||
|
const [isHintVisible, setIsHintVisible] = useState(true)
|
||||||
const hasPlacedSpawn = useScene((state) =>
|
const hasPlacedSpawn = useScene((state) =>
|
||||||
Object.values(state.nodes).some((node) => node.type === 'spawn'),
|
Object.values(state.nodes).some((node) => node.type === 'spawn'),
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePointerLockChange = () => {
|
const handlePointerLockChange = () => {
|
||||||
setIsLocked(document.pointerLockElement != null)
|
const locked = document.pointerLockElement != null
|
||||||
|
setIsLocked(locked)
|
||||||
|
if (locked) setIsHintVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePointerLockChange()
|
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(() => {
|
const handleExit = useCallback(() => {
|
||||||
if (document.pointerLockElement) {
|
if (document.pointerLockElement) {
|
||||||
document.exitPointerLock()
|
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">
|
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||||
ESC
|
ESC
|
||||||
</kbd>
|
</kbd>
|
||||||
Exit Street View
|
退出漫游
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasPlacedSpawn && (
|
{!hasPlacedSpawn && (
|
||||||
<div className="absolute top-4 left-1/2 z-50 -translate-x-1/2">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLocked && (
|
{isLocked && isHintVisible && (
|
||||||
<div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
|
<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">
|
<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" />
|
<div className="h-px w-full bg-border/30" />
|
||||||
<InlineControlHint keyLabel="Space" label="Jump" />
|
<InlineControlHint keyLabel="Space" label="跳跃" />
|
||||||
<InlineControlHint keyLabel="Shift" label="Sprint" />
|
<InlineControlHint keyLabel="Shift" label="加速" />
|
||||||
<InlineControlHint keyLabel="E / R" label="Interact" />
|
<InlineControlHint keyLabel="E / R" label="交互" />
|
||||||
<InlineControlHint keyLabel="T" label="Close" />
|
<InlineControlHint keyLabel="T" label="关闭" />
|
||||||
<div className="h-px w-full bg-border/30" />
|
<div className="h-px w-full bg-border/30" />
|
||||||
<span className="text-center text-muted-foreground/60 text-xs">
|
<span className="text-center text-muted-foreground/60 text-xs">
|
||||||
Click to look around
|
移动鼠标观察 · H 隐藏提示
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -362,13 +362,21 @@ const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
|
|||||||
keys: [{ value: 'Space' }, { value: 'Left click' }],
|
keys: [{ value: 'Space' }, { value: 'Left click' }],
|
||||||
alternativeKeys: [{ value: 'Middle click' }],
|
alternativeKeys: [{ value: 'Middle click' }],
|
||||||
},
|
},
|
||||||
{ action: '旋转', keys: [{ value: 'Right click' }] },
|
{
|
||||||
|
action: '旋转',
|
||||||
|
keys: [{ value: 'Left click' }],
|
||||||
|
alternativeKeys: [{ value: 'Right click' }],
|
||||||
|
},
|
||||||
{ action: '缩放', keys: [{ value: 'Scroll' }] },
|
{ action: '缩放', keys: [{ value: 'Scroll' }] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
|
const PREVIEW_CAMERA_CONTROL_HINTS: CameraControlHint[] = [
|
||||||
{ action: '平移', keys: [{ value: 'Left click' }] },
|
{ action: '平移', keys: [{ value: 'Middle click' }] },
|
||||||
{ action: '旋转', keys: [{ value: 'Right click' }] },
|
{
|
||||||
|
action: '旋转',
|
||||||
|
keys: [{ value: 'Left click' }],
|
||||||
|
alternativeKeys: [{ value: 'Right click' }],
|
||||||
|
},
|
||||||
{ action: '缩放', keys: [{ value: 'Scroll' }] },
|
{ action: '缩放', keys: [{ value: 'Scroll' }] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user