From 23a7be0fc929a869b790a121c43e0a8137542c75 Mon Sep 17 00:00:00 2001 From: JRussas <159085336+JMRussas@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:47:13 -0500 Subject: [PATCH 1/4] Furniture collision, cafe chairs/stools, and sit spot animations Add obstacle collision detection (circle + AABB shapes) for all three rooms so creatures can't walk through tables, bars, and couches. Cafe environment now has chairs around tables, bar stools, and 3 couch seats with per-spot animations (eat at tables, idle at bar, rest on couch). Sitting creatures use the sit spot's Y position for elevated surfaces. Also adds start/stop convenience scripts in tools/. Co-Authored-By: Claude Opus 4.6 --- .../server/src/socket/connectionHandler.ts | 15 +- Packages/shared/src/collision.test.ts | 160 ++++++++++++++++++ Packages/shared/src/collision.ts | 142 ++++++++++++++++ Packages/shared/src/constants/rooms.test.ts | 17 ++ Packages/shared/src/constants/rooms.ts | 53 +++++- Packages/shared/src/index.ts | 1 + Packages/shared/src/types/room.ts | 24 +++ apps/client/src/creatures/Creature.tsx | 27 +-- apps/client/src/creatures/RemoteCreature.tsx | 7 +- .../src/scene/environments/ClickPlane.tsx | 18 +- .../src/scene/environments/CozyCafe.tsx | 63 ++++++- .../src/scene/environments/RooftopGarden.tsx | 2 +- .../scene/environments/StarlightLounge.tsx | 2 +- tools/start.sh | 46 +++++ tools/stop.sh | 18 ++ 15 files changed, 556 insertions(+), 39 deletions(-) create mode 100644 Packages/shared/src/collision.test.ts create mode 100644 Packages/shared/src/collision.ts create mode 100644 tools/start.sh create mode 100644 tools/stop.sh diff --git a/Packages/server/src/socket/connectionHandler.ts b/Packages/server/src/socket/connectionHandler.ts index c6d27c7..48dcd66 100644 --- a/Packages/server/src/socket/connectionHandler.ts +++ b/Packages/server/src/socket/connectionHandler.ts @@ -5,7 +5,8 @@ // inputs are validated/sanitized. // // Depends on: @cozy/shared (event types, Player, CREATURES, SKINS, ROOMS, -// DEFAULT_ROOM, MAX_PLAYER_NAME, ROOM_SWITCH_COOLDOWN_MS), +// DEFAULT_ROOM, MAX_PLAYER_NAME, ROOM_SWITCH_COOLDOWN_MS, +// clampAndResolve), // socket/types.ts, socket/validation.ts, socket/chatHandler.ts, // socket/voiceHandler.ts, rooms/RoomManager.ts, config.ts, // db/playerQueries.ts, db/inventoryQueries.ts @@ -21,13 +22,14 @@ import { MAX_PLAYER_NAME, SKINS, ROOM_SWITCH_COOLDOWN_MS, + clampAndResolve, } from "@cozy/shared"; import type { CreatureTypeId, RoomId, SkinId } from "@cozy/shared"; 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 { @@ -202,13 +204,14 @@ export function registerConnectionHandler( const position = sanitizePosition(data.position); - // Clamp to room bounds + // Clamp to room bounds + obstacle collision 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); + const { bounds, obstacles } = roomConfig.environment; + const resolved = clampAndResolve(position.x, position.z, bounds, obstacles); + position.x = resolved.x; + position.z = resolved.z; } const room = roomManager.getRoom(roomId); diff --git a/Packages/shared/src/collision.test.ts b/Packages/shared/src/collision.test.ts new file mode 100644 index 0000000..9fd1006 --- /dev/null +++ b/Packages/shared/src/collision.test.ts @@ -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); + }); +}); diff --git a/Packages/shared/src/collision.ts b/Packages/shared/src/collision.ts new file mode 100644 index 0000000..00b907c --- /dev/null +++ b/Packages/shared/src/collision.ts @@ -0,0 +1,142 @@ +// Cozy Creatures - Collision Detection +// +// Pure functions for obstacle collision testing and resolution. +// Used by both client (movement clamping, click targets) and server +// (position validation). +// +// Depends on: types/room.ts (Obstacle, CircleObstacle, AABBObstacle, WalkableBounds) +// Used by: client (Creature.tsx, ClickPlane.tsx), server (connectionHandler.ts) + +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. + * This is the single function both client and server should call. + */ +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 }; +} diff --git a/Packages/shared/src/constants/rooms.test.ts b/Packages/shared/src/constants/rooms.test.ts index 250c0d4..4afe7bc 100644 --- a/Packages/shared/src/constants/rooms.test.ts +++ b/Packages/shared/src/constants/rooms.test.ts @@ -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", () => { @@ -21,4 +22,20 @@ 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); + } + }); + }); diff --git a/Packages/shared/src/constants/rooms.ts b/Packages/shared/src/constants/rooms.ts index 2ca3e9e..7931a3d 100644 --- a/Packages/shared/src/constants/rooms.ts +++ b/Packages/shared/src/constants/rooms.ts @@ -1,6 +1,7 @@ // Cozy Creatures - Room Constants // -// Predefined room configurations with environment data (bounds, sit spots). +// Predefined room configurations with environment data (bounds, sit spots, +// obstacle collision shapes). // // Depends on: types/room.ts, constants/config.ts // Used by: client room browser, server room manager, @@ -19,11 +20,32 @@ export const ROOMS = { environment: { bounds: { minX: -8, maxX: 8, minZ: -8, maxZ: 8 }, sitSpots: [ - { id: "cafe-table-1", position: { x: -3, y: 0, z: -2 }, rotation: 0, label: "Table" }, - { id: "cafe-table-2", position: { x: 3, y: 0, z: -2 }, rotation: Math.PI, label: "Table" }, - { id: "cafe-couch", position: { x: 0, y: 0, z: 4 }, rotation: Math.PI, label: "Couch" }, - { id: "cafe-stool-1", position: { x: -5, y: 0, z: 1 }, rotation: Math.PI / 2, label: "Bar Stool" }, - { id: "cafe-stool-2", position: { x: -5, y: 0, z: 3 }, rotation: Math.PI / 2, label: "Bar Stool" }, + // Table 1 chairs (4 around table at [-3, -2]) + { id: "cafe-table-1-n", position: { x: -3, y: 0, z: -2.85 }, rotation: 0, label: "Chair", animation: "eat" }, + { id: "cafe-table-1-s", position: { x: -3, y: 0, z: -1.15 }, rotation: Math.PI, label: "Chair", animation: "eat" }, + { id: "cafe-table-1-e", position: { x: -2.15, y: 0, z: -2 }, rotation: -Math.PI / 2, label: "Chair", animation: "eat" }, + { id: "cafe-table-1-w", position: { x: -3.85, y: 0, z: -2 }, rotation: Math.PI / 2, label: "Chair", animation: "eat" }, + // Table 2 chairs (4 around table at [3, -2]) + { id: "cafe-table-2-n", position: { x: 3, y: 0, z: -2.85 }, rotation: 0, label: "Chair", animation: "eat" }, + { id: "cafe-table-2-s", position: { x: 3, y: 0, z: -1.15 }, rotation: Math.PI, label: "Chair", animation: "eat" }, + { id: "cafe-table-2-e", position: { x: 3.85, y: 0, z: -2 }, rotation: -Math.PI / 2, label: "Chair", animation: "eat" }, + { id: "cafe-table-2-w", position: { x: 2.15, y: 0, z: -2 }, rotation: Math.PI / 2, label: "Chair", animation: "eat" }, + // Couch (3 seats across the 3-unit-wide couch, creature rests ON the surface) + { id: "cafe-couch-l", position: { x: -1, y: 0.2, z: 4.3 }, rotation: Math.PI, label: "Couch", animation: "rest" }, + { id: "cafe-couch-c", position: { x: 0, y: 0.2, z: 4.3 }, rotation: Math.PI, label: "Couch", animation: "rest" }, + { id: "cafe-couch-r", position: { x: 1, y: 0.2, z: 4.3 }, rotation: Math.PI, label: "Couch", animation: "rest" }, + // Bar stools (in front of bar counter) + { id: "cafe-stool-1", position: { x: -4.3, y: 0, z: 1 }, rotation: Math.PI / 2, label: "Bar Stool", animation: "idle" }, + { id: "cafe-stool-2", position: { x: -4.3, y: 0, z: 3 }, rotation: Math.PI / 2, label: "Bar Stool", animation: "idle" }, + ], + obstacles: [ + // Round tables + chairs (expanded radius to cover chairs) + { type: "circle", x: -3, z: -2, radius: 1.1 }, + { type: "circle", x: 3, z: -2, radius: 1.1 }, + // Bar counter (box 1×4 at [-5.5, 0, 2]) + { type: "aabb", minX: -6.0, maxX: -5.0, minZ: 0.0, maxZ: 4.0 }, + // Couch (box 3×0.8 at [0, 0.2, 4.3] + back) + { type: "aabb", minX: -1.5, maxX: 1.5, minZ: 3.9, maxZ: 4.7 }, ], }, }, @@ -41,6 +63,16 @@ export const ROOMS = { { id: "garden-cushion", position: { x: 0, y: 0, z: 3 }, rotation: Math.PI / 4, label: "Cushion" }, { id: "garden-swing", position: { x: -6, y: 0, z: 2 }, rotation: -Math.PI / 4, label: "Swing" }, ], + obstacles: [ + // Benches (box 1.5×0.5) + { type: "aabb", minX: -4.75, maxX: -3.25, minZ: -5.25, maxZ: -4.75 }, + { type: "aabb", minX: 3.25, maxX: 4.75, minZ: -5.25, maxZ: -4.75 }, + // Swing frame (legs ~1.2 wide, seat 0.8×0.4 at [-6, 0, 2]) + { type: "aabb", minX: -6.6, maxX: -5.4, minZ: 1.8, maxZ: 2.2 }, + // Interior potted plants (pot r=0.2, foliage r=0.3) + { type: "circle", x: 0, z: -8, radius: 0.3 }, + { type: "circle", x: -5, z: 6, radius: 0.3 }, + ], }, }, "starlight-lounge": { @@ -57,6 +89,15 @@ export const ROOMS = { { id: "lounge-pillow", position: { x: 0, y: 0, z: 5 }, rotation: Math.PI, label: "Pillow" }, { id: "lounge-bar", position: { x: 6, y: 0, z: 0 }, rotation: -Math.PI / 2, label: "Bar Seat" }, ], + obstacles: [ + // Rotated sofas (1.8×0.8 at ±π/4 — circular approximation) + { type: "circle", x: -3, z: -3, radius: 1.0 }, + { type: "circle", x: 3, z: -3, radius: 1.0 }, + // Floor pillow (cylinder r=0.5 at [0, 0.1, 5]) + { type: "circle", x: 0, z: 5, radius: 0.5 }, + // Bar counter (box 0.8×3 at [6.5, 0, 0]) + { type: "aabb", minX: 6.1, maxX: 6.9, minZ: -1.5, maxZ: 1.5 }, + ], }, }, } satisfies Record; diff --git a/Packages/shared/src/index.ts b/Packages/shared/src/index.ts index 2b409e2..19ae015 100644 --- a/Packages/shared/src/index.ts +++ b/Packages/shared/src/index.ts @@ -12,6 +12,7 @@ export * from "./types/chat.js"; export * from "./types/events.js"; export * from "./types/voice.js"; export * from "./types/skin.js"; +export * from "./collision.js"; export * from "./constants/config.js"; export * from "./constants/creatures.js"; export * from "./constants/rooms.js"; diff --git a/Packages/shared/src/types/room.ts b/Packages/shared/src/types/room.ts index 56b6a18..be844a2 100644 --- a/Packages/shared/src/types/room.ts +++ b/Packages/shared/src/types/room.ts @@ -33,14 +33,38 @@ export interface SitSpot { rotation: number; /** Display label shown on hover (e.g. "Table", "Bench"). */ label: string; + /** Animation to play when sitting (defaults to "rest"). */ + animation?: string; } +/** Circular obstacle (tables, pillows, rotated sofas). */ +export interface CircleObstacle { + type: "circle"; + x: number; + z: number; + radius: number; +} + +/** Axis-aligned box obstacle (bars, couches, benches). */ +export interface AABBObstacle { + type: "aabb"; + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +/** A ground-level furniture collision shape. */ +export type Obstacle = CircleObstacle | AABBObstacle; + /** Per-room environment data used by the client to render the 3D scene. */ export interface RoomEnvironment { /** Walkable boundary rectangle. Movement is clamped to this area. */ bounds: WalkableBounds; /** Interactive sit spots in this room. */ sitSpots: SitSpot[]; + /** Ground-level furniture collision shapes. */ + obstacles: Obstacle[]; } /** Static room definition used in the ROOMS constant. */ diff --git a/apps/client/src/creatures/Creature.tsx b/apps/client/src/creatures/Creature.tsx index 3b08657..856d23e 100644 --- a/apps/client/src/creatures/Creature.tsx +++ b/apps/client/src/creatures/Creature.tsx @@ -8,14 +8,14 @@ // Depends on: @react-three/fiber, three, stores/playerStore, stores/roomStore, // stores/skinStore, CreatureModel, CreatureFallback, CreatureShadow, // ChatBubble, SpeakingIndicator, AudioRangeRing, config, utils/math, -// @cozy/shared (ROOMS, SKINS, SIT_SPOT_ARRIVAL_THRESHOLD), +// @cozy/shared (ROOMS, SKINS, clampAndResolve), // networking/socket // Used by: scene/IsometricScene import { Suspense, useRef } from "react"; import { useFrame } from "@react-three/fiber"; import * as THREE from "three"; -import { ROOMS, SKINS } from "@cozy/shared"; +import { ROOMS, SKINS, clampAndResolve } from "@cozy/shared"; import type { RoomId, SkinId, RoomConfig } from "@cozy/shared"; import { usePlayerStore } from "../stores/playerStore"; import { useRoomStore } from "../stores/roomStore"; @@ -58,10 +58,11 @@ export default function Creature() { const store = usePlayerStore.getState(); const roomId = useRoomStore.getState().roomId; - // Get room bounds + // Get room bounds and obstacles const roomConfig: RoomConfig | undefined = roomId && roomId in ROOMS ? ROOMS[roomId as RoomId] : undefined; const bounds = roomConfig?.environment.bounds; + const obstacles = roomConfig?.environment.obstacles; // --- Sit/stand transition detection --- if (store.isSitting && !wasSitting.current) { @@ -70,18 +71,19 @@ export default function Creature() { (s) => s.id === store.sitSpotId, ); if (sitSpot) { - group.position.set(sitSpot.position.x, 0, sitSpot.position.z); + group.position.set(sitSpot.position.x, sitSpot.position.y, sitSpot.position.z); group.rotation.y = sitSpot.rotation; - store.setPosition({ x: sitSpot.position.x, y: 0, z: sitSpot.position.z }); + store.setPosition(sitSpot.position); } - modelRef.current?.setAnimation("rest"); + modelRef.current?.setAnimation(sitSpot?.animation ?? "rest"); wasSitting.current = true; return; } if (!store.isSitting && wasSitting.current) { - // Just stood up — emit player:stand and resume idle + // Just stood up — emit player:stand, reset Y to ground, resume idle socket.emit("player:stand"); + group.position.y = 0; modelRef.current?.setAnimation("idle"); wasSitting.current = false; } @@ -106,10 +108,13 @@ export default function Creature() { const step = Math.min(MOVE_SPEED * delta, distance); currentPos.addScaledVector(direction.current, step); - // Clamp to room bounds - if (bounds) { - currentPos.x = Math.max(bounds.minX, Math.min(bounds.maxX, currentPos.x)); - currentPos.z = Math.max(bounds.minZ, Math.min(bounds.maxZ, currentPos.z)); + // Clamp to room bounds + obstacle collision + if (bounds && obstacles) { + // Skip obstacle collision when walking to a sit spot (creature needs to reach it) + const useObstacles = store.pendingSitId ? [] : obstacles; + const resolved = clampAndResolve(currentPos.x, currentPos.z, bounds, useObstacles); + currentPos.x = resolved.x; + currentPos.z = resolved.z; } // Face movement direction (exponential smoothing — frame-rate independent) diff --git a/apps/client/src/creatures/RemoteCreature.tsx b/apps/client/src/creatures/RemoteCreature.tsx index 88f0d44..67304d4 100644 --- a/apps/client/src/creatures/RemoteCreature.tsx +++ b/apps/client/src/creatures/RemoteCreature.tsx @@ -79,11 +79,11 @@ export default function RemoteCreature({ playerId }: RemoteCreatureProps) { (s) => s.id === player.sitSpotId, ); if (sitSpot) { - group.position.set(sitSpot.position.x, 0, sitSpot.position.z); + group.position.set(sitSpot.position.x, sitSpot.position.y, sitSpot.position.z); group.rotation.y = sitSpot.rotation; } if (!wasSitting.current) { - modelRef.current?.setAnimation("rest"); + modelRef.current?.setAnimation(sitSpot?.animation ?? "rest"); wasSitting.current = true; // Reset hysteresis so we don't flicker on stand movingFrames.current = 0; @@ -94,8 +94,9 @@ export default function RemoteCreature({ playerId }: RemoteCreatureProps) { return; } - // Just stood up — resume idle + // Just stood up — reset Y to ground, resume idle if (wasSitting.current) { + group.position.y = 0; wasSitting.current = false; modelRef.current?.setAnimation("idle"); } diff --git a/apps/client/src/scene/environments/ClickPlane.tsx b/apps/client/src/scene/environments/ClickPlane.tsx index 4ecdbee..47232d9 100644 --- a/apps/client/src/scene/environments/ClickPlane.tsx +++ b/apps/client/src/scene/environments/ClickPlane.tsx @@ -1,23 +1,22 @@ // Cozy Creatures - Click Plane // // Invisible ground plane for click-to-move input. Clamps the click target -// to the room's walkable bounds. +// to the room's walkable bounds and resolves obstacle collisions. // -// Depends on: @cozy/shared (WalkableBounds), stores/playerStore +// Depends on: @cozy/shared (WalkableBounds, Obstacle, clampAndResolve), +// stores/playerStore // Used by: scene/environments/CozyCafe, RooftopGarden, StarlightLounge -import type { WalkableBounds } from "@cozy/shared"; +import type { WalkableBounds, Obstacle } from "@cozy/shared"; +import { clampAndResolve } from "@cozy/shared"; import { usePlayerStore } from "../../stores/playerStore"; interface ClickPlaneProps { bounds: WalkableBounds; + obstacles: readonly Obstacle[]; } -function clamp(v: number, min: number, max: number): number { - return Math.max(min, Math.min(max, v)); -} - -export default function ClickPlane({ bounds }: ClickPlaneProps) { +export default function ClickPlane({ bounds, obstacles }: ClickPlaneProps) { const setTarget = usePlayerStore((s) => s.setTarget); const setSitting = usePlayerStore((s) => s.setSitting); @@ -31,8 +30,7 @@ export default function ClickPlane({ bounds }: ClickPlaneProps) { position={[0, -0.01, 0]} onPointerDown={(e) => { e.stopPropagation(); - const x = clamp(e.point.x, bounds.minX, bounds.maxX); - const z = clamp(e.point.z, bounds.minZ, bounds.maxZ); + const { x, z } = clampAndResolve(e.point.x, e.point.z, bounds, obstacles); // Stand up if sitting, and cancel any pending sit walk const { isSitting, pendingSitId } = usePlayerStore.getState(); diff --git a/apps/client/src/scene/environments/CozyCafe.tsx b/apps/client/src/scene/environments/CozyCafe.tsx index 1a98b16..247dfac 100644 --- a/apps/client/src/scene/environments/CozyCafe.tsx +++ b/apps/client/src/scene/environments/CozyCafe.tsx @@ -61,6 +61,51 @@ function BarCounter() { ); } +/** Small chair with seat and back, rotated to face a direction. */ +function Chair({ position, rotation = 0 }: { position: [number, number, number]; rotation?: number }) { + return ( + + {/* Seat */} + + + + + {/* Back rest (-Z in local space, faces away from table) */} + + + + + {/* Legs */} + {[-0.15, 0.15].map((x) => + [-0.15, 0.15].map((z) => ( + + + + + )), + )} + + ); +} + +/** Bar stool — cylinder seat on a single leg. */ +function BarStool({ position }: { position: [number, number, number] }) { + return ( + + {/* Seat */} + + + + + {/* Leg */} + + + + + + ); +} + /** Hanging pendant light (emissive sphere). */ function PendantLight({ position }: { position: [number, number, number] }) { return ( @@ -91,7 +136,7 @@ export default function CozyCafe() { return ( <> - + {/* Ground — warm wood floor */} @@ -109,6 +154,18 @@ export default function CozyCafe() {
+ {/* Chairs around table 1 (-3, -2) */} + + + + + + {/* Chairs around table 2 (3, -2) */} + + + + + {/* Coffee cups on tables */} @@ -116,6 +173,10 @@ export default function CozyCafe() { {/* Bar counter */} + {/* Bar stools */} + + + {/* Couch area (simple box sofa) */} diff --git a/apps/client/src/scene/environments/RooftopGarden.tsx b/apps/client/src/scene/environments/RooftopGarden.tsx index 1042f5d..7ceb29e 100644 --- a/apps/client/src/scene/environments/RooftopGarden.tsx +++ b/apps/client/src/scene/environments/RooftopGarden.tsx @@ -117,7 +117,7 @@ export default function RooftopGarden() { return ( <> - + {/* Sunset sky */} - + {/* Dark background color */} diff --git a/tools/start.sh b/tools/start.sh new file mode 100644 index 0000000..1dfd075 --- /dev/null +++ b/tools/start.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Cozy Creatures — Launch everything +# +# Usage: ./tools/start.sh [--no-docker] +# +# Starts Docker (LiveKit), installs deps, and launches dev servers. +# Pass --no-docker to skip LiveKit (voice chat disabled). + +set -e +cd "$(dirname "$0")/.." + +SKIP_DOCKER=false +for arg in "$@"; do + case "$arg" in + --no-docker) SKIP_DOCKER=true ;; + esac +done + +echo "=== Cozy Creatures ===" + +# 1. Docker / LiveKit +if [ "$SKIP_DOCKER" = false ]; then + echo "" + echo "[1/3] Starting LiveKit (Docker)..." + if docker compose up -d 2>/dev/null; then + echo " LiveKit running on port 7880" + else + echo " WARNING: Docker not available — voice chat will be disabled" + fi +else + echo "" + echo "[1/3] Skipping Docker (--no-docker)" +fi + +# 2. Install dependencies +echo "" +echo "[2/3] Installing dependencies..." +pnpm install --silent + +# 3. Start dev servers +echo "" +echo "[3/3] Starting dev servers..." +echo " Client: https://localhost:5173" +echo " Server: http://localhost:3001" +echo "" +exec pnpm dev diff --git a/tools/stop.sh b/tools/stop.sh new file mode 100644 index 0000000..a6e6231 --- /dev/null +++ b/tools/stop.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Cozy Creatures — Stop everything +# +# Stops dev servers (if backgrounded) and Docker containers. + +set -e +cd "$(dirname "$0")/.." + +echo "=== Stopping Cozy Creatures ===" + +# Stop Docker containers +if docker compose down 2>/dev/null; then + echo " LiveKit stopped" +else + echo " Docker not running (or not available)" +fi + +echo " Done. Dev servers will stop when their terminal closes." From 669964122766f606cce5b3bd460f6b430e7c8baf Mon Sep 17 00:00:00 2001 From: JRussas <159085336+JMRussas@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:50:15 -0500 Subject: [PATCH 2/4] Fix server-side sit-walk rubber-banding and review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server was applying obstacle collision on player:move, causing rubber-banding when creatures walked to sit spots inside obstacle zones (e.g. chairs inside table collision circles). Server now only enforces bounds clamping — obstacle collision is a client UX concern. Also: set executable bit on tools scripts, add obstacle data validation test. Co-Authored-By: Claude Opus 4.6 --- Packages/server/src/socket/connectionHandler.ts | 15 +++++++-------- Packages/shared/src/constants/rooms.test.ts | 15 +++++++++++++++ tools/start.sh | 0 tools/stop.sh | 0 4 files changed, 22 insertions(+), 8 deletions(-) mode change 100644 => 100755 tools/start.sh mode change 100644 => 100755 tools/stop.sh diff --git a/Packages/server/src/socket/connectionHandler.ts b/Packages/server/src/socket/connectionHandler.ts index 48dcd66..5dda414 100644 --- a/Packages/server/src/socket/connectionHandler.ts +++ b/Packages/server/src/socket/connectionHandler.ts @@ -5,8 +5,7 @@ // inputs are validated/sanitized. // // Depends on: @cozy/shared (event types, Player, CREATURES, SKINS, ROOMS, -// DEFAULT_ROOM, MAX_PLAYER_NAME, ROOM_SWITCH_COOLDOWN_MS, -// clampAndResolve), +// DEFAULT_ROOM, MAX_PLAYER_NAME, ROOM_SWITCH_COOLDOWN_MS), // socket/types.ts, socket/validation.ts, socket/chatHandler.ts, // socket/voiceHandler.ts, rooms/RoomManager.ts, config.ts, // db/playerQueries.ts, db/inventoryQueries.ts @@ -22,7 +21,6 @@ import { MAX_PLAYER_NAME, SKINS, ROOM_SWITCH_COOLDOWN_MS, - clampAndResolve, } from "@cozy/shared"; import type { CreatureTypeId, RoomId, SkinId } from "@cozy/shared"; import type { RoomManager } from "../rooms/RoomManager.js"; @@ -204,14 +202,15 @@ export function registerConnectionHandler( const position = sanitizePosition(data.position); - // Clamp to room bounds + obstacle collision + // 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, obstacles } = roomConfig.environment; - const resolved = clampAndResolve(position.x, position.z, bounds, obstacles); - position.x = resolved.x; - position.z = resolved.z; + const { bounds } = roomConfig.environment; + 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); diff --git a/Packages/shared/src/constants/rooms.test.ts b/Packages/shared/src/constants/rooms.test.ts index 4afe7bc..b900fdf 100644 --- a/Packages/shared/src/constants/rooms.test.ts +++ b/Packages/shared/src/constants/rooms.test.ts @@ -38,4 +38,19 @@ describe("room constants", () => { } }); + 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); + } + } + } + }); + }); diff --git a/tools/start.sh b/tools/start.sh old mode 100644 new mode 100755 diff --git a/tools/stop.sh b/tools/stop.sh old mode 100644 new mode 100755 From 10525b9f619542f37e12391d14480aef2db14620 Mon Sep 17 00:00:00 2001 From: JRussas <159085336+JMRussas@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:52:42 -0500 Subject: [PATCH 3/4] Fix TS2339: widen sitSpot type in RemoteCreature to include animation The `satisfies` on ROOMS preserves narrow literal types, so rooms without `animation` on sit spots exclude it from the union. Explicitly typing roomConfig as RoomConfig widens to the SitSpot interface which has `animation?`. Co-Authored-By: Claude Opus 4.6 --- apps/client/src/creatures/RemoteCreature.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/creatures/RemoteCreature.tsx b/apps/client/src/creatures/RemoteCreature.tsx index 67304d4..5802f3e 100644 --- a/apps/client/src/creatures/RemoteCreature.tsx +++ b/apps/client/src/creatures/RemoteCreature.tsx @@ -15,7 +15,7 @@ import { Suspense, useRef } from "react"; import { useFrame } from "@react-three/fiber"; import * as THREE from "three"; import { DEFAULT_CREATURE, ROOMS, SKINS } from "@cozy/shared"; -import type { RoomId, SkinId } from "@cozy/shared"; +import type { RoomId, RoomConfig, SkinId } from "@cozy/shared"; import { useRoomStore } from "../stores/roomStore"; import CreatureModel from "./CreatureModel"; import type { CreatureModelHandle } from "./CreatureModel"; @@ -74,7 +74,7 @@ export default function RemoteCreature({ playerId }: RemoteCreatureProps) { if (isSitting) { // Snap to sit spot position/rotation and play rest animation - const roomConfig = roomId && roomId in ROOMS ? ROOMS[roomId as RoomId] : undefined; + const roomConfig: RoomConfig | undefined = roomId && roomId in ROOMS ? ROOMS[roomId as RoomId] : undefined; const sitSpot = roomConfig?.environment.sitSpots.find( (s) => s.id === player.sitSpotId, ); From c8e2abd0ebc80bdcd5cc7905291c1ce7a187af4f Mon Sep 17 00:00:00 2001 From: JRussas <159085336+JMRussas@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:55:53 -0500 Subject: [PATCH 4/4] Fix stale collision.ts header and JSDoc referencing server usage Server no longer calls clampAndResolve after the rubber-banding fix. Updated file header and JSDoc to reflect client-only usage. Co-Authored-By: Claude Opus 4.6 --- Packages/shared/src/collision.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Packages/shared/src/collision.ts b/Packages/shared/src/collision.ts index 00b907c..188e18f 100644 --- a/Packages/shared/src/collision.ts +++ b/Packages/shared/src/collision.ts @@ -1,11 +1,10 @@ // Cozy Creatures - Collision Detection // // Pure functions for obstacle collision testing and resolution. -// Used by both client (movement clamping, click targets) and server -// (position validation). +// 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), server (connectionHandler.ts) +// Used by: client (Creature.tsx, ClickPlane.tsx) import type { Obstacle, @@ -116,7 +115,6 @@ export function resolveCollisions( /** * Clamp a position to room bounds AND resolve obstacle collisions. - * This is the single function both client and server should call. */ export function clampAndResolve( px: number,