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;
};
}