Complete browser game inspired by OpenRCT2 with fantasy twist: - Custom roller coaster designer with physics-based ratings + on-ride POV - 10 animated rides, 7 shops, 16 scenery items, path network & guest AI - Heroes guild vs monster invasions (5 classes, XP/gear/bosses) - Magic spell system (8 spells), research tree, economy/marketing/loans - Day-night cycle, weather, park rating, awards, 4 scenarios - Save/load slots + autosave, procedural WebAudio SFX/music - Isometric canvas renderer, minimap, diagnostics overlay - Test suites: smoke(13), linkcheck, inputcheck, framecheck, rendercheck, flow
407 lines
15 KiB
JavaScript
407 lines
15 KiB
JavaScript
// ============ guests.js — guest spawning, needs AI, spending ============
|
|
import { GUEST_NAMES, GUEST_COLORS, THOUGHTS, WEATHER } from '../core/config.js';
|
|
import { makeRng, choice, clamp, uid } from '../core/util.js';
|
|
import { findPath, randomNearbyPath, snapToPath } from '../world/path.js';
|
|
import { earn } from './economy.js';
|
|
import { isNight } from './state.js';
|
|
|
|
const rng = Math.random; // guests tolerate non-seeded rng
|
|
|
|
export function spawnGuest(state) {
|
|
const m = state.map;
|
|
const ex = m.entranceX, ey = m.entranceY;
|
|
// entrance fee check
|
|
const willing = state.stats.rating * 0.45;
|
|
const fee = state.park.entranceFee;
|
|
if (fee > 0 && fee > willing && rng() > 0.25) return null; // refuses steep fees
|
|
const g = {
|
|
id: uid(), kind: 'guest',
|
|
name: choice(rng, GUEST_NAMES) + ' ' + String.fromCharCode(65 + Math.floor(rng() * 26)) + '.',
|
|
color: choice(rng, GUEST_COLORS),
|
|
x: ex + (rng() - 0.5) * 1.6, y: ey + 2.5,
|
|
path: [], state: 'entering', target: null,
|
|
hunger: 15 + rng() * 30, thirst: 15 + rng() * 30, toilet: rng() * 20, energy: 70 + rng() * 30,
|
|
happiness: 55 + rng() * 25, money: 60 + Math.floor(rng() * 140),
|
|
spendMul: 0.8 + rng() * 0.7,
|
|
prefThrill: rng(), // 0 = gentle lover, 1 = thrill seeker
|
|
thoughts: [], bubble: null, bubbleT: 0,
|
|
rideCd: 4 + rng() * 10,
|
|
paidEntrance: 0,
|
|
ridesCount: 0, favRide: null,
|
|
speed: 1.7 + rng() * 0.9,
|
|
fleeingT: 0,
|
|
};
|
|
if (fee > 0 && fee <= g.money) {
|
|
g.money -= fee; g.paidEntrance = fee;
|
|
earn(state, fee, 'entrance');
|
|
}
|
|
state.guests.push(g);
|
|
return g;
|
|
}
|
|
|
|
export function addThought(g, key, extra = {}) {
|
|
const pool = THOUGHTS[key];
|
|
if (!pool) return;
|
|
let text = choice(rng, pool);
|
|
if (extra.r) text = text.replace('{r}', extra.r);
|
|
g.thoughts.unshift(text);
|
|
if (g.thoughts.length > 6) g.thoughts.pop();
|
|
g.bubble = text; g.bubbleT = 4 + rng() * 3;
|
|
}
|
|
|
|
/** spawn accumulator driven by park conditions */
|
|
export function updateSpawning(state, dt) {
|
|
if (!state.park.open || isNight(state.time)) return;
|
|
if (state.spells.active.warding_sigil) { /* ward doesn't block guests */ }
|
|
const cap = state.sandbox ? 380 : 320;
|
|
if (state.guests.length >= cap) return;
|
|
const t = state.time.hour;
|
|
let curve = 0;
|
|
if (t < 9) curve = (t - 7) / 3;
|
|
else if (t < 11) curve = 0.7;
|
|
else if (t < 17) curve = 1.0;
|
|
else if (t < 19) curve = 0.6;
|
|
else curve = Math.max(0, (20.5 - t) / 2);
|
|
if (curve <= 0) return;
|
|
const w = WEATHER[state.weather.cur];
|
|
let pull = campaignPullCache(state);
|
|
const ratingFactor = clamp(state.stats.rating / 400, 0.2, 1.6);
|
|
let rate = (0.55 + pull * 0.22) * curve * w.spawnMul * ratingFactor;
|
|
state.guestSpawnAcc += rate * dt;
|
|
while (state.guestSpawnAcc >= 1) {
|
|
state.guestSpawnAcc -= 1;
|
|
if (state.guests.length >= cap) break;
|
|
spawnGuest(state);
|
|
}
|
|
}
|
|
let _pullCache = { t: 0, v: 0 };
|
|
function campaignPullCache(state) {
|
|
if (!state.campaigns.length) return 0;
|
|
if (performance.now() - _pullCache.t > 2000) { _pullCache.t = performance.now(); }
|
|
let p = 0; for (const c of state.campaigns) p += c.pull;
|
|
return p;
|
|
}
|
|
|
|
// ---------------- per-guest update ----------------
|
|
export function updateGuests(state, dt) {
|
|
updateSpawning(state, dt);
|
|
const m = state.map;
|
|
for (let i = state.guests.length - 1; i >= 0; i--) {
|
|
const g = state.guests[i];
|
|
stepGuest(state, g, dt);
|
|
if (g.state === 'gone') state.guests.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
function stepGuest(state, g, dt) {
|
|
const m = state.map;
|
|
if (g.rideCd > 0) g.rideCd -= dt;
|
|
// bubble timer
|
|
if (g.bubbleT > 0) g.bubbleT -= dt;
|
|
// needs rise
|
|
g.hunger = clamp(g.hunger + dt * 0.55, 0, 100);
|
|
g.thirst = clamp(g.thirst + dt * 0.75, 0, 100);
|
|
g.toilet = clamp(g.toilet + dt * 0.42 * (g.thirst / 60 + 0.5), 0, 100);
|
|
if (g.state !== 'riding') g.energy = clamp(g.energy - dt * 0.28, 0, 100);
|
|
// flee override
|
|
if (g.fleeingT > 0) {
|
|
g.fleeingT -= dt;
|
|
moveAlong(state, g, dt, 1.8);
|
|
if (g.path.length) return;
|
|
if (g.fleeingT > 0) wanderTo(state, g, 6);
|
|
return;
|
|
}
|
|
// happiness dynamics
|
|
let hd = -dt * 0.06;
|
|
if (g.hunger > 85 || g.thirst > 88 || g.toilet > 90) hd -= dt * 0.5;
|
|
hd -= WEATHER[state.weather.cur].happyDrain * dt;
|
|
if (state.stats.magicCount > 0) hd += dt * 0.01 * Math.min(state.stats.magicCount, 10);
|
|
if (state.spells.active.joy_aura) hd += dt * 1.6;
|
|
g.happiness = clamp(g.happiness + hd, 0, 100);
|
|
|
|
switch (g.state) {
|
|
case 'entering': {
|
|
// walk into plaza then start deciding
|
|
moveAlong(state, g, dt);
|
|
if (!g.path.length) { g.state = 'walking'; decide(state, g); }
|
|
break;
|
|
}
|
|
case 'walking': {
|
|
moveAlong(state, g, dt);
|
|
if (!g.path.length) arrive(state, g);
|
|
break;
|
|
}
|
|
case 'buying': {
|
|
g.targetT -= dt;
|
|
if (g.targetT <= 0) completePurchase(state, g);
|
|
break;
|
|
}
|
|
case 'resting': {
|
|
g.targetT -= dt;
|
|
g.energy = clamp(g.energy + dt * 6, 0, 100);
|
|
g.happiness = clamp(g.happiness + dt * 0.4, 0, 100);
|
|
if (g.targetT <= 0) { g.state = 'walking'; decide(state, g); }
|
|
break;
|
|
}
|
|
case 'queuing': case 'riding':
|
|
// managed by rides module
|
|
break;
|
|
case 'leaving': {
|
|
moveAlong(state, g, dt);
|
|
if (!g.path.length) {
|
|
if (g.y > state.map.size - 2.2) g.state = 'gone';
|
|
else decideLeave(state, g);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
// random leave chance late day
|
|
if (isNight(state.time) && (g.state === 'walking') && rng() < dt * 0.25) decideLeave(state, g);
|
|
if ((g.happiness < 12 || g.money < 3) && g.state === 'walking' && rng() < dt * 0.08) {
|
|
if (g.happiness < 12) addThought(g, g.money < 3 ? 'broke' : 'happy_park');
|
|
decideLeave(state, g);
|
|
}
|
|
}
|
|
|
|
function moveAlong(state, g, dt, mul = 1) {
|
|
if (!g.path.length) return;
|
|
const [tx, ty] = g.path[0];
|
|
const dx = tx - g.x, dy = ty - g.y;
|
|
const d = Math.hypot(dx, dy);
|
|
const stepLen = g.speed * mul * dt;
|
|
if (d <= stepLen) {
|
|
g.x = tx; g.y = ty; g.path.shift();
|
|
} else {
|
|
g.x += dx / d * stepLen; g.y += dy / d * stepLen;
|
|
}
|
|
}
|
|
|
|
function arrive(state, g) {
|
|
const tgt = g.target;
|
|
if (!tgt) { decide(state, g); return; }
|
|
if (tgt.kind === 'shop') {
|
|
const shop = state.shops.find(s => s.id === tgt.id);
|
|
if (!shop) { decide(state, g); return; }
|
|
if (shop.damaged > 0) { addThought(g, 'vandal'); decide(state, g); return; }
|
|
g.state = 'buying'; g.targetT = 1.2;
|
|
return;
|
|
}
|
|
if (tgt.kind === 'bench') {
|
|
g.state = 'resting'; g.targetT = 4 + rng() * 4;
|
|
return;
|
|
}
|
|
if (tgt.kind === 'ride') {
|
|
const ride = state.rides.find(r => r.id === tgt.id);
|
|
if (!ride || ride.status !== 'open') { decide(state, g); return; }
|
|
joinQueue(state, g, ride);
|
|
return;
|
|
}
|
|
decide(state, g);
|
|
}
|
|
|
|
export function finishRide(state, g, ride, verdict) {
|
|
g.ridesCount++;
|
|
g.rideCd = 8 + rng() * 14;
|
|
g.energy = clamp(g.energy - 6, 0, 100);
|
|
// excitement enjoyment depends on taste match
|
|
const fit = 1 - Math.abs(ride.intensity / 10 - g.prefThrill);
|
|
let gain = (verdict === 'great' ? 14 : verdict === 'good' ? 9 : verdict === 'meh' ? 3 : -6) * clamp(fit + 0.4, 0.2, 1.3);
|
|
g.happiness = clamp(g.happiness + gain, 0, 100);
|
|
if (verdict === 'great') addThought(g, 'great_ride', { r: ride.name });
|
|
else if (verdict === 'good') addThought(g, 'good_ride', { r: ride.name });
|
|
else if (verdict === 'meh') addThought(g, 'meh_ride', { r: ride.name });
|
|
else addThought(g, 'scary', { r: ride.name });
|
|
if (verdict !== 'bad') g.favRide = ride.type;
|
|
// nausea side effects
|
|
if (ride.nausea > 3.5 && rng() < ride.nausea / 26) {
|
|
const tx = Math.round(g.x), ty = Math.round(g.y);
|
|
if (state.map.inBounds(tx, ty)) state.map.vomit[state.map.idx(tx, ty)] = 1;
|
|
g.happiness = clamp(g.happiness - 8, 0, 100);
|
|
}
|
|
// litter from food items bought earlier
|
|
if (rng() < 0.25) dropLitter(state, g);
|
|
// walk out of the ride exit area before deciding
|
|
g.state = 'walking';
|
|
wanderTo(state, g, 3);
|
|
}
|
|
|
|
function completePurchase(state, g) {
|
|
const shop = state.shops.find(s => s.id === g.target?.id);
|
|
g.state = 'walking';
|
|
if (!shop) { decide(state, g); return; }
|
|
const fortuneMul = state.spells.active.fortune_rain ? 1.6 : 1;
|
|
const price = Math.round(shop.price * g.spendMul * fortuneMul);
|
|
if (g.money >= price) {
|
|
g.money -= price; shop.stock--; shop.sold++; shop.income += price;
|
|
earn(state, price, 'shopSales');
|
|
const def = shop.def;
|
|
if (def.need === 'hunger') g.hunger = Math.max(0, g.hunger - def.needFix);
|
|
if (def.need === 'thirst') g.thirst = Math.max(0, g.thirst - def.needFix);
|
|
if (def.need === 'toilet') g.toilet = Math.max(0, g.toilet - def.needFix);
|
|
if (def.need === 'health') g.happiness = clamp(g.happiness + 10, 0, 100);
|
|
if (def.happyBoost) g.happiness = clamp(g.happiness + def.happyBoost, 0, 100);
|
|
g.happiness = clamp(g.happiness + 4, 0, 100);
|
|
if (def.need === 'hunger' || def.need === 'thirst') {
|
|
// carrying food/drink may become litter
|
|
g.carryingFood = true;
|
|
}
|
|
if (def.need === 'toilet' && rng() < 0.02) {/* nothing */ }
|
|
} else if (rng() < 0.3) addThought(g, 'broke');
|
|
decide(state, g);
|
|
}
|
|
|
|
export function dropLitter(state, g) {
|
|
const tx = Math.round(g.x), ty = Math.round(g.y);
|
|
if (!state.map.inBounds(tx, ty)) return;
|
|
// bins nearby prevent litter
|
|
for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
|
|
const o = state.map.getObject(tx + dx, ty + dy);
|
|
if (o && o.kind === 'scenery') {
|
|
const sc = state.sceneryList.find(s => s.id === o.id);
|
|
if (sc && sc.def.antiLitter) return;
|
|
}
|
|
}
|
|
const i = state.map.idx(tx, ty);
|
|
if (state.map.pathType[i]) state.map.litter[i] = clamp(state.map.litter[i] + 0.6, 0, 1.5);
|
|
g.carryingFood = false;
|
|
}
|
|
|
|
// ---------------- decisions ----------------
|
|
function decide(state, g) {
|
|
// urgent needs first
|
|
if (g.toilet > 82) { if (tryGoShop(state, g, s => s.type === 'toilet')) return; }
|
|
if (g.hunger > 78) { if (tryGoShop(state, g, s => s.type === 'food')) { return; } }
|
|
if (g.thirst > 74) { if (tryGoShop(state, g, s => s.type === 'drinks' || s.type === 'icecream')) return; }
|
|
if (g.energy < 22) {
|
|
if (tryGoBench(state, g)) return;
|
|
if (rng() < 0.5) { decideLeave(state, g); return; }
|
|
}
|
|
// ride?
|
|
if (g.rideCd <= 0) {
|
|
const ride = pickRide(state, g);
|
|
if (ride) { goTarget(state, g, { kind: 'ride', id: ride.id }, ride.entranceX, ride.entranceY, ride); return; }
|
|
}
|
|
// casual shopping
|
|
if (rng() < 0.3 && tryGoShop(state, g, s => s.type === 'souvenir' || s.type === 'balloon')) return;
|
|
// satisfy needs opportunistically
|
|
if (g.hunger > 50 && tryGoShop(state, g, s => s.type === 'food')) return;
|
|
if (g.thirst > 48 && tryGoShop(state, g, s => s.type === 'drinks' || s.type === 'icecream')) return;
|
|
if (g.toilet > 55 && tryGoShop(state, g, s => s.type === 'toilet')) return;
|
|
// wander
|
|
wanderTo(state, g, 8 + Math.floor(rng() * 8));
|
|
}
|
|
|
|
function pickRide(state, g) {
|
|
const cands = state.rides.filter(r => r.status === 'open' && r.queue.length < 24);
|
|
if (!cands.length) return null;
|
|
// score: intensity preference match + closeness + low queue
|
|
let best = null, bestScore = -1;
|
|
for (const r of cands) {
|
|
const fit = 1 - Math.abs((r.intensity || 0) / 10 - g.prefThrill) * 2;
|
|
if (fit < 0.05 && rng() < 0.8) continue; // wrong vibe usually skip
|
|
const d = Math.hypot(r.entranceX - g.x, r.entranceY - g.y);
|
|
const score = fit * 2 + Math.max(0, 1.2 - d / 40) + (r.queue.length < 6 ? 0.4 : 0) + rng() * 0.6
|
|
- (r.price * g.spendMul > g.money ? 5 : 0)
|
|
+ (r.type === g.favRide ? 0.8 : 0);
|
|
if (score > bestScore) { bestScore = score; best = r; }
|
|
}
|
|
return bestScore > 0.3 ? best : null;
|
|
}
|
|
|
|
function goTarget(state, g, target, tx, ty, rideObj) {
|
|
const [sx, sy] = snapToPath(state.map, g.x, g.y);
|
|
let ex = tx, eyy = ty;
|
|
if (rideObj) {
|
|
// aim for a path tile adjacent to ride entrance
|
|
const adj = adjacentPath(state.map, rideObj.entranceX, rideObj.entranceY);
|
|
if (adj) { ex = adj[0]; eyy = adj[1]; }
|
|
else { wanderTo(state, g, 4); return false; }
|
|
}
|
|
const p = findPath(state.map, sx, sy, ex, eyy, 9000);
|
|
if (!p) { wanderTo(state, g, 5); return false; }
|
|
g.path = p; g.target = target; g.state = 'walking';
|
|
return true;
|
|
}
|
|
|
|
function tryGoShop(state, g, pred) {
|
|
const opts = state.shops.filter(s => pred(s) && !s.damaged && s.stock > 0);
|
|
if (!opts.length) return false;
|
|
opts.sort((a, b) => Math.hypot(a.x - g.x, a.y - g.y) - Math.hypot(b.x - g.x, b.y - g.y));
|
|
for (let k = 0; k < Math.min(3, opts.length); k++) {
|
|
const s = opts[k];
|
|
const adj = adjacentPath(state.map, s.x, s.y);
|
|
if (!adj) continue;
|
|
if (goTarget(state, g, { kind: 'shop', id: s.id }, adj[0], adj[1])) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function tryGoBench(state, g) {
|
|
const cands = state.sceneryList.filter(sc => sc.def.rest);
|
|
if (!cands.length) return false;
|
|
cands.sort((a, b) => Math.hypot(a.x - g.x, a.y - g.y) - Math.hypot(b.x - g.x, b.y - g.y));
|
|
const b = cands[0];
|
|
const adj = adjacentPath(state.map, b.x, b.y);
|
|
if (!adj) return false;
|
|
return goTarget(state, g, { kind: 'bench', id: b.id }, adj[0], adj[1]);
|
|
}
|
|
|
|
export function wanderTo(state, g, r) {
|
|
const [sx, sy] = snapToPath(state.map, g.x, g.y);
|
|
const dest = randomNearbyPath(state.map, rng, sx, sy, 2, r);
|
|
if (!dest) { /* isolated: stay */ g.path = []; return false; }
|
|
const p = findPath(state.map, sx, sy, dest[0], dest[1], 3000);
|
|
if (p) { g.path = p; g.target = { kind: 'wander' }; if (g.state !== 'queuing' && g.state !== 'riding' && g.state !== 'buying' && g.state !== 'resting') g.state = 'walking'; }
|
|
return !!p;
|
|
}
|
|
|
|
export function decideLeave(state, g) {
|
|
const m = state.map;
|
|
const gx = m.entranceX, gy = Math.min(m.size - 1, m.entranceY + 3);
|
|
const [sx, sy] = snapToPath(m, g.x, g.y);
|
|
const p = findPath(m, sx, sy, gx, gy, 12000);
|
|
if (p) { g.path = p; g.state = 'leaving'; g.target = { kind: 'exit' }; }
|
|
else { addThought(g, 'no_exit'); wanderTo(state, g, 6); }
|
|
}
|
|
|
|
export function adjacentPath(map, x, y, prefer = null) {
|
|
const dirs = prefer ? [[prefer[0], prefer[1]], ...[[1, 0], [0, 1], [-1, 0], [0, -1]].filter(d => d[0] !== prefer[0] || d[1] !== prefer[1])] : [[1, 0], [0, 1], [-1, 0], [0, -1]];
|
|
for (const [dx, dy] of dirs) {
|
|
if (map.isPath(x + dx, y + dy)) return [x + dx, y + dy];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// queue joining shared with rides module
|
|
import { joinQueue } from './rides.js';
|
|
|
|
export function scareGuests(state, x, y, radius) {
|
|
for (const g of state.guests) {
|
|
if (g.state === 'riding' || g.state === 'queuing') continue;
|
|
const d = Math.hypot(g.x - x, g.y - y);
|
|
if (d < radius) {
|
|
g.happiness = clamp(g.happiness - 14, 0, 100);
|
|
addThought(g, 'monster');
|
|
// flee away from x,y toward entrance-ish random direction
|
|
const ang = Math.atan2(g.y - y, g.x - x) + (rng() - 0.5);
|
|
const fx = Math.round(clamp(g.x + Math.cos(ang) * 8, 1, state.map.size - 2));
|
|
const fy = Math.round(clamp(g.y + Math.sin(ang) * 8, 1, state.map.size - 2));
|
|
const dest = nearestWalkableNear(state.map, fx, fy);
|
|
if (dest) {
|
|
const p = findPath(state.map, Math.round(g.x), Math.round(g.y), dest[0], dest[1], 2500);
|
|
if (p) { g.path = p; g.fleeingT = 3 + rng() * 3; g.state = 'walking'; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function nearestWalkableNear(map, x, y) {
|
|
for (let r = 0; r < 10; r++) {
|
|
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
|
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
|
if (map.isWalkable(x + dx, y + dy)) return [x + dx, y + dy];
|
|
}
|
|
}
|
|
return null;
|
|
}
|