fix(editor): keep walkthrough on ground, pass through ceilings
Two gaps in the first-person walkthrough collider world: - The visible default ground is the site node's mesh, but `site` is a `site`-category node and excluded from the generic collider sweep. The per-level fallback floor only fires for a level without a slab, so a spawn on the bare ground (no slab) had no floor and fell through. Add a dedicated site-ground collider derived from node data (not the rendered mesh, so it's immune to geometry-mount timing) covering the scene footprint at the site's ground plane. - Ceilings are `structure`-category and were swept in as colliders, so the player was held up as if standing on a floor slab. Exclude nodes whose registry `surfaceRole` is 'ceiling' so the player passes through them (they're a transparent mount surface for lights/fans), while walls, slabs and stairs keep colliding. Adds tests for both behaviors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6b8dc33b62
commit
ab271df9b6
@@ -2,12 +2,14 @@ import { afterEach, describe, expect, test } from 'bun:test'
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeDefinition,
|
type AnyNodeDefinition,
|
||||||
|
CeilingNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
ElevatorNode,
|
ElevatorNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
registerNode,
|
registerNode,
|
||||||
ShelfNode,
|
ShelfNode,
|
||||||
|
SiteNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -18,12 +20,14 @@ function registerColliderDefinition(
|
|||||||
kind: AnyNode['type'],
|
kind: AnyNode['type'],
|
||||||
schema: AnyNodeDefinition['schema'],
|
schema: AnyNodeDefinition['schema'],
|
||||||
category: AnyNodeDefinition['category'],
|
category: AnyNodeDefinition['category'],
|
||||||
|
surfaceRole?: AnyNodeDefinition['surfaceRole'],
|
||||||
) {
|
) {
|
||||||
registerNode({
|
registerNode({
|
||||||
kind,
|
kind,
|
||||||
schema,
|
schema,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
category,
|
category,
|
||||||
|
surfaceRole,
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
} as AnyNodeDefinition)
|
} as AnyNodeDefinition)
|
||||||
}
|
}
|
||||||
@@ -81,6 +85,26 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
|||||||
world?.dispose()
|
world?.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('excludes ceiling surfaces so the walkthrough player passes through them', () => {
|
||||||
|
registerColliderDefinition('column', ColumnNode, 'structure')
|
||||||
|
registerColliderDefinition('ceiling', CeilingNode, 'structure', 'ceiling')
|
||||||
|
|
||||||
|
const column = ColumnNode.parse({ id: 'column_test' })
|
||||||
|
const ceiling = CeilingNode.parse({ id: 'ceiling_test', polygon: [] })
|
||||||
|
setSceneNodes([column, ceiling])
|
||||||
|
mountNode(column, [1, 2, 1], [0, 1, 0])
|
||||||
|
// A wide ceiling at head height — if it were collected, bounds would span ±5.
|
||||||
|
mountNode(ceiling, [10, 0.1, 10], [0, 2.5, 0])
|
||||||
|
|
||||||
|
const world = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
|
||||||
|
expect(world).not.toBeNull()
|
||||||
|
// Bounds reflect only the 1×1 column; the ceiling contributed no geometry.
|
||||||
|
expect(world?.bounds?.min.x).toBeCloseTo(-0.5)
|
||||||
|
expect(world?.bounds?.max.x).toBeCloseTo(0.5)
|
||||||
|
world?.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
test('leaves elevators to their dedicated dynamic collider meshes', () => {
|
test('leaves elevators to their dedicated dynamic collider meshes', () => {
|
||||||
registerColliderDefinition('elevator', ElevatorNode, 'structure')
|
registerColliderDefinition('elevator', ElevatorNode, 'structure')
|
||||||
|
|
||||||
@@ -105,4 +129,21 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
|||||||
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
||||||
world?.dispose()
|
world?.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('adds a site ground collider so a spawn on bare ground has a floor', () => {
|
||||||
|
const site = SiteNode.parse({ id: 'site_test' })
|
||||||
|
setSceneNodes([site])
|
||||||
|
mountRegistryGroup(site)
|
||||||
|
|
||||||
|
const world = buildFirstPersonColliderWorldFromRegistry()
|
||||||
|
|
||||||
|
expect(world).not.toBeNull()
|
||||||
|
// Ground slab sits just below the site ground plane (y = 0).
|
||||||
|
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
|
||||||
|
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
||||||
|
// Default site footprint falls back to the 30 m minimum size.
|
||||||
|
expect(world?.bounds?.min.x).toBeCloseTo(-15)
|
||||||
|
expect(world?.bounds?.max.x).toBeCloseTo(15)
|
||||||
|
world?.dispose()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export type FirstPersonSpawn = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LevelNode = Extract<AnyNode, { type: 'level' }>
|
type LevelNode = Extract<AnyNode, { type: 'level' }>
|
||||||
|
type SiteNode = Extract<AnyNode, { type: 'site' }>
|
||||||
type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
|
type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
|
||||||
|
|
||||||
function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
||||||
@@ -55,7 +56,12 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[])
|
|||||||
function isGenericColliderNode(node: AnyNode) {
|
function isGenericColliderNode(node: AnyNode) {
|
||||||
if (node.visible === false) return false
|
if (node.visible === false) return false
|
||||||
if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false
|
if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false
|
||||||
return COLLIDER_NODE_CATEGORIES.has(nodeRegistry.get(node.type)?.category ?? '')
|
const def = nodeRegistry.get(node.type)
|
||||||
|
// Ceilings are a transparent mount surface for fixtures (lights, fans), not a
|
||||||
|
// walkable or blocking structure — the walkthrough player must pass through
|
||||||
|
// them rather than be held up as if standing on a floor slab.
|
||||||
|
if (def?.surfaceRole === 'ceiling') return false
|
||||||
|
return COLLIDER_NODE_CATEGORIES.has(def?.category ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBoxColliderGeometry(width: number, height: number, depth: number) {
|
function createBoxColliderGeometry(width: number, height: number, depth: number) {
|
||||||
@@ -118,6 +124,55 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
|
|||||||
return geometries
|
return geometries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The visible ground is the site node's ground mesh, but `site` is a `site`
|
||||||
|
// category node and therefore excluded from the generic collider sweep. Without
|
||||||
|
// a dedicated collider, a spawn on the bare ground (no slab, or not parented to
|
||||||
|
// a level that triggers the per-level fallback) has no floor to stand on and the
|
||||||
|
// walkthrough player falls through. Derive a thin ground slab from node data (not
|
||||||
|
// the rendered mesh) so it exists regardless of geometry-mount timing, sized to
|
||||||
|
// cover the whole scene footprint at the site's ground plane.
|
||||||
|
function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
|
||||||
|
if (site.visible === false) return null
|
||||||
|
|
||||||
|
const siteObject = sceneRegistry.nodes.get(site.id)
|
||||||
|
if (!siteObject?.visible) return null
|
||||||
|
|
||||||
|
const bounds = computeSceneBoundsXZ(nodes)
|
||||||
|
const [centerX, centerZ] = bounds?.center ?? [0, 0]
|
||||||
|
const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
|
||||||
|
const width = Math.max(
|
||||||
|
boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
||||||
|
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
|
||||||
|
)
|
||||||
|
const depth = Math.max(
|
||||||
|
boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
|
||||||
|
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
|
||||||
|
|
||||||
|
siteObject.updateWorldMatrix(true, false)
|
||||||
|
geometry.applyMatrix4(
|
||||||
|
new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
|
||||||
|
)
|
||||||
|
geometry.applyMatrix4(siteObject.matrixWorld)
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSiteGroundColliderGeometries(nodes: SceneNodes) {
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
for (const siteId of sceneRegistry.byType.site ?? []) {
|
||||||
|
const node = nodes[siteId as AnyNodeId]
|
||||||
|
if (node?.type !== 'site') continue
|
||||||
|
|
||||||
|
const geometry = createSiteGroundColliderGeometry(node, nodes)
|
||||||
|
if (geometry) geometries.push(geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return geometries
|
||||||
|
}
|
||||||
|
|
||||||
// Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
|
// Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
|
||||||
// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
|
// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
|
||||||
// merged geometry to share the same typed-array constructor for matching attributes, so
|
// merged geometry to share the same typed-array constructor for matching attributes, so
|
||||||
@@ -327,6 +382,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
|
|||||||
}
|
}
|
||||||
|
|
||||||
geometries.push(...collectLevelFallbackFloorGeometries(nodes))
|
geometries.push(...collectLevelFallbackFloorGeometries(nodes))
|
||||||
|
geometries.push(...collectSiteGroundColliderGeometries(nodes))
|
||||||
|
|
||||||
if (geometries.length === 0) {
|
if (geometries.length === 0) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user