- Procedural 3D world: dollhouse shop, town, day/night, weather, seasons - Customer AI with personalities (story NPCs, thieves, weekly regulars) - Economy: suppliers, negotiation, pricing psychology, daily accounting - Staff with traits/loyalty, 8 expansion levels, furniture & decoration - Events with choices, quests, achievements, 4 difficulties, rival shop - Animated daily report, analytics, save/load (3 slots + autosave) - Procedural music & SFX (WebAudio), zero external assets - Test harnesses: simtest (node), verify/check/e2e (headless browser)
502 lines
18 KiB
JavaScript
502 lines
18 KiB
JavaScript
import G from './state.js';
|
||
import { PRODUCT_BY_ID } from '../data/products.js';
|
||
import { ARCH_BY_ID, STORY_NPCS, NAMES_F, NAMES_M, SURNAMES } from '../data/customers.js';
|
||
import { purchaseWillingness, sellToCustomer } from './economy.js';
|
||
import { findPath, isWalkable, cellWorld, worldCell, doorCells, randomBrowseNear, counterQueueSpot, rebuildGrid } from './pathfinding.js';
|
||
import { clamp, rand, randi, chance, uid, pickWeighted, choice, emit, toast } from '../util.js';
|
||
import { footfallMult, TIME } from './daycycle.js';
|
||
|
||
// ============================================================
|
||
// CUSTOMER AI
|
||
// FSM: arriving -> entering -> browsing -> seeking -> evaluating ->
|
||
// queuing -> paying -> leaving (+ special: stealing, complaining)
|
||
// ============================================================
|
||
|
||
const MAX_CUSTOMERS = 14;
|
||
|
||
export function spawnTimerTick(dtMin) {
|
||
// Poisson-ish arrivals during open hours
|
||
if (!G.shop.isOpen) return;
|
||
if (G.customers.length >= MAX_CUSTOMERS) return;
|
||
const served = G.data.today.served;
|
||
const dayTarget = 6 + G.shop.rep * 0.55 + G.attractionScore() * 0.35;
|
||
const remaining = Math.max(0, dayTarget - served);
|
||
const openFrac = clamp((TIME.closeAt - G.shop.minutes) / (TIME.closeAt - TIME.openAt), 0, 1);
|
||
if (remaining <= 0 || openFrac <= 0) return;
|
||
// rush-hour shaping: lunch + evening bumps
|
||
const m = G.shop.minutes;
|
||
let shape = 1;
|
||
if ((m > 720 && m < 840) || (m > 1020 && m < 1140)) shape = 1.7; // noon & after-work rush
|
||
if (m < 600) shape = 0.6;
|
||
const ratePerMin = (remaining / (TIME.closeAt - m)) * 1.6 * shape * footfallMult();
|
||
if (Math.random() < ratePerMin * dtMin) spawnCustomer();
|
||
}
|
||
|
||
function pickArchetype() {
|
||
const d = G.data;
|
||
const entries = [];
|
||
for (const a of ARCH_BY_ID ? Object.values(ARCH_BY_ID) : []) {
|
||
if (a.repMin && d.shop.rep < a.repMin) continue;
|
||
let w = a.weight;
|
||
// story flavor boosts
|
||
if (a.id === 'tourist' && G.isFestivalDay()) w *= 2.5;
|
||
if (a.id === 'adventurer' && d.demandMods['tag:adventure']) w *= 2;
|
||
if (a.id === 'wealthy') w *= 0.4 + G.luxuryScore() / 12;
|
||
if (a.id === 'noble' && !G.shelves().length) w = 0;
|
||
if (w > 0) entries.push({ ...a, w });
|
||
}
|
||
if (!entries.length) return ARCH_BY_ID.regular;
|
||
return pickWeighted(entries);
|
||
}
|
||
|
||
export function spawnCustomer() {
|
||
const arch = pickArchetype();
|
||
const diffPat = { cozy: 1.25, normal: 1, business: 0.9, tycoon: 0.8 }[G.data.difficulty] || 1;
|
||
const c = {
|
||
id: uid(),
|
||
archId: arch.id,
|
||
name: chance(0.5) ? choice(NAMES_F) : choice(NAMES_M),
|
||
surname: SURNAMES.length && chance(0.6) ? choice(SURNAMES) : '',
|
||
budget: Math.round(rand(arch.budget[0], arch.budget[1]) * (0.8 + G.shop.rep / 120)),
|
||
tolerance: arch.tolerance,
|
||
patienceMax: rand(arch.patience[0], arch.patience[1]) * diffPat,
|
||
patience: 0,
|
||
mood: 0.55 + rand(-0.15, 0.25),
|
||
loyalty: 10 + randi(0, 20),
|
||
likes: arch.likes, dislikes: arch.dislikes,
|
||
storyId: null,
|
||
state: 'arriving',
|
||
t: 0, // state timer
|
||
path: null, pathI: 0,
|
||
pos: spawnOutsidePos(),
|
||
facing: 0,
|
||
speed: rand(1.5, 2.1),
|
||
wants: [], // product ids considered
|
||
targetFurn: null,
|
||
queueIndex: -1,
|
||
bought: [],
|
||
emote: null, emoteT: 0,
|
||
bubble: null, bubbleT: 0,
|
||
animPhase: Math.random() * 10,
|
||
stealAttempted: false,
|
||
};
|
||
// story npc takeover — some customers ARE the named folk
|
||
if (chance(0.3)) {
|
||
const npc = choice(STORY_NPCS);
|
||
c.storyId = npc.id; c.name = npc.name; c.archId = npc.arch;
|
||
c.likes = ARCH_BY_ID[npc.arch].likes; c.dislikes = ARCH_BY_ID[npc.arch].dislikes;
|
||
c.tolerance = ARCH_BY_ID[npc.arch].tolerance;
|
||
c.loyalty = clamp(G.relOf(npc.id), 0, 100);
|
||
}
|
||
// elder Tuesday medicine ritual
|
||
if (c.archId === 'elder' && new Date().getDay() === 2) c.wants = ['potion'];
|
||
if (c.storyId === 'alder' && G.shop.day % 7 === 2) { c.wants = ['potion', 'bandage']; c.state = 'arriving'; }
|
||
if (c.archId === 'knight') c.knight = true;
|
||
if (arch.thief) c.thief = true;
|
||
G.customers.push(c);
|
||
return c;
|
||
}
|
||
|
||
function spawnOutsidePos() {
|
||
// appear on the path south of the door, outside
|
||
const [[dx]] = doorCells();
|
||
const [wx] = cellWorld(dx, 0);
|
||
return [wx + rand(-2.5, 2.5), G.gridSize()[1] / 2 + rand(3, 7)];
|
||
}
|
||
|
||
// ---------- desire selection ----------
|
||
export function chooseDesire(c) {
|
||
const stocked = [];
|
||
for (const f of G.shelves()) {
|
||
for (const pid of Object.keys(G.data.shelfStock[f.id] || {})) {
|
||
if ((G.data.shelfStock[f.id][pid] || 0) > 0) stocked.push({ f, pid });
|
||
}
|
||
}
|
||
if (!stocked.length) return null;
|
||
// score each option by archetype taste
|
||
const scored = stocked.map(({ f, pid }) => {
|
||
const p = PRODUCT_BY_ID[pid];
|
||
let s = 0.4 + G.demandMult(pid) * 0.3;
|
||
if (c.likes.includes(p.cat)) s += 0.9;
|
||
if (c.dislikes.includes(p.cat)) s -= 1.2;
|
||
if (c.likes.includes('medicine') && p.tags.includes('medicine')) s += 0.8;
|
||
if (c.rareSeeker && p.rarity >= 3) s += 1.2;
|
||
if (c.nobleWantsLuxury) s += p.cat === 'luxury' ? 2 : -0.5;
|
||
if (p.tags.includes('luxury')) s += G.luxuryScore() * 0.02 * (c.dislikes.includes('luxury') ? -1 : 1);
|
||
s *= rand(0.75, 1.25);
|
||
if (G.priceOf(pid) > c.budget) s -= 1.4;
|
||
return { f, pid, s };
|
||
}).filter(o => o.s > 0);
|
||
if (!scored.length) return null;
|
||
scored.sort((a, b) => b.s - a.s);
|
||
const top = scored.slice(0, Math.min(3, scored.length));
|
||
const sel = top[Math.floor(Math.random() * Math.min(2, top.length))];
|
||
return sel;
|
||
}
|
||
|
||
// ---------- FSM update ----------
|
||
export function updateCustomer(c, dtSec) {
|
||
c.t += dtSec;
|
||
if (c.emoteT > 0) c.emoteT -= dtSec; else c.emote = null;
|
||
if (c.bubbleT > 0) c.bubbleT -= dtSec; else c.bubble = null;
|
||
|
||
switch (c.state) {
|
||
case 'arriving': {
|
||
const [dx, dz] = doorCells()[0];
|
||
const [tx, tz] = cellWorld(dx, dz);
|
||
moveTo(c, tx, tz + 1.2, dtSec);
|
||
if (dist(c.pos, [tx, tz + 1.2]) < 0.35) {
|
||
enterShop(c);
|
||
c.bubble = choice(['Hello!', '*looks around*', 'Ooh~']);
|
||
c.bubbleT = 2;
|
||
}
|
||
break;
|
||
}
|
||
case 'browsing': {
|
||
c.patience -= dtSec * 0.4; // browsing burns little patience
|
||
if (c.path && c.pathI < c.path.length) { followPath(c, dtSec); return; }
|
||
if (!c.desire) c.desire = chooseDesire(c);
|
||
if (!c.desire || c.t > 26) {
|
||
// nothing desired → maybe impulse buy or leave grumbling
|
||
if (c.desire === null && c.impulseOK !== false && chance(0.35)) {
|
||
leave(c, 'Nothing I need…');
|
||
} else leave(c, c.desire ? 'Just looking!' : 'Empty shelves…');
|
||
return;
|
||
}
|
||
// walk to the shelf holding the desire
|
||
const [fx, fz] = furnFrontCell(c.desire.f);
|
||
const path = findPath(...worldCell(c.pos[0], c.pos[1]), fx, fz);
|
||
if (!path) { leave(c, 'Can’t reach it!'); return; }
|
||
setPath(c, path);
|
||
c.state = 'seeking';
|
||
break;
|
||
}
|
||
case 'seeking': {
|
||
if (c.path && c.pathI < c.path.length) { followPath(c, dtSec); return; }
|
||
c.state = 'evaluating';
|
||
c.t = 0;
|
||
c.emote = '🤔'; c.emoteT = 2.5;
|
||
break;
|
||
}
|
||
case 'evaluating': {
|
||
if (c.t > rand(1.2, 2.6)) decidePurchase(c);
|
||
break;
|
||
}
|
||
case 'queuing': {
|
||
c.patience -= dtSec;
|
||
const spot = counterQueueSpot(Math.max(0, c.queueIndex));
|
||
if (dist(c.pos, spot) > 0.25) moveTo(c, spot[0], spot[1], dtSec);
|
||
else faceTowards(c, 0, -2);
|
||
if (c.patience <= 0) {
|
||
releaseQueue(c);
|
||
complainAndLeave(c, 'This line is TOO long!');
|
||
return;
|
||
}
|
||
tryCheckout(c);
|
||
break;
|
||
}
|
||
case 'paying': {
|
||
if (c.t > 0.9) finishLeave(c, payLine(c));
|
||
break;
|
||
}
|
||
case 'leaving': {
|
||
const [dx, dz] = doorCells()[0];
|
||
const [tx, tz] = cellWorld(dx, dz);
|
||
if (dist(c.pos, [tx, tz]) > 0.45) {
|
||
if (!c.leavePath) {
|
||
const p = findPath(...worldCell(c.pos[0], c.pos[1]), dx, dz);
|
||
c.leavePath = p || [];
|
||
c.leaveI = 0;
|
||
}
|
||
if (c.leaveI < c.leavePath.length) {
|
||
const [gx, gz] = cellWorld(c.leavePath[c.leaveI][0], c.leavePath[c.leaveI][1]);
|
||
moveDir(c, gx - c.pos[0], gz - c.pos[1], dtSec);
|
||
if (dist(c.pos, [gx, gz]) < 0.18) c.leaveI++;
|
||
} else {
|
||
moveTo(c, tx, tz + 2, dtSec);
|
||
}
|
||
} else {
|
||
removeCustomer(c);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// thief behavior — strike while unattended
|
||
if (c.thief && !c.stealAttempted && c.state !== 'leaving' && c.state !== 'paying') {
|
||
const attended = counterAttended();
|
||
if (!attended && chance(dtSec * 0.22)) attemptTheft(c);
|
||
}
|
||
}
|
||
|
||
function payLine(c) {
|
||
if (c.bought.length >= 3) return 'My pockets are full!';
|
||
if (c.mood > 0.8) return choice(['Lovely shop!', 'See you tomorrow!', 'Wonderful!']);
|
||
if (c.mood < 0.35) return 'Hmph.';
|
||
return choice(['Thanks!', 'Have a good one!', 'Bye!']);
|
||
}
|
||
|
||
function enterShop(c) {
|
||
rebuildGridIfNeeded();
|
||
c.state = 'browsing';
|
||
c.t = 0;
|
||
c.patience = c.patienceMax;
|
||
emit('doorChime');
|
||
// wander to a random browse point first
|
||
const [W, H] = G.gridSize();
|
||
const spot = randomBrowseNear(Math.floor(W / 2), Math.floor(H / 2), 3);
|
||
if (spot) {
|
||
const path = findPath(...worldCell(c.pos[0], c.pos[1]), spot[0], spot[1]);
|
||
if (path) setPath(c, path);
|
||
}
|
||
}
|
||
|
||
function decidePurchase(c) {
|
||
const desire = c.desire;
|
||
if (!desire) { leave(c, 'Nothing for me…'); return; }
|
||
const stockNow = (G.data.shelfStock[desire.f.id] || {})[desire.pid] || 0;
|
||
if (stockNow <= 0) {
|
||
// someone took the last one!
|
||
complainAndLeave(c, `No more ${PRODUCT_BY_ID[desire.pid].name}?!`);
|
||
return;
|
||
}
|
||
// knight refuses elven goods
|
||
if (c.knight) {
|
||
const elvenSuppliers = ['alchemist', 'wizard'];
|
||
const isElven = PRODUCT_BY_ID[desire.pid].tags.includes('elven');
|
||
if (isElven) {
|
||
c.emote = '😠'; c.emoteT = 3;
|
||
complainAndLeave(c, 'I’ll not buy ELVEN wares!');
|
||
return;
|
||
}
|
||
}
|
||
const w = purchaseWillingness(c, desire.pid);
|
||
if (w > 0.5 || (w > 0.32 && chance(0.6))) {
|
||
// BUY → join queue
|
||
joinQueue(c, desire);
|
||
} else if (w > 0.2) {
|
||
c.bubble = 'That’s a little expensive…'; c.bubbleT = 2.6;
|
||
G.data.today.complaints++;
|
||
c.mood -= 0.12;
|
||
c.desire = null; // look for something else
|
||
c.state = 'browsing'; c.t = 0;
|
||
} else {
|
||
complainAndLeave(c, 'Too rich for my blood!');
|
||
}
|
||
}
|
||
|
||
function joinQueue(c, desire) {
|
||
const queue = G.customers.filter(x => x.state === 'queuing').sort((a, b) => a.queueIndex - b.queueIndex);
|
||
let idx = queue.length;
|
||
// reuse freed slots
|
||
for (let i = 0; i < queue.length; i++) if (queue[i].queueIndex !== i) { idx = i; break; }
|
||
c.queueIndex = idx;
|
||
c.buyIntent = desire;
|
||
c.state = 'queuing';
|
||
c.patience = c.patienceMax * 0.9;
|
||
const [fx, fz] = furnFrontCell(desire.f);
|
||
const path = findPath(...worldCell(c.pos[0], c.pos[1]), ...(spotCell(idx)));
|
||
setPath(c, path || []);
|
||
if (idx === 0) c.emote = '🙋';
|
||
}
|
||
|
||
function spotCell(idx) {
|
||
const counter = G.data.furniture.find(f => f.type === 'counter');
|
||
const [W, H] = G.gridSize();
|
||
if (!counter) return [Math.floor(W / 2), H - 3];
|
||
const x = counter.cx + (idx % 2);
|
||
const z = Math.min(H - 2, counter.cz + 2 + Math.floor(idx / 2));
|
||
return isWalkable(x, z) ? [x, z] : [Math.max(1, Math.min(W - 2, x)), Math.min(H - 2, z)];
|
||
}
|
||
function releaseQueue(c) {
|
||
// shift everyone behind forward
|
||
for (const o of G.customers) if (o.state === 'queuing' && o.queueIndex > c.queueIndex) {
|
||
o.queueIndex--;
|
||
o.path = null;
|
||
}
|
||
c.queueIndex = -1;
|
||
}
|
||
|
||
let checkoutCd = {};
|
||
function tryCheckout(c) {
|
||
if (c.queueIndex !== 0) return;
|
||
const cdKey = 'checkout';
|
||
if ((checkoutCd[cdKey] ?? 0) > 0) { checkoutCd[cdKey] -= 1 / 60; return; }
|
||
const att = counterAttended(); // 'cashier' | 'near' | 'inside' | null
|
||
if (!att) {
|
||
// nobody home… hope fades
|
||
c.patience -= 0.02;
|
||
return;
|
||
}
|
||
const cashierSkill = G.staffByRole('cashier').reduce((m, s) => Math.max(m, s.skill), 30);
|
||
const base = att === 'cashier' ? clamp(1.5 - cashierSkill / 140, 0.45, 1.5)
|
||
: att === 'near' ? 1.1 : 2.3;
|
||
checkoutCd[cdKey] = base;
|
||
|
||
const intent = c.buyIntent;
|
||
const res = sellToCustomer(c, intent.f, intent.pid);
|
||
if (res) {
|
||
c.bought.push(intent.pid);
|
||
c.mood = clamp(c.mood + (res.satisfaction - 0.5) * 0.5, 0.05, 1);
|
||
c.emote = res.satisfaction > 0.66 ? '😊' : res.satisfaction > 0.4 ? '🙂' : '😕';
|
||
c.emoteT = 2.5;
|
||
emit('saleFx', { customer: c, price: res.price, satisfaction: res.satisfaction });
|
||
// loyal regulars may buy a second item!
|
||
if (c.loyalty > 45 && chance(0.35) && c.bought.length < 3) {
|
||
const second = chooseDesire(c);
|
||
if (second && (G.data.shelfStock[second.f.id] || {})[second.pid]) {
|
||
c.desire = second;
|
||
c.state = 'browsing'; c.t = 0;
|
||
releaseQueue(c);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
releaseQueue(c);
|
||
c.state = 'paying';
|
||
c.t = 0;
|
||
}
|
||
|
||
export function counterAttended() {
|
||
// a cashier on duty always counts…
|
||
if (G.staffByRole('cashier').length > 0) return 'cashier';
|
||
// …otherwise the owner has to be around
|
||
const counter = G.data.furniture.find(f => f.type === 'counter');
|
||
if (!counter) return null;
|
||
const [px, pz] = cellWorld(counter.cx, counter.cz);
|
||
const pdx = G.data.player.x - px, pdz = G.data.player.z - pz;
|
||
const d2 = pdx * pdx + pdz * pdz;
|
||
if (d2 < 5) return 'near';
|
||
const [W, H] = G.gridSize();
|
||
const inside = Math.abs(G.data.player.x) < W / 2 + 0.4 && Math.abs(G.data.player.z) < H / 2 + 0.4;
|
||
return inside ? 'inside' : null;
|
||
}
|
||
|
||
function attemptTheft(c) {
|
||
c.stealAttempted = true;
|
||
// guard deterrence
|
||
const guards = G.staffByRole('guard');
|
||
const guardPower = guards.reduce((m, g) => Math.max(m, g.skill / 100), 0);
|
||
if (guards.length && chance(guardPower)) {
|
||
c.bubble = 'EEK! A guard!'; c.bubbleT = 2.5;
|
||
c.emote = '😱'; c.emoteT = 3;
|
||
leave(c, 'Wrong shop, wrong day!');
|
||
toast(`${c.name} tried to steal but your guard glared them down!`, 'good', '🛡️');
|
||
G.addRep(0.5);
|
||
return;
|
||
}
|
||
// find priciest stocked item
|
||
let best = null, bp = -1;
|
||
for (const f of G.shelves()) {
|
||
for (const pid of Object.keys(G.data.shelfStock[f.id] || {})) {
|
||
const v = G.priceOf(pid);
|
||
if ((G.data.shelfStock[f.id][pid] || 0) > 0 && v > bp) { bp = v; best = { f, pid }; }
|
||
}
|
||
}
|
||
if (!best) return;
|
||
deleteFromShelf(best.f.id, best.pid, 1);
|
||
G.data.today.thefts++;
|
||
const loss = G.priceOf(best.pid);
|
||
G.data.today.theftLoss += loss;
|
||
c.hasStolen = true;
|
||
c.bubble = '*slinks away*'; c.bubbleT = 2.5;
|
||
c.emote = '😈'; c.emoteT = 3;
|
||
toast(`${c.name} stole ${PRODUCT_BY_ID[best.pid].name} (-${loss}g)! Hire a guard or watch the counter!`, 'bad', '🕵️');
|
||
G.addRep(-0.4);
|
||
emit('theftFx', { customer: c });
|
||
c.state = 'leaving';
|
||
c.leavePath = null;
|
||
}
|
||
|
||
export function deleteFromShelf(fid, pid, qty) {
|
||
const stock = G.data.shelfStock[fid];
|
||
if (!stock?.[pid]) return;
|
||
stock[pid] -= qty;
|
||
if (stock[pid] <= 0) delete stock[pid];
|
||
emit('stockChanged');
|
||
}
|
||
|
||
function complainAndLeave(c, msg) {
|
||
c.bubble = msg; c.bubbleT = 3;
|
||
c.emote = '😠'; c.emoteT = 3;
|
||
G.data.today.complaints++;
|
||
c.mood = clamp(c.mood - 0.25, 0.05, 1);
|
||
if (c.storyId) G.bumpRel(c.storyId, -1.5);
|
||
G.addRep(-0.15);
|
||
leave(c, msg);
|
||
}
|
||
function leave(c, bubble) {
|
||
releaseQueue(c);
|
||
c.state = 'leaving';
|
||
c.leavePath = null;
|
||
if (bubble && !c.bubble) { c.bubble = bubble; c.bubbleT = 2.5; }
|
||
// memory: satisfaction shapes future loyalty
|
||
const sat = clamp(c.mood, 0, 1);
|
||
if (sat > 0.65) c.loyalty = clamp(c.loyalty + 4, 0, 100);
|
||
else if (sat < 0.35) c.loyalty = clamp(c.loyalty - 5, 0, 100);
|
||
if (c.storyId) G.bumpRel(c.storyId, sat > 0.6 ? 1 : sat < 0.35 ? -1 : 0);
|
||
}
|
||
function finishLeave(c, bubble) {
|
||
if (bubble) { c.bubble = bubble; c.bubbleT = 2.2; }
|
||
c.state = 'leaving';
|
||
c.leavePath = null;
|
||
const sat = clamp(c.mood, 0, 1);
|
||
if (sat > 0.65) c.loyalty = clamp(c.loyalty + 4, 0, 100);
|
||
else if (sat < 0.35) c.loyalty = clamp(c.loyalty - 5, 0, 100);
|
||
if (c.storyId) G.bumpRel(c.storyId, sat > 0.6 ? 1 : sat < 0.35 ? -1 : 0);
|
||
}
|
||
export function removeCustomer(c) {
|
||
const i = G.customers.indexOf(c);
|
||
if (i >= 0) G.customers.splice(i, 1);
|
||
emit('customerRemoved', c);
|
||
}
|
||
|
||
// ---------- movement helpers ----------
|
||
function setPath(c, path) { c.path = path; c.pathI = 0; }
|
||
function followPath(c, dtSec) {
|
||
if (!c.path || c.pathI >= c.path.length) return;
|
||
const [gx, gz] = cellWorld(c.path[c.pathI][0], c.path[c.pathI][1]);
|
||
moveDir(c, gx - c.pos[0], gz - c.pos[1], dtSec);
|
||
if (dist(c.pos, [gx, gz]) < 0.16) c.pathI++;
|
||
}
|
||
function moveTo(c, tx, tz, dtSec) {
|
||
moveDir(c, tx - c.pos[0], tz - c.pos[1], dtSec);
|
||
}
|
||
function moveDir(c, dx, dz, dtSec) {
|
||
const len = Math.hypot(dx, dz);
|
||
if (len < 1e-4) return;
|
||
const sp = c.speed * dtSec;
|
||
const step = Math.min(sp, len);
|
||
c.pos[0] += (dx / len) * step;
|
||
c.pos[1] += (dz / len) * step;
|
||
c.targetFacing = Math.atan2(dx, dz);
|
||
c.animPhase += dtSec * 9;
|
||
}
|
||
function faceTowards(c, x, z) { c.targetFacing = Math.atan2(x - c.pos[0], z - c.pos[1]); }
|
||
export function dist(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1]); }
|
||
|
||
// front-of-furniture reachable cell
|
||
function furnFrontCell(f) {
|
||
const [W, H] = G.gridSize();
|
||
const rot = f.rot % 2;
|
||
const candidates = rot === 0
|
||
? [[f.cx, f.cz + 1], [f.cx + 1, f.cz + 1], [f.cx, f.cz - 1], [f.cx + 1, f.cz - 1]]
|
||
: [[f.cx - 1, f.cz], [f.cx - 1, f.cz + 1], [f.cx + 1, f.cz], [f.cx + 1, f.cz + 1]];
|
||
for (const [x, z] of candidates) if (isWalkable(x, z)) return [x, z];
|
||
return [Math.floor(W / 2), Math.floor(H / 2)];
|
||
}
|
||
|
||
let lastGridFurnCount = -1;
|
||
function rebuildGridIfNeeded() {
|
||
if (lastGridFurnCount !== G.data.furniture.length) {
|
||
rebuildGrid();
|
||
lastGridFurnCount = G.data.furniture.length;
|
||
}
|
||
}
|
||
export function forceGridRebuild() { rebuildGrid(); lastGridFurnCount = G.data.furniture.length; }
|
||
|
||
// ---------- end of day cleanup ----------
|
||
export function clearCustomers() {
|
||
for (const c of [...G.customers]) removeCustomer(c);
|
||
}
|