diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index e82e4992..beccc7ec 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -2305,3 +2305,46 @@ Vite's existing large-chunk advisory remains informational. compression, and release-bundle verification passed. Existing build-size advisories and deprecated physics initialization warnings remain. No hosted release was published. + +## 2026-09-12 - Non-blocking players and combat actors + +- Removed party soft separation and crowd-triggered combat repositioning. + Companions may overlap or cross one another without push-apart corrections + changing their routes. Normal follow formations and role-specific attack + ranges remain; a tank already in melee range does not move just because an + ally occupies the same position. +- Added a shared actor collision layer that interacts only with physical + terrain. The player capsule uses this layer. Player controller movement, + ground probes, camera sweeps, party direct/route validation, party grounding, + and combat ground-position queries use terrain-only filters. Actor body type + cannot turn an actor into a floor or movement obstruction. Party members, + remote players, mobs, and bosses currently render without physical colliders; + any future actor capsule must use the shared actor layer. +- Walls, floors, ceilings, dungeon objects, and the Stockades walking ramps + retain their terrain collision. Target-selection meshes, combat visibility, + damage, and attack-range checks were not removed. +- Ten new Rapier regressions cover overlapping actors with no contacts or + displacement, movement through fixed/kinematic/dynamic actors (including an + overlapped starting position), continued wall/floor collision, and ignoring + actors in ground, party-route, and camera queries. A terrain-layer control + confirms real actor/terrain contacts are still detected. Stockades shipping- + asset regressions now use the live player collision policy and still pass + down/up all three ramps without jumping. +- Browser smoke pass at 1280 x 720 used offline level-15 Priest Rolecheck with + Blood DK, Combat Rogue, Unholy DK, and Frost Mage companions. Inline roster + and dungeon screenshots show character bodies, weapons, unchanged visible + stairs, and the existing warm lighting. Attack moved companions down from + the entrance and out of its view without ROUTE BLOCKED; the tank took aggro + and melee/caster combat events continued. Experience rose from 11,098 to + 13,359. Stop followed by Recall returned the party to the nearby player. + Selecting Morvane and casting Power Word: Shield applied the buff successfully. +- Live QA was an entrance/trash/recall smoke pass, not a full dungeon clear or + a manual traversal of both wings. Actor overlap and passage guarantees are + backed by the physics regressions; physical Android and multiplayer QA remain + open. +- Validation: all 225 Vitest files passed, 1,390 tests passed, and six existing + tests remained skipped. The final focused checks passed again after cleanup. + Production TypeScript/Vite build, texture/performance audits, static bundle + compression, and release-bundle verification passed. Existing chunk-size + advisories and deprecated physics initialization warnings remain. No hosted + release was published. diff --git a/package-lock.json b/package-lock.json index f487be62..3accadf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.34", + "version": "0.1.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.34", + "version": "0.1.35", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index c3aad1e3..7718185b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.34", + "version": "0.1.35", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 3c6ed2cf..0b84d7ed 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.34", - "versionCode": 1034 + "versionName": "0.1.35", + "versionCode": 1035 } diff --git a/src/game/partyMovement.test.ts b/src/game/partyMovement.test.ts index 4c3a56a7..ccf3d90f 100644 --- a/src/game/partyMovement.test.ts +++ b/src/game/partyMovement.test.ts @@ -12,7 +12,6 @@ import { partyNaturalFollowPosition, partyPersonalizedCombatMovementGoal, partyPresentationY, - partySeparationGoal, partyShouldRecallForStuck, partyShouldMoveToComfortTarget, PARTY_STUCK_RECALL_DISTANCE, @@ -102,7 +101,7 @@ describe("party world movement", () => { } }); - it("uses a stable combat bearing and separates overlapping allies", () => { + it("uses a stable combat bearing without forcing in-range actors to reposition", () => { const out: [number, number, number] = [0, 0, 0]; expect(partyPersonalizedCombatMovementGoal( [20, 0, 0], @@ -110,23 +109,20 @@ describe("party world movement", () => { true, { minimum: 10, preferred: 14, maximum: 18 }, Math.PI / 2, - false, out, )).toBe("approach"); expect(out[0]).toBeCloseTo(14); expect(out[1]).toBe(0); expect(out[2]).toBeCloseTo(0); - const separated: [number, number, number] = [0, 0, 0]; - expect(partySeparationGoal( + expect(partyPersonalizedCombatMovementGoal( [1, 0, 1], - [2, 0, 2], - [[1, 0, 1]], + [0, 0, 0], + true, + { minimum: 0, preferred: 2.9, maximum: 3.15 }, Math.PI / 2, - separated, - )).toBe(true); - expect(separated[0]).toBeGreaterThan(2); - expect(separated[2]).toBeCloseTo(2); + out, + )).toBe("hold"); }); it("recalls only distant allies that should be moving and stopped making progress", () => { diff --git a/src/game/partyMovement.ts b/src/game/partyMovement.ts index afc88f3d..1c4679e8 100644 --- a/src/game/partyMovement.ts +++ b/src/game/partyMovement.ts @@ -19,7 +19,7 @@ export interface PartyBreadcrumbCursor { breadcrumbId: number | null; } -export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition"; +export type PartyCombatMovementIntent = "hold" | "approach" | "retreat"; export interface PartyMovementProfile { readonly followDistanceJitter: number; @@ -427,44 +427,6 @@ export function personalizedPartyCombatRangeBand( }; } -/** Adds a bounded local repulsion so allies do not occupy the same point. */ -export function partySeparationGoal( - current: PartyWorldPosition, - desired: PartyWorldPosition, - peers: readonly PartyWorldPosition[], - fallbackAngle: number, - out: MutablePartyWorldPosition, - separationDistance = 0.85, - maximumCorrection = 0.35, -): boolean { - let offsetX = 0; - let offsetZ = 0; - const safeSeparation = Math.max(EPSILON, separationDistance); - for (const peer of peers) { - const dx = current[0] - peer[0]; - const dz = current[2] - peer[2]; - const distance = Math.hypot(dx, dz); - if (!Number.isFinite(distance) || distance >= safeSeparation) continue; - const weight = (safeSeparation - distance) / safeSeparation; - if (distance <= EPSILON) { - offsetX += Math.sin(fallbackAngle) * weight; - offsetZ += Math.cos(fallbackAngle) * weight; - } else { - offsetX += dx / distance * weight; - offsetZ += dz / distance * weight; - } - } - const length = Math.hypot(offsetX, offsetZ); - out[0] = desired[0]; - out[1] = desired[1]; - out[2] = desired[2]; - if (length <= EPSILON) return false; - const correction = Math.min(Math.max(0, maximumCorrection), length); - out[0] += offsetX / length * correction; - out[2] += offsetZ / length * correction; - return true; -} - /** * Chooses an individual combat destination. Melee only closes distance; * ranged attackers also back out when a target collapses their preferred band. @@ -524,7 +486,6 @@ export function partyPersonalizedCombatMovementGoal( targetVisible: boolean, rangeBand: PartyCombatRangeLike, preferredBearing: number, - forceReposition: boolean, out: MutablePartyWorldPosition, ): PartyCombatMovementIntent { const intent = partyCombatMovementGoal( @@ -538,10 +499,10 @@ export function partyPersonalizedCombatMovementGoal( Math.sin(preferredBearing), Math.cos(preferredBearing), ); - if (!targetVisible || (intent === "hold" && !forceReposition)) return intent; + if (!targetVisible || intent === "hold") return intent; out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred; out[1] = target[1]; out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred; - return intent === "hold" ? "reposition" : intent; + return intent; } diff --git a/src/game/stockadesNavigation.test.ts b/src/game/stockadesNavigation.test.ts index b4bc3211..d72ca49f 100644 --- a/src/game/stockadesNavigation.test.ts +++ b/src/game/stockadesNavigation.test.ts @@ -19,6 +19,7 @@ import { derivePartySpawnSlots } from "./partySpawnSlots"; import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision"; import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal"; import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement"; +import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision"; const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb"; const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 }); @@ -302,7 +303,7 @@ describe("Stormwind Stockade party navigation", () => { const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation( player[0], player[1] + 0.84, player[2], )); - world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body); + world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body); world.step(); expect(createPartyDirectSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true); expect(createPartyRouteSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true); @@ -321,7 +322,7 @@ describe("Stormwind Stockade party navigation", () => { const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation( ramp.start[0] + dx * startT, startY + centerHeight + 0.02, ramp.start[2] + dz * startT, )); - const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36), body); + const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body); world.step(); const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET)); const progress = () => { @@ -338,7 +339,7 @@ describe("Stormwind Stockade party navigation", () => { x: dx / length * direction * 4.8 / 60, y: -0.5 / 60, z: dz / length * direction * 4.8 / 60, - }, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC); + }, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS); const position = body.translation(); const move = controller.computedMovement(); body.setNextKinematicTranslation({ x: position.x + move.x, y: position.y + move.y, z: position.z + move.z }); diff --git a/src/scene/PartyPopulation.tsx b/src/scene/PartyPopulation.tsx index b57eb5fc..a6121164 100644 --- a/src/scene/PartyPopulation.tsx +++ b/src/scene/PartyPopulation.tsx @@ -38,7 +38,6 @@ import { partyNaturalFollowPosition, partyPersonalizedCombatMovementGoal, partyPresentationY, - partySeparationGoal, partyShouldRecallForStuck, partyShouldMoveToComfortTarget, personalizedPartyCombatRangeBand, @@ -84,12 +83,12 @@ import { registerCombatSpatialProvider } from "../game/combatSpatial"; import { PLAYER_AGGRO_ID } from "../game/aggro"; import { hasDungeonLineOfSight } from "./dungeonLineOfSight"; import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry"; +import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision"; import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, PARTY_FLOOR_RAY_DEPTH, PARTY_FLOOR_RAY_HEIGHT, - partyTerrainQueryFlags, } from "./partyTraversal"; const PARTY_MOVE_SPEED = 5.25; @@ -300,7 +299,8 @@ function PartyActors({ ray, PARTY_FLOOR_RAY_DEPTH, true, - partyTerrainQueryFlags(rapier), + rapier.QueryFilterFlags.EXCLUDE_SENSORS, + ACTOR_TERRAIN_QUERY_GROUPS, ); const floorY = floor ? ray.origin.y - floor.timeOfImpact : null; return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION); @@ -394,7 +394,8 @@ function PartyActors({ ray, 20, true, - rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, + rapier.QueryFilterFlags.EXCLUDE_SENSORS, + ACTOR_TERRAIN_QUERY_GROUPS, ); return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null; }, @@ -636,11 +637,9 @@ function PartyActors({ const nextPosition: MutablePartyWorldPosition = [0, 0, 0]; const leaderDestination: MutablePartyWorldPosition = [0, 0, 0]; const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0]; - const separatedDesired: MutablePartyWorldPosition = [0, 0, 0]; const livingIndices = party.members .map((member, index) => member.health > 0 ? index : -1) .filter((index) => index >= 0); - const livingIds = new Set(livingIndices.map((index) => party.members[index].id)); const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => { const livingRank = Math.max(0, livingIndices.indexOf(memberIndex)); const key = `${memberId}|${livingRank}`; @@ -651,13 +650,6 @@ function PartyActors({ } return profile; }; - const peerPositionsFor = (memberId: string): PartyWorldPosition[] => { - const peers: PartyWorldPosition[] = []; - for (const [id, position] of positionsRef.current) { - if (id !== memberId && livingIds.has(id)) peers.push(position); - } - return peers; - }; let leaderHasWaypoint = false; let leaderMovementExpected = false; let leaderFollowingPlayer = false; @@ -712,15 +704,12 @@ function PartyActors({ }; combatAnchorsRef.current.set(leader.id, combatAnchor); } - const peers = peerPositionsFor(leader.id); - const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85); const movementIntent = partyPersonalizedCombatMovementGoal( leaderPosition, targetPosition, hasStaticLineOfSight(leaderPosition, targetPosition), rangeBand, combatAnchor.bearing, - crowded, leaderDestination, ); if (movementIntent === "hold") { @@ -1039,15 +1028,12 @@ function PartyActors({ }; combatAnchorsRef.current.set(member.id, combatAnchor); } - const peers = peerPositionsFor(member.id); - const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85); const movementIntent = partyPersonalizedCombatMovementGoal( current, memberTargetPosition, canSeeActiveTarget, rangeBand, combatAnchor.bearing, - crowded, desired, ); movementExpected = movementIntent !== "hold"; @@ -1245,23 +1231,6 @@ function PartyActors({ } const movementProfile = movementProfileFor(member.id, index); - if (hasDesired) { - const peers = peerPositionsFor(member.id); - if ( - partySeparationGoal( - current, - desired, - peers, - movementProfile.swayPhase, - separatedDesired, - ) - && directSegmentAllowed(current, separatedDesired) - ) { - desired[0] = separatedDesired[0]; - desired[1] = separatedDesired[1]; - desired[2] = separatedDesired[2]; - } - } const currentPlanarDistance = planarDistance(current, playerNavigation); if (member.health > 0 && party.command !== "stop" && hasDesired) { diff --git a/src/scene/PlayerRig.tsx b/src/scene/PlayerRig.tsx index a921f5df..63f10983 100644 --- a/src/scene/PlayerRig.tsx +++ b/src/scene/PlayerRig.tsx @@ -60,6 +60,7 @@ import { import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { useGameStore } from "../game/store"; import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry"; +import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision"; const PLAYER_RADIUS = 0.36; const PLAYER_CAPSULE_HALF_HEIGHT = 0.48; @@ -212,8 +213,8 @@ function PlayerController({ groundRay, groundProbeLength, true, - rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, - undefined, + rapier.QueryFilterFlags.EXCLUDE_SENSORS, + ACTOR_TERRAIN_QUERY_GROUPS, undefined, body, ); @@ -267,7 +268,8 @@ function PlayerController({ y: verticalVelocity * movementDeltaSeconds, z: worldZ * MOVE_SPEED * movementDeltaSeconds, }, - rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC, + rapier.QueryFilterFlags.EXCLUDE_SENSORS, + ACTOR_TERRAIN_QUERY_GROUPS, ); const computedMovement = characterController.computedMovement(); const nextTranslation = { @@ -347,7 +349,11 @@ function PlayerController({ spawn.footPosition[2], ]} > - + { + await RAPIER.init(); +}); + +function actor(world: RAPIER.World, bodyType: BodyType, z = 0, groups = ACTOR_COLLISION_GROUPS) { + const description = bodyType === "fixed" + ? RAPIER.RigidBodyDesc.fixed() + : bodyType === "kinematic" + ? RAPIER.RigidBodyDesc.kinematicPositionBased() + : RAPIER.RigidBodyDesc.dynamic(); + const body = world.createRigidBody(description.setTranslation(0, 0.84, z)); + const collider = world.createCollider( + RAPIER.ColliderDesc.capsule(0.48, 0.36) + .setCollisionGroups(groups) + .setActiveCollisionTypes(RAPIER.ActiveCollisionTypes.ALL), + body, + ); + return { body, collider }; +} + +function terrain(world: RAPIER.World, wall = false) { + const floor = world.createCollider(RAPIER.ColliderDesc.cuboid(5, 0.1, 8) + .setTranslation(0, -0.1, 0).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS)); + if (wall) world.createCollider(RAPIER.ColliderDesc.cuboid(5, 2, 0.1) + .setTranslation(0, 2, 2.5).setCollisionGroups(DUNGEON_WALL_COLLISION_GROUPS)); + return floor; +} + +describe("non-blocking combat actors", () => { + it.each(BODY_TYPES)("has no physical contacts with overlapping %s actors", (bodyType) => { + const world = new RAPIER.World({ x: 0, y: 0, z: 0 }); + const player = actor(world, "dynamic"); + // Party members, mobs, bosses, and remote players use the same actor policy. + const others = ["party", "mob", "boss", "remote-player"].map(() => actor(world, bodyType)); + world.step(); + for (const other of others) { + world.contactPair(player.collider, other.collider, () => { + throw new Error("Actors must not produce physical contacts"); + }); + expect(other.body.translation().z).toBeCloseTo(0); + } + expect(player.body.translation().z).toBeCloseTo(0); + world.free(); + }); + + it("detects contacts when an actor mistakenly uses the terrain layer", () => { + const world = new RAPIER.World({ x: 0, y: 0, z: 0 }); + const player = actor(world, "dynamic"); + const obstacle = actor(world, "fixed", 0.4, DUNGEON_PHYSICAL_COLLISION_GROUPS); + world.step(); + let contacts = 0; + world.contactPair(player.collider, obstacle.collider, (manifold) => { contacts += manifold.numContacts(); }); + expect(contacts).toBeGreaterThan(0); + world.free(); + }); + + it.each(BODY_TYPES)("walks through %s actors but still stops at a dungeon wall", (bodyType) => { + for (const hasWall of [false, true]) { + const world = new RAPIER.World({ x: 0, y: 0, z: 0 }); + terrain(world, hasWall); + actor(world, bodyType); + actor(world, bodyType, -2); // Start overlapped: no depenetration or detour. + const player = actor(world, "kinematic", -2); + const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET)); + world.step(); + for (let frame = 0; frame < 90; frame += 1) { + controller.computeColliderMovement(player.collider, { x: 0, y: -0.5 / 60, z: 4.8 / 60 }, + RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS); + const position = player.body.translation(); + const movement = controller.computedMovement(); + player.body.setNextKinematicTranslation({ x: position.x + movement.x, y: position.y + movement.y, z: position.z + movement.z }); + world.step(); + } + const position = player.body.translation(); + if (hasWall) { + expect(position.z).toBeGreaterThan(1.8); + expect(position.z).toBeLessThan(2.1); + } else expect(position.z).toBeGreaterThan(5); + expect(position.x).toBeCloseTo(0); + expect(position.y).toBeCloseTo(0.84, 1); + world.removeCharacterController(controller); + world.free(); + } + }); + + it.each(BODY_TYPES)("never treats a %s actor as ground, a route obstacle, or a camera wall", (bodyType) => { + const world = new RAPIER.World({ x: 0, y: 0, z: 0 }); + const floor = terrain(world); + actor(world, bodyType); + world.step(); + const ground = world.castRayAndGetNormal(new RAPIER.Ray({ x: 0, y: 4, z: 0 }, { x: 0, y: -1, z: 0 }), + 5, true, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS); + expect(ground?.collider.handle).toBe(floor.handle); + expect(ground?.timeOfImpact).toBeCloseTo(4); + expect(isWalkableGroundHit(ground, 5)).toBe(true); + expect(createPartyDirectSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true); + expect(createPartyRouteSegmentValidator(RAPIER, world)([0, 0, -1.5], [0, 0, 1.5])).toBe(true); + expect(world.castShape({ x: 0, y: 0.9, z: -1.5 }, { x: 0, y: 0, z: 0, w: 1 }, + { x: 0, y: 0, z: 1 }, new RAPIER.Ball(0.24), 0.08, 3, false, + RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS)).toBeNull(); + world.free(); + }); +}); diff --git a/src/scene/actorCollision.ts b/src/scene/actorCollision.ts new file mode 100644 index 00000000..28d09d4e --- /dev/null +++ b/src/scene/actorCollision.ts @@ -0,0 +1,15 @@ +import { interactionGroups } from "@react-three/rapier"; + +export const TERRAIN_COLLISION_GROUP = 0; +export const ACTOR_COLLISION_GROUP = 1; + +// Players, companions, mobs, and bosses never block or push one another. +// Any actor capsule added in future must use this layer, not the terrain layer. +export const ACTOR_COLLISION_GROUPS = interactionGroups( + ACTOR_COLLISION_GROUP, + TERRAIN_COLLISION_GROUP, +); + +// Movement, ground probes, and camera sweeps see only physical terrain, +// regardless of whether an actor's body is fixed, kinematic, or dynamic. +export const ACTOR_TERRAIN_QUERY_GROUPS = ACTOR_COLLISION_GROUPS; diff --git a/src/scene/dungeonLineOfSight.ts b/src/scene/dungeonLineOfSight.ts index dcf2d99d..bdc78175 100644 --- a/src/scene/dungeonLineOfSight.ts +++ b/src/scene/dungeonLineOfSight.ts @@ -1,4 +1,5 @@ import { interactionGroups } from "@react-three/rapier"; +import { TERRAIN_COLLISION_GROUP } from "./actorCollision"; import type { QueryFilterFlags, Ray, @@ -17,9 +18,9 @@ interface RapierApi { type WorldPosition = readonly [number, number, number]; export const DUNGEON_LINE_OF_SIGHT_GROUP = 15; -export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(0); +export const DUNGEON_PHYSICAL_COLLISION_GROUPS = interactionGroups(TERRAIN_COLLISION_GROUP); export const DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS = interactionGroups(DUNGEON_LINE_OF_SIGHT_GROUP); -export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([0, DUNGEON_LINE_OF_SIGHT_GROUP]); +export const DUNGEON_WALL_COLLISION_GROUPS = interactionGroups([TERRAIN_COLLISION_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP]); export const DUNGEON_SIGHT_QUERY_GROUPS = interactionGroups( DUNGEON_LINE_OF_SIGHT_GROUP, DUNGEON_LINE_OF_SIGHT_GROUP, diff --git a/src/scene/partyTraversal.ts b/src/scene/partyTraversal.ts index 015d290c..25a76d50 100644 --- a/src/scene/partyTraversal.ts +++ b/src/scene/partyTraversal.ts @@ -1,6 +1,7 @@ import type RAPIER from "@dimforge/rapier3d-compat"; import type { World } from "@dimforge/rapier3d-compat"; import type { PartyWorldPosition } from "../game/partyRuntimeRegistry"; +import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision"; const COLLISION_RADIUS = 0.34; const COLLISION_HALF_HEIGHT = 0.48; @@ -11,14 +12,6 @@ export const PARTY_FLOOR_RAY_DEPTH = 2; const FLOOR_SAMPLE_SPACING = 0.6; const MAX_FLOOR_DELTA = 0.55; -export function partyTerrainQueryFlags(rapier: typeof RAPIER) { - // Actor capsules are not terrain. Crowd spacing is handled separately by - // party separation; counting the player here blocks recalls and replans. - return rapier.QueryFilterFlags.EXCLUDE_SENSORS - | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC - | rapier.QueryFilterFlags.EXCLUDE_KINEMATIC; -} - /** Shared by live party routing and collision-backed traversal regressions. */ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: World) { const capsule = new rapier.Capsule( @@ -26,7 +19,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: COLLISION_RADIUS, ); const rotation = { x: 0, y: 0, z: 0, w: 1 }; - const flags = partyTerrainQueryFlags(rapier); + const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS; return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => { const dx = end[0] - start[0]; const dy = end[1] - start[1]; @@ -43,6 +36,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: distance, false, flags, + ACTOR_TERRAIN_QUERY_GROUPS, ); if (obstacle && obstacle.time_of_impact < distance - 0.04) return false; const sampleCount = Math.max(1, Math.ceil(distance / FLOOR_SAMPLE_SPACING)); @@ -54,7 +48,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: y: expectedY + PARTY_FLOOR_RAY_HEIGHT, z: start[2] + dz * progress, }, { x: 0, y: -1, z: 0 }); - const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags); + const floor = world.castRay(ray, PARTY_FLOOR_RAY_DEPTH, true, flags, ACTOR_TERRAIN_QUERY_GROUPS); if (!floor || Math.abs(ray.origin.y - floor.timeOfImpact - expectedY) > MAX_FLOOR_DELTA) return false; } return true; @@ -63,7 +57,7 @@ export function createPartyDirectSegmentValidator(rapier: typeof RAPIER, world: /** Triangle corridors need wall/overhang validation, not a second radius erosion. */ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: World) { - const flags = partyTerrainQueryFlags(rapier); + const flags = rapier.QueryFilterFlags.EXCLUDE_SENSORS; return (start: PartyWorldPosition, end: PartyWorldPosition): boolean => { const dx = end[0] - start[0]; const dy = end[1] - start[1]; @@ -74,7 +68,7 @@ export function createPartyRouteSegmentValidator(rapier: typeof RAPIER, world: W const ray = new rapier.Ray({ x: start[0], y: start[1] + 0.9, z: start[2], }, { x: dx / distance, y: dy / distance, z: dz / distance }); - const hit = world.castRay(ray, distance, true, flags); + const hit = world.castRay(ray, distance, true, flags, ACTOR_TERRAIN_QUERY_GROUPS); return !hit || hit.timeOfImpact >= distance - 0.04; }; }