Add spawn node support and refine stair and door cutouts
This commit is contained in:
@@ -12,6 +12,7 @@ import type {
|
|||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
SiteNode,
|
SiteNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
|
SpawnNode,
|
||||||
StairNode,
|
StairNode,
|
||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
@@ -53,6 +54,7 @@ export type BuildingEvent = NodeEvent<BuildingNode>
|
|||||||
export type LevelEvent = NodeEvent<LevelNode>
|
export type LevelEvent = NodeEvent<LevelNode>
|
||||||
export type ZoneEvent = NodeEvent<ZoneNode>
|
export type ZoneEvent = NodeEvent<ZoneNode>
|
||||||
export type SlabEvent = NodeEvent<SlabNode>
|
export type SlabEvent = NodeEvent<SlabNode>
|
||||||
|
export type SpawnEvent = NodeEvent<SpawnNode>
|
||||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||||
export type RoofEvent = NodeEvent<RoofNode>
|
export type RoofEvent = NodeEvent<RoofNode>
|
||||||
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
||||||
@@ -144,6 +146,7 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'level', LevelEvent> &
|
NodeEvents<'level', LevelEvent> &
|
||||||
NodeEvents<'zone', ZoneEvent> &
|
NodeEvents<'zone', ZoneEvent> &
|
||||||
NodeEvents<'slab', SlabEvent> &
|
NodeEvents<'slab', SlabEvent> &
|
||||||
|
NodeEvents<'spawn', SpawnEvent> &
|
||||||
NodeEvents<'ceiling', CeilingEvent> &
|
NodeEvents<'ceiling', CeilingEvent> &
|
||||||
NodeEvents<'roof', RoofEvent> &
|
NodeEvents<'roof', RoofEvent> &
|
||||||
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const sceneRegistry = {
|
|||||||
fence: new Set<string>(),
|
fence: new Set<string>(),
|
||||||
item: new Set<string>(),
|
item: new Set<string>(),
|
||||||
slab: new Set<string>(),
|
slab: new Set<string>(),
|
||||||
|
spawn: new Set<string>(),
|
||||||
zone: new Set<string>(),
|
zone: new Set<string>(),
|
||||||
roof: new Set<string>(),
|
roof: new Set<string>(),
|
||||||
'roof-segment': new Set<string>(),
|
'roof-segment': new Set<string>(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type {
|
|||||||
RoofSegmentEvent,
|
RoofSegmentEvent,
|
||||||
SiteEvent,
|
SiteEvent,
|
||||||
SlabEvent,
|
SlabEvent,
|
||||||
|
SpawnEvent,
|
||||||
StairEvent,
|
StairEvent,
|
||||||
StairSegmentEvent,
|
StairSegmentEvent,
|
||||||
WallEvent,
|
WallEvent,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export { ScanNode } from './nodes/scan'
|
|||||||
// Nodes
|
// Nodes
|
||||||
export { SiteNode } from './nodes/site'
|
export { SiteNode } from './nodes/site'
|
||||||
export { SlabNode } from './nodes/slab'
|
export { SlabNode } from './nodes/slab'
|
||||||
|
export { SpawnNode } from './nodes/spawn'
|
||||||
export {
|
export {
|
||||||
getEffectiveStairSurfaceMaterial,
|
getEffectiveStairSurfaceMaterial,
|
||||||
StairNode,
|
StairNode,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const DoorNode = BaseNode.extend({
|
|||||||
}).describe(dedent`Door node - a parametric door placed on a wall
|
}).describe(dedent`Door node - a parametric door placed on a wall
|
||||||
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
|
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
|
||||||
- segments: rows stacked top to bottom, each defining its own columnRatios
|
- segments: rows stacked top to bottom, each defining its own columnRatios
|
||||||
- type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed
|
- type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
|
||||||
- hingesSide/swingDirection: which way the door opens
|
- hingesSide/swingDirection: which way the door opens
|
||||||
- doorCloser/panicBar: commercial and emergency hardware options
|
- doorCloser/panicBar: commercial and emergency hardware options
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { GuideNode } from './guide'
|
|||||||
import { RoofNode } from './roof'
|
import { RoofNode } from './roof'
|
||||||
import { ScanNode } from './scan'
|
import { ScanNode } from './scan'
|
||||||
import { SlabNode } from './slab'
|
import { SlabNode } from './slab'
|
||||||
|
import { SpawnNode } from './spawn'
|
||||||
import { StairNode } from './stair'
|
import { StairNode } from './stair'
|
||||||
import { WallNode } from './wall'
|
import { WallNode } from './wall'
|
||||||
import { ZoneNode } from './zone'
|
import { ZoneNode } from './zone'
|
||||||
@@ -26,6 +27,7 @@ export const LevelNode = BaseNode.extend({
|
|||||||
StairNode.shape.id,
|
StairNode.shape.id,
|
||||||
ScanNode.shape.id,
|
ScanNode.shape.id,
|
||||||
GuideNode.shape.id,
|
GuideNode.shape.id,
|
||||||
|
SpawnNode.shape.id,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
export const SpawnNode = BaseNode.extend({
|
||||||
|
id: objectId('spawn'),
|
||||||
|
type: nodeType('spawn'),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SpawnNode = z.infer<typeof SpawnNode>
|
||||||
@@ -11,6 +11,7 @@ import { RoofSegmentNode } from './nodes/roof-segment'
|
|||||||
import { ScanNode } from './nodes/scan'
|
import { ScanNode } from './nodes/scan'
|
||||||
import { SiteNode } from './nodes/site'
|
import { SiteNode } from './nodes/site'
|
||||||
import { SlabNode } from './nodes/slab'
|
import { SlabNode } from './nodes/slab'
|
||||||
|
import { SpawnNode } from './nodes/spawn'
|
||||||
import { StairNode } from './nodes/stair'
|
import { StairNode } from './nodes/stair'
|
||||||
import { StairSegmentNode } from './nodes/stair-segment'
|
import { StairSegmentNode } from './nodes/stair-segment'
|
||||||
import { WallNode } from './nodes/wall'
|
import { WallNode } from './nodes/wall'
|
||||||
@@ -33,6 +34,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
ScanNode,
|
ScanNode,
|
||||||
GuideNode,
|
GuideNode,
|
||||||
|
SpawnNode,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
contentPadding,
|
contentPadding,
|
||||||
hingesSide,
|
hingesSide,
|
||||||
} = node
|
} = node
|
||||||
|
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
|
||||||
|
|
||||||
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
|
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
|
||||||
const leafW = width - 2 * frameThickness
|
const leafW = width - 2 * frameThickness
|
||||||
@@ -146,13 +147,13 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
|
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
|
||||||
const cpX = contentPadding[0]
|
const cpX = contentPadding[0]
|
||||||
const cpY = contentPadding[1]
|
const cpY = contentPadding[1]
|
||||||
if (cpY > 0) {
|
if (hasLeafContent && cpY > 0) {
|
||||||
// Top strip
|
// Top strip
|
||||||
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
|
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
|
||||||
// Bottom strip
|
// Bottom strip
|
||||||
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
|
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
|
||||||
}
|
}
|
||||||
if (cpX > 0) {
|
if (hasLeafContent && cpX > 0) {
|
||||||
const innerH = leafH - 2 * cpY
|
const innerH = leafH - 2 * cpY
|
||||||
// Left strip
|
// Left strip
|
||||||
addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
|
addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
|
||||||
@@ -188,20 +189,22 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Column dividers within this segment
|
// Column dividers within this segment
|
||||||
cx = -contentW / 2
|
if (seg.type !== 'empty') {
|
||||||
for (let c = 0; c < numCols - 1; c++) {
|
cx = -contentW / 2
|
||||||
cx += colWidths[c]!
|
for (let c = 0; c < numCols - 1; c++) {
|
||||||
addBox(
|
cx += colWidths[c]!
|
||||||
mesh,
|
addBox(
|
||||||
baseMaterial,
|
mesh,
|
||||||
seg.dividerThickness,
|
baseMaterial,
|
||||||
segH,
|
seg.dividerThickness,
|
||||||
leafDepth + 0.001,
|
segH,
|
||||||
cx + seg.dividerThickness / 2,
|
leafDepth + 0.001,
|
||||||
segCenterY,
|
cx + seg.dividerThickness / 2,
|
||||||
0,
|
segCenterY,
|
||||||
)
|
0,
|
||||||
cx += seg.dividerThickness
|
)
|
||||||
|
cx += seg.dividerThickness
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment content per column
|
// Segment content per column
|
||||||
@@ -225,8 +228,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
|
addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 'empty' — opaque backing, no detail
|
// 'empty' leaves the opening unfilled
|
||||||
addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Handle ──
|
// ── Handle ──
|
||||||
if (handle) {
|
if (hasLeafContent && handle) {
|
||||||
// Convert from floor-based height to mesh-center-based Y
|
// Convert from floor-based height to mesh-center-based Y
|
||||||
const handleY = handleHeight - height / 2
|
const handleY = handleHeight - height / 2
|
||||||
// Handle grip sits on the front face (+Z) of the leaf
|
// Handle grip sits on the front face (+Z) of the leaf
|
||||||
@@ -250,7 +252,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Door closer (commercial hardware at top) ──
|
// ── Door closer (commercial hardware at top) ──
|
||||||
if (doorCloser) {
|
if (hasLeafContent && doorCloser) {
|
||||||
const closerY = leafCenterY + leafH / 2 - 0.04
|
const closerY = leafCenterY + leafH / 2 - 0.04
|
||||||
// Body
|
// Body
|
||||||
addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)
|
addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)
|
||||||
@@ -268,13 +270,13 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Panic bar ──
|
// ── Panic bar ──
|
||||||
if (panicBar) {
|
if (hasLeafContent && panicBar) {
|
||||||
const barY = panicBarHeight - height / 2
|
const barY = panicBarHeight - height / 2
|
||||||
addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03)
|
addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hinges (3 knuckle-style hinges on the hinge side) ──
|
// ── Hinges (3 knuckle-style hinges on the hinge side) ──
|
||||||
{
|
if (hasLeafContent) {
|
||||||
const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012
|
const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012
|
||||||
const hingeZ = 0 // centered in leaf depth
|
const hingeZ = 0 // centered in leaf depth
|
||||||
const hingeH = 0.1
|
const hingeH = 0.1
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { AnyNode, AnyNodeId, CeilingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
|
import type {
|
||||||
|
AnyNode,
|
||||||
|
AnyNodeId,
|
||||||
|
CeilingNode,
|
||||||
|
LevelNode,
|
||||||
|
SlabNode,
|
||||||
|
StairNode,
|
||||||
|
StairSegmentNode,
|
||||||
|
} from '../../schema'
|
||||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||||
|
|
||||||
@@ -27,9 +35,10 @@ type AxisAlignedRect = {
|
|||||||
maxZ: number
|
maxZ: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
|
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.9
|
||||||
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
|
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
|
||||||
const STAIR_SLAB_OPENING_TIGHTENING = 0
|
const STAIR_SLAB_OPENING_TIGHTENING = 0
|
||||||
|
const CURVED_STAIR_OPENING_STEP_PADDING = 3
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.min(max, Math.max(min, value))
|
return Math.min(max, Math.max(min, value))
|
||||||
@@ -58,7 +67,8 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]
|
|||||||
if (left.length !== right.length) return false
|
if (left.length !== right.length) return false
|
||||||
return left.every(
|
return left.every(
|
||||||
(entry, index) =>
|
(entry, index) =>
|
||||||
entry.source === right[index]?.source && (entry.stairId ?? null) === (right[index]?.stairId ?? null),
|
entry.source === right[index]?.source &&
|
||||||
|
(entry.stairId ?? null) === (right[index]?.stairId ?? null),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +188,10 @@ function getResolvedStairLevelIds(stair: StairNode, nodes: Record<string, AnyNod
|
|||||||
function resolveStraightSegments(stair: StairNode, nodes: Record<string, AnyNode>) {
|
function resolveStraightSegments(stair: StairNode, nodes: Record<string, AnyNode>) {
|
||||||
return (stair.children ?? [])
|
return (stair.children ?? [])
|
||||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||||
.filter((segment): segment is StairSegmentNode => segment?.type === 'stair-segment' && segment.visible !== false)
|
.filter(
|
||||||
|
(segment): segment is StairSegmentNode =>
|
||||||
|
segment?.type === 'stair-segment' && segment.visible !== false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D {
|
function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D {
|
||||||
@@ -186,7 +199,10 @@ function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Poi
|
|||||||
return [stair.position[0] + worldX, stair.position[2] + worldZ]
|
return [stair.position[0] + worldX, stair.position[2] + worldZ]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStraightStairLayouts(stair: StairNode, nodes: Record<string, AnyNode>): StraightStairLayout[] {
|
function getStraightStairLayouts(
|
||||||
|
stair: StairNode,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
): StraightStairLayout[] {
|
||||||
const segments = resolveStraightSegments(stair, nodes)
|
const segments = resolveStraightSegments(stair, nodes)
|
||||||
const transforms = computeSegmentTransforms(segments)
|
const transforms = computeSegmentTransforms(segments)
|
||||||
|
|
||||||
@@ -204,7 +220,10 @@ function getStraightStairLayouts(stair: StairNode, nodes: Record<string, AnyNode
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStraightSegmentFootprintPolygon(stair: StairNode, layout: StraightStairLayout): Point2D[] {
|
function getStraightSegmentFootprintPolygon(
|
||||||
|
stair: StairNode,
|
||||||
|
layout: StraightStairLayout,
|
||||||
|
): Point2D[] {
|
||||||
return getStraightSegmentSlicePolygon(stair, layout, 0, layout.segment.length)
|
return getStraightSegmentSlicePolygon(stair, layout, 0, layout.segment.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,11 +261,16 @@ function getStraightSegmentSlicePolygon(
|
|||||||
startAlong: number,
|
startAlong: number,
|
||||||
endAlong: number,
|
endAlong: number,
|
||||||
): Point2D[] {
|
): Point2D[] {
|
||||||
return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => toWorldPlanPoint(stair, x, z))
|
return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) =>
|
||||||
|
toWorldPlanPoint(stair, x, z),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) {
|
function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) {
|
||||||
const treadDepth = Math.max(0.2, segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1))
|
const treadDepth = Math.max(
|
||||||
|
0.2,
|
||||||
|
segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1),
|
||||||
|
)
|
||||||
return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8))
|
return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,12 +313,16 @@ function expandRect(rect: AxisAlignedRect, offset: number): AxisAlignedRect {
|
|||||||
function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
|
function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
|
||||||
if (rects.length === 0) return []
|
if (rects.length === 0) return []
|
||||||
|
|
||||||
const xs = Array.from(new Set(rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))))).sort(
|
const xs = Array.from(
|
||||||
(a, b) => a - b,
|
new Set(
|
||||||
)
|
rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))),
|
||||||
const zs = Array.from(new Set(rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))))).sort(
|
),
|
||||||
(a, b) => a - b,
|
).sort((a, b) => a - b)
|
||||||
)
|
const zs = Array.from(
|
||||||
|
new Set(
|
||||||
|
rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))),
|
||||||
|
),
|
||||||
|
).sort((a, b) => a - b)
|
||||||
if (xs.length < 2 || zs.length < 2) return []
|
if (xs.length < 2 || zs.length < 2) return []
|
||||||
|
|
||||||
const occupied = new Set<string>()
|
const occupied = new Set<string>()
|
||||||
@@ -367,24 +395,39 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
|
|||||||
return polygons
|
return polygons
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
|
function getCurvedOpeningStepCount(
|
||||||
const width = Math.max(stair.width ?? 1, 0.4)
|
stair: StairNode,
|
||||||
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
|
innerRadius: number,
|
||||||
const outerRadius = innerRadius + width
|
outerRadius: number,
|
||||||
const totalSweep = stair.sweepAngle ?? Math.PI / 2
|
totalSweep: number,
|
||||||
const openingSweep =
|
) {
|
||||||
Math.sign(totalSweep || 1) *
|
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||||
|
const stepSweep = Math.abs(totalSweep) / stepCount
|
||||||
|
const midRadius = Math.max((innerRadius + outerRadius) * 0.5, 0.01)
|
||||||
|
const treadDepth = Math.max(stepSweep * midRadius, 0.2)
|
||||||
|
return Math.min(
|
||||||
|
stepCount,
|
||||||
Math.max(
|
Math.max(
|
||||||
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
|
1,
|
||||||
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
|
Math.ceil(1.8 / treadDepth),
|
||||||
)
|
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO),
|
||||||
const startAngle = totalSweep / 2 - openingSweep
|
),
|
||||||
const endAngle = totalSweep / 2
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArcOpeningPolygon(
|
||||||
|
stair: StairNode,
|
||||||
|
innerRadius: number,
|
||||||
|
outerRadius: number,
|
||||||
|
startAngle: number,
|
||||||
|
endAngle: number,
|
||||||
|
): Point2D[] {
|
||||||
|
const sweep = endAngle - startAngle
|
||||||
const segmentCount = Math.max(
|
const segmentCount = Math.max(
|
||||||
10,
|
10,
|
||||||
Math.min(
|
Math.min(
|
||||||
32,
|
32,
|
||||||
Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
|
Math.ceil(Math.abs(sweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const outerPoints: Point2D[] = []
|
const outerPoints: Point2D[] = []
|
||||||
@@ -392,19 +435,56 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
|
|||||||
|
|
||||||
for (let index = 0; index <= segmentCount; index++) {
|
for (let index = 0; index <= segmentCount; index++) {
|
||||||
const t = index / segmentCount
|
const t = index / segmentCount
|
||||||
const angle = startAngle + (endAngle - startAngle) * t
|
const angle = startAngle + sweep * t
|
||||||
outerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius))
|
outerPoints.push(
|
||||||
|
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let index = segmentCount; index >= 0; index--) {
|
for (let index = segmentCount; index >= 0; index--) {
|
||||||
const t = index / segmentCount
|
const t = index / segmentCount
|
||||||
const angle = startAngle + (endAngle - startAngle) * t
|
const angle = startAngle + sweep * t
|
||||||
innerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius))
|
innerPoints.push(
|
||||||
|
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...outerPoints, ...innerPoints]
|
return [...outerPoints, ...innerPoints]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurvedOpeningPolygon(stair: StairNode, targetElevation?: number): Point2D[] {
|
||||||
|
const width = Math.max(stair.width ?? 1, 0.4)
|
||||||
|
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
|
||||||
|
const outerRadius = innerRadius + width
|
||||||
|
const totalSweep = stair.sweepAngle ?? Math.PI / 2
|
||||||
|
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||||
|
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
|
||||||
|
const stepSweep = totalSweep / stepCount
|
||||||
|
const targetThreshold = Math.max(stepHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
|
||||||
|
const endAngle = totalSweep / 2
|
||||||
|
|
||||||
|
const fallbackStartStepIndex = Math.max(
|
||||||
|
0,
|
||||||
|
stepCount - getCurvedOpeningStepCount(stair, innerRadius, outerRadius, totalSweep),
|
||||||
|
)
|
||||||
|
let startStepIndex = fallbackStartStepIndex
|
||||||
|
if (typeof targetElevation === 'number') {
|
||||||
|
for (let index = 0; index < stepCount; index += 1) {
|
||||||
|
const stepTopElevation = stepHeight * (index + 1)
|
||||||
|
if (stepTopElevation >= targetElevation - targetThreshold) {
|
||||||
|
startStepIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(fallbackStartStepIndex, index - CURVED_STAIR_OPENING_STEP_PADDING),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAngle = -totalSweep / 2 + stepSweep * startStepIndex
|
||||||
|
return buildArcOpeningPolygon(stair, innerRadius, outerRadius, startAngle, endAngle)
|
||||||
|
}
|
||||||
|
|
||||||
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
|
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
|
||||||
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
|
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
|
||||||
const segmentCount = 48
|
const segmentCount = 48
|
||||||
@@ -440,7 +520,11 @@ function getStraightOpeningPolygonsForSurface(
|
|||||||
if (Math.abs(targetElevation - segmentTopElevation) <= targetThreshold) {
|
if (Math.abs(targetElevation - segmentTopElevation) <= targetThreshold) {
|
||||||
const openingDepth = getStraightFlightOpeningDepth(stair, segment)
|
const openingDepth = getStraightFlightOpeningDepth(stair, segment)
|
||||||
const flightRect = getAxisAlignedRectFromPolygon(
|
const flightRect = getAxisAlignedRectFromPolygon(
|
||||||
getStraightSegmentLocalSlicePolygon(layout, Math.max(0, segment.length - openingDepth), segment.length),
|
getStraightSegmentLocalSlicePolygon(
|
||||||
|
layout,
|
||||||
|
Math.max(0, segment.length - openingDepth),
|
||||||
|
segment.length,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (flightRect) openingRects.push(expandRect(flightRect, openingOffset))
|
if (flightRect) openingRects.push(expandRect(flightRect, openingOffset))
|
||||||
}
|
}
|
||||||
@@ -452,7 +536,9 @@ function getStraightOpeningPolygonsForSurface(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const landingRects: AxisAlignedRect[] = []
|
const landingRects: AxisAlignedRect[] = []
|
||||||
const landingRect = getAxisAlignedRectFromPolygon(getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length))
|
const landingRect = getAxisAlignedRectFromPolygon(
|
||||||
|
getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length),
|
||||||
|
)
|
||||||
if (landingRect) landingRects.push(expandRect(landingRect, openingOffset))
|
if (landingRect) landingRects.push(expandRect(landingRect, openingOffset))
|
||||||
const previous = layouts[index - 1]
|
const previous = layouts[index - 1]
|
||||||
if (previous?.segment.segmentType === 'stair') {
|
if (previous?.segment.segmentType === 'stair') {
|
||||||
@@ -503,7 +589,7 @@ function getStairOpeningPolygons(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stair.stairType === 'curved') {
|
if (stair.stairType === 'curved') {
|
||||||
return [getCurvedOpeningPolygon(stair)]
|
return [getCurvedOpeningPolygon(stair, targetElevation)]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stair.stairType === 'spiral') {
|
if (stair.stairType === 'spiral') {
|
||||||
@@ -556,10 +642,18 @@ function getTargetCeilingElevationForStair(
|
|||||||
return ceiling.height ?? DEFAULT_WALL_HEIGHT
|
return ceiling.height ?? DEFAULT_WALL_HEIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
return (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - (stair.position[1] ?? 0)
|
return (
|
||||||
|
(ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
|
||||||
|
(ceiling.height ?? DEFAULT_WALL_HEIGHT) -
|
||||||
|
(stair.position[1] ?? 0)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Record<string, AnyNode>) {
|
function shouldApplyStairToSlab(
|
||||||
|
stair: StairNode,
|
||||||
|
slabLevelId: string,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
) {
|
||||||
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
|
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
|
||||||
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
||||||
const toLevel = getLevelNumber(toLevelId, nodes)
|
const toLevel = getLevelNumber(toLevelId, nodes)
|
||||||
@@ -578,7 +672,11 @@ function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Re
|
|||||||
return slabLevel > minLevel && slabLevel <= maxLevel
|
return slabLevel > minLevel && slabLevel <= maxLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nodes: Record<string, AnyNode>) {
|
function shouldApplyStairToCeiling(
|
||||||
|
stair: StairNode,
|
||||||
|
ceilingLevelId: string,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
) {
|
||||||
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
|
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
|
||||||
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
||||||
const toLevel = getLevelNumber(toLevelId, nodes)
|
const toLevel = getLevelNumber(toLevelId, nodes)
|
||||||
@@ -598,16 +696,22 @@ function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nod
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||||
const stairs = Object.values(nodes).filter((node): node is StairNode => node.type === 'stair' && node.visible !== false)
|
const stairs = Object.values(nodes).filter(
|
||||||
|
(node): node is StairNode => node.type === 'stair' && node.visible !== false,
|
||||||
|
)
|
||||||
const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab')
|
const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab')
|
||||||
const ceilings = Object.values(nodes).filter((node): node is CeilingNode => node.type === 'ceiling')
|
const ceilings = Object.values(nodes).filter(
|
||||||
|
(node): node is CeilingNode => node.type === 'ceiling',
|
||||||
|
)
|
||||||
const updates: Array<{ id: AnyNodeId; data: Partial<SlabNode | CeilingNode> }> = []
|
const updates: Array<{ id: AnyNodeId; data: Partial<SlabNode | CeilingNode> }> = []
|
||||||
|
|
||||||
for (const slab of slabs) {
|
for (const slab of slabs) {
|
||||||
const slabLevelId = resolveLevelId(slab, nodes)
|
const slabLevelId = resolveLevelId(slab, nodes)
|
||||||
const existingHoles = slab.holes ?? []
|
const existingHoles = slab.holes ?? []
|
||||||
const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata)
|
const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata)
|
||||||
const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair')
|
const manualHoles = existingHoles.filter(
|
||||||
|
(_hole, index) => existingMetadata[index]?.source !== 'stair',
|
||||||
|
)
|
||||||
const manualMetadata = existingMetadata
|
const manualMetadata = existingMetadata
|
||||||
.filter((entry) => entry.source !== 'stair')
|
.filter((entry) => entry.source !== 'stair')
|
||||||
.map((entry) => ({ ...entry }))
|
.map((entry) => ({ ...entry }))
|
||||||
@@ -637,7 +741,10 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
|||||||
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
||||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
||||||
|
|
||||||
if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) {
|
if (
|
||||||
|
!polygonsEqual(existingHoles, nextHoles) ||
|
||||||
|
!metadataEqual(existingMetadata, nextMetadata)
|
||||||
|
) {
|
||||||
updates.push({
|
updates.push({
|
||||||
id: slab.id,
|
id: slab.id,
|
||||||
data: {
|
data: {
|
||||||
@@ -652,7 +759,9 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
|||||||
const ceilingLevelId = resolveLevelId(ceiling, nodes)
|
const ceilingLevelId = resolveLevelId(ceiling, nodes)
|
||||||
const existingHoles = ceiling.holes ?? []
|
const existingHoles = ceiling.holes ?? []
|
||||||
const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata)
|
const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata)
|
||||||
const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair')
|
const manualHoles = existingHoles.filter(
|
||||||
|
(_hole, index) => existingMetadata[index]?.source !== 'stair',
|
||||||
|
)
|
||||||
const manualMetadata = existingMetadata
|
const manualMetadata = existingMetadata
|
||||||
.filter((entry) => entry.source !== 'stair')
|
.filter((entry) => entry.source !== 'stair')
|
||||||
.map((entry) => ({ ...entry }))
|
.map((entry) => ({ ...entry }))
|
||||||
@@ -682,7 +791,10 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
|||||||
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
||||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
||||||
|
|
||||||
if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) {
|
if (
|
||||||
|
!polygonsEqual(existingHoles, nextHoles) ||
|
||||||
|
!metadataEqual(existingMetadata, nextMetadata)
|
||||||
|
) {
|
||||||
updates.push({
|
updates.push({
|
||||||
id: ceiling.id,
|
id: ceiling.id,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"motion": "^12.34.3",
|
"motion": "^12.34.3",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"three-mesh-bvh": "^0.9.8",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
|
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
||||||
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
@@ -22,7 +22,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
|||||||
export const CustomCameraControls = () => {
|
export const CustomCameraControls = () => {
|
||||||
const controls = useRef<CameraControlsImpl>(null!)
|
const controls = useRef<CameraControlsImpl>(null!)
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
const walkthroughMode = useViewer((s) => s.walkthroughMode)
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
const currentLevelId = selection.levelId
|
const currentLevelId = selection.levelId
|
||||||
@@ -365,8 +365,8 @@ export const CustomCameraControls = () => {
|
|||||||
useViewer.getState().setCameraDragging(false)
|
useViewer.getState().setCameraDragging(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (walkthroughMode) {
|
if (isFirstPersonMode) {
|
||||||
return <WalkthroughControls />
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,102 +1,153 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import '../../three-types'
|
||||||
|
import { KeyboardControls } from '@react-three/drei'
|
||||||
|
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame, useThree } from '@react-three/fiber'
|
import { useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Euler, Vector3 } from 'three'
|
import { Euler, Vector3 } from 'three'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
import BVHEcctrl from './first-person/bvh-ecctrl'
|
||||||
|
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
|
||||||
|
import {
|
||||||
|
buildFirstPersonColliderWorldFromRegistry,
|
||||||
|
deriveFirstPersonSpawn,
|
||||||
|
FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
||||||
|
type FirstPersonColliderWorld,
|
||||||
|
type FirstPersonSpawn,
|
||||||
|
} from './first-person/build-collider-world'
|
||||||
|
|
||||||
// Average human eye height in meters
|
const CAMERA_EYE_OFFSET = 0.45
|
||||||
const EYE_HEIGHT = 1.65
|
const LOOK_SENSITIVITY = 0.002
|
||||||
// Movement speed in meters per second
|
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
||||||
const MOVE_SPEED = 5
|
const keyboardMap = [
|
||||||
// Sprint multiplier when holding Shift
|
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||||
const SPRINT_MULTIPLIER = 2
|
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||||
// Vertical float speed in meters per second
|
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
||||||
const VERTICAL_SPEED = 3
|
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
|
||||||
// Mouse look sensitivity
|
{ name: 'jump', keys: ['Space'] },
|
||||||
const MOUSE_SENSITIVITY = 0.002
|
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
||||||
// Min Y position (eye height above ground)
|
]
|
||||||
const MIN_Y = EYE_HEIGHT
|
|
||||||
|
|
||||||
// Reusable vectors to avoid allocations in the render loop
|
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
||||||
const _forward = new Vector3()
|
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
||||||
const _right = new Vector3()
|
const spawnWorldPosition = new Vector3()
|
||||||
const _moveVector = new Vector3()
|
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
||||||
const _euler = new Euler(0, 0, 0, 'YXZ')
|
|
||||||
|
const resolvePlacedSpawnNode = (
|
||||||
|
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||||
|
_levelId: string | null,
|
||||||
|
) => {
|
||||||
|
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
|
||||||
|
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export const FirstPersonControls = () => {
|
export const FirstPersonControls = () => {
|
||||||
const { camera, gl } = useThree()
|
const { camera, gl } = useThree()
|
||||||
const keysRef = useRef<Set<string>>(new Set())
|
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||||
|
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
|
||||||
|
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||||
const yawRef = useRef(0)
|
const yawRef = useRef(0)
|
||||||
const pitchRef = useRef(0)
|
const pitchRef = useRef(0)
|
||||||
const isLockedRef = useRef(false)
|
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
||||||
const initializedRef = useRef(false)
|
|
||||||
|
|
||||||
// Initialize camera for first-person view: start at center of scene, on the ground
|
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
||||||
useEffect(() => {
|
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
||||||
if (initializedRef.current) return
|
|
||||||
initializedRef.current = true
|
|
||||||
|
|
||||||
// Place camera at the origin (center of grid) at eye height, looking along +X
|
const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id)
|
||||||
camera.position.set(0, EYE_HEIGHT, 0)
|
if (spawnObject) {
|
||||||
yawRef.current = 0
|
spawnObject.updateWorldMatrix(true, false)
|
||||||
pitchRef.current = 0
|
spawnObject.getWorldPosition(spawnWorldPosition)
|
||||||
}, [camera])
|
spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ')
|
||||||
|
|
||||||
// Pointer lock and event handlers
|
return {
|
||||||
useEffect(() => {
|
position: [
|
||||||
const canvas = gl.domElement
|
spawnWorldPosition.x,
|
||||||
|
spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
||||||
const requestLock = () => {
|
spawnWorldPosition.z,
|
||||||
if (!isLockedRef.current) {
|
],
|
||||||
canvas.requestPointerLock()
|
yaw: spawnWorldEuler.y,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerLockChange = () => {
|
return {
|
||||||
isLockedRef.current = document.pointerLockElement === canvas
|
position: [
|
||||||
|
placedSpawnNode.position[0],
|
||||||
|
placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
||||||
|
placedSpawnNode.position[2],
|
||||||
|
],
|
||||||
|
yaw: placedSpawnNode.rotation,
|
||||||
|
}
|
||||||
|
}, [placedSpawnNode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextWorld = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
if (!nextWorld) {
|
||||||
|
setWorld(null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
setWorld(nextWorld)
|
||||||
if (!isLockedRef.current) return
|
|
||||||
|
|
||||||
yawRef.current -= e.movementX * MOUSE_SENSITIVITY
|
return () => {
|
||||||
pitchRef.current -= e.movementY * MOUSE_SENSITIVITY
|
nextWorld.dispose()
|
||||||
// Clamp pitch to prevent flipping (almost straight up/down)
|
setWorld(null)
|
||||||
|
}
|
||||||
|
}, [camera])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!world) return
|
||||||
|
yawRef.current = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
|
||||||
|
pitchRef.current = 0
|
||||||
|
}, [camera, placedSpawn, world])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = gl.domElement
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (document.pointerLockElement !== canvas) return
|
||||||
|
|
||||||
|
yawRef.current -= e.movementX * LOOK_SENSITIVITY
|
||||||
pitchRef.current = Math.max(
|
pitchRef.current = Math.max(
|
||||||
-Math.PI / 2 + 0.05,
|
-(Math.PI / 2 - 0.05),
|
||||||
Math.min(Math.PI / 2 - 0.05, pitchRef.current),
|
Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
// Skip if user is typing in an input
|
const target = event.target
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
if (!canvas.contains(target)) return
|
||||||
|
if (document.pointerLockElement !== canvas) {
|
||||||
|
canvas.requestPointerLock?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('click', handleClick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('click', handleClick)
|
||||||
|
if (document.pointerLockElement === canvas) {
|
||||||
|
document.exitPointerLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [gl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = gl.domElement
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = e.code
|
if (event.code === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
// Movement keys
|
event.stopPropagation()
|
||||||
if (
|
|
||||||
code === 'KeyW' ||
|
|
||||||
code === 'KeyA' ||
|
|
||||||
code === 'KeyS' ||
|
|
||||||
code === 'KeyD' ||
|
|
||||||
code === 'KeyQ' ||
|
|
||||||
code === 'KeyE' ||
|
|
||||||
code === 'ShiftLeft' ||
|
|
||||||
code === 'ShiftRight'
|
|
||||||
) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
keysRef.current.add(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC exits first-person mode
|
|
||||||
if (code === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
if (document.pointerLockElement === canvas) {
|
if (document.pointerLockElement === canvas) {
|
||||||
document.exitPointerLock()
|
document.exitPointerLock()
|
||||||
}
|
}
|
||||||
@@ -104,75 +155,73 @@ export const FirstPersonControls = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
|
||||||
keysRef.current.delete(e.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.addEventListener('click', requestLock)
|
|
||||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
|
||||||
// Use capture phase so we intercept movement keys before the global keyboard handler
|
|
||||||
document.addEventListener('keydown', handleKeyDown, true)
|
document.addEventListener('keydown', handleKeyDown, true)
|
||||||
document.addEventListener('keyup', handleKeyUp)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
canvas.removeEventListener('click', requestLock)
|
|
||||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
|
||||||
document.removeEventListener('keydown', handleKeyDown, true)
|
document.removeEventListener('keydown', handleKeyDown, true)
|
||||||
document.removeEventListener('keyup', handleKeyUp)
|
|
||||||
if (document.pointerLockElement === canvas) {
|
|
||||||
document.exitPointerLock()
|
|
||||||
}
|
|
||||||
keysRef.current.clear()
|
|
||||||
}
|
}
|
||||||
}, [gl])
|
}, [gl])
|
||||||
|
|
||||||
// Per-frame movement and camera rotation
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
// Clamp delta to avoid huge jumps (e.g. tab switching)
|
if (!controllerRef.current?.group) return
|
||||||
const dt = Math.min(delta, 0.1)
|
|
||||||
const keys = keysRef.current
|
|
||||||
|
|
||||||
const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight')
|
const group = controllerRef.current.group
|
||||||
const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1)
|
group.rotation.y = 0
|
||||||
|
camera.position.copy(group.position).add(cameraOffset)
|
||||||
// Calculate forward and right vectors on the XZ plane (ignore pitch for movement)
|
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
||||||
_forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
|
camera.quaternion.setFromEuler(cameraEuler)
|
||||||
_right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))
|
camera.updateMatrixWorld(true)
|
||||||
|
|
||||||
_moveVector.set(0, 0, 0)
|
|
||||||
|
|
||||||
if (keys.has('KeyW')) _moveVector.add(_forward)
|
|
||||||
if (keys.has('KeyS')) _moveVector.sub(_forward)
|
|
||||||
if (keys.has('KeyA')) _moveVector.sub(_right)
|
|
||||||
if (keys.has('KeyD')) _moveVector.add(_right)
|
|
||||||
|
|
||||||
// Normalize diagonal movement so it's not faster
|
|
||||||
if (_moveVector.lengthSq() > 0) {
|
|
||||||
_moveVector.normalize().multiplyScalar(speed * dt)
|
|
||||||
camera.position.add(_moveVector)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical movement (Q = up, E = down)
|
|
||||||
if (keys.has('KeyQ')) {
|
|
||||||
camera.position.y += VERTICAL_SPEED * dt
|
|
||||||
}
|
|
||||||
if (keys.has('KeyE')) {
|
|
||||||
camera.position.y -= VERTICAL_SPEED * dt
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp Y so camera never goes below ground level + eye height
|
|
||||||
if (camera.position.y < MIN_Y) {
|
|
||||||
camera.position.y = MIN_Y
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply look rotation
|
|
||||||
_euler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
|
||||||
camera.quaternion.setFromEuler(_euler)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
const controllerPosition = useMemo(() => {
|
||||||
|
if (!world) return null
|
||||||
|
const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position
|
||||||
|
return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const
|
||||||
|
}, [camera, placedSpawn, world])
|
||||||
|
|
||||||
|
const spawnYaw = useMemo(() => {
|
||||||
|
if (!world) return 0
|
||||||
|
return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
|
||||||
|
}, [camera, placedSpawn, world])
|
||||||
|
|
||||||
|
if (!world) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{controllerPosition && (
|
||||||
|
<KeyboardControls map={keyboardMap}>
|
||||||
|
<BVHEcctrl
|
||||||
|
ref={controllerRef}
|
||||||
|
key={`${world.mesh.uuid}:${controllerPosition.join(':')}:${spawnYaw}`}
|
||||||
|
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||||
|
colliderMeshes={[world.mesh]}
|
||||||
|
collisionCheckIteration={3}
|
||||||
|
collisionPushBackDamping={0.1}
|
||||||
|
collisionPushBackThreshold={0.001}
|
||||||
|
debug={false}
|
||||||
|
delay={0}
|
||||||
|
fallGravityFactor={4}
|
||||||
|
floatCheckType="BOTH"
|
||||||
|
floatDampingC={36}
|
||||||
|
floatHeight={0.5}
|
||||||
|
floatPullBackHeight={0.35}
|
||||||
|
floatSensorRadius={0.15}
|
||||||
|
floatSpringK={1200}
|
||||||
|
gravity={9.81}
|
||||||
|
jumpVel={6}
|
||||||
|
maxRunSpeed={5.5}
|
||||||
|
maxSlope={1.2}
|
||||||
|
maxWalkSpeed={4}
|
||||||
|
position={controllerPosition}
|
||||||
|
acceleration={26}
|
||||||
|
airDragFactor={0.3}
|
||||||
|
deceleration={30}
|
||||||
|
/>
|
||||||
|
</KeyboardControls>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,6 +229,23 @@ export const FirstPersonControls = () => {
|
|||||||
* Rendered as a regular DOM overlay (not inside the Canvas).
|
* Rendered as a regular DOM overlay (not inside the Canvas).
|
||||||
*/
|
*/
|
||||||
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
||||||
|
const [isLocked, setIsLocked] = useState(false)
|
||||||
|
const hasPlacedSpawn = useScene((state) =>
|
||||||
|
Object.values(state.nodes).some((node) => node.type === 'spawn'),
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerLockChange = () => {
|
||||||
|
setIsLocked(document.pointerLockElement != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerLockChange()
|
||||||
|
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleExit = useCallback(() => {
|
const handleExit = useCallback(() => {
|
||||||
if (document.pointerLockElement) {
|
if (document.pointerLockElement) {
|
||||||
document.exitPointerLock()
|
document.exitPointerLock()
|
||||||
@@ -189,15 +255,15 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Crosshair */}
|
{isLocked && (
|
||||||
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
|
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
|
||||||
<div className="relative h-6 w-6">
|
<div className="relative h-7 w-7">
|
||||||
<div className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-white/60" />
|
<div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" />
|
||||||
<div className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-white/60" />
|
<div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Exit button — top-right */}
|
|
||||||
<div className="fixed top-4 right-4 z-50">
|
<div className="fixed top-4 right-4 z-50">
|
||||||
<button
|
<button
|
||||||
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"
|
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"
|
||||||
@@ -211,30 +277,37 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls hint — bottom-center */}
|
{!hasPlacedSpawn && (
|
||||||
<div className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2">
|
<div className="fixed top-4 left-1/2 z-50 -translate-x-1/2">
|
||||||
<div className="flex items-center gap-4 rounded-2xl border border-border/35 bg-background/80 px-5 py-3 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">
|
||||||
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
|
Place a Spawn Point from the Build tab to control where walkthrough starts.
|
||||||
<div className="h-5 w-px bg-border/30" />
|
</div>
|
||||||
<ControlHint label="Up" keys={['Q']} />
|
|
||||||
<ControlHint label="Down" keys={['E']} />
|
|
||||||
<div className="h-5 w-px bg-border/30" />
|
|
||||||
<ControlHint label="Sprint" keys={['Shift']} />
|
|
||||||
<div className="h-5 w-px bg-border/30" />
|
|
||||||
<span className="text-muted-foreground/60 text-xs">Click to look around</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{isLocked && (
|
||||||
|
<div className="pointer-events-none fixed 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 label="Move" keys={['W', 'A', 'S', 'D']} />
|
||||||
|
<div className="h-px w-full bg-border/30" />
|
||||||
|
<InlineControlHint label="Jump" keyLabel="Space" />
|
||||||
|
<InlineControlHint label="Sprint" keyLabel="Shift" />
|
||||||
|
<div className="h-px w-full bg-border/30" />
|
||||||
|
<span className="text-center text-muted-foreground/60 text-xs">Click to look around</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||||
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
|
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
<kbd
|
<kbd
|
||||||
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"
|
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"
|
||||||
@@ -247,3 +320,16 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em] uppercase">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<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">
|
||||||
|
{keyLabel}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
acceleratedRaycast,
|
||||||
|
computeBoundsTree,
|
||||||
|
disposeBoundsTree,
|
||||||
|
} from 'three-mesh-bvh'
|
||||||
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
const COLLIDER_NODE_TYPES = [
|
||||||
|
'wall',
|
||||||
|
'fence',
|
||||||
|
'slab',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
|
'door',
|
||||||
|
'item',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
|
||||||
|
const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
|
||||||
|
const DOWN = new THREE.Vector3(0, -1, 0)
|
||||||
|
const UP = new THREE.Vector3(0, 1, 0)
|
||||||
|
const SPAWN_EYE_HEIGHT = 1.65
|
||||||
|
const RAYCAST_CLEARANCE = 25
|
||||||
|
|
||||||
|
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
|
||||||
|
|
||||||
|
export type FirstPersonColliderWorld = {
|
||||||
|
mesh: THREE.Mesh
|
||||||
|
bounds: THREE.Box3 | null
|
||||||
|
dispose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FirstPersonSpawn = {
|
||||||
|
position: [number, number, number]
|
||||||
|
yaw: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number]
|
||||||
|
|
||||||
|
function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
||||||
|
return 'isMesh' in object && (object as THREE.Mesh).isMesh
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneWorldGeometry(mesh: THREE.Mesh) {
|
||||||
|
const sourceGeometry = mesh.geometry
|
||||||
|
const position = sourceGeometry.getAttribute('position')
|
||||||
|
if (!position || position.count < 3) return null
|
||||||
|
|
||||||
|
const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone()
|
||||||
|
const cleanGeometry = new THREE.BufferGeometry()
|
||||||
|
cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone())
|
||||||
|
|
||||||
|
const normal = workingGeometry.getAttribute('normal')
|
||||||
|
if (normal) {
|
||||||
|
cleanGeometry.setAttribute('normal', normal.clone())
|
||||||
|
} else {
|
||||||
|
cleanGeometry.computeVertexNormals()
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanGeometry.applyMatrix4(mesh.matrixWorld)
|
||||||
|
workingGeometry.dispose()
|
||||||
|
|
||||||
|
const worldPosition = cleanGeometry.getAttribute('position')
|
||||||
|
if (!worldPosition || worldPosition.count < 3) {
|
||||||
|
cleanGeometry.dispose()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanGeometry
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) {
|
||||||
|
if (type !== 'door') return false
|
||||||
|
|
||||||
|
const node = useScene.getState().nodes[nodeId]
|
||||||
|
if (!node || node.type !== 'door') return false
|
||||||
|
|
||||||
|
if (!node.segments.length) return true
|
||||||
|
|
||||||
|
return node.segments.every((segment) => segment.type === 'empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegisteredNodeTypeLookup() {
|
||||||
|
const nodeTypes = new Map<string, ColliderNodeType>()
|
||||||
|
|
||||||
|
for (const type of COLLIDER_NODE_TYPES) {
|
||||||
|
for (const nodeId of sceneRegistry.byType[type]) {
|
||||||
|
nodeTypes.set(nodeId, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectColliderGeometriesFromNode(
|
||||||
|
root: THREE.Object3D,
|
||||||
|
rootNodeId: string,
|
||||||
|
visitedMeshes: WeakSet<THREE.Object3D>,
|
||||||
|
registeredObjectIds: Map<THREE.Object3D, string>,
|
||||||
|
registeredNodeTypes: Map<string, ColliderNodeType>,
|
||||||
|
): THREE.BufferGeometry[] {
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
const visit = (object: THREE.Object3D) => {
|
||||||
|
if (visitedMeshes.has(object)) return
|
||||||
|
visitedMeshes.add(object)
|
||||||
|
|
||||||
|
if (isMesh(object) && object.visible && !SKIPPED_MESH_NAMES.has(object.name)) {
|
||||||
|
const geometry = cloneWorldGeometry(object)
|
||||||
|
if (geometry) {
|
||||||
|
geometries.push(geometry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of object.children) {
|
||||||
|
const childNodeId = registeredObjectIds.get(child)
|
||||||
|
if (childNodeId && childNodeId !== rootNodeId) {
|
||||||
|
const childType = registeredNodeTypes.get(childNodeId)
|
||||||
|
if (childType && COLLIDER_NODE_TYPES.includes(childType)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(root)
|
||||||
|
|
||||||
|
return geometries
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
const visitedMeshes = new WeakSet<THREE.Object3D>()
|
||||||
|
const registeredNodeTypes = buildRegisteredNodeTypeLookup()
|
||||||
|
const registeredObjectIds = new Map<THREE.Object3D, string>()
|
||||||
|
|
||||||
|
for (const [nodeId, object] of sceneRegistry.nodes) {
|
||||||
|
registeredObjectIds.set(object, nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of COLLIDER_NODE_TYPES) {
|
||||||
|
for (const nodeId of sceneRegistry.byType[type]) {
|
||||||
|
if (shouldSkipColliderNode(nodeId, type)) continue
|
||||||
|
|
||||||
|
const root = sceneRegistry.nodes.get(nodeId)
|
||||||
|
if (!root) continue
|
||||||
|
|
||||||
|
root.updateMatrixWorld(true)
|
||||||
|
geometries.push(
|
||||||
|
...collectColliderGeometriesFromNode(
|
||||||
|
root,
|
||||||
|
nodeId,
|
||||||
|
visitedMeshes,
|
||||||
|
registeredObjectIds,
|
||||||
|
registeredNodeTypes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometries.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedGeometry = mergeGeometries(geometries, false)
|
||||||
|
geometries.forEach((geometry) => geometry.dispose())
|
||||||
|
|
||||||
|
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
|
||||||
|
mergedGeometry?.dispose()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bvhGeometry = mergedGeometry as THREE.BufferGeometry & {
|
||||||
|
computeBoundsTree?: typeof computeBoundsTree
|
||||||
|
disposeBoundsTree?: typeof disposeBoundsTree
|
||||||
|
}
|
||||||
|
|
||||||
|
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
|
||||||
|
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
|
||||||
|
bvhGeometry.computeBoundsTree?.({
|
||||||
|
maxLeafTris: 12,
|
||||||
|
strategy: 0,
|
||||||
|
} as never)
|
||||||
|
bvhGeometry.computeBoundingBox()
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(bvhGeometry, COLLIDER_MATERIAL)
|
||||||
|
mesh.raycast = acceleratedRaycast
|
||||||
|
mesh.visible = true
|
||||||
|
mesh.userData = {
|
||||||
|
type: 'STATIC',
|
||||||
|
friction: 0.8,
|
||||||
|
restitution: 0.05,
|
||||||
|
excludeFloatHit: false,
|
||||||
|
excludeCollisionCheck: false,
|
||||||
|
}
|
||||||
|
mesh.updateMatrixWorld(true)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mesh,
|
||||||
|
bounds: bvhGeometry.boundingBox?.clone() ?? null,
|
||||||
|
dispose: () => {
|
||||||
|
bvhGeometry.disposeBoundsTree?.()
|
||||||
|
bvhGeometry.dispose()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveFirstPersonSpawn(
|
||||||
|
camera: THREE.Camera,
|
||||||
|
world: FirstPersonColliderWorld,
|
||||||
|
): FirstPersonSpawn {
|
||||||
|
const direction = new THREE.Vector3()
|
||||||
|
camera.getWorldDirection(direction)
|
||||||
|
direction.y = 0
|
||||||
|
if (direction.lengthSq() < 1e-6) {
|
||||||
|
direction.set(0, 0, -1)
|
||||||
|
} else {
|
||||||
|
direction.normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const yaw = Math.atan2(-direction.x, -direction.z)
|
||||||
|
const raycaster = new THREE.Raycaster()
|
||||||
|
const candidates: Array<[number, number]> = [[camera.position.x, camera.position.z]]
|
||||||
|
|
||||||
|
const boundsCenter = world.bounds?.getCenter(new THREE.Vector3())
|
||||||
|
if (boundsCenter) {
|
||||||
|
candidates.push([boundsCenter.x, boundsCenter.z])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [x, z] of candidates) {
|
||||||
|
const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE
|
||||||
|
raycaster.set(new THREE.Vector3(x, topY, z), DOWN)
|
||||||
|
const intersections = raycaster.intersectObject(world.mesh, false)
|
||||||
|
const hit = intersections.find((intersection) => {
|
||||||
|
if (!intersection.face) return true
|
||||||
|
const normal = intersection.face.normal.clone().transformDirection(world.mesh.matrixWorld)
|
||||||
|
return normal.dot(UP) > 0.2
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
return {
|
||||||
|
position: [hit.point.x, hit.point.y + SPAWN_EYE_HEIGHT, hit.point.z],
|
||||||
|
yaw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: [
|
||||||
|
camera.position.x,
|
||||||
|
Math.max(camera.position.y, SPAWN_EYE_HEIGHT),
|
||||||
|
camera.position.z,
|
||||||
|
],
|
||||||
|
yaw,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,795 @@
|
|||||||
|
import '../../../three-types'
|
||||||
|
import { TransformControls, useKeyboardControls } from '@react-three/drei'
|
||||||
|
import { useFrame, useThree, type ThreeElements } from '@react-three/fiber'
|
||||||
|
import {
|
||||||
|
Suspense,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { clamp } from 'three/src/math/MathUtils.js'
|
||||||
|
|
||||||
|
export type MovementInput = {
|
||||||
|
forward?: boolean
|
||||||
|
backward?: boolean
|
||||||
|
leftward?: boolean
|
||||||
|
rightward?: boolean
|
||||||
|
joystick?: { x: number; y: number }
|
||||||
|
run?: boolean
|
||||||
|
jump?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CharacterAnimationStatus =
|
||||||
|
| 'IDLE'
|
||||||
|
| 'WALK'
|
||||||
|
| 'RUN'
|
||||||
|
| 'JUMP_START'
|
||||||
|
| 'JUMP_IDLE'
|
||||||
|
| 'JUMP_FALL'
|
||||||
|
| 'JUMP_LAND'
|
||||||
|
|
||||||
|
export type FloatCheckType = 'RAYCAST' | 'SHAPECAST' | 'BOTH'
|
||||||
|
|
||||||
|
export interface BVHEcctrlApi {
|
||||||
|
group: THREE.Group | null
|
||||||
|
model: THREE.Group | null
|
||||||
|
resetLinVel: () => void
|
||||||
|
addLinVel: (v: THREE.Vector3) => void
|
||||||
|
setLinVel: (v: THREE.Vector3) => void
|
||||||
|
setMovement: (input: MovementInput) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EcctrlProps extends Omit<ThreeElements['group'], 'ref'> {
|
||||||
|
children?: ReactNode
|
||||||
|
debug?: boolean
|
||||||
|
colliderMeshes?: THREE.Mesh[]
|
||||||
|
colliderCapsuleArgs?: [
|
||||||
|
radius: number,
|
||||||
|
length: number,
|
||||||
|
capSegments: number,
|
||||||
|
radialSegments: number,
|
||||||
|
]
|
||||||
|
paused?: boolean
|
||||||
|
delay?: number
|
||||||
|
gravity?: number
|
||||||
|
fallGravityFactor?: number
|
||||||
|
maxFallSpeed?: number
|
||||||
|
mass?: number
|
||||||
|
sleepTimeout?: number
|
||||||
|
slowMotionFactor?: number
|
||||||
|
turnSpeed?: number
|
||||||
|
maxWalkSpeed?: number
|
||||||
|
maxRunSpeed?: number
|
||||||
|
acceleration?: number
|
||||||
|
deceleration?: number
|
||||||
|
counterAccFactor?: number
|
||||||
|
airDragFactor?: number
|
||||||
|
jumpVel?: number
|
||||||
|
floatCheckType?: FloatCheckType
|
||||||
|
maxSlope?: number
|
||||||
|
floatHeight?: number
|
||||||
|
floatPullBackHeight?: number
|
||||||
|
floatSensorRadius?: number
|
||||||
|
floatSpringK?: number
|
||||||
|
floatDampingC?: number
|
||||||
|
collisionCheckIteration?: number
|
||||||
|
collisionPushBackDamping?: number
|
||||||
|
collisionPushBackThreshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterStatus = {
|
||||||
|
position: THREE.Vector3
|
||||||
|
linvel: THREE.Vector3
|
||||||
|
quaternion: THREE.Quaternion
|
||||||
|
inputDir: THREE.Vector3
|
||||||
|
movingDir: THREE.Vector3
|
||||||
|
isOnGround: boolean
|
||||||
|
isOnMovingPlatform: boolean
|
||||||
|
animationStatus: CharacterAnimationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const characterStatus: CharacterStatus = {
|
||||||
|
position: new THREE.Vector3(),
|
||||||
|
linvel: new THREE.Vector3(),
|
||||||
|
quaternion: new THREE.Quaternion(),
|
||||||
|
inputDir: new THREE.Vector3(),
|
||||||
|
movingDir: new THREE.Vector3(),
|
||||||
|
isOnGround: false,
|
||||||
|
isOnMovingPlatform: false,
|
||||||
|
animationStatus: 'IDLE',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
debug = false,
|
||||||
|
colliderMeshes = [],
|
||||||
|
colliderCapsuleArgs = [0.3, 0.6, 4, 8],
|
||||||
|
paused = false,
|
||||||
|
delay = 1.5,
|
||||||
|
gravity = 9.81,
|
||||||
|
fallGravityFactor = 4,
|
||||||
|
maxFallSpeed = 50,
|
||||||
|
mass = 1,
|
||||||
|
sleepTimeout = 10,
|
||||||
|
slowMotionFactor = 1,
|
||||||
|
turnSpeed = 15,
|
||||||
|
maxWalkSpeed = 3,
|
||||||
|
maxRunSpeed = 5,
|
||||||
|
acceleration = 30,
|
||||||
|
deceleration = 20,
|
||||||
|
counterAccFactor = 0.5,
|
||||||
|
airDragFactor = 0.3,
|
||||||
|
jumpVel = 5,
|
||||||
|
floatCheckType = 'BOTH',
|
||||||
|
maxSlope = 1,
|
||||||
|
floatHeight = 0.2,
|
||||||
|
floatPullBackHeight = 0.25,
|
||||||
|
floatSensorRadius = 0.12,
|
||||||
|
floatSpringK = 600,
|
||||||
|
floatDampingC = 28,
|
||||||
|
collisionCheckIteration = 3,
|
||||||
|
collisionPushBackDamping = 0.1,
|
||||||
|
collisionPushBackThreshold = 0.05,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { camera } = useThree()
|
||||||
|
const capsuleRadius = useMemo(() => colliderCapsuleArgs[0], [colliderCapsuleArgs])
|
||||||
|
const capsuleLength = useMemo(() => colliderCapsuleArgs[1], [colliderCapsuleArgs])
|
||||||
|
const characterGroupRef = useRef<THREE.Group | null>(null)
|
||||||
|
const characterColliderRef = useRef<THREE.Mesh | null>(null)
|
||||||
|
const characterModelRef = useRef<THREE.Group | null>(null)
|
||||||
|
const debugLineStart = useRef<THREE.Mesh | null>(null)
|
||||||
|
const debugLineEnd = useRef<THREE.Mesh | null>(null)
|
||||||
|
const debugRaySensorStart = useRef<THREE.Mesh | null>(null)
|
||||||
|
const debugRaySensorEnd = useRef<THREE.Mesh | null>(null)
|
||||||
|
const standPointRef = useRef<THREE.Mesh | null>(null)
|
||||||
|
const lookDirRef = useRef<THREE.Mesh | null>(null)
|
||||||
|
const inputDirRef = useRef<THREE.ArrowHelper | null>(null)
|
||||||
|
const moveDirRef = useRef<THREE.ArrowHelper | null>(null)
|
||||||
|
const elapsedRef = useRef(0)
|
||||||
|
|
||||||
|
function useIsInsideKeyboardControls() {
|
||||||
|
try {
|
||||||
|
return !!useKeyboardControls()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInsideKeyboardControls = useIsInsideKeyboardControls()
|
||||||
|
const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null]
|
||||||
|
const presetKeys = {
|
||||||
|
forward: false,
|
||||||
|
backward: false,
|
||||||
|
leftward: false,
|
||||||
|
rightward: false,
|
||||||
|
jump: false,
|
||||||
|
run: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const upAxis = useRef(new THREE.Vector3(0, 1, 0))
|
||||||
|
const localUpAxis = useRef(new THREE.Vector3())
|
||||||
|
const gravityDir = useRef(new THREE.Vector3(0, -1, 0))
|
||||||
|
const currentLinVel = useRef(new THREE.Vector3())
|
||||||
|
const currentLinVelOnPlane = useRef(new THREE.Vector3())
|
||||||
|
const isFalling = useRef(false)
|
||||||
|
const idleTime = useRef(0)
|
||||||
|
const isSleeping = useRef(false)
|
||||||
|
const camProjDir = useRef(new THREE.Vector3())
|
||||||
|
const camRightDir = useRef(new THREE.Vector3())
|
||||||
|
const inputDir = useRef(new THREE.Vector3())
|
||||||
|
const inputDirOnPlane = useRef(new THREE.Vector3())
|
||||||
|
const movingDir = useRef(new THREE.Vector3())
|
||||||
|
const deltaLinVel = useRef(new THREE.Vector3())
|
||||||
|
const wantToMoveVel = useRef(new THREE.Vector3())
|
||||||
|
const forwardState = useRef(false)
|
||||||
|
const backwardState = useRef(false)
|
||||||
|
const leftwardState = useRef(false)
|
||||||
|
const rightwardState = useRef(false)
|
||||||
|
const joystickState = useRef(new THREE.Vector2())
|
||||||
|
const runState = useRef(false)
|
||||||
|
const jumpState = useRef(false)
|
||||||
|
const isOnGround = useRef(false)
|
||||||
|
const prevIsOnGround = useRef(false)
|
||||||
|
const prevAnimation = useRef<CharacterAnimationStatus>('IDLE')
|
||||||
|
const characterModelTargetQuat = useRef(new THREE.Quaternion())
|
||||||
|
const characterModelLookMatrix = useRef(new THREE.Matrix4())
|
||||||
|
const characterOrigin = useMemo(() => new THREE.Vector3(0, 0, 0), [])
|
||||||
|
const contactDepth = useRef(0)
|
||||||
|
const contactNormal = useRef(new THREE.Vector3())
|
||||||
|
const triContactPoint = useRef(new THREE.Vector3())
|
||||||
|
const capsuleContactPoint = useRef(new THREE.Vector3())
|
||||||
|
const totalDepth = useRef(0)
|
||||||
|
const triangleCount = useRef(0)
|
||||||
|
const accumulatedContactNormal = useRef(new THREE.Vector3())
|
||||||
|
const accumulatedContactPoint = useRef(new THREE.Vector3())
|
||||||
|
const absorbVel = useRef(new THREE.Vector3())
|
||||||
|
const pushBackVel = useRef(new THREE.Vector3())
|
||||||
|
const characterBbox = useRef(new THREE.Box3())
|
||||||
|
const characterSegment = useRef(new THREE.Line3())
|
||||||
|
const localCharacterBbox = useRef(new THREE.Box3())
|
||||||
|
const localCharacterSegment = useRef(new THREE.Line3())
|
||||||
|
const collideInvertMatrix = useRef(new THREE.Matrix4())
|
||||||
|
const relativeCollideVel = useRef(new THREE.Vector3())
|
||||||
|
const scaledContactRadiusVec = useRef(new THREE.Vector3())
|
||||||
|
const deltaDist = useRef(new THREE.Vector3())
|
||||||
|
const currSlopeAngle = useRef(0)
|
||||||
|
const localMinDistance = useRef(Infinity)
|
||||||
|
const localClosestPoint = useRef(new THREE.Vector3())
|
||||||
|
const localHitNormal = useRef(new THREE.Vector3())
|
||||||
|
const triNormal = useRef(new THREE.Vector3())
|
||||||
|
const globalMinDistance = useRef(Infinity)
|
||||||
|
const globalClosestPoint = useRef(new THREE.Vector3())
|
||||||
|
const triHitPoint = useRef(new THREE.Vector3())
|
||||||
|
const segHitPoint = useRef(new THREE.Vector3())
|
||||||
|
const floatHitNormal = useRef(new THREE.Vector3())
|
||||||
|
const groundFriction = useRef(0.8)
|
||||||
|
const floatSensorBbox = useRef(new THREE.Box3())
|
||||||
|
const floatSensorBboxExpendPoint = useRef(new THREE.Vector3())
|
||||||
|
const floatSensorSegment = useRef(new THREE.Line3())
|
||||||
|
const localFloatSensorBbox = useRef(new THREE.Box3())
|
||||||
|
const localFloatSensorBboxExpendPoint = useRef(new THREE.Vector3())
|
||||||
|
const localFloatSensorSegment = useRef(new THREE.Line3())
|
||||||
|
const floatInvertMatrix = useRef(new THREE.Matrix4())
|
||||||
|
const floatNormalInverseMatrix = useRef(new THREE.Matrix3())
|
||||||
|
const floatNormalMatrix = useRef(new THREE.Matrix3())
|
||||||
|
const floatRaycaster = useRef(new THREE.Raycaster())
|
||||||
|
const relativeHitPoint = useRef(new THREE.Vector3())
|
||||||
|
const totalPlatformDeltaPos = useRef(new THREE.Vector3())
|
||||||
|
const isOnMovingPlatform = useRef(false)
|
||||||
|
const floatTempPos = useRef(new THREE.Vector3())
|
||||||
|
const floatTempQuat = useRef(new THREE.Quaternion())
|
||||||
|
const floatTempScale = useRef(new THREE.Vector3())
|
||||||
|
const scaledFloatRadiusVec = useRef(new THREE.Vector3())
|
||||||
|
const deltaHit = useRef(new THREE.Vector3())
|
||||||
|
const rotationDeltaPos = useRef(new THREE.Vector3())
|
||||||
|
const yawQuaternion = useRef(new THREE.Quaternion())
|
||||||
|
const contactTempPos = useRef(new THREE.Vector3())
|
||||||
|
const contactTempQuat = useRef(new THREE.Quaternion())
|
||||||
|
const contactTempScale = useRef(new THREE.Vector3())
|
||||||
|
|
||||||
|
floatRaycaster.current.far = capsuleRadius + floatHeight + floatPullBackHeight
|
||||||
|
|
||||||
|
const floatRaycastCandidates = useMemo(
|
||||||
|
() =>
|
||||||
|
colliderMeshes.filter(
|
||||||
|
(mesh) => mesh.geometry.boundsTree && !(mesh instanceof THREE.InstancedMesh),
|
||||||
|
),
|
||||||
|
[colliderMeshes],
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyGravity = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
gravityDir.current.copy(upAxis.current).negate()
|
||||||
|
const fallingSpeed = currentLinVel.current.dot(gravityDir.current)
|
||||||
|
isFalling.current = fallingSpeed > 0
|
||||||
|
if (fallingSpeed < maxFallSpeed) {
|
||||||
|
currentLinVel.current.addScaledVector(
|
||||||
|
gravityDir.current,
|
||||||
|
gravity * (isFalling.current ? fallGravityFactor : 1) * delta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fallGravityFactor, gravity, maxFallSpeed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const checkCharacterSleep = useCallback(
|
||||||
|
(jump: boolean, delta: number) => {
|
||||||
|
const moving = currentLinVel.current.lengthSq() > 1e-6
|
||||||
|
const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6
|
||||||
|
|
||||||
|
if (!moving && isOnGround.current && !jump && !isOnMovingPlatform.current && !platformIsMoving) {
|
||||||
|
idleTime.current += delta
|
||||||
|
if (idleTime.current > sleepTimeout) isSleeping.current = true
|
||||||
|
} else {
|
||||||
|
idleTime.current = 0
|
||||||
|
isSleeping.current = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sleepTimeout],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setInputDirection = useCallback(
|
||||||
|
(dir: {
|
||||||
|
forward?: boolean
|
||||||
|
backward?: boolean
|
||||||
|
leftward?: boolean
|
||||||
|
rightward?: boolean
|
||||||
|
joystick?: THREE.Vector2
|
||||||
|
}) => {
|
||||||
|
inputDir.current.set(0, 0, 0)
|
||||||
|
|
||||||
|
camera.getWorldDirection(camProjDir.current)
|
||||||
|
camProjDir.current.projectOnPlane(upAxis.current).normalize()
|
||||||
|
camRightDir.current.crossVectors(camProjDir.current, upAxis.current).normalize()
|
||||||
|
|
||||||
|
if (dir.joystick && dir.joystick.lengthSq() > 0) {
|
||||||
|
inputDir.current
|
||||||
|
.addScaledVector(camProjDir.current, dir.joystick.y)
|
||||||
|
.addScaledVector(camRightDir.current, dir.joystick.x)
|
||||||
|
} else {
|
||||||
|
if (dir.forward) inputDir.current.add(camProjDir.current)
|
||||||
|
if (dir.backward) inputDir.current.sub(camProjDir.current)
|
||||||
|
if (dir.leftward) inputDir.current.sub(camRightDir.current)
|
||||||
|
if (dir.rightward) inputDir.current.add(camRightDir.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputDir.current.normalize()
|
||||||
|
},
|
||||||
|
[camera],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCharacterMovement = useCallback(
|
||||||
|
(run: boolean, delta: number) => {
|
||||||
|
const friction = clamp(groundFriction.current, 0, 1)
|
||||||
|
|
||||||
|
if (inputDir.current.lengthSq() > 0) {
|
||||||
|
if (characterModelRef.current) {
|
||||||
|
inputDirOnPlane.current.copy(inputDir.current).projectOnPlane(upAxis.current)
|
||||||
|
characterModelLookMatrix.current.lookAt(
|
||||||
|
inputDirOnPlane.current,
|
||||||
|
characterOrigin,
|
||||||
|
upAxis.current,
|
||||||
|
)
|
||||||
|
characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current)
|
||||||
|
characterModelRef.current.quaternion.slerp(characterModelTargetQuat.current, delta * turnSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSpeed = run ? maxRunSpeed : maxWalkSpeed
|
||||||
|
wantToMoveVel.current.copy(inputDir.current).multiplyScalar(maxSpeed)
|
||||||
|
const dot = movingDir.current.dot(inputDir.current)
|
||||||
|
|
||||||
|
deltaLinVel.current.subVectors(wantToMoveVel.current, currentLinVelOnPlane.current)
|
||||||
|
deltaLinVel.current.clampLength(
|
||||||
|
0,
|
||||||
|
(dot <= 0 ? 1 + counterAccFactor : 1) *
|
||||||
|
acceleration *
|
||||||
|
friction *
|
||||||
|
delta *
|
||||||
|
(isOnGround.current ? 1 : airDragFactor),
|
||||||
|
)
|
||||||
|
currentLinVel.current.add(deltaLinVel.current)
|
||||||
|
} else if (isOnGround.current) {
|
||||||
|
deltaLinVel.current.copy(currentLinVelOnPlane.current).clampLength(0, deceleration * friction * delta)
|
||||||
|
currentLinVel.current.sub(deltaLinVel.current)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateSegmentBBox = useCallback(() => {
|
||||||
|
if (!characterGroupRef.current) return
|
||||||
|
|
||||||
|
characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position)
|
||||||
|
characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position)
|
||||||
|
|
||||||
|
characterBbox.current
|
||||||
|
.makeEmpty()
|
||||||
|
.expandByPoint(characterSegment.current.start)
|
||||||
|
.expandByPoint(characterSegment.current.end)
|
||||||
|
.expandByScalar(capsuleRadius)
|
||||||
|
|
||||||
|
floatSensorSegment.current.start.copy(characterSegment.current.end)
|
||||||
|
floatSensorSegment.current.end
|
||||||
|
.copy(floatSensorSegment.current.start)
|
||||||
|
.addScaledVector(gravityDir.current, floatHeight + capsuleRadius)
|
||||||
|
floatSensorBboxExpendPoint.current
|
||||||
|
.copy(floatSensorSegment.current.end)
|
||||||
|
.addScaledVector(gravityDir.current, floatPullBackHeight)
|
||||||
|
|
||||||
|
floatSensorBbox.current
|
||||||
|
.makeEmpty()
|
||||||
|
.expandByPoint(floatSensorSegment.current.start)
|
||||||
|
.expandByPoint(floatSensorBboxExpendPoint.current)
|
||||||
|
.expandByScalar(floatSensorRadius)
|
||||||
|
}, [capsuleLength, capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius])
|
||||||
|
|
||||||
|
const collisionCheck = useCallback(
|
||||||
|
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => {
|
||||||
|
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeCollisionCheck) return
|
||||||
|
|
||||||
|
originMatrix.decompose(contactTempPos.current, contactTempQuat.current, contactTempScale.current)
|
||||||
|
collideInvertMatrix.current.copy(originMatrix).invert()
|
||||||
|
localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current)
|
||||||
|
|
||||||
|
scaledContactRadiusVec.current.set(
|
||||||
|
capsuleRadius / contactTempScale.current.x,
|
||||||
|
capsuleRadius / contactTempScale.current.y,
|
||||||
|
capsuleRadius / contactTempScale.current.z,
|
||||||
|
)
|
||||||
|
|
||||||
|
localCharacterBbox.current
|
||||||
|
.makeEmpty()
|
||||||
|
.expandByPoint(localCharacterSegment.current.start)
|
||||||
|
.expandByPoint(localCharacterSegment.current.end)
|
||||||
|
localCharacterBbox.current.min.addScaledVector(scaledContactRadiusVec.current, -1)
|
||||||
|
localCharacterBbox.current.max.add(scaledContactRadiusVec.current)
|
||||||
|
|
||||||
|
contactDepth.current = 0
|
||||||
|
contactNormal.current.set(0, 0, 0)
|
||||||
|
absorbVel.current.set(0, 0, 0)
|
||||||
|
pushBackVel.current.set(0, 0, 0)
|
||||||
|
totalDepth.current = 0
|
||||||
|
triangleCount.current = 0
|
||||||
|
accumulatedContactNormal.current.set(0, 0, 0)
|
||||||
|
accumulatedContactPoint.current.set(0, 0, 0)
|
||||||
|
|
||||||
|
mesh.geometry.boundsTree.shapecast({
|
||||||
|
intersectsBounds: (box) => box.intersectsBox(localCharacterBbox.current),
|
||||||
|
intersectsTriangle: (tri) => {
|
||||||
|
tri.closestPointToSegment(
|
||||||
|
localCharacterSegment.current,
|
||||||
|
triContactPoint.current,
|
||||||
|
capsuleContactPoint.current,
|
||||||
|
)
|
||||||
|
|
||||||
|
deltaDist.current.copy(triContactPoint.current).sub(capsuleContactPoint.current)
|
||||||
|
deltaDist.current.divide(scaledContactRadiusVec.current)
|
||||||
|
|
||||||
|
if (deltaDist.current.lengthSq() < 1) {
|
||||||
|
triContactPoint.current.applyMatrix4(originMatrix)
|
||||||
|
capsuleContactPoint.current.applyMatrix4(originMatrix)
|
||||||
|
|
||||||
|
contactNormal.current
|
||||||
|
.copy(capsuleContactPoint.current)
|
||||||
|
.sub(triContactPoint.current)
|
||||||
|
.normalize()
|
||||||
|
contactDepth.current =
|
||||||
|
capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current)
|
||||||
|
|
||||||
|
accumulatedContactNormal.current.addScaledVector(contactNormal.current, contactDepth.current)
|
||||||
|
accumulatedContactPoint.current.add(triContactPoint.current)
|
||||||
|
totalDepth.current += contactDepth.current
|
||||||
|
triangleCount.current += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (triangleCount.current > 0) {
|
||||||
|
accumulatedContactNormal.current.normalize()
|
||||||
|
accumulatedContactPoint.current.divideScalar(triangleCount.current)
|
||||||
|
const avgDepth = totalDepth.current / triangleCount.current
|
||||||
|
relativeCollideVel.current.copy(currentLinVel.current)
|
||||||
|
const intoSurfaceVel = relativeCollideVel.current.dot(accumulatedContactNormal.current)
|
||||||
|
|
||||||
|
if (intoSurfaceVel < 0) {
|
||||||
|
absorbVel.current
|
||||||
|
.copy(accumulatedContactNormal.current)
|
||||||
|
.multiplyScalar(-intoSurfaceVel * (1 + (mesh.userData.restitution ?? 0.05)))
|
||||||
|
currentLinVel.current.add(absorbVel.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avgDepth > collisionPushBackThreshold) {
|
||||||
|
const correction = (collisionPushBackDamping / delta) * avgDepth
|
||||||
|
pushBackVel.current.copy(accumulatedContactNormal.current).multiplyScalar(correction)
|
||||||
|
currentLinVel.current.add(pushBackVel.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[capsuleRadius, collisionPushBackDamping, collisionPushBackThreshold],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCollisionResponse = useCallback(
|
||||||
|
(meshes: THREE.Mesh[], delta: number) => {
|
||||||
|
if (meshes.length === 0) return
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < collisionCheckIteration; iteration += 1) {
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
collisionCheck(mesh, mesh.matrixWorld, delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[collisionCheck, collisionCheckIteration],
|
||||||
|
)
|
||||||
|
|
||||||
|
const floatingCheck = useCallback(
|
||||||
|
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => {
|
||||||
|
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeFloatHit) return
|
||||||
|
|
||||||
|
originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current)
|
||||||
|
floatInvertMatrix.current.copy(originMatrix).invert()
|
||||||
|
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
|
||||||
|
floatNormalMatrix.current.getNormalMatrix(originMatrix)
|
||||||
|
|
||||||
|
localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current)
|
||||||
|
localFloatSensorBboxExpendPoint.current
|
||||||
|
.copy(floatSensorBboxExpendPoint.current)
|
||||||
|
.applyMatrix4(floatInvertMatrix.current)
|
||||||
|
|
||||||
|
scaledFloatRadiusVec.current.set(
|
||||||
|
floatSensorRadius / floatTempScale.current.x,
|
||||||
|
floatSensorRadius / floatTempScale.current.y,
|
||||||
|
floatSensorRadius / floatTempScale.current.z,
|
||||||
|
)
|
||||||
|
|
||||||
|
localFloatSensorBbox.current
|
||||||
|
.makeEmpty()
|
||||||
|
.expandByPoint(localFloatSensorSegment.current.start)
|
||||||
|
.expandByPoint(localFloatSensorBboxExpendPoint.current)
|
||||||
|
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
|
||||||
|
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
|
||||||
|
|
||||||
|
localMinDistance.current = Infinity
|
||||||
|
localClosestPoint.current.set(Infinity, Infinity, Infinity)
|
||||||
|
|
||||||
|
mesh.geometry.boundsTree.shapecast({
|
||||||
|
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
|
||||||
|
intersectsTriangle: (tri) => {
|
||||||
|
tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current)
|
||||||
|
localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize()
|
||||||
|
deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start)
|
||||||
|
deltaHit.current.divide(scaledFloatRadiusVec.current)
|
||||||
|
|
||||||
|
const totalLengthSq = deltaHit.current.lengthSq()
|
||||||
|
const dot = deltaHit.current.dot(localUpAxis.current)
|
||||||
|
const verticalLength = Math.abs(dot) / ((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
|
||||||
|
const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot))
|
||||||
|
|
||||||
|
if (horizontalLength < 1 && verticalLength < 1) {
|
||||||
|
tri.getNormal(triNormal.current)
|
||||||
|
triNormal.current.applyMatrix3(floatNormalMatrix.current).normalize()
|
||||||
|
triHitPoint.current.applyMatrix4(originMatrix)
|
||||||
|
|
||||||
|
const slopeAngle = triNormal.current.angleTo(upAxis.current)
|
||||||
|
if (verticalLength < localMinDistance.current && slopeAngle < maxSlope) {
|
||||||
|
localMinDistance.current = verticalLength
|
||||||
|
localClosestPoint.current.copy(triHitPoint.current)
|
||||||
|
localHitNormal.current.copy(triNormal.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (localMinDistance.current < globalMinDistance.current) {
|
||||||
|
globalMinDistance.current = localMinDistance.current
|
||||||
|
globalClosestPoint.current.copy(localClosestPoint.current)
|
||||||
|
floatHitNormal.current.copy(localHitNormal.current)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius, maxSlope],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFloatingResponse = useCallback(
|
||||||
|
(meshes: THREE.Mesh[], jump: boolean, delta: number) => {
|
||||||
|
if (meshes.length === 0) return
|
||||||
|
|
||||||
|
globalMinDistance.current = Infinity
|
||||||
|
globalClosestPoint.current.set(Infinity, Infinity, Infinity)
|
||||||
|
floatHitNormal.current.set(0, 1, 0)
|
||||||
|
isOnGround.current = false
|
||||||
|
totalPlatformDeltaPos.current.set(0, 0, 0)
|
||||||
|
isOnMovingPlatform.current = false
|
||||||
|
|
||||||
|
if (floatCheckType !== 'RAYCAST') {
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
floatingCheck(mesh, mesh.matrixWorld)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (floatCheckType !== 'SHAPECAST' && floatRaycastCandidates.length > 0 && globalMinDistance.current === Infinity) {
|
||||||
|
floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start)
|
||||||
|
floatRaycaster.current.ray.direction.copy(gravityDir.current)
|
||||||
|
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
|
||||||
|
const hit = hits[0]
|
||||||
|
if (hit?.point) {
|
||||||
|
globalClosestPoint.current.copy(hit.point)
|
||||||
|
if (hit.face) {
|
||||||
|
floatHitNormal.current.copy(hit.face.normal).transformDirection(hit.object.matrixWorld).normalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalClosestPoint.current.x === Infinity) return
|
||||||
|
|
||||||
|
relativeHitPoint.current.copy(globalClosestPoint.current).sub(floatSensorSegment.current.start)
|
||||||
|
const currentDistance = relativeHitPoint.current.length()
|
||||||
|
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
|
||||||
|
|
||||||
|
if (currentDistance < floatHeight + capsuleRadius) {
|
||||||
|
isOnGround.current = true
|
||||||
|
jump = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jump) {
|
||||||
|
const displacement = floatHeight + capsuleRadius - currentDistance
|
||||||
|
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
|
||||||
|
const springForce = displacement * floatSpringK
|
||||||
|
const dampingForce = -velocityOnHitNormal * floatDampingC
|
||||||
|
const totalForce = springForce + dampingForce - mass * gravity
|
||||||
|
|
||||||
|
currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[capsuleRadius, floatCheckType, floatDampingC, floatHeight, floatRaycastCandidates, floatSpringK, floatingCheck, gravity, mass],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateCharacterWithPlatform = useCallback(() => {
|
||||||
|
if (!characterGroupRef.current) return
|
||||||
|
rotationDeltaPos.current.copy(totalPlatformDeltaPos.current)
|
||||||
|
characterGroupRef.current.position.add(rotationDeltaPos.current)
|
||||||
|
yawQuaternion.current.setFromUnitVectors(upAxis.current, floatHitNormal.current)
|
||||||
|
}, [upAxis])
|
||||||
|
|
||||||
|
const updateCharacterAnimation = useCallback(
|
||||||
|
(run: boolean, jump: boolean): CharacterAnimationStatus => {
|
||||||
|
if (prevIsOnGround.current && jump) return 'JUMP_START'
|
||||||
|
if (!isOnGround.current && currentLinVel.current.y > 0) return 'JUMP_IDLE'
|
||||||
|
if (!isOnGround.current && currentLinVel.current.y <= 0) return 'JUMP_FALL'
|
||||||
|
if (!prevIsOnGround.current && isOnGround.current) return 'JUMP_LAND'
|
||||||
|
if (inputDir.current.lengthSq() > 0) return run ? 'RUN' : 'WALK'
|
||||||
|
return 'IDLE'
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateCharacterStatus = useCallback(
|
||||||
|
(run: boolean, jump: boolean) => {
|
||||||
|
characterModelRef.current?.getWorldPosition(characterStatus.position)
|
||||||
|
characterModelRef.current?.getWorldQuaternion(characterStatus.quaternion)
|
||||||
|
characterStatus.linvel.copy(currentLinVel.current)
|
||||||
|
characterStatus.inputDir.copy(inputDir.current)
|
||||||
|
characterStatus.movingDir.copy(movingDir.current)
|
||||||
|
characterStatus.isOnGround = isOnGround.current
|
||||||
|
characterStatus.isOnMovingPlatform = isOnMovingPlatform.current
|
||||||
|
characterStatus.animationStatus = updateCharacterAnimation(run, jump)
|
||||||
|
prevAnimation.current = characterStatus.animationStatus
|
||||||
|
},
|
||||||
|
[updateCharacterAnimation],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
|
||||||
|
const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), [])
|
||||||
|
const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), [])
|
||||||
|
const setMovement = useCallback((movement: MovementInput) => {
|
||||||
|
if (movement.forward !== undefined) forwardState.current = movement.forward
|
||||||
|
if (movement.backward !== undefined) backwardState.current = movement.backward
|
||||||
|
if (movement.leftward !== undefined) leftwardState.current = movement.leftward
|
||||||
|
if (movement.rightward !== undefined) rightwardState.current = movement.rightward
|
||||||
|
if (movement.joystick) joystickState.current.set(movement.joystick.x, movement.joystick.y)
|
||||||
|
if (movement.run !== undefined) runState.current = movement.run
|
||||||
|
if (movement.jump !== undefined) jumpState.current = movement.jump
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
get group() {
|
||||||
|
return characterGroupRef.current
|
||||||
|
},
|
||||||
|
get model() {
|
||||||
|
return characterModelRef.current
|
||||||
|
},
|
||||||
|
resetLinVel,
|
||||||
|
addLinVel,
|
||||||
|
setLinVel,
|
||||||
|
setMovement,
|
||||||
|
}),
|
||||||
|
[addLinVel, resetLinVel, setLinVel, setMovement],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateDebugger = useCallback(() => {
|
||||||
|
debugLineStart.current?.position.copy(characterSegment.current.start)
|
||||||
|
debugLineEnd.current?.position.copy(characterSegment.current.end)
|
||||||
|
debugRaySensorStart.current?.position.copy(floatSensorSegment.current.start)
|
||||||
|
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
|
||||||
|
standPointRef.current?.position.copy(globalClosestPoint.current)
|
||||||
|
if (characterGroupRef.current) {
|
||||||
|
lookDirRef.current?.position.copy(characterGroupRef.current.position).addScaledVector(upAxis.current, 0.7)
|
||||||
|
}
|
||||||
|
lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current))
|
||||||
|
inputDirRef.current?.position.copy(characterSegment.current.end)
|
||||||
|
inputDirRef.current?.setDirection(inputDir.current)
|
||||||
|
inputDirRef.current?.setLength(inputDir.current.lengthSq())
|
||||||
|
moveDirRef.current?.position.copy(characterSegment.current.end)
|
||||||
|
moveDirRef.current?.setDirection(currentLinVel.current)
|
||||||
|
moveDirRef.current?.setLength(currentLinVel.current.length() / maxWalkSpeed)
|
||||||
|
}, [characterSegment, maxWalkSpeed])
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
elapsedRef.current += delta
|
||||||
|
if (paused || elapsedRef.current < delay) return
|
||||||
|
|
||||||
|
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
|
||||||
|
const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys
|
||||||
|
const forward = forwardState.current || keys.forward
|
||||||
|
const backward = backwardState.current || keys.backward
|
||||||
|
const leftward = leftwardState.current || keys.leftward
|
||||||
|
const rightward = rightwardState.current || keys.rightward
|
||||||
|
const run = runState.current || keys.run
|
||||||
|
const jump = jumpState.current || keys.jump
|
||||||
|
|
||||||
|
setInputDirection({
|
||||||
|
forward,
|
||||||
|
backward,
|
||||||
|
leftward,
|
||||||
|
rightward,
|
||||||
|
joystick: joystickState.current,
|
||||||
|
})
|
||||||
|
handleCharacterMovement(run, deltaTime)
|
||||||
|
if (jump && isOnGround.current) currentLinVel.current.y = jumpVel
|
||||||
|
movingDir.current.copy(currentLinVel.current).normalize()
|
||||||
|
currentLinVelOnPlane.current.copy(currentLinVel.current).projectOnPlane(upAxis.current)
|
||||||
|
|
||||||
|
checkCharacterSleep(jump, deltaTime)
|
||||||
|
if (!isSleeping.current) {
|
||||||
|
if (!isOnGround.current) applyGravity(deltaTime)
|
||||||
|
|
||||||
|
updateSegmentBBox()
|
||||||
|
handleCollisionResponse(colliderMeshes, deltaTime)
|
||||||
|
handleFloatingResponse(colliderMeshes, jump, deltaTime)
|
||||||
|
updateCharacterWithPlatform()
|
||||||
|
|
||||||
|
if (characterGroupRef.current) {
|
||||||
|
characterGroupRef.current.position.addScaledVector(currentLinVel.current, deltaTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharacterStatus(run, jump)
|
||||||
|
prevIsOnGround.current = isOnGround.current
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debug) updateDebugger()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<group {...props} ref={characterGroupRef} dispose={null}>
|
||||||
|
{debug && (
|
||||||
|
<mesh ref={characterColliderRef}>
|
||||||
|
<capsuleGeometry args={colliderCapsuleArgs} />
|
||||||
|
<meshNormalMaterial wireframe />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
<group name="BVHEcctrl-Model" ref={characterModelRef}>
|
||||||
|
{children}
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{debug && (
|
||||||
|
<group>
|
||||||
|
<TransformControls object={characterGroupRef.current!} />
|
||||||
|
<box3Helper args={[characterBbox.current]} />
|
||||||
|
<mesh ref={debugLineStart}>
|
||||||
|
<octahedronGeometry args={[0.05, 0]} />
|
||||||
|
<meshNormalMaterial />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={debugLineEnd}>
|
||||||
|
<octahedronGeometry args={[0.05, 0]} />
|
||||||
|
<meshNormalMaterial />
|
||||||
|
</mesh>
|
||||||
|
<box3Helper args={[floatSensorBbox.current]} />
|
||||||
|
<mesh ref={debugRaySensorStart}>
|
||||||
|
<octahedronGeometry args={[0.1, 0]} />
|
||||||
|
<meshBasicMaterial color="yellow" wireframe />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={debugRaySensorEnd}>
|
||||||
|
<octahedronGeometry args={[0.1, 0]} />
|
||||||
|
<meshBasicMaterial color="yellow" wireframe />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={lookDirRef} scale={[1, 0.5, 4]}>
|
||||||
|
<octahedronGeometry args={[0.1, 0]} />
|
||||||
|
<meshNormalMaterial />
|
||||||
|
</mesh>
|
||||||
|
<arrowHelper ref={inputDirRef} args={[undefined, undefined, undefined, '#00f']} />
|
||||||
|
<arrowHelper ref={moveDirRef} args={[undefined, undefined, undefined, '#f00']} />
|
||||||
|
<mesh ref={standPointRef}>
|
||||||
|
<octahedronGeometry args={[0.12, 0]} />
|
||||||
|
<meshBasicMaterial color="red" opacity={0.2} transparent />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
BVHEcctrl.displayName = 'BVHEcctrl'
|
||||||
|
|
||||||
|
export default BVHEcctrl
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
|
SpawnNode,
|
||||||
StairNode,
|
StairNode,
|
||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
@@ -39,6 +40,7 @@ const ALLOWED_TYPES = [
|
|||||||
'fence',
|
'fence',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
|
'spawn',
|
||||||
]
|
]
|
||||||
const DELETE_ONLY_TYPES: string[] = []
|
const DELETE_ONLY_TYPES: string[] = []
|
||||||
const HOLE_TYPES = ['slab', 'ceiling']
|
const HOLE_TYPES = ['slab', 'ceiling']
|
||||||
@@ -184,6 +186,7 @@ export function FloatingActionMenu() {
|
|||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
|
node.type === 'spawn' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
node.type === 'roof-segment' ||
|
node.type === 'roof-segment' ||
|
||||||
node.type === 'stair' ||
|
node.type === 'stair' ||
|
||||||
@@ -266,6 +269,8 @@ export function FloatingActionMenu() {
|
|||||||
duplicate = StairNode.parse(duplicateInfo)
|
duplicate = StairNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'stair-segment') {
|
} else if (node.type === 'stair-segment') {
|
||||||
duplicate = StairSegmentNode.parse(duplicateInfo)
|
duplicate = StairSegmentNode.parse(duplicateInfo)
|
||||||
|
} else if (node.type === 'spawn') {
|
||||||
|
duplicate = SpawnNode.parse(duplicateInfo)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse duplicate', error)
|
console.error('Failed to parse duplicate', error)
|
||||||
@@ -358,6 +363,7 @@ export function FloatingActionMenu() {
|
|||||||
duplicate.type === 'door' ||
|
duplicate.type === 'door' ||
|
||||||
duplicate.type === 'roof' ||
|
duplicate.type === 'roof' ||
|
||||||
duplicate.type === 'roof-segment' ||
|
duplicate.type === 'roof-segment' ||
|
||||||
|
duplicate.type === 'spawn' ||
|
||||||
duplicate.type === 'stair-segment'
|
duplicate.type === 'stair-segment'
|
||||||
) {
|
) {
|
||||||
setMovingNode(duplicate as any)
|
setMovingNode(duplicate as any)
|
||||||
@@ -453,7 +459,10 @@ export function FloatingActionMenu() {
|
|||||||
}
|
}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onDuplicate={
|
onDuplicate={
|
||||||
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
|
node &&
|
||||||
|
node.type !== 'spawn' &&
|
||||||
|
!DELETE_ONLY_TYPES.includes(node.type) &&
|
||||||
|
!HOLE_TYPES.includes(node.type)
|
||||||
? handleDuplicate
|
? handleDuplicate
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -939,7 +939,9 @@ export default function Editor({
|
|||||||
presetsAdapter,
|
presetsAdapter,
|
||||||
commandPaletteEmptyAction,
|
commandPaletteEmptyAction,
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
useKeyboard({ isVersionPreviewMode })
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
|
|
||||||
|
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
|
||||||
|
|
||||||
const { isLoadingSceneRef } = useAutoSave({
|
const { isLoadingSceneRef } = useAutoSave({
|
||||||
onSave,
|
onSave,
|
||||||
@@ -951,7 +953,6 @@ export default function Editor({
|
|||||||
const [isSceneLoading, setIsSceneLoading] = useState(false)
|
const [isSceneLoading, setIsSceneLoading] = useState(false)
|
||||||
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
|
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
|
||||||
|
|
||||||
const sidebarWidth = useSidebarStore((s) => s.width)
|
const sidebarWidth = useSidebarStore((s) => s.width)
|
||||||
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type SelectableNodeType =
|
|||||||
| 'roof-segment'
|
| 'roof-segment'
|
||||||
| 'stair'
|
| 'stair'
|
||||||
| 'stair-segment'
|
| 'stair-segment'
|
||||||
|
| 'spawn'
|
||||||
| 'window'
|
| 'window'
|
||||||
| 'door'
|
| 'door'
|
||||||
|
|
||||||
@@ -548,6 +549,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
'roof-segment',
|
'roof-segment',
|
||||||
'stair',
|
'stair',
|
||||||
'stair-segment',
|
'stair-segment',
|
||||||
|
'spawn',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
],
|
],
|
||||||
@@ -598,7 +600,8 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
node.type === 'roof-segment' ||
|
node.type === 'roof-segment' ||
|
||||||
node.type === 'stair' ||
|
node.type === 'stair' ||
|
||||||
node.type === 'stair-segment'
|
node.type === 'stair-segment' ||
|
||||||
|
node.type === 'spawn'
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
if (node.type === 'item') {
|
if (node.type === 'item') {
|
||||||
@@ -661,6 +664,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|||||||
node.type === 'roof-segment' ||
|
node.type === 'roof-segment' ||
|
||||||
node.type === 'stair' ||
|
node.type === 'stair' ||
|
||||||
node.type === 'stair-segment' ||
|
node.type === 'stair-segment' ||
|
||||||
|
node.type === 'spawn' ||
|
||||||
node.type === 'window' ||
|
node.type === 'window' ||
|
||||||
node.type === 'door'
|
node.type === 'door'
|
||||||
) {
|
) {
|
||||||
@@ -965,6 +969,7 @@ export const SelectionManager = () => {
|
|||||||
'roof-segment',
|
'roof-segment',
|
||||||
'stair',
|
'stair',
|
||||||
'stair-segment',
|
'stair-segment',
|
||||||
|
'spawn',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
'zone',
|
'zone',
|
||||||
@@ -1134,6 +1139,7 @@ export const SelectionManager = () => {
|
|||||||
'roof-segment',
|
'roof-segment',
|
||||||
'stair',
|
'stair',
|
||||||
'stair-segment',
|
'stair-segment',
|
||||||
|
'spawn',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
]
|
]
|
||||||
@@ -1227,6 +1233,7 @@ export const SelectionManager = () => {
|
|||||||
node.type === 'roof-segment' ||
|
node.type === 'roof-segment' ||
|
||||||
node.type === 'stair' ||
|
node.type === 'stair' ||
|
||||||
node.type === 'stair-segment' ||
|
node.type === 'stair-segment' ||
|
||||||
|
node.type === 'spawn' ||
|
||||||
node.type === 'window' ||
|
node.type === 'window' ||
|
||||||
node.type === 'door'
|
node.type === 'door'
|
||||||
) {
|
) {
|
||||||
@@ -1279,6 +1286,7 @@ export const SelectionManager = () => {
|
|||||||
'roof-segment',
|
'roof-segment',
|
||||||
'stair',
|
'stair',
|
||||||
'stair-segment',
|
'stair-segment',
|
||||||
|
'spawn',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
'zone',
|
'zone',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
|
SpawnNode,
|
||||||
StairNode,
|
StairNode,
|
||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
@@ -21,6 +22,7 @@ import { MoveDoorTool } from '../door/move-door-tool'
|
|||||||
import { MoveFenceTool } from '../fence/move-fence-tool'
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||||
import { MoveSlabTool } from '../slab/move-slab-tool'
|
import { MoveSlabTool } from '../slab/move-slab-tool'
|
||||||
|
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
|
||||||
import { MoveWallTool } from '../wall/move-wall-tool'
|
import { MoveWallTool } from '../wall/move-wall-tool'
|
||||||
import { MoveWindowTool } from '../window/move-window-tool'
|
import { MoveWindowTool } from '../window/move-window-tool'
|
||||||
import type { PlacementState } from './placement-types'
|
import type { PlacementState } from './placement-types'
|
||||||
@@ -100,6 +102,7 @@ export const MoveTool: React.FC = () => {
|
|||||||
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
||||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||||
|
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
|
||||||
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
||||||
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
||||||
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import '../../../three-types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
sceneRegistry,
|
||||||
|
type SpawnNode,
|
||||||
|
useLiveTransforms,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
|
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||||
|
const worldVector = new Vector3()
|
||||||
|
|
||||||
|
function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
|
||||||
|
const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
|
||||||
|
if (!levelObject) {
|
||||||
|
return [
|
||||||
|
roundToHalf(event.localPosition[0]),
|
||||||
|
event.localPosition[1],
|
||||||
|
roundToHalf(event.localPosition[2]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||||
|
levelObject.updateWorldMatrix(true, false)
|
||||||
|
levelObject.worldToLocal(worldVector)
|
||||||
|
|
||||||
|
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => {
|
||||||
|
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||||
|
|
||||||
|
const exitMoveMode = useCallback(() => {
|
||||||
|
useEditor.getState().setMovingNode(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
let committed = false
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
const nextPosition: [number, number, number] = [
|
||||||
|
roundToHalf(event.localPosition[0]),
|
||||||
|
event.localPosition[1],
|
||||||
|
roundToHalf(event.localPosition[2]),
|
||||||
|
]
|
||||||
|
setPreviewPosition(nextPosition)
|
||||||
|
useLiveTransforms.getState().set(node.id, {
|
||||||
|
position: [...nextPosition],
|
||||||
|
rotation: node.rotation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
const nextPosition = getLevelLocalSpawnPosition(node, event)
|
||||||
|
|
||||||
|
committed = true
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
useScene.getState().updateNode(node.id, { position: nextPosition })
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
|
emitter.on('grid:click', onGridClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
if (!committed) {
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [exitMoveMode, node])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import '../../../three-types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
type LevelNode,
|
||||||
|
sceneRegistry,
|
||||||
|
SpawnNode,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { Group } from 'three'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
|
const SPAWN_ICON = (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
alt="Spawn Point"
|
||||||
|
src="/icons/spawn-point.svg"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||||
|
const worldVector = new Vector3()
|
||||||
|
|
||||||
|
function getExistingSpawnIds() {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
return Object.values(nodes)
|
||||||
|
.filter((node) => node.type === 'spawn')
|
||||||
|
.map((node) => node.id)
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelLocalSpawnPosition(
|
||||||
|
levelId: LevelNode['id'],
|
||||||
|
event: GridEvent,
|
||||||
|
): [number, number, number] {
|
||||||
|
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||||
|
if (!levelObject) {
|
||||||
|
return [
|
||||||
|
roundToHalf(event.localPosition[0]),
|
||||||
|
event.localPosition[1],
|
||||||
|
roundToHalf(event.localPosition[2]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||||
|
levelObject.updateWorldMatrix(true, false)
|
||||||
|
levelObject.worldToLocal(worldVector)
|
||||||
|
|
||||||
|
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpawnTool: React.FC = () => {
|
||||||
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||||
|
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
|
||||||
|
const cursorRef = useRef<Group>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentLevelId) return
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
const nextPosition: [number, number, number] = [
|
||||||
|
roundToHalf(event.localPosition[0]),
|
||||||
|
event.localPosition[1],
|
||||||
|
roundToHalf(event.localPosition[2]),
|
||||||
|
]
|
||||||
|
setCursorPosition(nextPosition)
|
||||||
|
cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
|
||||||
|
|
||||||
|
const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
|
||||||
|
if (existingSpawnId) {
|
||||||
|
useScene.getState().updateNode(existingSpawnId, {
|
||||||
|
parentId: currentLevelId,
|
||||||
|
position: nextPosition,
|
||||||
|
rotation: 0,
|
||||||
|
})
|
||||||
|
if (duplicateSpawnIds.length > 0) {
|
||||||
|
useScene.getState().deleteNodes(duplicateSpawnIds)
|
||||||
|
}
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [existingSpawnId] })
|
||||||
|
} else {
|
||||||
|
const spawn = SpawnNode.parse({
|
||||||
|
name: 'Spawn Point',
|
||||||
|
position: nextPosition,
|
||||||
|
rotation: 0,
|
||||||
|
})
|
||||||
|
useScene.getState().createNode(spawn, currentLevelId)
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [spawn.id] })
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
|
useEditor.getState().setTool(null)
|
||||||
|
useEditor.getState().setMode('select')
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
|
emitter.on('grid:click', onGridClick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
emitter.off('grid:click', onGridClick)
|
||||||
|
}
|
||||||
|
}, [currentLevelId])
|
||||||
|
|
||||||
|
if (!currentLevelId) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CursorSphere
|
||||||
|
color="#60a5fa"
|
||||||
|
height={2.2}
|
||||||
|
ref={cursorRef}
|
||||||
|
showTooltip
|
||||||
|
tooltipContent={SPAWN_ICON}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
|||||||
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
||||||
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
||||||
import { SlabTool } from './slab/slab-tool'
|
import { SlabTool } from './slab/slab-tool'
|
||||||
|
import { SpawnTool } from './spawn/spawn-tool'
|
||||||
import { StairTool } from './stair/stair-tool'
|
import { StairTool } from './stair/stair-tool'
|
||||||
import { CurveWallTool } from './wall/curve-wall-tool'
|
import { CurveWallTool } from './wall/curve-wall-tool'
|
||||||
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
|
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
|
||||||
@@ -43,6 +44,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|||||||
door: DoorTool,
|
door: DoorTool,
|
||||||
item: ItemTool,
|
item: ItemTool,
|
||||||
zone: ZoneTool,
|
zone: ZoneTool,
|
||||||
|
spawn: SpawnTool,
|
||||||
window: WindowTool,
|
window: WindowTool,
|
||||||
},
|
},
|
||||||
furnish: {
|
furnish: {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const tools: ToolConfig[] = [
|
|||||||
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
||||||
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
||||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||||
|
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function StructureTools() {
|
export function StructureTools() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface SliderControlProps {
|
|||||||
label: React.ReactNode
|
label: React.ReactNode
|
||||||
value: number
|
value: number
|
||||||
onChange: (value: number) => void
|
onChange: (value: number) => void
|
||||||
|
onCommit?: (value: number) => void
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
precision?: number
|
precision?: number
|
||||||
@@ -39,6 +40,7 @@ export function SliderControl({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onCommit,
|
||||||
min = Number.NEGATIVE_INFINITY,
|
min = Number.NEGATIVE_INFINITY,
|
||||||
max = Number.POSITIVE_INFINITY,
|
max = Number.POSITIVE_INFINITY,
|
||||||
precision = 0,
|
precision = 0,
|
||||||
@@ -76,10 +78,11 @@ export function SliderControl({
|
|||||||
const newValue = clamp(valueRef.current + direction * s)
|
const newValue = clamp(valueRef.current + direction * s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
|
onCommit?.(final)
|
||||||
}
|
}
|
||||||
el.addEventListener('wheel', handleWheel, { passive: false })
|
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
return () => el.removeEventListener('wheel', handleWheel)
|
return () => el.removeEventListener('wheel', handleWheel)
|
||||||
}, [isEditing, step, clamp, onChange])
|
}, [isEditing, step, clamp, onChange, onCommit])
|
||||||
|
|
||||||
// Arrow key support while hovered
|
// Arrow key support while hovered
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,11 +97,12 @@ export function SliderControl({
|
|||||||
const newValue = clamp(valueRef.current + direction * s)
|
const newValue = clamp(valueRef.current + direction * s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
|
onCommit?.(final)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [isHovered, isEditing, step, clamp, onChange])
|
}, [isHovered, isEditing, step, clamp, onChange, onCommit])
|
||||||
|
|
||||||
const handleLabelPointerDown = useCallback(
|
const handleLabelPointerDown = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -140,11 +144,13 @@ export function SliderControl({
|
|||||||
onChange(startValue)
|
onChange(startValue)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
onChange(finalVal)
|
onChange(finalVal)
|
||||||
|
onCommit?.(finalVal)
|
||||||
} else {
|
} else {
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
onCommit?.(finalVal)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange, onCommit],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleValueClick = useCallback(() => {
|
const handleValueClick = useCallback(() => {
|
||||||
@@ -157,10 +163,12 @@ export function SliderControl({
|
|||||||
if (Number.isNaN(numValue)) {
|
if (Number.isNaN(numValue)) {
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(value.toFixed(precision))
|
||||||
} else {
|
} else {
|
||||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision)))
|
||||||
|
onChange(nextValue)
|
||||||
|
onCommit?.(nextValue)
|
||||||
}
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}, [inputValue, onChange, clamp, precision, value])
|
}, [inputValue, onChange, onCommit, clamp, precision, value])
|
||||||
|
|
||||||
const handleInputKeyDown = useCallback(
|
const handleInputKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -302,7 +302,10 @@ export function FloatingLevelSelector() {
|
|||||||
|
|
||||||
createNodes(createOps)
|
createNodes(createOps)
|
||||||
|
|
||||||
setSelection({ buildingId: resolvedBuildingId ?? undefined, levelId: newLevelId })
|
setSelection({
|
||||||
|
buildingId: resolvedBuildingId ?? undefined,
|
||||||
|
levelId: newLevelId as LevelNode['id'],
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[createNodes, levels, resolvedBuildingId, setSelection],
|
[createNodes, levels, resolvedBuildingId, setSelection],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ReferencePanel } from './reference-panel'
|
|||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
import { RoofSegmentPanel } from './roof-segment-panel'
|
import { RoofSegmentPanel } from './roof-segment-panel'
|
||||||
import { SlabPanel } from './slab-panel'
|
import { SlabPanel } from './slab-panel'
|
||||||
|
import { SpawnPanel } from './spawn-panel'
|
||||||
import { StairPanel } from './stair-panel'
|
import { StairPanel } from './stair-panel'
|
||||||
import { StairSegmentPanel } from './stair-segment-panel'
|
import { StairSegmentPanel } from './stair-segment-panel'
|
||||||
import { WallPanel } from './wall-panel'
|
import { WallPanel } from './wall-panel'
|
||||||
@@ -60,6 +61,8 @@ export function PanelManager() {
|
|||||||
return <StairSegmentPanel />
|
return <StairSegmentPanel />
|
||||||
case 'slab':
|
case 'slab':
|
||||||
return <SlabPanel />
|
return <SlabPanel />
|
||||||
|
case 'spawn':
|
||||||
|
return <SpawnPanel />
|
||||||
case 'ceiling':
|
case 'ceiling':
|
||||||
return <CeilingPanel />
|
return <CeilingPanel />
|
||||||
case 'wall':
|
case 'wall':
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Move, Trash2 } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
|
import { PanelSection } from '../controls/panel-section'
|
||||||
|
import { SliderControl } from '../controls/slider-control'
|
||||||
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
|
export function SpawnPanel() {
|
||||||
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
|
const node = useScene((s) =>
|
||||||
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined,
|
||||||
|
)
|
||||||
|
const [draftRotation, setDraftRotation] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(node && node.type === 'spawn')) {
|
||||||
|
setDraftRotation(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraftRotation(node.rotation)
|
||||||
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
}, [node?.id, node?.rotation, node?.type])
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<SpawnNode>) => {
|
||||||
|
if (!(selectedId && node)) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
},
|
||||||
|
[node, selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRotationChange = useCallback(
|
||||||
|
(degrees: number) => {
|
||||||
|
if (!(node && selectedId)) return
|
||||||
|
const nextRotation = (degrees * Math.PI) / 180
|
||||||
|
setDraftRotation(nextRotation)
|
||||||
|
useLiveTransforms.getState().set(selectedId as AnyNode['id'], {
|
||||||
|
position: [...node.position],
|
||||||
|
rotation: nextRotation,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[node, selectedId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const commitRotation = useCallback(
|
||||||
|
(degrees: number) => {
|
||||||
|
if (!(node && selectedId)) return
|
||||||
|
const nextRotation = (degrees * Math.PI) / 180
|
||||||
|
useLiveTransforms.getState().clear(selectedId as AnyNode['id'])
|
||||||
|
setDraftRotation(nextRotation)
|
||||||
|
if (Math.abs(nextRotation - node.rotation) > 1e-6) {
|
||||||
|
updateNode(selectedId as AnyNode['id'], { rotation: nextRotation })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[node, selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(node)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!selectedId) return
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
deleteNode(selectedId as AnyNode['id'])
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [deleteNode, selectedId, setSelection])
|
||||||
|
|
||||||
|
if (!(node && node.type === 'spawn' && selectedId)) return null
|
||||||
|
|
||||||
|
const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI))
|
||||||
|
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelWrapper icon="/icons/spawn-point.svg" onClose={handleClose} title="Spawn Point" width={300}>
|
||||||
|
<PanelSection title="Position">
|
||||||
|
<SliderControl
|
||||||
|
label="X"
|
||||||
|
max={node.position[0] + 2}
|
||||||
|
min={node.position[0] - 2}
|
||||||
|
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Y"
|
||||||
|
max={node.position[1] + 2}
|
||||||
|
min={node.position[1] - 2}
|
||||||
|
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.position[1] * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Z"
|
||||||
|
max={node.position[2] + 2}
|
||||||
|
min={node.position[2] - 2}
|
||||||
|
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.position[2] * 100) / 100}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Facing">
|
||||||
|
<SliderControl
|
||||||
|
label="Yaw"
|
||||||
|
max={storedRotationDegrees + 90}
|
||||||
|
min={storedRotationDegrees - 90}
|
||||||
|
onChange={handleRotationChange}
|
||||||
|
onCommit={commitRotation}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
unit="°"
|
||||||
|
value={rotationDegrees}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Actions">
|
||||||
|
<ActionGroup>
|
||||||
|
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||||
|
<ActionButton
|
||||||
|
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||||
|
icon={<Trash2 className="h-4 w-4" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
|
</PanelWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Building2, Plus } from 'lucide-react'
|
import { Building2, Plus } from 'lucide-react'
|
||||||
import { memo, useState } from 'react'
|
import { memo, useState } from 'react'
|
||||||
@@ -12,7 +12,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
|||||||
import { TreeNodeActions } from './tree-node-actions'
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
interface BuildingTreeNodeProps {
|
interface BuildingTreeNodeProps {
|
||||||
nodeId: AnyNodeId
|
nodeId: BuildingNode['id']
|
||||||
depth: number
|
depth: number
|
||||||
isLast?: boolean
|
isLast?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ const ReferenceItem = memo(function ReferenceItem({
|
|||||||
<InlineRenameInput
|
<InlineRenameInput
|
||||||
defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
|
defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
node={refNode}
|
nodeId={refNode.id}
|
||||||
onStartEditing={() => setIsEditing(true)}
|
onStartEditing={() => setIsEditing(true)}
|
||||||
onStopEditing={() => setIsEditing(false)}
|
onStopEditing={() => setIsEditing(false)}
|
||||||
/>
|
/>
|
||||||
@@ -688,7 +688,7 @@ const LevelItem = memo(function LevelItem({
|
|||||||
<InlineRenameInput
|
<InlineRenameInput
|
||||||
defaultName={`Level ${level.level}`}
|
defaultName={`Level ${level.level}`}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
node={level}
|
nodeId={level.id}
|
||||||
onStartEditing={() => setIsEditing(true)}
|
onStartEditing={() => setIsEditing(true)}
|
||||||
onStopEditing={() => setIsEditing(false)}
|
onStopEditing={() => setIsEditing(false)}
|
||||||
/>
|
/>
|
||||||
@@ -1133,7 +1133,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
|
|||||||
<InlineRenameInput
|
<InlineRenameInput
|
||||||
defaultName={defaultName}
|
defaultName={defaultName}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
node={zone}
|
nodeId={zone.id}
|
||||||
onStartEditing={() => setIsEditing(true)}
|
onStartEditing={() => setIsEditing(true)}
|
||||||
onStopEditing={() => setIsEditing(false)}
|
onStopEditing={() => setIsEditing(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core'
|
import { type LevelNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
@@ -8,7 +8,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
|||||||
import { TreeNodeActions } from './tree-node-actions'
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
interface LevelTreeNodeProps {
|
interface LevelTreeNodeProps {
|
||||||
nodeId: AnyNodeId
|
nodeId: LevelNode['id']
|
||||||
depth: number
|
depth: number
|
||||||
isLast?: boolean
|
isLast?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type SpawnNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
|
interface SpawnTreeNodeProps {
|
||||||
|
nodeId: SpawnNode['id']
|
||||||
|
depth: number
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpawnTreeNode = memo(function SpawnTreeNode({
|
||||||
|
nodeId,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
}: SpawnTreeNodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const handled = handleTreeSelection(
|
||||||
|
e,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.selectedIds,
|
||||||
|
setSelection,
|
||||||
|
)
|
||||||
|
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="object-contain"
|
||||||
|
height={14}
|
||||||
|
src="/icons/spawn-point.svg"
|
||||||
|
width={14}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName="Spawn Point"
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -62,6 +62,7 @@ import { ItemTreeNode } from './item-tree-node'
|
|||||||
import { LevelTreeNode } from './level-tree-node'
|
import { LevelTreeNode } from './level-tree-node'
|
||||||
import { RoofTreeNode } from './roof-tree-node'
|
import { RoofTreeNode } from './roof-tree-node'
|
||||||
import { SlabTreeNode } from './slab-tree-node'
|
import { SlabTreeNode } from './slab-tree-node'
|
||||||
|
import { SpawnTreeNode } from './spawn-tree-node'
|
||||||
import { StairTreeNode } from './stair-tree-node'
|
import { StairTreeNode } from './stair-tree-node'
|
||||||
import { WallTreeNode } from './wall-tree-node'
|
import { WallTreeNode } from './wall-tree-node'
|
||||||
import { WindowTreeNode } from './window-tree-node'
|
import { WindowTreeNode } from './window-tree-node'
|
||||||
@@ -80,13 +81,15 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|||||||
|
|
||||||
switch (nodeType) {
|
switch (nodeType) {
|
||||||
case 'building':
|
case 'building':
|
||||||
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
|
||||||
case 'ceiling':
|
case 'ceiling':
|
||||||
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'level':
|
case 'level':
|
||||||
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
||||||
case 'slab':
|
case 'slab':
|
||||||
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
|
case 'spawn':
|
||||||
|
return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
|
||||||
case 'wall':
|
case 'wall':
|
||||||
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'fence':
|
case 'fence':
|
||||||
@@ -102,7 +105,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|||||||
case 'window':
|
case 'window':
|
||||||
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'zone':
|
case 'zone':
|
||||||
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core'
|
import { useScene, type ZoneNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { memo, useCallback, useState } from 'react'
|
||||||
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
||||||
@@ -7,7 +7,7 @@ import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
|||||||
import { TreeNodeActions } from './tree-node-actions'
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
interface ZoneTreeNodeProps {
|
interface ZoneTreeNodeProps {
|
||||||
nodeId: AnyNodeId
|
nodeId: ZoneNode['id']
|
||||||
depth: number
|
depth: number
|
||||||
isLast?: boolean
|
isLast?: boolean
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|||||||
depth={depth}
|
depth={depth}
|
||||||
expanded={false}
|
expanded={false}
|
||||||
hasChildren={false}
|
hasChildren={false}
|
||||||
icon={<ColorDot color={color} onChange={(c) => updateNode(nodeId, { color: c })} />}
|
icon={<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
@@ -78,8 +78,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const j = (i + 1) % n
|
const j = (i + 1) % n
|
||||||
area += polygon[i]?.[0] * polygon[j]?.[1]
|
const current = polygon[i]
|
||||||
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
const next = polygon[j]
|
||||||
|
if (!(current && next)) continue
|
||||||
|
area += current[0] * next[1]
|
||||||
|
area -= next[0] * current[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.abs(area) / 2
|
return Math.abs(area) / 2
|
||||||
|
|||||||
@@ -12,8 +12,18 @@ export const markToolCancelConsumed = () => {
|
|||||||
_toolCancelConsumed = true
|
_toolCancelConsumed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
export const useKeyboard = ({
|
||||||
|
isVersionPreviewMode = false,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
isVersionPreviewMode?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
} = {}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Don't handle shortcuts if user is typing in an input
|
// Don't handle shortcuts if user is typing in an input
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
@@ -21,9 +31,6 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
// If in walkthrough mode, let WalkthroughControls handle ESC
|
|
||||||
if (useViewer.getState().walkthroughMode) return
|
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
_toolCancelConsumed = false
|
_toolCancelConsumed = false
|
||||||
emitter.emit('tool:cancel')
|
emitter.emit('tool:cancel')
|
||||||
@@ -220,7 +227,7 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [isVersionPreviewMode])
|
}, [disabled, isVersionPreviewMode])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type LevelNode,
|
type LevelNode,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
|
type SpawnNode,
|
||||||
type RoofSurfaceMaterialRole,
|
type RoofSurfaceMaterialRole,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
type Space,
|
type Space,
|
||||||
@@ -59,6 +60,7 @@ export type StructureTool =
|
|||||||
| 'stair'
|
| 'stair'
|
||||||
| 'item'
|
| 'item'
|
||||||
| 'zone'
|
| 'zone'
|
||||||
|
| 'spawn'
|
||||||
| 'window'
|
| 'window'
|
||||||
| 'door'
|
| 'door'
|
||||||
|
|
||||||
@@ -132,6 +134,7 @@ type EditorState = {
|
|||||||
| WallNode
|
| WallNode
|
||||||
| RoofNode
|
| RoofNode
|
||||||
| RoofSegmentNode
|
| RoofSegmentNode
|
||||||
|
| SpawnNode
|
||||||
| StairNode
|
| StairNode
|
||||||
| StairSegmentNode
|
| StairSegmentNode
|
||||||
| BuildingNode
|
| BuildingNode
|
||||||
@@ -147,6 +150,7 @@ type EditorState = {
|
|||||||
| WallNode
|
| WallNode
|
||||||
| RoofNode
|
| RoofNode
|
||||||
| RoofSegmentNode
|
| RoofSegmentNode
|
||||||
|
| SpawnNode
|
||||||
| StairNode
|
| StairNode
|
||||||
| StairSegmentNode
|
| StairSegmentNode
|
||||||
| BuildingNode
|
| BuildingNode
|
||||||
@@ -200,6 +204,7 @@ type EditorState = {
|
|||||||
// First-person walkthrough mode (street view)
|
// First-person walkthrough mode (street view)
|
||||||
isFirstPersonMode: boolean
|
isFirstPersonMode: boolean
|
||||||
_viewModeBeforeFirstPerson: ViewMode | null
|
_viewModeBeforeFirstPerson: ViewMode | null
|
||||||
|
_levelIdBeforeFirstPerson: LevelNode['id'] | null
|
||||||
setFirstPersonMode: (enabled: boolean) => void
|
setFirstPersonMode: (enabled: boolean) => void
|
||||||
// Development-only camera debug flag for inspecting underside geometry
|
// Development-only camera debug flag for inspecting underside geometry
|
||||||
allowUndergroundCamera: boolean
|
allowUndergroundCamera: boolean
|
||||||
@@ -632,14 +637,18 @@ const useEditor = create<EditorState>()(
|
|||||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
||||||
isFirstPersonMode: false,
|
isFirstPersonMode: false,
|
||||||
_viewModeBeforeFirstPerson: null as ViewMode | null,
|
_viewModeBeforeFirstPerson: null as ViewMode | null,
|
||||||
|
_levelIdBeforeFirstPerson: null as LevelNode['id'] | null,
|
||||||
setFirstPersonMode: (enabled) => {
|
setFirstPersonMode: (enabled) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
const currentViewMode = get().viewMode
|
const currentViewMode = get().viewMode
|
||||||
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
useViewer.getState().setCameraMode('perspective')
|
useViewer.getState().setCameraMode('perspective')
|
||||||
useViewer.getState().setWallMode('up')
|
useViewer.getState().setWallMode('up')
|
||||||
|
useViewer.getState().setWalkthroughMode(true)
|
||||||
set({
|
set({
|
||||||
isFirstPersonMode: true,
|
isFirstPersonMode: true,
|
||||||
_viewModeBeforeFirstPerson: currentViewMode,
|
_viewModeBeforeFirstPerson: currentViewMode,
|
||||||
|
_levelIdBeforeFirstPerson: currentLevelId,
|
||||||
viewMode: '3d',
|
viewMode: '3d',
|
||||||
isFloorplanOpen: false,
|
isFloorplanOpen: false,
|
||||||
mode: 'select',
|
mode: 'select',
|
||||||
@@ -649,11 +658,25 @@ const useEditor = create<EditorState>()(
|
|||||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||||
} else {
|
} else {
|
||||||
const prevMode = get()._viewModeBeforeFirstPerson
|
const prevMode = get()._viewModeBeforeFirstPerson
|
||||||
|
const prevLevelId = get()._levelIdBeforeFirstPerson
|
||||||
|
useViewer.getState().setWalkthroughMode(false)
|
||||||
set({
|
set({
|
||||||
isFirstPersonMode: false,
|
isFirstPersonMode: false,
|
||||||
_viewModeBeforeFirstPerson: null,
|
_viewModeBeforeFirstPerson: null,
|
||||||
|
_levelIdBeforeFirstPerson: null,
|
||||||
...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
|
...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (prevLevelId) {
|
||||||
|
const prevLevelNode = useScene.getState().nodes[prevLevelId]
|
||||||
|
if (prevLevelNode?.type === 'level') {
|
||||||
|
useViewer.getState().setSelection({
|
||||||
|
levelId: prevLevelId,
|
||||||
|
zoneId: null,
|
||||||
|
selectedIds: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
|
|||||||
import { ScanRenderer } from './scan/scan-renderer'
|
import { ScanRenderer } from './scan/scan-renderer'
|
||||||
import { SiteRenderer } from './site/site-renderer'
|
import { SiteRenderer } from './site/site-renderer'
|
||||||
import { SlabRenderer } from './slab/slab-renderer'
|
import { SlabRenderer } from './slab/slab-renderer'
|
||||||
|
import { SpawnRenderer } from './spawn/spawn-renderer'
|
||||||
import { StairRenderer } from './stair/stair-renderer'
|
import { StairRenderer } from './stair/stair-renderer'
|
||||||
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
|
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
|
||||||
import { WallRenderer } from './wall/wall-renderer'
|
import { WallRenderer } from './wall/wall-renderer'
|
||||||
@@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
|||||||
{node.type === 'level' && <LevelRenderer node={node} />}
|
{node.type === 'level' && <LevelRenderer node={node} />}
|
||||||
{node.type === 'item' && <ItemRenderer node={node} />}
|
{node.type === 'item' && <ItemRenderer node={node} />}
|
||||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||||
|
{node.type === 'spawn' && <SpawnRenderer node={node} />}
|
||||||
{node.type === 'wall' && <WallRenderer node={node} />}
|
{node.type === 'wall' && <WallRenderer node={node} />}
|
||||||
{node.type === 'fence' && <FenceRenderer node={node} />}
|
{node.type === 'fence' && <FenceRenderer node={node} />}
|
||||||
{node.type === 'door' && <DoorRenderer node={node} />}
|
{node.type === 'door' && <DoorRenderer node={node} />}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
|
||||||
|
import { useMemo, useRef } from 'react'
|
||||||
|
import type { Group } from 'three'
|
||||||
|
import { Color, Shape } from 'three'
|
||||||
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
|
|
||||||
|
// Mirrors the current first-person controller capsule in the editor package:
|
||||||
|
// colliderCapsuleArgs={[0.25, 0.8, 4, 8]} with the controller center 0.8m above the floor.
|
||||||
|
const PLAYER_CAPSULE_RADIUS = 0.25
|
||||||
|
const PLAYER_CAPSULE_LENGTH = 0.8
|
||||||
|
const PLAYER_CAPSULE_CENTER_Y = 0.8
|
||||||
|
|
||||||
|
const SPAWN_COLOR = new Color('#22c55e')
|
||||||
|
|
||||||
|
export const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
|
||||||
|
const ref = useRef<Group>(null!)
|
||||||
|
const handlers = useNodeEvents(node, 'spawn')
|
||||||
|
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||||
|
const walkthroughMode = useViewer((state) => state.walkthroughMode)
|
||||||
|
|
||||||
|
useRegistry(node.id, 'spawn', ref)
|
||||||
|
|
||||||
|
const materialProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
color: SPAWN_COLOR,
|
||||||
|
emissive: SPAWN_COLOR,
|
||||||
|
emissiveIntensity: 0.08,
|
||||||
|
metalness: 0.03,
|
||||||
|
roughness: 0.42,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const arrowShape = useMemo(() => {
|
||||||
|
const shape = new Shape()
|
||||||
|
// Positive local Y becomes negative world Z after the -90deg X rotation below,
|
||||||
|
// so this tip points "forward" for the player/spawn direction.
|
||||||
|
shape.moveTo(0, 0.24)
|
||||||
|
shape.lineTo(-0.18, -0.14)
|
||||||
|
shape.lineTo(0.18, -0.14)
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
position={liveTransform?.position ?? node.position}
|
||||||
|
ref={ref}
|
||||||
|
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]}
|
||||||
|
visible={!walkthroughMode}
|
||||||
|
>
|
||||||
|
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
|
||||||
|
<ringGeometry args={[0.34, 0.48, 48]} />
|
||||||
|
<meshStandardMaterial {...materialProps} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<mesh
|
||||||
|
position={[0, 0.1, -0.52]}
|
||||||
|
rotation={[-Math.PI / 2, 0, ( Math.PI) / 180]}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
|
<shapeGeometry args={[arrowShape]} />
|
||||||
|
<meshStandardMaterial {...materialProps} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<group position={[0, 0.09, 0]}>
|
||||||
|
<mesh position={[0, 0.48, 0]} {...handlers}>
|
||||||
|
<boxGeometry args={[0.4, 0.82, 0.28]} />
|
||||||
|
<meshStandardMaterial {...materialProps} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<mesh position={[0, 1.08, 0]} {...handlers}>
|
||||||
|
<boxGeometry args={[0.24, 0.24, 0.24]} />
|
||||||
|
<meshStandardMaterial {...materialProps} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
type SiteNode,
|
type SiteNode,
|
||||||
type SlabEvent,
|
type SlabEvent,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
|
type SpawnEvent,
|
||||||
|
type SpawnNode,
|
||||||
type StairEvent,
|
type StairEvent,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairSegmentEvent,
|
type StairSegmentEvent,
|
||||||
@@ -44,6 +46,7 @@ type NodeConfig = {
|
|||||||
level: { node: LevelNode; event: LevelEvent }
|
level: { node: LevelNode; event: LevelEvent }
|
||||||
zone: { node: ZoneNode; event: ZoneEvent }
|
zone: { node: ZoneNode; event: ZoneEvent }
|
||||||
slab: { node: SlabNode; event: SlabEvent }
|
slab: { node: SlabNode; event: SlabEvent }
|
||||||
|
spawn: { node: SpawnNode; event: SpawnEvent }
|
||||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||||
roof: { node: RoofNode; event: RoofEvent }
|
roof: { node: RoofNode; event: RoofEvent }
|
||||||
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
|
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="30" fill="#0f172a"/>
|
||||||
|
<circle cx="32" cy="32" r="22" fill="#1d4ed8"/>
|
||||||
|
<circle cx="32" cy="18" r="7" fill="#dbeafe"/>
|
||||||
|
<path d="M22 46c0-6.6 4.5-11 10-11s10 4.4 10 11" fill="#93c5fd"/>
|
||||||
|
<path d="M32 56l7-9h-14l7 9Z" fill="#bfdbfe"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
Reference in New Issue
Block a user