Release Healer Man 0.1.35

This commit is contained in:
phenom committed 2026-09-12 16:02:15 -04:00
1 parent 46c272f889
commit 40d9f346d8
13 files changed
+218 -118

No files matched your search

+43
View File
@@ -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.
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.34",
"versionCode": 1034
"versionName": "0.1.35",
"versionCode": 1035
}
+7 -11
View File
@@ -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", () => {
+3 -42
View File
@@ -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;
}
+4 -3
View File
@@ -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 });
+5 -36
View File
@@ -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) {
+13 -7
View File
@@ -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],
]}
>
<CapsuleCollider args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]} friction={0} />
<CapsuleCollider
args={[PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS]}
friction={0}
collisionGroups={ACTOR_COLLISION_GROUPS}
/>
<group ref={avatarRef} position={[0, -PLAYER_CENTER_HEIGHT, 0]}>
<CharacterModel
identity={identity}
@@ -408,7 +414,7 @@ function ThirdPersonCamera({
// Cast a small sphere instead of a single ray. This behaves like a camera
// spring arm and keeps the whole near-camera volume out of walls, pillars,
// and low ceilings. The player's own capsule is explicitly ignored.
// and low ceilings. All actor capsules are ignored, including the player.
const hit = world.castShape(
target,
shapeRotation,
@@ -417,8 +423,8 @@ function ThirdPersonCamera({
CAMERA_WALL_GAP,
fullDistance,
false,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
undefined,
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
ACTOR_TERRAIN_QUERY_GROUPS,
undefined,
body,
);
+114
View File
@@ -0,0 +1,114 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { beforeAll, describe, expect, it } from "vitest";
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "../game/playerMovement";
import { isWalkableGroundHit } from "../game/playerJump";
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "./partyTraversal";
const BODY_TYPES = ["fixed", "kinematic", "dynamic"] as const;
type BodyType = typeof BODY_TYPES[number];
beforeAll(async () => {
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();
});
});
+15
View File
@@ -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;
+3 -2
View File
@@ -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,
+6 -12
View File
@@ -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;
};
}