Add dynamic SAT collision, oval/circle shapes, rotation support, and score display

This commit is contained in:
2026-10-17 22:01:54 -04:00
parent d1af53cf07
commit f596a48899
11 changed files with 278 additions and 112 deletions
+29 -34
View File
@@ -1,4 +1,8 @@
import type { Entrance, FurnitureConfig, LayoutState, Rect, RoomConfig, Size } from '../types';
import type { LayoutState, RoomConfig } from '../types';
import { colliderCenter, satMTV, type Collider } from './collision';
import { overlapsAny } from './collision';
export { overlapsAny };
export interface Bounds {
x: number;
@@ -7,24 +11,24 @@ export interface Bounds {
height: number;
}
export function clampRectToRoom(rect: Bounds, room: RoomConfig, minSize = 10): Bounds {
export function clampRectToRoom(rect: Collider, room: RoomConfig, minSize = 10): Collider {
const width = Math.min(Math.max(minSize, rect.width), room.width);
const height = Math.min(Math.max(minSize, rect.height), room.height);
const x = Math.min(Math.max(0, rect.x), Math.max(0, room.width - width));
const y = Math.min(Math.max(0, rect.y), Math.max(0, room.height - height));
return { x, y, width, height };
return { ...rect, x, y, width, height };
}
export function clampStateToRoom(state: LayoutState): LayoutState {
const room = state.room;
const furniture: FurnitureConfig[] = state.furniture.map((f) => {
const furniture = state.furniture.map((f) => {
const b = clampRectToRoom(
{ x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height },
room,
);
return { ...f, shape: { ...f.shape, ...b } };
return { ...f, shape: { ...f.shape, x: b.x, y: b.y, width: b.width, height: b.height } };
});
let entrance: Entrance | null = state.entrance;
let entrance = state.entrance;
if (entrance) {
const ev = clampRectToRoom(
{ x: entrance.x, y: entrance.y, width: entrance.width ?? 20, height: entrance.height ?? 20 },
@@ -36,32 +40,22 @@ export function clampStateToRoom(state: LayoutState): LayoutState {
return { ...state, furniture, entrance };
}
export function rectsOverlap(a: Rect, b: Rect): boolean {
return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
}
export function overlapsAny(rect: Rect, others: Rect[]): boolean {
return others.some((o) => rectsOverlap(rect, o));
}
export function resolveMove(moving: Bounds, others: Rect[], room: RoomConfig, minSize = 10): Bounds {
export function resolveMove(moving: Collider, others: Collider[], room: RoomConfig, minSize = 10): Collider {
let r = clampRectToRoom(moving, room, minSize);
for (let iter = 0; iter < 12; iter++) {
let pushed = false;
for (const o of others) {
if (!rectsOverlap(r, o)) continue;
const overlapLeft = r.x + r.width - o.x;
const overlapRight = o.x + o.width - r.x;
const overlapTop = r.y + r.height - o.y;
const overlapBottom = o.y + o.height - r.y;
const minX = Math.min(overlapLeft, overlapRight);
const minY = Math.min(overlapTop, overlapBottom);
if (minX < minY) {
r.x = overlapLeft < overlapRight ? o.x - r.width : o.x + o.width;
} else {
r.y = overlapTop < overlapBottom ? o.y - r.height : o.y + o.height;
}
r = clampRectToRoom(r, room, minSize);
const mtv = satMTV(r, o);
if (!mtv) continue;
const cM = colliderCenter(r);
const cO = colliderCenter(o);
const dir = (cM.x - cO.x) * mtv.axis.x + (cM.y - cO.y) * mtv.axis.y;
const sign = dir >= 0 ? 1 : -1;
r = clampRectToRoom(
{ ...r, x: r.x + sign * mtv.axis.x * mtv.depth, y: r.y + sign * mtv.axis.y * mtv.depth },
room,
minSize,
);
pushed = true;
}
if (!pushed) break;
@@ -69,7 +63,7 @@ export function resolveMove(moving: Bounds, others: Rect[], room: RoomConfig, mi
return r;
}
export function findFreeSpot(room: RoomConfig, size: Size, others: Rect[], tries = 40): Bounds {
export function findFreeSpot(room: RoomConfig, size: { width: number; height: number }, others: Collider[], tries = 40): Collider {
for (let i = 0; i < tries; i++) {
const candidate = clampRectToRoom(
{
@@ -84,13 +78,10 @@ export function findFreeSpot(room: RoomConfig, size: Size, others: Rect[], tries
const resolved = resolveMove(candidate, others, room);
if (!overlapsAny(resolved, others)) return resolved;
}
return clampRectToRoom(
{ x: 0, y: 0, width: size.width, height: size.height },
room,
);
return clampRectToRoom({ x: 0, y: 0, width: size.width, height: size.height }, room);
}
export function entranceRect(state: LayoutState): Rect | null {
export function entranceCollider(state: LayoutState): Collider | null {
if (!state.entrance) return null;
return {
x: state.entrance.x,
@@ -99,3 +90,7 @@ export function entranceRect(state: LayoutState): Rect | null {
height: state.entrance.height ?? 20,
};
}
export function furnitureCollider(f: { shape: { x: number; y: number; width: number; height: number; rotation?: number } }): Collider {
return { x: f.shape.x, y: f.shape.y, width: f.shape.width, height: f.shape.height, rotation: f.shape.rotation };
}