653 lines
21 KiB
TypeScript
653 lines
21 KiB
TypeScript
// The living crowd. Renderer-agnostic on purpose: this owns where everyone is
|
|
// and what they're doing, the Phaser layer just draws whatever it finds here.
|
|
|
|
import type { EventBus } from '../../core/EventBus';
|
|
import type { RngStream } from '../../core/SeededRNG';
|
|
import type { Archetype, DrunkStage, Patron } from '../../data/types';
|
|
import { drunkStage } from '../../rules/drunk';
|
|
import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMap';
|
|
import type { StallDef, VenueMap, WorldPoint } from './venueMap';
|
|
|
|
export type Activity =
|
|
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'
|
|
| 'squaringUp';
|
|
|
|
export interface Agent {
|
|
patron: Patron;
|
|
x: number; y: number;
|
|
vx: number; vy: number;
|
|
activity: Activity;
|
|
target: WorldPoint;
|
|
dwellMs: number; // ms left at the current anchor
|
|
bobPhase: number; // 0..1, advanced on beat:tick, drives the dance bob
|
|
stallId?: string; // set while activity === 'inStall'
|
|
handled: boolean; // player has already dealt with this one
|
|
gone: boolean; // reached the exit / left the sim; scene should drop the sprite
|
|
lastBumpMs: number; // cooldown so bumping can't spam meters
|
|
/** In-game minutes this patron intends to stay before going home. */
|
|
stayMinutes: number;
|
|
/** Clock minute they hit the floor (set on first update tick). */
|
|
arrivedAtMin?: number;
|
|
/** Done for the night: walking to the entry, then gone (emits floor:leave). */
|
|
headingHome?: boolean;
|
|
}
|
|
|
|
export interface CrowdSimOpts {
|
|
map: VenueMap;
|
|
bus: EventBus;
|
|
rng: RngStream;
|
|
}
|
|
|
|
type Haunt = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke';
|
|
|
|
const HAUNT_KINDS: readonly Haunt[] = ['bar', 'dance', 'booth', 'toilet', 'smoke'];
|
|
|
|
// Where each sort of punter gravitates. Weights, not probabilities — every
|
|
// archetype needs an entry or the crowd stops moving.
|
|
const HAUNTS: Readonly<Record<Archetype, Readonly<Record<Haunt, number>>>> = {
|
|
fresh18: { bar: 3, dance: 6, booth: 2, toilet: 2, smoke: 1 },
|
|
almost18: { bar: 2, dance: 5, booth: 3, toilet: 2, smoke: 2 },
|
|
financeBro: { bar: 8, dance: 2, booth: 3, toilet: 2, smoke: 1 },
|
|
influencer: { bar: 2, dance: 8, booth: 2, toilet: 2, smoke: 1 },
|
|
regular: { bar: 6, dance: 2, booth: 3, toilet: 1, smoke: 4 },
|
|
ownersMate: { bar: 7, dance: 2, booth: 5, toilet: 1, smoke: 2 },
|
|
quietMessy: { bar: 3, dance: 1, booth: 7, toilet: 3, smoke: 2 },
|
|
inspector: { bar: 2, dance: 1, booth: 4, toilet: 2, smoke: 1 },
|
|
punter: { bar: 4, dance: 4, booth: 3, toilet: 2, smoke: 2 },
|
|
};
|
|
|
|
const ACTIVITY_FOR: Readonly<Record<Haunt, Activity>> = {
|
|
bar: 'atBar',
|
|
dance: 'dancing',
|
|
booth: 'inBooth',
|
|
toilet: 'inStall',
|
|
smoke: 'smoking',
|
|
};
|
|
|
|
const SPEED_MIN = 28;
|
|
const SPEED_MAX = 44;
|
|
// Transits across a 1280x720 venue take ~30s at these speeds, so short dwells
|
|
// left ~68% of the crowd permanently walking and the bar/dance floor reading as
|
|
// empty. The player has to be able to FIND infractions where they'd expect them.
|
|
const DWELL_MIN_MS = 7000;
|
|
const DWELL_MAX_MS = 22_000;
|
|
const ARRIVE_PX = 2.5;
|
|
const WAYPOINT_PX = 9; // half a tile — loose enough that a drunk sway can't orbit one
|
|
const MAX_STEP_MS = 50;
|
|
const STUCK_MS = 700;
|
|
|
|
// Intoxication per in-game minute for a patron with zero tolerance. At the bar
|
|
// you're actively buying; everywhere else you're nursing what you've got.
|
|
const BAR_DRIFT_PER_MIN = 0.011;
|
|
const AMBIENT_DRIFT_PER_MIN = 0.0012;
|
|
|
|
const SWAY_BY_STAGE: Readonly<Record<DrunkStage, number>> = {
|
|
sober: 0, tipsy: 4, loose: 10, messy: 18, maggot: 26,
|
|
};
|
|
const SWAY_RATE = 0.006; // radians per ms — roughly a one-second wobble
|
|
const STUMBLE_CHANCE_PER_SEC = 0.5;
|
|
const STUMBLE_MIN_MS = 180;
|
|
const STUMBLE_MAX_MS = 520;
|
|
const STUMBLE_ARC = 0.9; // worst heading error, radians
|
|
|
|
const BUMP_DIST_PX = 10;
|
|
const BUMP_COOLDOWN_MS = 12_000;
|
|
// Per-agent cooldown alone doesn't bound the LEAK — the emission rate scales
|
|
// with how many rowdy patrons are on the floor, so a full late-night venue
|
|
// pinned vibe to 0 and aggro to 100 by ~1AM with the player doing nothing.
|
|
// This gate makes ambient chaos cost a roughly fixed amount per night
|
|
// regardless of crowd size; the player's own mistakes stay legible against it.
|
|
const BUMP_GATE_MS = 8000;
|
|
const BUMP_VIBE = -0.4;
|
|
const BUMP_AGGRO = 0.6;
|
|
|
|
// Chance a dunny-bound patron shoulders into a cubicle that's already busy.
|
|
const STALL_SHARE_CHANCE = 0.3;
|
|
const STALL_CAP = 2;
|
|
|
|
const ESCORT_SPEED = 22; // the walk of a bloke who knows it's over
|
|
const ESCORT_AHEAD_PX = 18; // they only shuffle on while you're this close behind
|
|
const BOB_STEP = 0.25; // four beats to a full bob, i.e. one bar
|
|
|
|
const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
|
|
|
|
interface Plan {
|
|
haunt: Haunt;
|
|
stallId?: string; // reserved while walking, so the cap survives the trip over
|
|
path: WorldPoint[]; // detour waypoints; empty means steer straight at the target
|
|
bestDist: number; // closest approach to the current steer point so far
|
|
stuckMs: number;
|
|
stumbleMs: number;
|
|
stumbleAngle: number;
|
|
}
|
|
|
|
function rowdy(a: Agent): boolean {
|
|
const s = drunkStage(a.patron.intoxication);
|
|
return s === 'messy' || s === 'maggot';
|
|
}
|
|
|
|
/** Deterministic per-patron pace — same doll, same dawdle, every run. */
|
|
function paceOf(patron: Patron): number {
|
|
const span = SPEED_MAX - SPEED_MIN + 1;
|
|
return SPEED_MIN + (Math.abs(patron.dollSeed) % span);
|
|
}
|
|
|
|
export class CrowdSim {
|
|
readonly agents: Agent[] = [];
|
|
|
|
private readonly map: VenueMap;
|
|
private readonly bus: EventBus;
|
|
private readonly rng: RngStream;
|
|
private readonly plans = new WeakMap<Agent, Plan>();
|
|
private elapsedMs = 0;
|
|
private prevClockMin = Number.NaN;
|
|
private bumpGateMs = BUMP_GATE_MS;
|
|
|
|
constructor(opts: CrowdSimOpts) {
|
|
this.map = opts.map;
|
|
this.bus = opts.bus;
|
|
this.rng = opts.rng;
|
|
}
|
|
|
|
spawn(patron: Patron): Agent {
|
|
const entries = this.map.anchors.entry;
|
|
const at = entries.length > 0 ? this.rng.pick(entries) : { x: 0, y: 0 };
|
|
const agent: Agent = {
|
|
patron,
|
|
x: at.x,
|
|
y: at.y,
|
|
vx: 0,
|
|
vy: 0,
|
|
activity: 'walking',
|
|
target: { x: at.x, y: at.y },
|
|
dwellMs: 0,
|
|
bobPhase: (Math.abs(patron.dollSeed) % 100) / 100,
|
|
handled: false,
|
|
gone: false,
|
|
lastBumpMs: BUMP_COOLDOWN_MS,
|
|
// Nobody stays all night. 50-140 in-game minutes, then they head home —
|
|
// without this, capacity was a one-way ratchet and the door had to lock
|
|
// shut from 1 AM (tuning pass 2026-07-19). Derived from dollSeed, not the
|
|
// stream: drawing here would shift every later roll in the sim.
|
|
stayMinutes: 50 + (Math.abs(patron.dollSeed) % 91),
|
|
};
|
|
this.plans.set(agent, {
|
|
haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0,
|
|
});
|
|
this.agents.push(agent);
|
|
this.chooseTarget(agent);
|
|
return agent;
|
|
}
|
|
|
|
/** deltaMs of real time; clockMin is the in-game minute (drives intoxication drift). */
|
|
update(deltaMs: number, clockMin: number): void {
|
|
const ms = Math.max(0, Math.min(deltaMs, MAX_STEP_MS));
|
|
this.elapsedMs += ms;
|
|
this.bumpGateMs += ms;
|
|
|
|
this.drift(clockMin);
|
|
|
|
for (const a of this.agents) {
|
|
if (a.gone) continue;
|
|
a.lastBumpMs += ms;
|
|
if (a.arrivedAtMin === undefined) a.arrivedAtMin = clockMin;
|
|
// Both are driven from outside: the player's escort, and the fight director.
|
|
if (a.activity === 'escorted' || a.activity === 'squaringUp') continue;
|
|
if (
|
|
!a.headingHome &&
|
|
a.activity !== 'inStall' &&
|
|
clockMin >= a.arrivedAtMin + a.stayMinutes
|
|
) {
|
|
this.headHome(a);
|
|
}
|
|
if (a.activity === 'walking') this.walk(a, ms);
|
|
else this.dwell(a, ms);
|
|
}
|
|
|
|
this.bumps();
|
|
}
|
|
|
|
/** Call from a bus.on('beat:tick') handler. Advances dance bob only. */
|
|
onBeat(beatIndex: number): void {
|
|
// The downbeat lands harder than the rest of the bar.
|
|
const step = beatIndex % 4 === 0 ? BOB_STEP * 1.5 : BOB_STEP;
|
|
for (const a of this.agents) {
|
|
if (a.gone || a.activity !== 'dancing') continue;
|
|
a.bobPhase = (a.bobPhase + step) % 1;
|
|
}
|
|
}
|
|
|
|
/** Agents currently inside each stall, keyed by stall id. */
|
|
stallOccupancy(): Map<string, Agent[]> {
|
|
const out = new Map<string, Agent[]>();
|
|
for (const s of this.map.stalls) out.set(s.id, []);
|
|
for (const a of this.agents) {
|
|
if (a.gone || a.activity !== 'inStall' || a.stallId === undefined) continue;
|
|
const list = out.get(a.stallId);
|
|
if (list) list.push(a);
|
|
else out.set(a.stallId, [a]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Attach to the player for a walk-to-the-exit escort. Returns false if already escorted. */
|
|
beginEscort(agent: Agent): boolean {
|
|
if (agent.gone || agent.activity === 'escorted' || agent.activity === 'leaving') return false;
|
|
const exit = this.nearestExit(agent.x, agent.y);
|
|
if (!exit) return false;
|
|
|
|
if (agent.activity === 'inStall') {
|
|
const stall = this.stallById(agent.stallId);
|
|
if (stall) {
|
|
agent.x = stall.door.x;
|
|
agent.y = stall.door.y;
|
|
}
|
|
}
|
|
agent.stallId = undefined;
|
|
agent.activity = 'escorted';
|
|
agent.handled = true;
|
|
agent.dwellMs = 0;
|
|
agent.target = exit;
|
|
|
|
const plan = this.plans.get(agent);
|
|
if (plan) {
|
|
plan.stallId = undefined;
|
|
plan.stumbleMs = 0;
|
|
plan.bestDist = Infinity;
|
|
plan.stuckMs = 0;
|
|
// Always route the perp walk — the door is a long way from the dunnies and
|
|
// a straight line would wedge them in a corner with you shouting at them.
|
|
plan.path = this.findPath(agent.x, agent.y, exit) ?? [];
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Called each frame with the player position so escorted agents walk ahead of you. */
|
|
updateEscorts(playerX: number, playerY: number, deltaMs: number): void {
|
|
const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000;
|
|
const step = ESCORT_SPEED * dt;
|
|
|
|
for (const a of this.agents) {
|
|
if (a.gone || a.activity !== 'escorted') continue;
|
|
const plan = this.plans.get(a);
|
|
if (!plan) continue;
|
|
|
|
const point = this.steerPoint(a, plan);
|
|
const dx = point.x - a.x;
|
|
const dy = point.y - a.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
|
|
if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) {
|
|
a.x = point.x;
|
|
a.y = point.y;
|
|
a.vx = 0;
|
|
a.vy = 0;
|
|
a.activity = 'leaving';
|
|
a.gone = true;
|
|
continue;
|
|
}
|
|
|
|
// They shuffle on only while you're breathing down their neck; stop pushing
|
|
// and they stop walking.
|
|
if (Math.hypot(playerX - a.x, playerY - a.y) > ESCORT_AHEAD_PX) {
|
|
a.vx = 0;
|
|
a.vy = 0;
|
|
continue;
|
|
}
|
|
|
|
const ux = dx / (dist || 1);
|
|
const uy = dy / (dist || 1);
|
|
a.vx = ux * ESCORT_SPEED;
|
|
a.vy = uy * ESCORT_SPEED;
|
|
this.tryMove(a, ux * step, uy * step);
|
|
}
|
|
}
|
|
|
|
/** Pin an agent for a brewing fight. False if it isn't available (gone/escorted/in a stall). */
|
|
pinForFight(agent: Agent): boolean {
|
|
if (agent.gone) return false;
|
|
if (
|
|
agent.activity === 'escorted' || agent.activity === 'leaving' ||
|
|
agent.activity === 'inStall' || agent.activity === 'squaringUp'
|
|
) return false;
|
|
|
|
agent.activity = 'squaringUp';
|
|
agent.vx = 0;
|
|
agent.vy = 0;
|
|
// Whatever they were nursing is over; the fight owns the dwell now.
|
|
agent.dwellMs = 0;
|
|
return true;
|
|
}
|
|
|
|
/** Release back into the crowd; they walk it off. */
|
|
releaseFromFight(agent: Agent): void {
|
|
// Anything else got hold of them mid-fight (escorted, then gone) — leave it alone.
|
|
if (agent.activity !== 'squaringUp') return;
|
|
agent.activity = 'walking';
|
|
agent.dwellMs = 0;
|
|
agent.stallId = undefined;
|
|
const plan = this.plans.get(agent);
|
|
if (plan) plan.stumbleMs = 0;
|
|
this.chooseTarget(agent);
|
|
}
|
|
|
|
find(patronId: string): Agent | undefined {
|
|
return this.agents.find((a) => a.patron.id === patronId);
|
|
}
|
|
|
|
destroy(): void {
|
|
// Nothing to unsubscribe — beat ticks are pushed in by the owning scene.
|
|
this.agents.length = 0;
|
|
}
|
|
|
|
// ---- internals ----
|
|
|
|
private drift(clockMin: number): void {
|
|
const prev = this.prevClockMin;
|
|
this.prevClockMin = clockMin;
|
|
if (Number.isNaN(prev)) return;
|
|
const mins = clockMin - prev;
|
|
if (mins <= 0) return;
|
|
|
|
for (const a of this.agents) {
|
|
if (a.gone) continue;
|
|
const base = a.activity === 'atBar' ? BAR_DRIFT_PER_MIN : AMBIENT_DRIFT_PER_MIN;
|
|
a.patron.intoxication = Math.min(1, a.patron.intoxication + base * (1 - a.patron.tolerance) * mins);
|
|
}
|
|
}
|
|
|
|
private dwell(a: Agent, ms: number): void {
|
|
a.vx = 0;
|
|
a.vy = 0;
|
|
a.dwellMs -= ms;
|
|
if (a.dwellMs > 0) return;
|
|
|
|
if (a.activity === 'inStall') {
|
|
const stall = this.stallById(a.stallId);
|
|
if (stall) {
|
|
a.x = stall.door.x;
|
|
a.y = stall.door.y;
|
|
}
|
|
a.stallId = undefined;
|
|
}
|
|
a.dwellMs = 0;
|
|
a.activity = 'walking';
|
|
this.chooseTarget(a);
|
|
}
|
|
|
|
private walk(a: Agent, ms: number): void {
|
|
const plan = this.plans.get(a);
|
|
if (!plan) return;
|
|
|
|
const dt = ms / 1000;
|
|
const speed = paceOf(a.patron);
|
|
const step = speed * dt;
|
|
const point = this.steerPoint(a, plan);
|
|
const dx = point.x - a.x;
|
|
const dy = point.y - a.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
|
|
if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) {
|
|
this.arrive(a, plan);
|
|
return;
|
|
}
|
|
|
|
// Walked into geometry the straight line can't solve (the dunny room is one
|
|
// long concave corner). Fall back to a real route rather than pushing at a wall.
|
|
if (dist < plan.bestDist - 0.5) {
|
|
plan.bestDist = dist;
|
|
plan.stuckMs = 0;
|
|
} else {
|
|
plan.stuckMs += ms;
|
|
if (plan.stuckMs >= STUCK_MS) this.unstick(a, plan);
|
|
}
|
|
|
|
let heading = Math.atan2(dy, dx);
|
|
const stage = drunkStage(a.patron.intoxication);
|
|
|
|
if (plan.stumbleMs > 0) {
|
|
plan.stumbleMs -= ms;
|
|
heading += plan.stumbleAngle;
|
|
} else if ((stage === 'messy' || stage === 'maggot') && this.rng.chance(STUMBLE_CHANCE_PER_SEC * dt)) {
|
|
plan.stumbleMs = this.rng.int(STUMBLE_MIN_MS, STUMBLE_MAX_MS);
|
|
plan.stumbleAngle = (this.rng.next() * 2 - 1) * STUMBLE_ARC;
|
|
heading += plan.stumbleAngle;
|
|
}
|
|
|
|
const ux = Math.cos(heading);
|
|
const uy = Math.sin(heading);
|
|
// Sway rides perpendicular to travel — the wider the wobble, the drunker the walk.
|
|
const sway = Math.sin(this.elapsedMs * SWAY_RATE + a.patron.dollSeed) * SWAY_BY_STAGE[stage];
|
|
a.vx = ux * speed - uy * sway;
|
|
a.vy = uy * speed + ux * sway;
|
|
this.tryMove(a, a.vx * dt, a.vy * dt);
|
|
}
|
|
|
|
/** Current thing to walk at: the next detour waypoint, else the target itself. */
|
|
private steerPoint(a: Agent, plan: Plan): WorldPoint {
|
|
while (plan.path.length > 0) {
|
|
const wp = plan.path[0];
|
|
if (!wp) {
|
|
plan.path.shift();
|
|
continue;
|
|
}
|
|
if (Math.hypot(wp.x - a.x, wp.y - a.y) > WAYPOINT_PX) return wp;
|
|
plan.path.shift();
|
|
plan.bestDist = Infinity;
|
|
plan.stuckMs = 0;
|
|
}
|
|
return a.target;
|
|
}
|
|
|
|
private unstick(a: Agent, plan: Plan): void {
|
|
plan.stuckMs = 0;
|
|
plan.bestDist = Infinity;
|
|
const path = this.findPath(a.x, a.y, a.target);
|
|
if (path && path.length > 0) plan.path = path;
|
|
else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be
|
|
}
|
|
|
|
/** The bar cut them off: done for the night, they walk themselves out. */
|
|
sendHome(a: Agent): void {
|
|
if (a.gone || a.activity === 'escorted') return;
|
|
this.headHome(a);
|
|
}
|
|
|
|
/** Done for the night: point them at the way out. */
|
|
private headHome(a: Agent): void {
|
|
const plan = this.plans.get(a);
|
|
if (!plan) return;
|
|
a.headingHome = true;
|
|
a.activity = 'walking';
|
|
a.dwellMs = 0;
|
|
plan.path = [];
|
|
plan.bestDist = Infinity;
|
|
plan.stuckMs = 0;
|
|
plan.stallId = undefined;
|
|
const entries = this.map.anchors.entry;
|
|
if (entries.length > 0) a.target = this.rng.pick(entries);
|
|
}
|
|
|
|
private arrive(a: Agent, plan: Plan): void {
|
|
a.vx = 0;
|
|
a.vy = 0;
|
|
|
|
if (a.headingHome) {
|
|
a.activity = 'leaving';
|
|
a.gone = true;
|
|
this.bus.emit('floor:leave', { patronId: a.patron.id });
|
|
return;
|
|
}
|
|
|
|
a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS);
|
|
|
|
if (plan.haunt === 'toilet' && plan.stallId !== undefined) {
|
|
const stall = this.stallById(plan.stallId);
|
|
if (stall) {
|
|
a.activity = 'inStall';
|
|
a.stallId = stall.id;
|
|
a.x = stall.inside.x;
|
|
a.y = stall.inside.y;
|
|
return;
|
|
}
|
|
}
|
|
a.x = a.target.x;
|
|
a.y = a.target.y;
|
|
a.activity = ACTIVITY_FOR[plan.haunt];
|
|
}
|
|
|
|
private chooseTarget(a: Agent): void {
|
|
const plan = this.plans.get(a);
|
|
if (!plan) return;
|
|
plan.stallId = undefined;
|
|
plan.path = [];
|
|
plan.bestDist = Infinity;
|
|
plan.stuckMs = 0;
|
|
|
|
const weights = HAUNTS[a.patron.archetype];
|
|
let haunt = this.rng.weighted(HAUNT_KINDS.map((k) => [k, weights[k]] as const));
|
|
|
|
if (haunt === 'toilet') {
|
|
const stall = this.claimStall();
|
|
if (stall) {
|
|
plan.haunt = 'toilet';
|
|
plan.stallId = stall.id;
|
|
a.target = stall.door;
|
|
return;
|
|
}
|
|
haunt = 'bar'; // every dunny's full — go queue for another drink instead
|
|
}
|
|
|
|
const spots = this.map.anchors[haunt];
|
|
plan.haunt = haunt;
|
|
if (spots.length > 0) a.target = this.rng.pick(spots);
|
|
}
|
|
|
|
/** Reserve a cubicle, occasionally squeezing into one that's already busy. */
|
|
private claimStall(): StallDef | undefined {
|
|
const counts = new Map<string, number>();
|
|
for (const s of this.map.stalls) counts.set(s.id, 0);
|
|
const bump = (id: string | undefined): void => {
|
|
if (id === undefined) return;
|
|
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
};
|
|
for (const other of this.agents) {
|
|
if (other.gone) continue;
|
|
bump(other.stallId);
|
|
const p = this.plans.get(other);
|
|
if (other.activity === 'walking' && p) bump(p.stallId);
|
|
}
|
|
|
|
const free = this.map.stalls.filter((s) => (counts.get(s.id) ?? 0) === 0);
|
|
const busy = this.map.stalls.filter((s) => {
|
|
const n = counts.get(s.id) ?? 0;
|
|
return n > 0 && n < STALL_CAP;
|
|
});
|
|
|
|
if (busy.length > 0 && (free.length === 0 || this.rng.chance(STALL_SHARE_CHANCE))) {
|
|
return this.rng.pick(busy);
|
|
}
|
|
if (free.length > 0) return this.rng.pick(free);
|
|
return undefined;
|
|
}
|
|
|
|
private bumps(): void {
|
|
for (let i = 0; i < this.agents.length; i++) {
|
|
const a = this.agents[i];
|
|
if (!a || a.gone) continue;
|
|
for (let j = i + 1; j < this.agents.length; j++) {
|
|
const b = this.agents[j];
|
|
if (!b || b.gone) continue;
|
|
if (Math.hypot(a.x - b.x, a.y - b.y) >= BUMP_DIST_PX) continue;
|
|
for (const who of [a, b]) {
|
|
if (!rowdy(who) || who.lastBumpMs < BUMP_COOLDOWN_MS) continue;
|
|
who.lastBumpMs = 0;
|
|
// Still track the individual cooldown above (so one pair grinding
|
|
// together can't chain), but only the gate opens the till.
|
|
if (this.bumpGateMs < BUMP_GATE_MS) continue;
|
|
this.bumpGateMs = 0;
|
|
this.bus.emit('meters:delta', { vibe: BUMP_VIBE, aggro: BUMP_AGGRO });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private tryMove(a: Agent, dx: number, dy: number): void {
|
|
if (this.open(a.x + dx, a.y + dy)) {
|
|
a.x += dx;
|
|
a.y += dy;
|
|
return;
|
|
}
|
|
if (this.open(a.x + dx, a.y)) a.x += dx; // slide along the wall
|
|
else if (this.open(a.x, a.y + dy)) a.y += dy;
|
|
}
|
|
|
|
private open(x: number, y: number): boolean {
|
|
return isWalkableWorld(this.map, x, y);
|
|
}
|
|
|
|
/** 4-connected BFS over walkable tiles. Waypoints are tile centres. */
|
|
private findPath(fromX: number, fromY: number, to: WorldPoint): WorldPoint[] | undefined {
|
|
const w = this.map.width;
|
|
const h = this.map.height;
|
|
const start = worldToTile(fromX, fromY);
|
|
const goal = worldToTile(to.x, to.y);
|
|
const startK = start.ty * w + start.tx;
|
|
const goalK = goal.ty * w + goal.tx;
|
|
if (startK === goalK) return [];
|
|
if (!isWalkable(this.map, goal.tx, goal.ty)) return undefined;
|
|
|
|
const prev = new Int32Array(w * h).fill(-1);
|
|
const seen = new Uint8Array(w * h);
|
|
const queue = [startK];
|
|
seen[startK] = 1;
|
|
for (let head = 0; head < queue.length; head++) {
|
|
const cur = queue[head] ?? -1;
|
|
if (cur === goalK) break;
|
|
const cx = cur % w;
|
|
const cy = (cur - cx) / w;
|
|
for (const [dx, dy] of NEIGHBOURS) {
|
|
const nx = cx + dx;
|
|
const ny = cy + dy;
|
|
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
|
const k = ny * w + nx;
|
|
if (seen[k] === 1 || !isWalkable(this.map, nx, ny)) continue;
|
|
seen[k] = 1;
|
|
prev[k] = cur;
|
|
queue.push(k);
|
|
}
|
|
}
|
|
if (seen[goalK] !== 1) return undefined;
|
|
|
|
const out: WorldPoint[] = [];
|
|
let cur = goalK;
|
|
while (cur !== startK) {
|
|
const cx = cur % w;
|
|
out.push(tileToWorld(cx, (cur - cx) / w));
|
|
const p = prev[cur] ?? -1;
|
|
if (p < 0) break;
|
|
cur = p;
|
|
}
|
|
out.reverse();
|
|
return out;
|
|
}
|
|
|
|
private stallById(id: string | undefined): StallDef | undefined {
|
|
if (id === undefined) return undefined;
|
|
return this.map.stalls.find((s) => s.id === id);
|
|
}
|
|
|
|
private nearestExit(x: number, y: number): WorldPoint | undefined {
|
|
let best: WorldPoint | undefined;
|
|
let bestD = Infinity;
|
|
for (const e of this.map.anchors.exit) {
|
|
const d = Math.hypot(e.x - x, e.y - y);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = e;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
}
|