Files
deepseek ac00687480 Arcane Tycoon — Heroes & Magic theme park tycoon game
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
2026-08-23 06:59:21 +00:00

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;
}