fix: refresh Pascal scene interactions live
This commit is contained in:
parent
64dec38ff3
commit
e820df7eb6
@ -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'))
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
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`
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' }],
|
||||
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' }] },
|
||||
]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user