Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Packages/server/src/socket/connectionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type { RoomManager } from "../rooms/RoomManager.js";
import type { Room } from "../rooms/Room.js";
import { config } from "../config.js";
import type { TypedServer, TypedSocket } from "./types.js";
import { sanitizePosition, stripControlChars, createRateLimiter, clamp } from "./validation.js";
import { sanitizePosition, stripControlChars, createRateLimiter } from "./validation.js";
import { sendChatHistory, cleanupChat, clearHistory } from "./chatHandler.js";
import { cleanupVoice } from "./voiceHandler.js";
import {
Expand Down Expand Up @@ -202,13 +202,15 @@ export function registerConnectionHandler(

const position = sanitizePosition(data.position);

// Clamp to room bounds
// Clamp to room bounds (obstacle collision is client-side UX only —
// the server can't know if the player is walking to a sit spot inside
// an obstacle zone, so we just enforce the outer bounds here)
const roomConfig: RoomConfig | undefined =
roomId in ROOMS ? ROOMS[roomId as RoomId] : undefined;
if (roomConfig) {
const { bounds } = roomConfig.environment;
position.x = clamp(position.x, bounds.minX, bounds.maxX);
position.z = clamp(position.z, bounds.minZ, bounds.maxZ);
position.x = Math.max(bounds.minX, Math.min(bounds.maxX, position.x));
position.z = Math.max(bounds.minZ, Math.min(bounds.maxZ, position.z));
}

const room = roomManager.getRoom(roomId);
Expand Down
160 changes: 160 additions & 0 deletions Packages/shared/src/collision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Cozy Creatures - Collision Detection Tests
//
// Depends on: collision.ts
// Used by: test runner

import { describe, it, expect } from "vitest";
import {
resolveCollisions,
clampAndResolve,
CREATURE_COLLISION_RADIUS,
} from "./collision.js";
import type { Obstacle, WalkableBounds } from "./types/room.js";

const R = CREATURE_COLLISION_RADIUS;

describe("resolveCollisions", () => {
describe("circle obstacles", () => {
const circle: Obstacle = { type: "circle", x: 0, z: 0, radius: 1 };

it("returns original position when outside", () => {
const result = resolveCollisions(5, 5, [circle], R);
expect(result.x).toBe(5);
expect(result.z).toBe(5);
});

it("pushes out when inside", () => {
const result = resolveCollisions(0.5, 0, [circle], R);
const totalRadius = 1 + R;
expect(result.x).toBeGreaterThan(totalRadius - 0.1);
expect(result.z).toBeCloseTo(0, 1);
});

it("pushes along correct direction", () => {
// Inside, approaching from -X side
const result = resolveCollisions(-0.5, 0, [circle], R);
expect(result.x).toBeLessThan(-(1 + R - 0.1));
expect(result.z).toBeCloseTo(0, 1);
});

it("handles dead center (pushes +X)", () => {
const result = resolveCollisions(0, 0, [circle], R);
expect(result.x).toBeGreaterThan(1 + R);
expect(result.z).toBeCloseTo(0, 5);
});

it("no collision at exactly the boundary", () => {
// Point at distance = radius + creatureRadius should NOT collide
const dist = 1 + R;
const result = resolveCollisions(dist, 0, [circle], R);
expect(result.x).toBe(dist);
expect(result.z).toBe(0);
});
});

describe("AABB obstacles", () => {
const box: Obstacle = { type: "aabb", minX: -1, maxX: 1, minZ: -1, maxZ: 1 };

it("returns original position when outside", () => {
const result = resolveCollisions(5, 5, [box], R);
expect(result.x).toBe(5);
expect(result.z).toBe(5);
});

it("pushes to nearest edge when inside", () => {
// Slightly right of center — nearest edge is right (+X)
const result = resolveCollisions(0.5, 0, [box], R);
expect(result.x).toBeGreaterThan(1 + R);
expect(result.z).toBe(0);
});

it("pushes to left edge", () => {
const result = resolveCollisions(-0.5, 0, [box], R);
expect(result.x).toBeLessThan(-(1 + R));
expect(result.z).toBe(0);
});

it("pushes to top edge (minZ)", () => {
const result = resolveCollisions(0, -0.5, [box], R);
expect(result.x).toBe(0);
expect(result.z).toBeLessThan(-(1 + R));
});

it("pushes to bottom edge (maxZ)", () => {
const result = resolveCollisions(0, 0.5, [box], R);
expect(result.x).toBe(0);
expect(result.z).toBeGreaterThan(1 + R);
});

it("no collision when outside expanded bounds", () => {
const result = resolveCollisions(1 + R + 0.1, 0, [box], R);
expect(result.x).toBeCloseTo(1 + R + 0.1, 5);
expect(result.z).toBe(0);
});
});

describe("multiple obstacles", () => {
it("resolves both when overlapping two obstacles", () => {
const obstacles: Obstacle[] = [
{ type: "circle", x: 0, z: 0, radius: 1 },
{ type: "circle", x: 3, z: 0, radius: 1 },
];
// Point between two circles — should be pushed out of whichever it's in
const result = resolveCollisions(0.5, 0, obstacles, R);
// Should be outside both circles
const dist1 = Math.sqrt(result.x ** 2 + result.z ** 2);
const dist2 = Math.sqrt((result.x - 3) ** 2 + result.z ** 2);
expect(dist1).toBeGreaterThanOrEqual(1 + R - 0.02);
expect(dist2).toBeGreaterThanOrEqual(1 + R - 0.02);
});

it("returns original when outside all obstacles", () => {
const obstacles: Obstacle[] = [
{ type: "circle", x: -5, z: -5, radius: 0.5 },
{ type: "aabb", minX: 5, maxX: 6, minZ: 5, maxZ: 6 },
];
const result = resolveCollisions(0, 0, obstacles, R);
expect(result.x).toBe(0);
expect(result.z).toBe(0);
});
});

it("empty obstacles array returns original position", () => {
const result = resolveCollisions(3, 4, [], R);
expect(result.x).toBe(3);
expect(result.z).toBe(4);
});
});

describe("clampAndResolve", () => {
const bounds: WalkableBounds = { minX: -10, maxX: 10, minZ: -10, maxZ: 10 };
const obstacles: Obstacle[] = [
{ type: "circle", x: 0, z: 0, radius: 1 },
];

it("clamps to bounds when no obstacle collision", () => {
const result = clampAndResolve(15, 15, bounds, []);
expect(result.x).toBe(10);
expect(result.z).toBe(10);
});

it("resolves obstacle after bounds clamp", () => {
const result = clampAndResolve(0.5, 0, bounds, obstacles);
expect(result.x).toBeGreaterThan(1 + R - 0.1);
});

it("re-clamps to bounds if obstacle pushes outside", () => {
// Obstacle near the bounds edge
const edgeObs: Obstacle[] = [
{ type: "circle", x: 9.5, z: 0, radius: 1 },
];
const result = clampAndResolve(9.5, 0, bounds, edgeObs);
expect(result.x).toBeLessThanOrEqual(10);
});

it("handles no obstacles", () => {
const result = clampAndResolve(5, 5, bounds, []);
expect(result.x).toBe(5);
expect(result.z).toBe(5);
});
});
140 changes: 140 additions & 0 deletions Packages/shared/src/collision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Cozy Creatures - Collision Detection
//
// Pure functions for obstacle collision testing and resolution.
// Used by the client for movement clamping and click targets.
//
// Depends on: types/room.ts (Obstacle, CircleObstacle, AABBObstacle, WalkableBounds)
// Used by: client (Creature.tsx, ClickPlane.tsx)

import type {
Obstacle,
CircleObstacle,
AABBObstacle,
WalkableBounds,
} from "./types/room.js";

/** Creature body collision radius (slightly larger than visual). */
export const CREATURE_COLLISION_RADIUS = 0.35;

/** Small epsilon to push resolved positions just outside the boundary. */
const RESOLVE_EPSILON = 0.01;

/**
* Resolve a circle obstacle collision. Returns the nearest point on the
* expanded circle boundary, or null if no collision.
*/
function resolveCircle(
px: number,
pz: number,
obs: CircleObstacle,
creatureRadius: number,
): { x: number; z: number } | null {
const dx = px - obs.x;
const dz = pz - obs.z;
const distSq = dx * dx + dz * dz;
const totalRadius = obs.radius + creatureRadius;

if (distSq >= totalRadius * totalRadius) return null;

const dist = Math.sqrt(distSq);
if (dist < 0.0001) {
// Dead center — push along +X arbitrarily
return { x: obs.x + totalRadius + RESOLVE_EPSILON, z: obs.z };
}

const nx = dx / dist;
const nz = dz / dist;
return {
x: obs.x + nx * (totalRadius + RESOLVE_EPSILON),
z: obs.z + nz * (totalRadius + RESOLVE_EPSILON),
};
}

/**
* Resolve an AABB obstacle collision. Returns the nearest point on the
* expanded AABB boundary, or null if no collision.
*/
function resolveAABB(
px: number,
pz: number,
obs: AABBObstacle,
creatureRadius: number,
): { x: number; z: number } | null {
const eMinX = obs.minX - creatureRadius;
const eMaxX = obs.maxX + creatureRadius;
const eMinZ = obs.minZ - creatureRadius;
const eMaxZ = obs.maxZ + creatureRadius;

if (px < eMinX || px > eMaxX || pz < eMinZ || pz > eMaxZ) return null;

// Push toward the nearest edge
const dLeft = px - eMinX;
const dRight = eMaxX - px;
const dTop = pz - eMinZ;
const dBottom = eMaxZ - pz;
const minD = Math.min(dLeft, dRight, dTop, dBottom);

if (minD === dLeft) return { x: eMinX - RESOLVE_EPSILON, z: pz };
if (minD === dRight) return { x: eMaxX + RESOLVE_EPSILON, z: pz };
if (minD === dTop) return { x: px, z: eMinZ - RESOLVE_EPSILON };
return { x: px, z: eMaxZ + RESOLVE_EPSILON };
}

/**
* Resolve all obstacle collisions for a position. Iterates obstacles and
* pushes the point out of any it overlaps. Multiple passes handle cases
* where resolving one collision pushes into another.
*/
export function resolveCollisions(
px: number,
pz: number,
obstacles: readonly Obstacle[],
creatureRadius: number = CREATURE_COLLISION_RADIUS,
): { x: number; z: number } {
let x = px;
let z = pz;

for (let pass = 0; pass < 3; pass++) {
let resolved = false;
for (const obs of obstacles) {
const result =
obs.type === "circle"
? resolveCircle(x, z, obs, creatureRadius)
: resolveAABB(x, z, obs, creatureRadius);
if (result) {
x = result.x;
z = result.z;
resolved = true;
}
}
if (!resolved) break;
}

return { x, z };
}

/**
* Clamp a position to room bounds AND resolve obstacle collisions.
*/
export function clampAndResolve(
px: number,
pz: number,
bounds: WalkableBounds,
obstacles: readonly Obstacle[],
creatureRadius: number = CREATURE_COLLISION_RADIUS,
): { x: number; z: number } {
// Clamp to bounds first
let x = Math.max(bounds.minX, Math.min(bounds.maxX, px));
let z = Math.max(bounds.minZ, Math.min(bounds.maxZ, pz));

// Resolve obstacles
const resolved = resolveCollisions(x, z, obstacles, creatureRadius);
x = resolved.x;
z = resolved.z;

// Re-clamp to bounds (obstacle resolution may push outside)
x = Math.max(bounds.minX, Math.min(bounds.maxX, x));
z = Math.max(bounds.minZ, Math.min(bounds.maxZ, z));

return { x, z };
}
32 changes: 32 additions & 0 deletions Packages/shared/src/constants/rooms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { describe, it, expect } from "vitest";
import { ROOMS, DEFAULT_ROOM } from "./rooms.js";
import { BASE_ANIMATIONS } from "./creatures.js";

describe("room constants", () => {
it("DEFAULT_ROOM exists in the registry", () => {
Expand All @@ -21,4 +22,35 @@ describe("room constants", () => {
}
});

it("sit spot animations are valid creature animations", () => {
for (const [, config] of Object.entries(ROOMS)) {
for (const spot of config.environment.sitSpots) {
if (spot.animation) {
expect(BASE_ANIMATIONS).toContain(spot.animation);
}
}
}
});

it("every room has obstacles array", () => {
for (const [, config] of Object.entries(ROOMS)) {
expect(Array.isArray(config.environment.obstacles)).toBe(true);
}
});

it("obstacle definitions are well-formed", () => {
for (const [, config] of Object.entries(ROOMS)) {
for (const obs of config.environment.obstacles) {
if (obs.type === "circle") {
expect(obs.radius).toBeGreaterThan(0);
expect(Number.isFinite(obs.x)).toBe(true);
expect(Number.isFinite(obs.z)).toBe(true);
} else {
expect(obs.minX).toBeLessThan(obs.maxX);
expect(obs.minZ).toBeLessThan(obs.maxZ);
}
}
}
});

});
Loading