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 {
|
||||
type AnyNode,
|
||||
type AnyNodeDefinition,
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
ElevatorNode,
|
||||
LevelNode,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
ShelfNode,
|
||||
SiteNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -18,12 +20,14 @@ function registerColliderDefinition(
|
||||
kind: AnyNode['type'],
|
||||
schema: AnyNodeDefinition['schema'],
|
||||
category: AnyNodeDefinition['category'],
|
||||
surfaceRole?: AnyNodeDefinition['surfaceRole'],
|
||||
) {
|
||||
registerNode({
|
||||
kind,
|
||||
schema,
|
||||
schemaVersion: 1,
|
||||
category,
|
||||
surfaceRole,
|
||||
capabilities: {},
|
||||
} as AnyNodeDefinition)
|
||||
}
|
||||
@@ -81,6 +85,26 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
||||
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', () => {
|
||||
registerColliderDefinition('elevator', ElevatorNode, 'structure')
|
||||
|
||||
@@ -105,4 +129,21 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
||||
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
||||
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 SiteNode = Extract<AnyNode, { type: 'site' }>
|
||||
type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
|
||||
|
||||
function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
||||
@@ -55,7 +56,12 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[])
|
||||
function isGenericColliderNode(node: AnyNode) {
|
||||
if (node.visible === false) 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) {
|
||||
@@ -118,6 +124,55 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
|
||||
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
|
||||
// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
|
||||
// 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(...collectSiteGroundColliderGeometries(nodes))
|
||||
|
||||
if (geometries.length === 0) {
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user