@pascal-app/editor 0.9.2 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -15,8 +15,11 @@ import {
|
|
|
15
15
|
getElevatorShaftDepth,
|
|
16
16
|
getElevatorShaftWallThickness,
|
|
17
17
|
getElevatorShaftWidth,
|
|
18
|
+
getLevelDisplayName,
|
|
19
|
+
getLevelElevations,
|
|
18
20
|
getResolvedElevatorDoorStyle,
|
|
19
21
|
openElevatorDoor,
|
|
22
|
+
pointInPolygon2D,
|
|
20
23
|
requestElevatorLevel,
|
|
21
24
|
resolveElevatorBuildingLevels,
|
|
22
25
|
resolveElevatorDispatchTarget,
|
|
@@ -25,7 +28,22 @@ import {
|
|
|
25
28
|
useInteractive,
|
|
26
29
|
useScene,
|
|
27
30
|
} from '@pascal-app/core'
|
|
28
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
BVHEcctrl,
|
|
33
|
+
type BVHEcctrlApi,
|
|
34
|
+
CROUCH_CAPSULE,
|
|
35
|
+
CROUCH_EYE_OFFSET,
|
|
36
|
+
CROUCH_FLOAT_HEIGHT,
|
|
37
|
+
CROUCH_RUN_SPEED,
|
|
38
|
+
CROUCH_WALK_SPEED,
|
|
39
|
+
EYE_LERP_SPEED,
|
|
40
|
+
type MovementInput,
|
|
41
|
+
STAND_CAPSULE,
|
|
42
|
+
STAND_CLEARANCE,
|
|
43
|
+
STAND_FLOAT_HEIGHT,
|
|
44
|
+
useViewer,
|
|
45
|
+
WALKTHROUGH_FOV,
|
|
46
|
+
} from '@pascal-app/viewer'
|
|
29
47
|
import { KeyboardControls } from '@react-three/drei'
|
|
30
48
|
import { useFrame, useThree } from '@react-three/fiber'
|
|
31
49
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
@@ -38,6 +56,7 @@ import {
|
|
|
38
56
|
Mesh,
|
|
39
57
|
MeshBasicMaterial,
|
|
40
58
|
type Object3D,
|
|
59
|
+
type PerspectiveCamera,
|
|
41
60
|
Ray,
|
|
42
61
|
Raycaster,
|
|
43
62
|
Vector2,
|
|
@@ -45,19 +64,22 @@ import {
|
|
|
45
64
|
} from 'three'
|
|
46
65
|
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
|
47
66
|
import '../../three-types'
|
|
48
|
-
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
|
|
49
67
|
import {
|
|
50
68
|
closeDoorOpenState,
|
|
51
69
|
DOOR_SWING_OPEN_ANGLE,
|
|
70
|
+
getDisplayedDoorValue,
|
|
52
71
|
isOperationDoorType,
|
|
53
72
|
toggleDoorOpenState,
|
|
54
73
|
} from '../../lib/door-interaction'
|
|
55
74
|
import {
|
|
56
75
|
closeWindowOpenState,
|
|
76
|
+
getDisplayedWindowValue,
|
|
57
77
|
isOperableWindowType,
|
|
58
78
|
toggleWindowOpenState,
|
|
59
79
|
} from '../../lib/window-interaction'
|
|
60
80
|
import useEditor from '../../store/use-editor'
|
|
81
|
+
import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
|
|
82
|
+
import { WalkthroughHud } from '../walkthrough-hud'
|
|
61
83
|
import {
|
|
62
84
|
buildFirstPersonColliderWorldFromRegistry,
|
|
63
85
|
deriveFirstPersonSpawn,
|
|
@@ -76,8 +98,8 @@ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
|
|
|
76
98
|
const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
|
|
77
99
|
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
|
|
78
100
|
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
|
|
79
|
-
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
|
80
101
|
const VOID_FALL_RESPAWN_DEPTH = 12
|
|
102
|
+
const HUD_LABEL_SAMPLE_FRAMES = 10
|
|
81
103
|
|
|
82
104
|
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
|
|
83
105
|
|
|
@@ -120,8 +142,10 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
|
|
|
120
142
|
canvas.focus({ preventScroll: true })
|
|
121
143
|
}
|
|
122
144
|
|
|
123
|
-
const cameraOffset = new Vector3(
|
|
145
|
+
const cameraOffset = new Vector3()
|
|
124
146
|
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
|
147
|
+
const standClearanceRaycaster = new Raycaster()
|
|
148
|
+
const standClearanceUp = new Vector3(0, 1, 0)
|
|
125
149
|
const centerScreenPoint = new Vector2(0, 0)
|
|
126
150
|
const doorInteractionRaycaster = new Raycaster()
|
|
127
151
|
const doorLeafBox = new Box3()
|
|
@@ -146,6 +170,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
|
|
|
146
170
|
const spawnWorldPosition = new Vector3()
|
|
147
171
|
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
|
148
172
|
const windowInteractionRaycaster = new Raycaster()
|
|
173
|
+
const hudBuildingLocalEyePosition = new Vector3()
|
|
174
|
+
const hudWorldEyePosition = new Vector3()
|
|
175
|
+
const hudLevelBounds = new Box3()
|
|
149
176
|
|
|
150
177
|
type ElevatorColliderKind =
|
|
151
178
|
| 'cab-back'
|
|
@@ -202,6 +229,128 @@ type ElevatorButtonTarget = {
|
|
|
202
229
|
levelId?: AnyNodeId
|
|
203
230
|
}
|
|
204
231
|
|
|
232
|
+
function getLevelChildren(
|
|
233
|
+
level: Extract<AnyNode, { type: 'level' }>,
|
|
234
|
+
nodes: Record<string, AnyNode>,
|
|
235
|
+
) {
|
|
236
|
+
const childIds = new Set<string>(level.children)
|
|
237
|
+
return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pointIsInLevelFootprint(
|
|
241
|
+
point: [number, number],
|
|
242
|
+
worldPoint: Vector3,
|
|
243
|
+
level: Extract<AnyNode, { type: 'level' }>,
|
|
244
|
+
nodes: Record<string, AnyNode>,
|
|
245
|
+
) {
|
|
246
|
+
const children = getLevelChildren(level, nodes)
|
|
247
|
+
const slabs = children.filter(
|
|
248
|
+
(node): node is Extract<AnyNode, { type: 'slab' }> =>
|
|
249
|
+
node.type === 'slab' && node.polygon.length >= 3,
|
|
250
|
+
)
|
|
251
|
+
const zones = children.filter(
|
|
252
|
+
(node): node is Extract<AnyNode, { type: 'zone' }> =>
|
|
253
|
+
node.type === 'zone' && node.polygon.length >= 3,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if (slabs.length > 0) {
|
|
257
|
+
return slabs.some(
|
|
258
|
+
(slab) =>
|
|
259
|
+
pointInPolygon2D(point, slab.polygon) &&
|
|
260
|
+
!slab.holes.some((hole) => pointInPolygon2D(point, hole)),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (zones.length > 0) {
|
|
265
|
+
if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const levelObject = sceneRegistry.nodes.get(level.id)
|
|
269
|
+
if (!levelObject) return false
|
|
270
|
+
hudLevelBounds.setFromObject(levelObject)
|
|
271
|
+
return (
|
|
272
|
+
!hudLevelBounds.isEmpty() &&
|
|
273
|
+
worldPoint.x >= hudLevelBounds.min.x &&
|
|
274
|
+
worldPoint.x <= hudLevelBounds.max.x &&
|
|
275
|
+
worldPoint.z >= hudLevelBounds.min.z &&
|
|
276
|
+
worldPoint.z <= hudLevelBounds.max.z
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function resolveFirstPersonHudLabels(worldPoint: Vector3) {
|
|
281
|
+
const nodes = useScene.getState().nodes
|
|
282
|
+
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
|
283
|
+
|
|
284
|
+
for (const building of Object.values(nodes)) {
|
|
285
|
+
if (building.type !== 'building') continue
|
|
286
|
+
const buildingObject = sceneRegistry.nodes.get(building.id)
|
|
287
|
+
if (!buildingObject) continue
|
|
288
|
+
|
|
289
|
+
buildingObject.updateWorldMatrix(true, true)
|
|
290
|
+
hudBuildingLocalEyePosition.copy(worldPoint)
|
|
291
|
+
buildingObject.worldToLocal(hudBuildingLocalEyePosition)
|
|
292
|
+
|
|
293
|
+
const levels = Object.values(nodes)
|
|
294
|
+
.filter((node) => node.type === 'level')
|
|
295
|
+
.filter((level) => levelElevations.get(level.id)?.buildingId === building.id)
|
|
296
|
+
.sort(
|
|
297
|
+
(left, right) =>
|
|
298
|
+
(levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
let activeLevel: (typeof levels)[number] | null = null
|
|
302
|
+
for (const level of levels) {
|
|
303
|
+
const elevation = levelElevations.get(level.id)
|
|
304
|
+
if (!elevation) continue
|
|
305
|
+
if (
|
|
306
|
+
hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 &&
|
|
307
|
+
hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5
|
|
308
|
+
) {
|
|
309
|
+
activeLevel = level
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!activeLevel) continue
|
|
313
|
+
|
|
314
|
+
const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z]
|
|
315
|
+
if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue
|
|
316
|
+
|
|
317
|
+
const zone = getLevelChildren(activeLevel, nodes).find(
|
|
318
|
+
(node) =>
|
|
319
|
+
node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
floorLabel: getLevelDisplayName(activeLevel),
|
|
324
|
+
zoneLabel: zone?.type === 'zone' ? zone.name : null,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { floorLabel: null, zoneLabel: null }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract {
|
|
332
|
+
if (!target) return null
|
|
333
|
+
if (target.type === 'elevator') {
|
|
334
|
+
return {
|
|
335
|
+
label: target.action === 'open-door' ? 'door button' : 'elevator button',
|
|
336
|
+
verb: 'press',
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const node = useScene.getState().nodes[target.id]
|
|
341
|
+
if (target.type === 'window') {
|
|
342
|
+
if (node?.type !== 'window') return null
|
|
343
|
+
const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0
|
|
344
|
+
return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (node?.type !== 'door') return null
|
|
348
|
+
const isOpen = isOperationDoorType(node.doorType)
|
|
349
|
+
? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0
|
|
350
|
+
: getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0
|
|
351
|
+
return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
|
|
352
|
+
}
|
|
353
|
+
|
|
205
354
|
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
|
|
206
355
|
let current: Object3D | null = object
|
|
207
356
|
|
|
@@ -281,37 +430,17 @@ function isInsideElevatorCab(
|
|
|
281
430
|
)
|
|
282
431
|
}
|
|
283
432
|
|
|
284
|
-
function getFirstPersonLevelHeight(levelId: string, nodes: Record<string, AnyNode>) {
|
|
285
|
-
const level = nodes[levelId as AnyNodeId]
|
|
286
|
-
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
|
|
287
|
-
|
|
288
|
-
let maxTop = 0
|
|
289
|
-
for (const childId of level.children) {
|
|
290
|
-
const child = nodes[childId as AnyNodeId]
|
|
291
|
-
if (!child) continue
|
|
292
|
-
|
|
293
|
-
if (child.type === 'ceiling') {
|
|
294
|
-
maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)
|
|
295
|
-
continue
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (child.type === 'wall') {
|
|
299
|
-
const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0)
|
|
300
|
-
maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT))
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
|
|
305
|
-
}
|
|
306
|
-
|
|
307
433
|
function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
|
|
308
434
|
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
|
|
435
|
+
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
|
309
436
|
|
|
310
437
|
const baseYByLevelId = new Map<string, number>()
|
|
311
438
|
let cumulativeY = 0
|
|
312
439
|
for (const level of allLevels) {
|
|
313
|
-
|
|
314
|
-
|
|
440
|
+
const elevation = levelElevations.get(level.id)
|
|
441
|
+
const baseY = elevation?.baseY ?? 0
|
|
442
|
+
baseYByLevelId.set(level.id, baseY)
|
|
443
|
+
cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0))
|
|
315
444
|
}
|
|
316
445
|
|
|
317
446
|
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
|
|
@@ -573,6 +702,11 @@ export const FirstPersonControls = () => {
|
|
|
573
702
|
const yawRef = useRef(0)
|
|
574
703
|
const pitchRef = useRef(0)
|
|
575
704
|
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
|
705
|
+
const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
|
|
706
|
+
const crouchKeyRef = useRef(false)
|
|
707
|
+
const suspendRef = useRef(false)
|
|
708
|
+
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
|
|
709
|
+
const [crouched, setCrouched] = useState(false)
|
|
576
710
|
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
|
|
577
711
|
const ridingElevatorRef = useRef<{
|
|
578
712
|
elevatorId: AnyNodeId
|
|
@@ -589,6 +723,35 @@ export const FirstPersonControls = () => {
|
|
|
589
723
|
yaw: number
|
|
590
724
|
} | null>(null)
|
|
591
725
|
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
const previousCameraMode = useViewer.getState().cameraMode
|
|
728
|
+
if (previousCameraMode === 'orthographic') {
|
|
729
|
+
useViewer.getState().setCameraMode('perspective')
|
|
730
|
+
}
|
|
731
|
+
return () => {
|
|
732
|
+
if (previousCameraMode === 'orthographic') {
|
|
733
|
+
useViewer.getState().setCameraMode('orthographic')
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}, [])
|
|
737
|
+
|
|
738
|
+
useEffect(() => {
|
|
739
|
+
const perspectiveCamera = camera as PerspectiveCamera
|
|
740
|
+
if (!perspectiveCamera.isPerspectiveCamera) return
|
|
741
|
+
const previousFov = perspectiveCamera.fov
|
|
742
|
+
perspectiveCamera.fov = WALKTHROUGH_FOV
|
|
743
|
+
perspectiveCamera.updateProjectionMatrix()
|
|
744
|
+
return () => {
|
|
745
|
+
perspectiveCamera.fov = previousFov
|
|
746
|
+
perspectiveCamera.updateProjectionMatrix()
|
|
747
|
+
}
|
|
748
|
+
}, [camera])
|
|
749
|
+
|
|
750
|
+
useEffect(() => {
|
|
751
|
+
useFirstPersonHud.getState().reset()
|
|
752
|
+
return () => useFirstPersonHud.getState().reset()
|
|
753
|
+
}, [])
|
|
754
|
+
|
|
592
755
|
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
|
|
593
756
|
worldRef.current?.dispose()
|
|
594
757
|
worldRef.current = nextWorld
|
|
@@ -965,6 +1128,20 @@ export const FirstPersonControls = () => {
|
|
|
965
1128
|
return () => window.cancelAnimationFrame(frame)
|
|
966
1129
|
}, [gl])
|
|
967
1130
|
|
|
1131
|
+
// The pointer-lock effect below must be mount-stable. Its cleanup exits
|
|
1132
|
+
// pointer lock, and `toggleInteractableTarget` is recreated whenever the
|
|
1133
|
+
// camera object changes — which happens right after entry when the
|
|
1134
|
+
// persisted orthographic mode swaps to perspective. If the async lock
|
|
1135
|
+
// grant lands before that re-run, the cleanup's exitPointerLock fires an
|
|
1136
|
+
// unlock the handler reads as "user left walkthrough", instantly
|
|
1137
|
+
// cancelling a fresh entry (and arming the browser's ~1.25s re-lock
|
|
1138
|
+
// cooldown, so the next presses fail too). Route the callback through a
|
|
1139
|
+
// ref so the effect deps stay `[gl]`.
|
|
1140
|
+
const toggleInteractableTargetRef = useRef(toggleInteractableTarget)
|
|
1141
|
+
useEffect(() => {
|
|
1142
|
+
toggleInteractableTargetRef.current = toggleInteractableTarget
|
|
1143
|
+
}, [toggleInteractableTarget])
|
|
1144
|
+
|
|
968
1145
|
useEffect(() => {
|
|
969
1146
|
const canvas = gl.domElement
|
|
970
1147
|
const handleMouseMove = (e: MouseEvent) => {
|
|
@@ -992,16 +1169,22 @@ export const FirstPersonControls = () => {
|
|
|
992
1169
|
|
|
993
1170
|
event.preventDefault()
|
|
994
1171
|
event.stopPropagation()
|
|
995
|
-
|
|
1172
|
+
toggleInteractableTargetRef.current()
|
|
996
1173
|
}
|
|
997
1174
|
|
|
998
1175
|
const handlePointerLockChange = () => {
|
|
999
1176
|
const isLocked = document.pointerLockElement === canvas
|
|
1000
1177
|
if (isLocked) {
|
|
1001
1178
|
hadPointerLockRef.current = true
|
|
1179
|
+
suspendRef.current = false
|
|
1180
|
+
useViewer.getState().setWalkthroughSuspended(false)
|
|
1002
1181
|
return
|
|
1003
1182
|
}
|
|
1004
1183
|
|
|
1184
|
+
// Deliberately released (screenshot pause) — stay in first person;
|
|
1185
|
+
// clicking the canvas re-locks.
|
|
1186
|
+
if (suspendRef.current) return
|
|
1187
|
+
|
|
1005
1188
|
if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
|
|
1006
1189
|
useEditor.getState().setFirstPersonMode(false)
|
|
1007
1190
|
}
|
|
@@ -1018,11 +1201,12 @@ export const FirstPersonControls = () => {
|
|
|
1018
1201
|
document.removeEventListener('click', handleClick)
|
|
1019
1202
|
document.removeEventListener('mousedown', handleMouseDown, true)
|
|
1020
1203
|
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
1204
|
+
useViewer.getState().setWalkthroughSuspended(false)
|
|
1021
1205
|
if (document.pointerLockElement === canvas) {
|
|
1022
1206
|
document.exitPointerLock()
|
|
1023
1207
|
}
|
|
1024
1208
|
}
|
|
1025
|
-
}, [gl
|
|
1209
|
+
}, [gl])
|
|
1026
1210
|
|
|
1027
1211
|
useEffect(() => {
|
|
1028
1212
|
const canvas = gl.domElement
|
|
@@ -1049,7 +1233,11 @@ export const FirstPersonControls = () => {
|
|
|
1049
1233
|
return
|
|
1050
1234
|
}
|
|
1051
1235
|
|
|
1052
|
-
if (event.code === '
|
|
1236
|
+
if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
|
|
1237
|
+
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
|
|
1238
|
+
// screenshot) must not toggle it under the user.
|
|
1239
|
+
if (!suspendRef.current) crouchKeyRef.current = true
|
|
1240
|
+
} else if (event.code === 'Escape') {
|
|
1053
1241
|
event.preventDefault()
|
|
1054
1242
|
event.stopPropagation()
|
|
1055
1243
|
if (document.pointerLockElement === canvas) {
|
|
@@ -1064,18 +1252,41 @@ export const FirstPersonControls = () => {
|
|
|
1064
1252
|
event.preventDefault()
|
|
1065
1253
|
event.stopPropagation()
|
|
1066
1254
|
closeInteractableTarget()
|
|
1255
|
+
} else if (event.code === 'KeyP') {
|
|
1256
|
+
// P toggles a cursor pause (advertised in the HUD): frees the pointer
|
|
1257
|
+
// without leaving first person — e.g. for an OS screenshot, which
|
|
1258
|
+
// needs a movable cursor — and click or P resumes.
|
|
1259
|
+
event.preventDefault()
|
|
1260
|
+
event.stopPropagation()
|
|
1261
|
+
if (document.pointerLockElement === canvas) {
|
|
1262
|
+
suspendRef.current = true
|
|
1263
|
+
useViewer.getState().setWalkthroughSuspended(true)
|
|
1264
|
+
document.exitPointerLock()
|
|
1265
|
+
} else if (suspendRef.current) {
|
|
1266
|
+
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
|
|
1267
|
+
if (result && typeof result.catch === 'function') result.catch(() => {})
|
|
1268
|
+
}
|
|
1067
1269
|
}
|
|
1068
1270
|
}
|
|
1069
1271
|
|
|
1070
1272
|
const handleKeyUp = (event: KeyboardEvent) => {
|
|
1273
|
+
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
|
|
1274
|
+
crouchKeyRef.current = false
|
|
1275
|
+
}
|
|
1071
1276
|
applyMovementKey(event, false)
|
|
1072
1277
|
}
|
|
1073
1278
|
|
|
1279
|
+
const handleBlur = () => {
|
|
1280
|
+
if (!suspendRef.current) crouchKeyRef.current = false
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1074
1283
|
document.addEventListener('keydown', handleKeyDown, true)
|
|
1075
1284
|
document.addEventListener('keyup', handleKeyUp, true)
|
|
1285
|
+
window.addEventListener('blur', handleBlur)
|
|
1076
1286
|
return () => {
|
|
1077
1287
|
document.removeEventListener('keydown', handleKeyDown, true)
|
|
1078
1288
|
document.removeEventListener('keyup', handleKeyUp, true)
|
|
1289
|
+
window.removeEventListener('blur', handleBlur)
|
|
1079
1290
|
}
|
|
1080
1291
|
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
|
1081
1292
|
|
|
@@ -1301,11 +1512,33 @@ export const FirstPersonControls = () => {
|
|
|
1301
1512
|
[camera, setElevatorRideLocked],
|
|
1302
1513
|
)
|
|
1303
1514
|
|
|
1304
|
-
|
|
1515
|
+
const hasStandingClearance = useCallback((position: Vector3) => {
|
|
1516
|
+
standClearanceRaycaster.set(position, standClearanceUp)
|
|
1517
|
+
standClearanceRaycaster.far = STAND_CLEARANCE
|
|
1518
|
+
const meshes: Mesh[] = []
|
|
1519
|
+
if (worldRef.current) meshes.push(worldRef.current.mesh)
|
|
1520
|
+
for (const mesh of elevatorColliderMeshesRef.current) {
|
|
1521
|
+
if (mesh.visible) meshes.push(mesh)
|
|
1522
|
+
}
|
|
1523
|
+
return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
|
|
1524
|
+
}, [])
|
|
1525
|
+
|
|
1526
|
+
useFrame((_, delta) => {
|
|
1305
1527
|
if (!controllerRef.current?.group) return
|
|
1306
1528
|
|
|
1307
1529
|
const group = controllerRef.current.group
|
|
1308
1530
|
|
|
1531
|
+
// Crouch follows the held key; standing back up waits for headroom.
|
|
1532
|
+
// Frozen while the cursor pause is active.
|
|
1533
|
+
if (!suspendRef.current && crouchKeyRef.current !== crouched) {
|
|
1534
|
+
if (crouchKeyRef.current) setCrouched(true)
|
|
1535
|
+
else if (hasStandingClearance(group.position)) setCrouched(false)
|
|
1536
|
+
}
|
|
1537
|
+
const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET
|
|
1538
|
+
eyeOffsetRef.current +=
|
|
1539
|
+
(targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED)
|
|
1540
|
+
cameraOffset.set(0, eyeOffsetRef.current, 0)
|
|
1541
|
+
|
|
1309
1542
|
// The site ground collider is effectively unbounded, but scenes without a
|
|
1310
1543
|
// site node only have finite fallback floors — if the controller still ends
|
|
1311
1544
|
// up below every collider it can never land, so put it back at the spawn.
|
|
@@ -1346,6 +1579,17 @@ export const FirstPersonControls = () => {
|
|
|
1346
1579
|
interactableTargetRef.current = nextInteractableTarget
|
|
1347
1580
|
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
|
1348
1581
|
}
|
|
1582
|
+
|
|
1583
|
+
useFirstPersonHud.getState().setHud({
|
|
1584
|
+
interact: resolveHudInteract(nextInteractableTarget),
|
|
1585
|
+
})
|
|
1586
|
+
|
|
1587
|
+
hudLabelFrameRef.current += 1
|
|
1588
|
+
if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) {
|
|
1589
|
+
hudLabelFrameRef.current = 0
|
|
1590
|
+
camera.getWorldPosition(hudWorldEyePosition)
|
|
1591
|
+
useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition))
|
|
1592
|
+
}
|
|
1349
1593
|
}, 2.5)
|
|
1350
1594
|
|
|
1351
1595
|
useEffect(() => {
|
|
@@ -1372,7 +1616,7 @@ export const FirstPersonControls = () => {
|
|
|
1372
1616
|
<BVHEcctrl
|
|
1373
1617
|
acceleration={26}
|
|
1374
1618
|
airDragFactor={0.3}
|
|
1375
|
-
colliderCapsuleArgs={
|
|
1619
|
+
colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
|
|
1376
1620
|
colliderMeshes={firstPersonColliderMeshes}
|
|
1377
1621
|
collisionCheckIteration={3}
|
|
1378
1622
|
collisionPushBackDamping={0.1}
|
|
@@ -1383,16 +1627,16 @@ export const FirstPersonControls = () => {
|
|
|
1383
1627
|
fallGravityFactor={4}
|
|
1384
1628
|
floatCheckType="BOTH"
|
|
1385
1629
|
floatDampingC={36}
|
|
1386
|
-
floatHeight={
|
|
1630
|
+
floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
|
|
1387
1631
|
floatPullBackHeight={0.35}
|
|
1388
1632
|
floatSensorRadius={0.15}
|
|
1389
1633
|
floatSpringK={1200}
|
|
1390
1634
|
gravity={9.81}
|
|
1391
1635
|
jumpVel={5}
|
|
1392
1636
|
key="first-person-controller"
|
|
1393
|
-
maxRunSpeed={5}
|
|
1637
|
+
maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
|
|
1394
1638
|
maxSlope={1.2}
|
|
1395
|
-
maxWalkSpeed={2}
|
|
1639
|
+
maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
|
|
1396
1640
|
paused={isElevatorRideLocked}
|
|
1397
1641
|
position={controllerStart.position}
|
|
1398
1642
|
ref={setControllerApi}
|
|
@@ -1403,27 +1647,14 @@ export const FirstPersonControls = () => {
|
|
|
1403
1647
|
)
|
|
1404
1648
|
}
|
|
1405
1649
|
|
|
1406
|
-
/**
|
|
1407
|
-
* Overlay UI for first-person mode: crosshair, controls hint, exit button.
|
|
1408
|
-
* Rendered as a regular DOM overlay (not inside the Canvas).
|
|
1409
|
-
*/
|
|
1410
1650
|
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|
1411
|
-
const [isLocked, setIsLocked] = useState(false)
|
|
1412
1651
|
const hasPlacedSpawn = useScene((state) =>
|
|
1413
1652
|
Object.values(state.nodes).some((node) => node.type === 'spawn'),
|
|
1414
1653
|
)
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
handlePointerLockChange()
|
|
1422
|
-
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
1423
|
-
return () => {
|
|
1424
|
-
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
1425
|
-
}
|
|
1426
|
-
}, [])
|
|
1654
|
+
const floorLabel = useFirstPersonHud((state) => state.floorLabel)
|
|
1655
|
+
const zoneLabel = useFirstPersonHud((state) => state.zoneLabel)
|
|
1656
|
+
const interact = useFirstPersonHud((state) => state.interact)
|
|
1657
|
+
const suspended = useViewer((state) => state.walkthroughSuspended)
|
|
1427
1658
|
|
|
1428
1659
|
const handleExit = useCallback(() => {
|
|
1429
1660
|
if (document.pointerLockElement) {
|
|
@@ -1433,86 +1664,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|
|
1433
1664
|
}, [onExit])
|
|
1434
1665
|
|
|
1435
1666
|
return (
|
|
1436
|
-
|
|
1437
|
-
{
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
</div>
|
|
1444
|
-
)}
|
|
1445
|
-
|
|
1446
|
-
<div className="absolute top-4 right-4 z-50">
|
|
1447
|
-
<button
|
|
1448
|
-
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
|
|
1449
|
-
onClick={handleExit}
|
|
1450
|
-
type="button"
|
|
1451
|
-
>
|
|
1452
|
-
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
1453
|
-
ESC
|
|
1454
|
-
</kbd>
|
|
1455
|
-
Exit Street View
|
|
1456
|
-
</button>
|
|
1457
|
-
</div>
|
|
1458
|
-
|
|
1667
|
+
<WalkthroughHud
|
|
1668
|
+
floorLabel={floorLabel}
|
|
1669
|
+
interact={interact}
|
|
1670
|
+
onExit={handleExit}
|
|
1671
|
+
suspended={suspended}
|
|
1672
|
+
zoneLabel={zoneLabel}
|
|
1673
|
+
>
|
|
1459
1674
|
{!hasPlacedSpawn && (
|
|
1460
|
-
<div className="
|
|
1461
|
-
|
|
1462
|
-
Place a Spawn Point from the Build tab to control where walkthrough starts.
|
|
1463
|
-
</div>
|
|
1675
|
+
<div className="corner-smooth rounded-full border border-border/40 bg-background/80 px-3 py-1 text-center text-muted-foreground text-xs shadow-elevation-3 backdrop-blur-xl">
|
|
1676
|
+
Place a spawn point from the Build tab to control where walkthrough starts.
|
|
1464
1677
|
</div>
|
|
1465
1678
|
)}
|
|
1466
|
-
|
|
1467
|
-
{isLocked && (
|
|
1468
|
-
<div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
|
|
1469
|
-
<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">
|
|
1470
|
-
<ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
|
|
1471
|
-
<div className="h-px w-full bg-border/30" />
|
|
1472
|
-
<InlineControlHint keyLabel="Space" label="Jump" />
|
|
1473
|
-
<InlineControlHint keyLabel="Shift" label="Sprint" />
|
|
1474
|
-
<InlineControlHint keyLabel="E / R" label="Interact" />
|
|
1475
|
-
<InlineControlHint keyLabel="T" label="Close" />
|
|
1476
|
-
<div className="h-px w-full bg-border/30" />
|
|
1477
|
-
<span className="text-center text-muted-foreground/60 text-xs">
|
|
1478
|
-
Click to look around
|
|
1479
|
-
</span>
|
|
1480
|
-
</div>
|
|
1481
|
-
</div>
|
|
1482
|
-
)}
|
|
1483
|
-
</>
|
|
1484
|
-
)
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
|
1488
|
-
return (
|
|
1489
|
-
<div className="flex flex-col items-center gap-1.5 text-center">
|
|
1490
|
-
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
|
|
1491
|
-
{label}
|
|
1492
|
-
</span>
|
|
1493
|
-
<div className="flex flex-wrap items-center justify-center gap-1">
|
|
1494
|
-
{keys.map((key) => (
|
|
1495
|
-
<kbd
|
|
1496
|
-
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
|
|
1497
|
-
key={key}
|
|
1498
|
-
>
|
|
1499
|
-
{key}
|
|
1500
|
-
</kbd>
|
|
1501
|
-
))}
|
|
1502
|
-
</div>
|
|
1503
|
-
</div>
|
|
1504
|
-
)
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
|
|
1508
|
-
return (
|
|
1509
|
-
<div className="flex items-center justify-between gap-3">
|
|
1510
|
-
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
|
|
1511
|
-
{label}
|
|
1512
|
-
</span>
|
|
1513
|
-
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
|
|
1514
|
-
{keyLabel}
|
|
1515
|
-
</kbd>
|
|
1516
|
-
</div>
|
|
1679
|
+
</WalkthroughHud>
|
|
1517
1680
|
)
|
|
1518
1681
|
}
|