fecd8bb9c9
562 Ersetzungen in 50 Dateien: Halbgeviertstrich zwischen zwei Ziffern (= "bis"-Bereich) bekommt Leerzeichen davor/danach (3–4 -> 3 – 4). Regex /([0-9])\s*–\s*(?=[0-9])/ auf Rohtext (format-erhaltend, CRLF/Einrueckung unveraendert), Bindestriche in Woertern und Minuszeichen unangetastet, alle JSON weiter gueltig. Energiemanager-Zeitbloecke sind Display-Labels (kein Logik-Key) -> safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
834 lines
32 KiB
JavaScript
834 lines
32 KiB
JavaScript
// Simulationskern: Spielzustand + tick(state, dt). Enthält Spawning,
|
||
// Bewegung entlang der Routen, Infrastrukturwirkung (§11: Zielgruppen-,
|
||
// Saison-, Umwelt-, Kapazitätsfaktor, Bremsen, Einwirkzeit), Ökonomie,
|
||
// Bewertung beim Verlassen, Phasen/Saisonen, Ereignisse und Endauswertung.
|
||
// Komplett DOM-frei – läuft auch im Headless-Test (tests/model-test.mjs).
|
||
|
||
import {
|
||
GAME_LEN, PHASE_LEN, SEASON_LEN, SEASONS, SEASON_KEYS,
|
||
CONTACT_NORM, MAX_GAIN_FACTOR, MAX_BRAKE, START_BUDGET, START_ENV,
|
||
GROUPS, BUILDINGS, WAVES, EVENTS, FEED_TEXTS, PROFILES, GOALS, SYNERGIES, currentAvg,
|
||
} from './data.js';
|
||
import {
|
||
prepMap, mulberry32, makeRoute, cumLengths, TILE2PX, canPlace as worldCanPlace,
|
||
} from './world.js';
|
||
|
||
export { GOALS, currentAvg };
|
||
export const canPlace = worldCanPlace;
|
||
|
||
const LIFT_SPEED = 1.4; // Kacheln/s in der Liftfahrt
|
||
const REV_SCALE = 0.42; // globale Einnahmen-Skala (Balancing: Geld darf nicht trivial werden)
|
||
|
||
export function createState(opts = {}) {
|
||
const seed = opts.seed ?? 20260703;
|
||
const map = prepMap(opts.mapId || 'talschleife');
|
||
const state = {
|
||
map,
|
||
slots: map.slots,
|
||
playerName: opts.playerName || '',
|
||
classCode: opts.classCode || '',
|
||
time: 0, phase: 0, season: SEASONS[0], seasonKey: SEASON_KEYS[0],
|
||
ended: false, result: null,
|
||
budget: START_BUDGET, revenueTotal: 0, opCostTotal: 0,
|
||
env: START_ENV, traffic: 0,
|
||
fame: 1.0, festUntil: 0, natureBoost: 1,
|
||
buildings: [], nextBid: 1,
|
||
visitors: [], nextVid: 1,
|
||
unlocked: new Set(),
|
||
spawnQueue: [],
|
||
phaseExitStars: [],
|
||
feedPool: [],
|
||
pendingCards: [],
|
||
firedEvents: new Set(),
|
||
stats: {
|
||
all: { count: 0, stars: 0 },
|
||
groups: {},
|
||
buildingRevenue: {},
|
||
transports: { foot: 0, bike: 0, bus: 0, car: 0 },
|
||
exits: { E: 0, SE: 0 },
|
||
spawned: 0, skippedNoParking: 0,
|
||
},
|
||
fx: [],
|
||
heat: new Float32Array(48 * 32), heatCols: 48, heatRows: 32,
|
||
history: { t: [], budget: [], rating: [], env: [], visitors: [], satPct: [] },
|
||
lastSatPct: 0,
|
||
actionLog: [],
|
||
opAccum: 0, sampleAccum: 0,
|
||
waveStatus: 'pause', pauseLeft: 22, waveStartTime: 0,
|
||
recentStars: [],
|
||
rng: mulberry32(seed + 7),
|
||
};
|
||
prepareWave(state, 1, { silent: true });
|
||
return state;
|
||
}
|
||
|
||
// ---------- Haupt-Tick ----------
|
||
export function tick(state, dt) {
|
||
if (state.ended || dt <= 0) return;
|
||
state.time += dt;
|
||
|
||
// ---------- Wellen-Maschine (Tower-Defense-Rhythmus) ----------
|
||
// Baupause → Welle läuft → alle Gäste der Welle abgereist → nächste
|
||
// Baupause. „Nächste Welle rufen" startet früher (mit Bonus).
|
||
if (state.waveStatus === 'pause') {
|
||
state.pauseLeft -= dt;
|
||
if (state.pauseLeft <= 0) launchWave(state);
|
||
} else if (state.waveStatus === 'running') {
|
||
const active = state.spawnQueue.length > 0
|
||
|| state.visitors.some(v => v.wave === state.phase);
|
||
if (!active) {
|
||
if (state.phase >= 8) { finish(state); return; }
|
||
prepareWave(state, state.phase + 1);
|
||
state.waveStatus = 'pause';
|
||
state.pauseLeft = 16;
|
||
}
|
||
}
|
||
|
||
// ---------- Baustellen: Bau- und Ausbauzeit ----------
|
||
for (const b of state.buildings) {
|
||
if (b.construction > 0) {
|
||
b.construction -= dt;
|
||
if (b.construction <= 0) {
|
||
b.construction = 0;
|
||
state.pendingCards.push({ kind: 'built', type: b.type, level: b.level });
|
||
}
|
||
} else if (b.upgrading) {
|
||
b.upgrading.left -= dt;
|
||
if (b.upgrading.left <= 0) {
|
||
b.level++;
|
||
state.env = clampEnv(state.env + (BUILDINGS[b.type].env[b.level - 1] || 0));
|
||
b.upgrading = null;
|
||
refreshInfoBoosts(state);
|
||
state.pendingCards.push({ kind: 'built', type: b.type, level: b.level });
|
||
}
|
||
}
|
||
}
|
||
|
||
spawnDue(state);
|
||
moveAndAffect(state, dt);
|
||
|
||
// Betriebskosten alle 10 s (§18.7)
|
||
state.opAccum += dt;
|
||
while (state.opAccum >= 10) {
|
||
state.opAccum -= 10;
|
||
let cost = 0;
|
||
for (const b of state.buildings) {
|
||
if (b.construction > 0) continue; // Baustelle: noch keine Betriebskosten
|
||
cost += BUILDINGS[b.type].opCost[b.level - 1];
|
||
}
|
||
cost /= 6; // pro Minute → pro 10 s
|
||
state.budget -= cost;
|
||
state.opCostTotal += cost;
|
||
|
||
// Übertourismus (§10 Raumnutzungskonflikt): dichte Bebauung + viel Verkehr
|
||
// zehren die Umwelt langsam aus. Ab ~6 fertigen Bauwerken beginnt der Druck.
|
||
// „Alles zubauen" drückt die Umwelt so über die Partie klar unter die
|
||
// 45er-Schwelle — vorher blieb sie fast konstant. Naturschutzgebiet (+20)
|
||
// und maßvolles Bauen sind die Gegenmittel.
|
||
const builtCount = state.buildings.reduce((n, b) => n + (b.construction <= 0 ? 1 : 0), 0);
|
||
const overbuild = Math.max(0, builtCount - 6);
|
||
const envErode = overbuild * 0.06 + state.traffic * 0.012; // pro 10 s
|
||
if (envErode > 0) state.env = clampEnv(state.env - envErode);
|
||
|
||
// Einmalige Lern-Warnung, sobald die Umwelt kippt (didaktischer Hinweis).
|
||
if (state.env < 50 && !state.envWarned) {
|
||
state.envWarned = true;
|
||
state.pendingCards.push({
|
||
kind: 'card', icon: '🌲', title: 'Die Umwelt leidet!', sound: 'card',
|
||
text: 'Deine Umwelt ist unter 50 gefallen. Umweltbewusste Gäste – '
|
||
+ 'Wanderer, Naturfreunde, Radgruppen – werden unzufrieden, wenn sie '
|
||
+ 'unter 45 sinkt, und bleiben irgendwann ganz weg.\n\n'
|
||
+ 'Was hilft: maßvoller bauen, auf sanfte Anreise (Bus, Rad, Bahn) '
|
||
+ 'setzen – oder ein 🌿 Naturschutzgebiet bauen, das die Umwelt sofort '
|
||
+ 'um +20 hebt. Naturschutz gegen Ausbau abwägen ist der Kern des Spiels.',
|
||
});
|
||
}
|
||
}
|
||
|
||
// Verkehr: Parkplätze + aktive Auto-/Busgruppen
|
||
let traffic = 0;
|
||
for (const b of state.buildings) {
|
||
if (b.type === 'parkplatz') traffic += BUILDINGS.parkplatz.traffic[b.level - 1];
|
||
}
|
||
for (const v of state.visitors) {
|
||
const tr = GROUPS[v.type].transport;
|
||
if (tr === 'car') traffic += 1.5;
|
||
else if (tr === 'bus') traffic += 2.5;
|
||
}
|
||
state.traffic = Math.round(traffic);
|
||
|
||
// Verlauf (für den Graphen) alle 5 s
|
||
state.sampleAccum += dt;
|
||
if (state.sampleAccum >= 5) {
|
||
state.sampleAccum = 0;
|
||
const h = state.history;
|
||
h.t.push(state.time);
|
||
h.budget.push(Math.round(state.budget));
|
||
h.rating.push(Number(currentAvg(state).toFixed(2)));
|
||
h.env.push(Math.round(state.env));
|
||
h.visitors.push(state.visitors.length);
|
||
// Live-Erfüllungsgrad: wie „voll" sind die Zufriedenheitsbalken gerade?
|
||
// (Ziel wie im Tower Defense: 100 % = Gast komplett glücklich)
|
||
if (state.visitors.length) {
|
||
const mean = state.visitors.reduce((a, v) =>
|
||
a + Math.max(0, Math.min(1.1, v.satisfaction / GROUPS[v.type].satTarget)), 0) / state.visitors.length;
|
||
state.lastSatPct = Math.round(mean * 100);
|
||
}
|
||
h.satPct.push(state.lastSatPct || 0);
|
||
}
|
||
|
||
if (state.time > 2400) finish(state); // Sicherheitsdeckel
|
||
}
|
||
|
||
// ---------- Wellen-Vorbereitung: Ruhm, Feed, Freischaltungen, Plan ----------
|
||
function prepareWave(state, phase, opts = {}) {
|
||
// Besucherfaktor aus den Bewertungen der Vorphase (§8.4)
|
||
if (state.phaseExitStars.length) {
|
||
const avg = state.phaseExitStars.reduce((a, b) => a + b, 0) / state.phaseExitStars.length;
|
||
state.fame = 0.7 + avg / 5;
|
||
// Social-Media-Feed der Vorphase
|
||
const picks = pickFeed(state);
|
||
if (picks.length) {
|
||
state.pendingCards.push({
|
||
kind: 'feed', icon: '📱', phase: state.phase,
|
||
title: `Bewertungen aus Phase ${state.phase}`,
|
||
items: picks, avg, fame: state.fame,
|
||
});
|
||
}
|
||
}
|
||
state.phaseExitStars = [];
|
||
state.feedPool = [];
|
||
state.phase = phase;
|
||
|
||
// Saison hängt an der Welle (2 Wellen je Jahreszeit)
|
||
const si = Math.min(3, Math.floor((phase - 1) / 2));
|
||
if (SEASON_KEYS[si] !== state.seasonKey) {
|
||
state.seasonKey = SEASON_KEYS[si];
|
||
state.season = SEASONS[si];
|
||
state.pendingCards.push({
|
||
kind: 'season', icon: ['🌸', '☀️', '🍂', '❄️'][si],
|
||
title: `Saisonwechsel: ${state.season}`,
|
||
text: {
|
||
summer: 'Der Sommer ist da! Badesee, Eis, Rad und Wandern sind jetzt besonders gefragt.',
|
||
autumn: 'Herbst: Kulinarik, Kultur und Wandern haben Hochsaison – Badespaß ist vorbei.',
|
||
winter: 'Winter! Jetzt zählen Lift, Almhütte, Gasthaus und Aussicht. Der See ist zugefroren.',
|
||
}[state.seasonKey] || '',
|
||
});
|
||
}
|
||
|
||
// Ankündigungen/Entscheidungen dieser Welle
|
||
for (const ev of EVENTS) {
|
||
if (ev.wave === phase && !state.firedEvents.has(ev.id)) {
|
||
state.firedEvents.add(ev.id);
|
||
state.pendingCards.push({ kind: ev.type, ...ev });
|
||
}
|
||
}
|
||
|
||
// Freischaltungen
|
||
const fresh = Object.entries(BUILDINGS)
|
||
.filter(([id, d]) => d.unlockPhase === phase && !state.unlocked.has(id));
|
||
for (const [id] of fresh) state.unlocked.add(id);
|
||
|
||
// Welle planen
|
||
const wave = WAVES[phase] || {};
|
||
const parkPlus = sumBonus(state, 'parkplatz', 'visitorPlus');
|
||
const busPlus = sumBonus(state, 'bushaltestelle', 'busPlus');
|
||
const hasParking = state.buildings.some(b => b.type === 'parkplatz');
|
||
state.spawnQueue = [];
|
||
const waveCounts = [];
|
||
let slot = 0;
|
||
const t0 = 0; // relativ – launchWave addiert die echte Startzeit
|
||
for (const [type, base] of Object.entries(wave)) {
|
||
const g = GROUPS[type];
|
||
let mult = state.fame;
|
||
if (g.transport === 'car') mult *= 1 + parkPlus;
|
||
if (g.transport === 'bus') mult *= 1 + busPlus + parkPlus * 0.5;
|
||
let n = Math.max(1, Math.round(base * mult));
|
||
if (g.needsParking && !hasParking) {
|
||
const skipped = Math.floor(n / 2);
|
||
n -= skipped;
|
||
state.stats.skippedNoParking += skipped;
|
||
if (skipped > 0) state.feedPool.push({ stars: 1, emoji: g.emoji, text: FEED_TEXTS.noParking, prio: 3 });
|
||
}
|
||
waveCounts.push(`${n}× ${GROUPS[type].name}`);
|
||
// Konvoi statt Streuung: Die Welle einer Gruppe kommt gebündelt kurz
|
||
// hintereinander (alle 4 – 6 s) und nimmt dieselbe Route – so ist sie
|
||
// als zusammengehörende Welle erkennbar.
|
||
// Konvois innerhalb der Welle: Gruppe für Gruppe, eng beieinander
|
||
const clusterStart = t0 + 1 + slot * (n * 1.2 + 9);
|
||
const routePick = state.rng();
|
||
state.spawnQueue.push({ t: Math.max(t0 + 0.5, clusterStart - 1.2), banner: { type, n } });
|
||
for (let i = 0; i < n; i++) {
|
||
state.spawnQueue.push({ t: clusterStart + i * (2.4 + state.rng() * 0.8), type, routePick });
|
||
}
|
||
slot++;
|
||
}
|
||
state.spawnQueue.sort((a, b) => a.t - b.t);
|
||
|
||
// Wellen-Ankündigung: Wer kommt? (Porträts neuer Gruppen) + neue Bauoptionen
|
||
const newcomers = Object.keys(wave).filter(t => GROUPS[t].fromPhase === phase);
|
||
let text = `Erwartete Gäste: ${waveCounts.join(' · ')}.`;
|
||
for (const t of newcomers) {
|
||
const g = GROUPS[t];
|
||
text += `\n\n${g.emoji} ${g.name}: ${g.who}`;
|
||
}
|
||
if (fresh.length) {
|
||
text += `\n\n🔓 Neu baubar: ${fresh.map(([, d]) => `${d.emoji} ${d.name}`).join(' · ')}`;
|
||
}
|
||
state.pendingCards.push({
|
||
kind: 'phase', icon: '👥',
|
||
title: phase === 1 ? 'Phase 1: Die ersten Gäste kommen' : `Phase ${phase}: Neue Welle im Anmarsch`,
|
||
text,
|
||
});
|
||
}
|
||
|
||
// Welle wirklich starten: geplante Spawns auf die aktuelle Zeit schieben
|
||
function launchWave(state) {
|
||
for (const e of state.spawnQueue) e.t += state.time;
|
||
state.waveStatus = 'running';
|
||
state.waveStartTime = state.time;
|
||
state.pauseLeft = 0;
|
||
}
|
||
|
||
function sumBonus(state, type, key) {
|
||
let sum = 0;
|
||
for (const b of state.buildings) {
|
||
if (b.type === type) sum += BUILDINGS[type][key][b.level - 1];
|
||
}
|
||
return sum;
|
||
}
|
||
|
||
// ---------- Spawning ----------
|
||
function spawnDue(state) {
|
||
if (state.waveStatus !== 'running') return;
|
||
while (state.spawnQueue.length && state.spawnQueue[0].t <= state.time) {
|
||
const { type, routePick, banner } = state.spawnQueue.shift();
|
||
if (banner) {
|
||
state.pendingCards.push({ kind: 'banner', type: banner.type, n: banner.n });
|
||
continue;
|
||
}
|
||
if (state.visitors.length >= 120) continue; // Performance-Deckel
|
||
const g = GROUPS[type];
|
||
const route = makeRoute(state, g, state.rng, routePick);
|
||
const hasParking = state.buildings.some(b => b.type === 'parkplatz');
|
||
// Busgruppen fahren im Bus bis zur Haltestelle und steigen dort aus
|
||
let travel = 'walk', busStopS = 0;
|
||
if (g.transport === 'bus') {
|
||
const stop = state.buildings.find(b => b.type === 'bushaltestelle');
|
||
if (stop) {
|
||
let bestD = Infinity;
|
||
for (let i = 0; i < route.pts.length; i++) {
|
||
const d = Math.hypot(route.pts[i].x - stop.x, route.pts[i].y - stop.y);
|
||
if (d < bestD && route.cum[i] > 40) { bestD = d; busStopS = route.cum[i]; }
|
||
}
|
||
if (bestD < 260 && busStopS < route.cum[route.cum.length - 1] - 60) travel = 'bus';
|
||
}
|
||
}
|
||
state.visitors.push({
|
||
wave: state.phase,
|
||
travel, busStopS, busWait: 0,
|
||
id: state.nextVid++,
|
||
type,
|
||
pts: route.pts,
|
||
cum: route.cum,
|
||
exitLabel: route.exit,
|
||
s: 0, seg: 0,
|
||
x: route.pts[0].x, y: route.pts[0].y,
|
||
satisfaction: g.needsParking && !hasParking ? -10 : 0,
|
||
revFx: {},
|
||
spent: 0,
|
||
gained: {},
|
||
hadService: false,
|
||
fxNext: {},
|
||
brake: 0,
|
||
timeIn: 0,
|
||
mode: 'walk',
|
||
wobble: state.rng() * 7,
|
||
});
|
||
state.stats.spawned++;
|
||
state.stats.transports[g.transport]++;
|
||
}
|
||
}
|
||
|
||
// ---------- Bewegung + Infrastrukturwirkung ----------
|
||
// Tower-Defense-Kapazität (§11.7, verschärft): Jedes Gebäude bedient nur so
|
||
// viele Gäste, wie es Kapazität hat – die nächstgelegenen zuerst. Wer keinen
|
||
// Platz bekommt, geht leer aus (sichtbar: Auslastungsbogen + Bedien-Bögen).
|
||
function moveAndAffect(state, dt) {
|
||
const bs = state.buildings;
|
||
const servedBy = new Array(bs.length);
|
||
for (let i = 0; i < bs.length; i++) {
|
||
const b = bs[i];
|
||
b.arcs = [];
|
||
if (b.construction > 0 || b.upgrading) {
|
||
// Baustelle: bedient niemanden
|
||
b.inRange = 0; b.util = 0; b.serving = [];
|
||
servedBy[i] = new Set();
|
||
continue;
|
||
}
|
||
const R = range(b);
|
||
const near = [];
|
||
for (const v of state.visitors) {
|
||
if (v.travel === 'bus') continue; // im Bus wird niemand bedient
|
||
const d = dist(v, b);
|
||
if (d <= R) near.push([d, v]);
|
||
}
|
||
near.sort((a, b2) => a[0] - b2[0]);
|
||
const cap = BUILDINGS[b.type].capacity[b.level - 1];
|
||
b.inRange = near.length;
|
||
b.util = cap >= 999 ? 0 : near.length / cap;
|
||
const taken = near.slice(0, cap);
|
||
servedBy[i] = new Set(taken.map(x => x[1].id));
|
||
b.serving = taken.slice(0, 10).map(x => x[1].id);
|
||
}
|
||
|
||
const envFBase = 0.6 + state.env / 100; // §11.9
|
||
const fest = state.time < state.festUntil ? 1.15 : 1;
|
||
|
||
for (const v of state.visitors) {
|
||
const g = GROUPS[v.type];
|
||
v.timeIn += dt;
|
||
|
||
// Wirkung aller Gebäude, die diesen Gast gerade bedienen
|
||
let brakeProduct = 1;
|
||
for (let i = 0; i < bs.length; i++) {
|
||
if (!servedBy[i].has(v.id)) continue;
|
||
const b = bs[i];
|
||
const def = BUILDINGS[b.type];
|
||
const lvl = b.level - 1;
|
||
const tf = targetFactor(def, b.level, v.type);
|
||
if (def.service) v.hadService = true;
|
||
if (def.slow[lvl] > 0 && tf >= 0.3 && v.mode !== 'lift') {
|
||
brakeProduct *= 1 - def.slow[lvl];
|
||
}
|
||
if (tf <= 0.05) continue;
|
||
|
||
const sf = def.season[state.seasonKey] ?? 1;
|
||
const envF = 1 + g.envSens * (envFBase - 1);
|
||
const natF = def.natureBoosted ? state.natureBoost : 1;
|
||
const elevB = def.elevBonus && b.slotZone === 'berg' ? 1.3 : 1;
|
||
const eff = def.sat[lvl] * tf * sf * envF * natF * (b.infoBoost || 1) * (b.synergy || 1) * elevB * fest;
|
||
|
||
const maxGain = def.sat[lvl] * tf * sf * MAX_GAIN_FACTOR;
|
||
const got = v.gained[b.id] ?? 0;
|
||
const add = Math.min((eff / CONTACT_NORM) * dt, Math.max(0, maxGain - got));
|
||
if (add > 0) {
|
||
v.satisfaction += add;
|
||
v.gained[b.id] = got + add;
|
||
// Bogen nur, solange die Bedienung wirklich etwas bringt
|
||
if (b.arcs.length < 10 && !b.arcs.includes(v.id)) b.arcs.push(v.id);
|
||
}
|
||
|
||
// sichtbare Interaktion: Objekt/Erlebnis schwebt vom Gebäude zum Gast –
|
||
// beim ersten Kontakt und dann alle paar Sekunden wieder
|
||
// Objekt-Flug nur für wirklich passende Zielgruppen (tf ≥ 0.5),
|
||
// Produkt je Zielgruppe (itemsBy), Geräusch je Dienstleistung
|
||
if (tf >= 0.5 && (v.gained[b.id] ?? 0) >= 2 && state.time >= (v.fxNext[b.id] ?? 0)) {
|
||
v.fxNext[b.id] = state.time + 2.2 + state.rng() * 1.2;
|
||
const emoji = def.itemsBy?.[v.type] || def.item || def.fx;
|
||
if (emoji && state.fx.length < 90) {
|
||
const amount = Math.round(v.revFx[b.id] || 0);
|
||
if (amount > 0) v.revFx[b.id] = 0;
|
||
state.fx.push({ emoji, fx: b.x, fy: b.y, vid: v.id, amount, sound: def.sound || 'pling' });
|
||
}
|
||
}
|
||
|
||
// Einnahmen als Folge von Zufriedenheit (§11.8, §25)
|
||
const baseRev = def.revenue[lvl];
|
||
if (baseRev > 0 && tf > 0.3) {
|
||
const satF = Math.min(1.5, 0.5 + Math.max(0, v.satisfaction) / g.satTarget);
|
||
const rev = Math.min((baseRev * REV_SCALE * tf * satF / CONTACT_NORM) * dt, g.budget - v.spent);
|
||
if (rev > 0) {
|
||
state.budget += rev;
|
||
state.revenueTotal += rev;
|
||
v.spent += rev;
|
||
v.revFx[b.id] = (v.revFx[b.id] || 0) + rev;
|
||
state.stats.buildingRevenue[b.type] = (state.stats.buildingRevenue[b.type] || 0) + rev;
|
||
}
|
||
}
|
||
}
|
||
v.brake = Math.min(MAX_BRAKE, 1 - brakeProduct);
|
||
|
||
// Bewegung entlang der Route (Bogenlänge in Bild-Pixeln)
|
||
const liftSeg = v.pts[v.seg + 1]?.lift;
|
||
v.mode = liftSeg ? 'lift' : 'walk';
|
||
// starke Bremse ⇒ sichtbares Verweilen: Stop-and-Go statt Zeitlupe
|
||
v.stopped = false;
|
||
if (!liftSeg && v.travel === 'walk' && v.brake > 0.28) {
|
||
const cycle = (state.time + v.id * 1.7) % 7;
|
||
if (cycle < 2.8) v.stopped = true;
|
||
}
|
||
let speed = (liftSeg ? LIFT_SPEED : g.speed * (1 - v.brake * 0.55)) * TILE2PX;
|
||
if (v.stopped) speed = 0;
|
||
if (v.travel === 'bus') {
|
||
speed = 2.6 * TILE2PX; // Busfahrt bis zur Haltestelle
|
||
if (v.s >= v.busStopS) { v.travel = 'busstop'; v.busWait = 2.6; }
|
||
} else if (v.travel === 'busstop') {
|
||
speed = 0; // Halt: Fahrgäste steigen aus
|
||
v.busWait -= dt;
|
||
if (v.busWait <= 0) v.travel = 'walk';
|
||
}
|
||
v.s += speed * dt;
|
||
|
||
const total = v.cum[v.cum.length - 1];
|
||
if (v.s >= total) {
|
||
exitVisitor(state, v);
|
||
} else {
|
||
while (v.seg < v.pts.length - 2 && v.cum[v.seg + 1] <= v.s) v.seg++;
|
||
const a = v.pts[v.seg], b2 = v.pts[v.seg + 1];
|
||
const len = v.cum[v.seg + 1] - v.cum[v.seg] || 1;
|
||
const f = (v.s - v.cum[v.seg]) / len;
|
||
v.x = a.x + (b2.x - a.x) * f;
|
||
v.y = a.y + (b2.y - a.y) * f;
|
||
v.headRight = b2.x >= a.x;
|
||
}
|
||
}
|
||
// Besucherstrom-Dichte fürs Heatmap-Raster akkumulieren (§15.7)
|
||
const HC = state.heatCols, HR = state.heatRows;
|
||
const mw = state.map.w, mh = state.map.h;
|
||
for (const v of state.visitors) {
|
||
if (v.exited || v.travel === 'bus') continue;
|
||
const c = Math.max(0, Math.min(HC - 1, Math.floor(v.x / mw * HC)));
|
||
const r = Math.max(0, Math.min(HR - 1, Math.floor(v.y / mh * HR)));
|
||
state.heat[r * HC + c] += dt;
|
||
}
|
||
|
||
state.visitors = state.visitors.filter(v => !v.exited);
|
||
}
|
||
|
||
function dist(v, b) {
|
||
return Math.hypot(v.x - b.x, v.y - b.y);
|
||
}
|
||
function range(b) {
|
||
return BUILDINGS[b.type].range[b.level - 1] * TILE2PX;
|
||
}
|
||
function targetFactor(def, level, groupId) {
|
||
const byLevel = def.targetByLevel?.[groupId];
|
||
if (byLevel) return byLevel[level - 1];
|
||
return def.target[groupId] ?? 0.4;
|
||
}
|
||
|
||
// ---------- Abreise + Bewertung (§8, §18.5) ----------
|
||
function exitVisitor(state, v) {
|
||
v.exited = true;
|
||
const g = GROUPS[v.type];
|
||
let sat = v.satisfaction;
|
||
const malus = { service: false, traffic: false, env: false, time: false };
|
||
|
||
if (g.needsService && !v.hadService && v.timeIn > 60) { sat -= 10; malus.service = true; }
|
||
const tPen = state.traffic * 0.08 * g.trafficSens;
|
||
if (tPen > 4) malus.traffic = true;
|
||
sat -= tPen;
|
||
// Umwelt-Malus WIRKLICH anwenden (war zuvor nur ein toter Flag ohne Wirkung):
|
||
// eine kaputte Landschaft vergrault Gäste — je umweltbewusster, desto stärker,
|
||
// und je tiefer die Umwelt fällt, desto heftiger. Ab env<60 spürbar, <40 hart.
|
||
// Das ist der Raumnutzungskonflikt: zu viel zubauen → Umwelt kippt → Rating fällt.
|
||
if (state.env < 60 && g.envSens >= 0.4) {
|
||
sat -= g.envSens * (60 - state.env) * 0.5; // env 40 → ~6, env 20 → ~12 (× envSens)
|
||
if (state.env < 45) malus.env = true;
|
||
}
|
||
if (v.timeIn > g.maxTime) { sat *= 0.92; malus.time = true; }
|
||
|
||
const ratio = Math.max(0, sat) / g.satTarget;
|
||
const stars = Math.max(1, Math.min(5, Math.ceil(ratio * 5)));
|
||
|
||
// sichtbares Abschluss-Feedback am Ausgang (wie „Gegner besiegt")
|
||
if (state.fx.length < 90) {
|
||
state.fx.push({ pop: true, stars, fx: v.x, fy: v.y });
|
||
}
|
||
state.recentStars.push(stars);
|
||
if (state.recentStars.length > 12) state.recentStars.shift();
|
||
if (stars <= 2) state.lastBadExit = state.time;
|
||
|
||
const st = state.stats;
|
||
st.all.count++; st.all.stars += stars;
|
||
st.groups[v.type] = st.groups[v.type] || { count: 0, stars: 0 };
|
||
st.groups[v.type].count++;
|
||
st.groups[v.type].stars += stars;
|
||
st.groups[v.type].avg = st.groups[v.type].stars / st.groups[v.type].count;
|
||
st.exits[v.exitLabel] = (st.exits[v.exitLabel] || 0) + 1;
|
||
state.phaseExitStars.push(stars);
|
||
|
||
// Feed-Text erzeugen
|
||
let text;
|
||
if (malus.traffic && stars <= 3 && state.rng() < 0.5) text = FEED_TEXTS.traffic;
|
||
else if (malus.env && stars <= 3 && state.rng() < 0.4) text = FEED_TEXTS.env;
|
||
else {
|
||
const pool = FEED_TEXTS[stars];
|
||
text = pool[Math.floor(state.rng() * pool.length)]
|
||
.replace('{best}', bestBuildingName(state, v))
|
||
.replace('{missing}', g.wish);
|
||
}
|
||
state.feedPool.push({ stars, emoji: g.emoji, group: g.name, text, prio: stars === 5 || stars <= 2 ? 2 : 1 });
|
||
}
|
||
|
||
function bestBuildingName(state, v) {
|
||
let best = null, val = 3;
|
||
for (const [bid, gain] of Object.entries(v.gained)) {
|
||
if (gain > val) {
|
||
const b = state.buildings.find(x => x.id === Number(bid));
|
||
if (b) { best = b; val = gain; }
|
||
}
|
||
}
|
||
if (!best) return 'die Landschaft';
|
||
const def = BUILDINGS[best.type];
|
||
return (def.levelNames ? def.levelNames[best.level - 1] : def.name);
|
||
}
|
||
|
||
function pickFeed(state) {
|
||
const sorted = [...state.feedPool].sort((a, b) => (b.prio - a.prio) || (state.rng() - 0.5));
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const f of sorted) {
|
||
if (seen.has(f.text)) continue;
|
||
seen.add(f.text);
|
||
out.push(f);
|
||
if (out.length >= 3) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------- Bauen / Ausbauen / Abreißen (auf festen Slots) ----------
|
||
export function place(state, type, slotIdx) {
|
||
const check = canPlace(state, type, slotIdx);
|
||
if (!check.ok) return check;
|
||
const def = BUILDINGS[type];
|
||
const slot = state.slots[slotIdx];
|
||
state.budget -= def.cost[0];
|
||
const b = {
|
||
id: state.nextBid++, type, level: 1,
|
||
x: slot.x, y: slot.y, slotIdx, slotZone: slot.zone, infoBoost: 1,
|
||
construction: 5 + def.cost[0] / 50, // Baustellenzeit in Sekunden
|
||
upgrading: null, synergy: 1, synergyList: [],
|
||
};
|
||
state.buildings.push(b);
|
||
slot.building = b;
|
||
state.env = clampEnv(state.env + def.env[0]);
|
||
state.actionLog.push({ t: state.time, action: 'build', type, level: 1 });
|
||
|
||
if (def.opensBerg) {
|
||
state.pendingCards.push({
|
||
kind: 'card', icon: '⛰️', title: 'Der Berg ist erschlossen!',
|
||
text: 'Die Seilbahn bringt Gäste auf den Berg – oben warten neue Bauplätze '
|
||
+ '(Almhütte, Aussichtspunkt). Wanderer und junge Gruppen lieben das, '
|
||
+ 'im Winter läuft der Lift auf Hochtouren.',
|
||
});
|
||
}
|
||
if (type === 'naturschutz') {
|
||
state.natureBoost = 1.15;
|
||
// Schutzgebiet sperrt umliegende freie Bauplätze (§10: Zielkonflikt
|
||
// Wirtschaft ↔ Schutz). Reichweite in Pixeln.
|
||
const R = (def.blocksRadius || 3.2) * TILE2PX;
|
||
b.blockedSlots = [];
|
||
for (const s of state.slots) {
|
||
if (s === slot || s.building || s.zone === 'lake' || s.zone === 'road') continue;
|
||
if (Math.hypot(s.x - slot.x, s.y - slot.y) <= R) {
|
||
s.blocked = true;
|
||
b.blockedSlots.push(s.idx);
|
||
}
|
||
}
|
||
}
|
||
refreshInfoBoosts(state);
|
||
return { ok: true, building: b };
|
||
}
|
||
|
||
export function upgrade(state, b) {
|
||
const def = BUILDINGS[b.type];
|
||
if (b.construction > 0 || b.upgrading) return { ok: false, reason: 'Hier wird gerade gebaut.' };
|
||
if (b.level >= def.maxLevel) return { ok: false, reason: 'Bereits voll ausgebaut.' };
|
||
const cost = def.cost[b.level];
|
||
if (state.budget < cost) return { ok: false, reason: `Zu teuer – Ausbau kostet ${cost} €.` };
|
||
state.budget -= cost;
|
||
b.upgrading = { left: 4 + cost / 60 }; // Umbauzeit; Stufe zählt erst danach
|
||
state.actionLog.push({ t: state.time, action: 'upgrade', type: b.type, level: b.level + 1 });
|
||
return { ok: true, cost, time: Math.round(b.upgrading.left) };
|
||
}
|
||
|
||
export function demolish(state, b) {
|
||
if (b.type === 'naturschutz') return { ok: false, reason: 'Ein Schutzgebiet wird nicht einfach wieder aufgehoben.' };
|
||
const def = BUILDINGS[b.type];
|
||
let invested = 0;
|
||
for (let i = 0; i < b.level; i++) invested += def.cost[i];
|
||
const refund = Math.round(invested * 0.3);
|
||
state.budget += refund;
|
||
state.buildings = state.buildings.filter(x => x !== b);
|
||
const slot = state.slots[b.slotIdx];
|
||
if (slot) slot.building = null;
|
||
// gesperrte Bauplätze eines Schutzgebiets wieder freigeben
|
||
if (b.blockedSlots) for (const i of b.blockedSlots) { if (state.slots[i]) state.slots[i].blocked = false; }
|
||
state.actionLog.push({ t: state.time, action: 'demolish', type: b.type });
|
||
if (def.opensBerg) {
|
||
// Wer gerade am Berg unterwegs ist, steigt um: Rest des Wegs zu Fuß
|
||
for (const v of state.visitors) {
|
||
if (v.pts.some(p => p.lift)) {
|
||
for (const p of v.pts) delete p.lift;
|
||
}
|
||
}
|
||
}
|
||
refreshInfoBoosts(state);
|
||
return { ok: true, refund, name: def.name };
|
||
}
|
||
|
||
// Tourismusinfo-Multiplikator (§10) + Synergien (§9.2) je Gebäude neu berechnen.
|
||
// Wird nach jedem Bau/Ausbau/Abriss aufgerufen.
|
||
function refreshInfoBoosts(state) {
|
||
const infos = state.buildings.filter(b => b.type === 'tourismusinfo');
|
||
for (const b of state.buildings) {
|
||
let boost = 1;
|
||
for (const inf of infos) {
|
||
if (inf === b) continue;
|
||
const r = BUILDINGS.tourismusinfo.range[inf.level - 1] * TILE2PX;
|
||
if (Math.hypot(inf.x - b.x, inf.y - b.y) <= r) {
|
||
boost += BUILDINGS.tourismusinfo.boost[inf.level - 1];
|
||
}
|
||
}
|
||
b.infoBoost = boost;
|
||
b.synergy = 1;
|
||
b.synergyList = [];
|
||
}
|
||
// Synergie-Paare: beide Partner bekommen den Bonus, wenn in Reichweite
|
||
for (const rule of SYNERGIES) {
|
||
for (const x of state.buildings) {
|
||
if (x.type !== rule.a) continue;
|
||
for (const y of state.buildings) {
|
||
if (y.type !== rule.b) continue;
|
||
if (Math.hypot(x.x - y.x, x.y - y.y) <= rule.radius) {
|
||
x.synergy += rule.bonus; x.synergyList.push(rule.why);
|
||
y.synergy += rule.bonus; y.synergyList.push(rule.why);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function clampEnv(v) {
|
||
return Math.max(5, Math.min(100, v));
|
||
}
|
||
|
||
// ---------- „Nächste Welle" (Tower-Defense-Klassiker) ----------
|
||
// Ruft die nächste Phase sofort – Bonus fürs Risiko, restliche Spawns der
|
||
// laufenden Phase entfallen. In Phase 8 nicht mehr möglich.
|
||
export function callNextWave(state) {
|
||
if (state.ended) return { ok: false, reason: 'Die Partie ist vorbei.' };
|
||
if (state.waveStatus === 'pause') {
|
||
// Baupause überspringen: kleiner Bonus für die gesparte Zeit
|
||
const bonus = Math.round(Math.max(0, state.pauseLeft) * 2) + 10;
|
||
state.budget += bonus;
|
||
launchWave(state);
|
||
state.actionLog.push({ t: state.time, action: 'next_wave', bonus });
|
||
return { ok: true, bonus };
|
||
}
|
||
if (state.phase >= 8) return { ok: false, reason: 'Die letzte Welle läuft bereits.' };
|
||
// Welle früh rufen: Risiko-Bonus je noch aktivem Gast
|
||
const remaining = state.visitors.filter(v => v.wave === state.phase).length;
|
||
const bonus = 15 + remaining * 4;
|
||
state.budget += bonus;
|
||
prepareWave(state, state.phase + 1);
|
||
launchWave(state);
|
||
state.actionLog.push({ t: state.time, action: 'next_wave', bonus, early: true });
|
||
return { ok: true, bonus };
|
||
}
|
||
|
||
// ---------- Endauswertung (§12, §21) ----------
|
||
export function finish(state) {
|
||
if (state.ended) return;
|
||
state.ended = true;
|
||
const st = state.stats;
|
||
const avgStars = st.all.count ? st.all.stars / st.all.count : 0;
|
||
|
||
const nonCar = st.transports.foot + st.transports.bike + st.transports.bus;
|
||
const total = Math.max(1, st.spawned);
|
||
let lenkung = (nonCar / total) * 100;
|
||
if (state.buildings.some(b => b.type === 'bushaltestelle')) lenkung = Math.min(100, lenkung + 10);
|
||
|
||
const scores = {
|
||
rating: (avgStars / 5) * 100,
|
||
revenue: Math.min(100, (state.revenueTotal / 2500) * 100),
|
||
sustain: state.env,
|
||
lenkung,
|
||
finance: state.budget >= 0 ? 100 : Math.max(0, 100 * (1 + state.budget / 1000)),
|
||
};
|
||
const score = Math.round(
|
||
scores.rating * 0.35 + scores.revenue * 0.25 + scores.sustain * 0.20
|
||
+ scores.lenkung * 0.10 + scores.finance * 0.10,
|
||
);
|
||
|
||
state.result = {
|
||
avgStars, score, scores,
|
||
revenue: Math.round(state.revenueTotal),
|
||
env: Math.round(state.env),
|
||
budget: Math.round(state.budget),
|
||
visitors: st.spawned,
|
||
rated: st.all.count,
|
||
goals: GOALS.map(g => ({ label: g.label, done: g.check(state) })),
|
||
};
|
||
state.result.profile = PROFILES.find(p => p.check(state));
|
||
state.result.feedback = buildFeedback(state);
|
||
state.pendingCards.push({ kind: 'end' });
|
||
}
|
||
|
||
function buildFeedback(state) {
|
||
const st = state.stats;
|
||
const lines = [];
|
||
|
||
// stärkste und schwächste Gruppe
|
||
let best = null, worst = null;
|
||
for (const [id, g] of Object.entries(st.groups)) {
|
||
if (g.count < 3) continue;
|
||
if (!best || g.avg > best.g.avg) best = { id, g };
|
||
if (!worst || g.avg < worst.g.avg) worst = { id, g };
|
||
}
|
||
if (best && best.g.avg >= 3) {
|
||
lines.push(`Besonders wohl fühlten sich ${plural(best.id)} (Ø ${best.g.avg.toFixed(1)} Sterne) – `
|
||
+ 'dein Angebot passte zu ihren Bedürfnissen.');
|
||
} else if (best) {
|
||
lines.push('Keine Besuchergruppe wurde richtig glücklich – die Region bot zu wenig passende '
|
||
+ 'Angebote entlang der Wege.');
|
||
}
|
||
|
||
// Wirtschaft
|
||
const topRev = Object.entries(st.buildingRevenue).sort((a, b) => b[1] - a[1])[0];
|
||
if (topRev) {
|
||
lines.push(`Wirtschaftlich trug ${BUILDINGS[topRev[0]].name} am meisten zur regionalen `
|
||
+ `Wertschöpfung bei (${Math.round(topRev[1])} € von ${Math.round(state.revenueTotal)} €).`);
|
||
}
|
||
if (state.budget < 0) {
|
||
lines.push('Das Budget rutschte ins Minus – Betriebskosten laufen weiter, auch wenn keine Gäste da sind. '
|
||
+ 'Teure Infrastruktur braucht Zeit, bis sie sich rechnet (Amortisation).');
|
||
}
|
||
|
||
// Umwelt
|
||
if (state.env >= 65) {
|
||
lines.push('Der Umweltwert blieb hoch. Das zahlt sich doppelt aus: Naturgruppen bewerten besser, '
|
||
+ 'und die Landschaft bleibt als wichtigster Standortfaktor erhalten.');
|
||
} else if (state.env < 45) {
|
||
lines.push('Der Umweltwert ist stark gesunken – Parkflächen, Lift und Ausbau haben Spuren hinterlassen. '
|
||
+ 'Naturnahe Gäste haben das in ihren Bewertungen deutlich gemacht.');
|
||
}
|
||
|
||
// Verkehr / Mobilität
|
||
if (state.stats.skippedNoParking > 2) {
|
||
lines.push(`${state.stats.skippedNoParking} Busgruppen fanden keinen Parkplatz und fuhren weiter – `
|
||
+ 'Erreichbarkeit entscheidet, wer überhaupt Gast werden kann.');
|
||
}
|
||
if (state.traffic > 30) {
|
||
lines.push('Der Verkehr im Ort wurde zur Belastung – empfindliche Gäste (Senioren, Familien) '
|
||
+ 'bewerteten schlechter. Bushaltestelle und Besucherlenkung könnten helfen.');
|
||
}
|
||
|
||
// Verbesserungstipp
|
||
if (worst && worst.g.avg < 3) {
|
||
lines.push(`Am schlechtesten schnitten ${plural(worst.id)} ab (Ø ${worst.g.avg.toFixed(1)} Sterne). `
|
||
+ `Ihnen fehlte: ${GROUPS[worst.id].wish}.`);
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
function plural(id) {
|
||
return {
|
||
spaziergaenger: 'Spaziergänger', wanderer: 'Wanderer', familie: 'Familien',
|
||
radfahrer: 'Radfahrer', senioren: 'Seniorengruppen', schulklasse: 'Schulklassen',
|
||
busgruppe: 'Busreisegruppen', junge_gruppe: 'junge Reisegruppen', luxuspaar: 'wohlhabende Paare',
|
||
}[id] || id;
|
||
}
|