diff --git a/App/sims/tourismustal/assets/sprites/bikepark.png b/App/sims/tourismustal/assets/sprites/bikepark.png index 8f6c7b5..02c9a0b 100644 Binary files a/App/sims/tourismustal/assets/sprites/bikepark.png and b/App/sims/tourismustal/assets/sprites/bikepark.png differ diff --git a/App/sims/tourismustal/js/data.js b/App/sims/tourismustal/js/data.js index 56b6732..c4ad69b 100644 --- a/App/sims/tourismustal/js/data.js +++ b/App/sims/tourismustal/js/data.js @@ -60,8 +60,8 @@ export const BUILDINGS = { }, bikepark: { name: 'Bikepark', emoji: '🚵', cost: 10000, upkeep: 80, - zone: 'mid', staff: 2, summerCap: 160, trailLen: 5, - desc: 'Flowtrails am Hang: bis 160 Sommergäste. Braucht eine Bahn in der Nähe (max. 4 Felder), die die Bikes hinaufbringt.', + zone: 'mid', staff: 2, summerCap: 160, trailLen: 5, size: 2, + desc: 'Großes Basis-Areal (2×2 Felder) mit Flowtrails: bis 160 Sommergäste. Braucht eine Bahn in der Nähe (max. 4 Felder), die die Bikes hinaufbringt.', }, ropes: { name: 'Hochseilgarten', emoji: '🧗', cost: 6500, upkeep: 45, diff --git a/App/sims/tourismustal/js/iso.js b/App/sims/tourismustal/js/iso.js index 6187a55..d47b214 100644 --- a/App/sims/tourismustal/js/iso.js +++ b/App/sims/tourismustal/js/iso.js @@ -35,7 +35,7 @@ const SPRITE_DEFS = { access: { w: 52, bottom: 8 }, rescue: { w: 60, bottom: 9 }, snow: { w: 44, bottom: 7 }, - bikepark: { w: 58, bottom: 8 }, + bikepark: { w: 108, bottom: 14 }, // 2×2-Areal, zentriert gezeichnet ropes: { w: 58, bottom: 8 }, bikerental: { w: 50, bottom: 8 }, platform: { w: 62, bottom: 9 }, @@ -629,7 +629,8 @@ export class IsoRenderer { const incident = this.heli || this.guests.some(g => g.mode === 'crashed'); if (!incident) { for (const g of this.guests) { - if (g.mode === 'ski' && Math.random() < 0.02 * dt) { + // (0.01: Tage sind seit der Zeit-Halbierung doppelt so lang) + if (g.mode === 'ski' && Math.random() < 0.01 * dt) { g.mode = 'crashed'; g.crashAt = this.simTime; g.route = []; g.nextLift = null; g.moving = false; this._say(g, '\u{1F198}', 9e8); // 🆘 bleibt sichtbar @@ -783,6 +784,32 @@ export class IsoRenderer { ctx.lineWidth = 1; } + // Problem-Sprechblasen: Gebäude mit akutem Problem melden sich alle ~3 + // Minuten (Echtzeit) mit einer aufsteigenden Blase – z. B. „Personal + // gesucht!" – damit man Ursachen auch ohne Klick ins Gebäude sieht. + _problemBubbles() { + const s = this.state; + const now = this.time; + if (!this._probNext) this._probNext = {}; + const staffGap = s.staffNeeded > s.staffAvailable; + for (const b of s.buildings) { + const def = BUILDINGS[b.type]; + let msg = null; + if (staffGap && (def.staff || 0) > 0) msg = '👷 Personal gesucht!'; + else if ((b.type === 'lift' || b.type === 'gondola') + && s.season === 'Winter' && !liftSkiable(s, b.alt ?? 3)) msg = '❄️ Kein Schnee!'; + const k = `${b.c},${b.r}`; + if (!msg) { delete this._probNext[k]; continue; } + if (!this._probNext[k]) { + // erster Hinweis nach 15–35 s, danach im 3-Minuten-Takt + this._probNext[k] = now + 15000 + Math.random() * 20000; + } else if (now >= this._probNext[k]) { + this._probNext[k] = now + 180000; + this.addFloat(msg, '#c0392b', b.c + 0.5, b.r - 0.4, true); + } + } + } + // Sichtbarer Umbau: ~10 s Bauzeit (Spielzeit), danach wird die neue Stufe // scharf geschaltet. Läuft für ALLE Baustellen (auch außerhalb des Bildes). CONSTRUCT_MS = 10000; @@ -894,6 +921,7 @@ export class IsoRenderer { this._drawBus(); this._drawFestivals(winter); this._drawStaffAlerts(); + this._problemBubbles(); this._drawConstruction(); this._drawRiders(); this._drawStaff(); @@ -1084,7 +1112,8 @@ export class IsoRenderer { this._drawTree(x, y, snowy, tile.tint); } if (tile.deco) this._drawDeco(tile.deco, x, y); - if (tile.building) this._drawBuilding(tile.building, x, y, tile); + // Große Gebäude (2×2) zeichnet nur die Anker-Kachel – zentriert übers Areal + if (tile.building && !tile.buildingRef) this._drawBuilding(tile.building, x, y, tile); // Noch nicht erschlossene Hochlagen liegen unter Nebel if (tile.r < this.state.stageRow) { @@ -1635,6 +1664,12 @@ export class IsoRenderer { _drawBuilding(type, x, y, tile) { const ctx = this.ctx; const level = tile.level || 1; + // 2×2-Gebäude: zentriert über dem Areal zeichnen (Anker = links oben) + if ((BUILDINGS[type]?.size || 1) > 1) { + const half = (BUILDINGS[type].size - 1) / 2; + const ctr = this.tileToWorld(tile.c + half, tile.r + half, tile.elev); + x = ctr.x; y = ctr.y; + } // KI-Sprites für Gebäude (Bahnen & Rodelbahn bleiben Vektor: dynamisch) const spriteName = type === 'hotel' && level >= 2 ? 'hotel2' : type; const spriteScale = type === 'hotel' diff --git a/App/sims/tourismustal/js/main.js b/App/sims/tourismustal/js/main.js index 5cd2dad..dd23881 100644 --- a/App/sims/tourismustal/js/main.js +++ b/App/sims/tourismustal/js/main.js @@ -11,7 +11,7 @@ import { player } from './player.js'; window.__ttLoaded = true; // Signal an den file://-Fallback in index.html -const MS_PER_DAY = 2000; // 1 Tag = 2 s bei Geschwindigkeit 1× – ruhiges Tempo +const MS_PER_DAY = 4000; // 1 Tag = 4 s bei 1× – ruhiges Tempo (halbiert 2026-07-11) // Ereignis-Pacing: Schonfrist zum Ankommen + Mindestabstand zwischen // Ereignis-/Entscheidungskarten, damit am Anfang nicht mehrere Karten im diff --git a/App/sims/tourismustal/js/model.js b/App/sims/tourismustal/js/model.js index b42e8d0..f18b1bb 100644 --- a/App/sims/tourismustal/js/model.js +++ b/App/sims/tourismustal/js/model.js @@ -248,20 +248,31 @@ export function canPlace(state, type, c, r) { const def = BUILDINGS[type]; const tile = state.map[r]?.[c]; if (!def || !tile) return { ok: false, reason: 'Außerhalb der Karte.' }; - if (!inCore(c, r)) return { ok: false, reason: 'Hier ist unwegsames Hochgebirge – im Tal bauen.' }; if (!state.unlocked.has(type)) return { ok: false, reason: 'Noch nicht freigeschaltet.' }; - if (tile.building) return { ok: false, reason: 'Hier steht schon ein Gebäude.' }; - if (tile.terrain === 'rock') return { ok: false, reason: 'Auf Fels kann nicht gebaut werden.' }; - if (tile.terrain === 'water') return { ok: false, reason: 'Im Fluss kann nicht gebaut werden.' }; - if (tile.terrain === 'village') return { ok: false, reason: 'Hier steht das alte Dorf.' }; - if (tile.road) return { ok: false, reason: 'Auf der Straße kann nicht gebaut werden.' }; - if (r < state.stageRow) return { ok: false, reason: 'Die Hochlagen sind noch nicht erschlossen.' }; if (state.money < def.cost) return { ok: false, reason: 'Nicht genug Geld.' }; + // Footprint prüfen: 1×1 Standard, große Gebäude (size 2 = 2×2) brauchen + // jede Teilfläche frei. Anker ist die Kachel links oben (c, r). + const size = def.size || 1; + for (let dr = 0; dr < size; dr++) { + for (let dc = 0; dc < size; dc++) { + const t = state.map[r + dr]?.[c + dc]; + const platz = size > 1 ? ` (${def.name} braucht ${size}×${size} freie Felder)` : ''; + if (!t) return { ok: false, reason: 'Außerhalb der Karte.' + platz }; + if (!inCore(c + dc, r + dr)) return { ok: false, reason: 'Hier ist unwegsames Hochgebirge – im Tal bauen.' + platz }; + if (t.building) return { ok: false, reason: 'Hier steht schon ein Gebäude.' + platz }; + if (t.terrain === 'rock') return { ok: false, reason: 'Auf Fels kann nicht gebaut werden.' + platz }; + if (t.terrain === 'water') return { ok: false, reason: 'Im Fluss kann nicht gebaut werden.' + platz }; + if (t.terrain === 'village') return { ok: false, reason: 'Hier steht das alte Dorf.' + platz }; + if (t.road) return { ok: false, reason: 'Auf der Straße kann nicht gebaut werden.' + platz }; + if (r + dr < state.stageRow) return { ok: false, reason: 'Die Hochlagen sind noch nicht erschlossen.' }; + } + } if (def.zone !== 'any') { const [lo, hi] = ZONE_RANGE[def.zone]; // Lift/Gondel wirken wie eine eigene Basis: in ihrem Umkreis (4 Felder) - // darf auch am Gebirge alles gebaut werden (außer Bus & Bad). - const nearLift = ['lift', 'gondola', 'restaurant', 'hotel', 'staffhouse'].includes(type) + // darf auch am Gebirge alles gebaut werden (außer Bus & Bad). Der + // Bikepark hängt ohnehin an der Bahn – Bahn-Nähe ersetzt die Zonen-Regel. + const nearLift = ['lift', 'gondola', 'restaurant', 'hotel', 'staffhouse', 'bikepark'].includes(type) && state.buildings.some(b => (b.type === 'lift' || b.type === 'gondola') && dist(b, { c, r }) <= 4); if (!nearLift && (tile.elev < lo || tile.elev > hi)) { @@ -296,11 +307,21 @@ export function place(state, type, c, r) { const def = BUILDINGS[type]; const tile = state.map[r][c]; const wasForest = tile.terrain === 'forest'; - tile.building = type; - state.buildings.push({ type, c, r, alt: tile.elev }); + const size = def.size || 1; + state.buildings.push({ type, c, r, alt: tile.elev, size }); state.money -= def.cost; - // Bauen kostet Naturwert – Rodung von Wald deutlich mehr - state.nature = Math.max(15, state.nature - (wasForest ? 5 : 2)); + // Alle Teilflächen belegen; Nicht-Anker-Kacheln zeigen auf den Anker + // (buildingRef), damit Klick/Abriss überall auf dem Areal funktioniert. + for (let dr = 0; dr < size; dr++) { + for (let dc = 0; dc < size; dc++) { + const t = state.map[r + dr]?.[c + dc]; + if (!t) continue; + t.building = type; + if (dr || dc) t.buildingRef = { c, r }; + // Bauen kostet Naturwert – Rodung von Wald deutlich mehr + state.nature = Math.max(15, state.nature - (t.terrain === 'forest' ? 5 : 2)); + } + } if (def.pisteLen) { // Piste entsteht hangaufwärts über der Talstation (Lift & Gondel) @@ -328,9 +349,12 @@ export function place(state, type, c, r) { } export function demolish(state, c, r) { - const tile = state.map[r][c]; + let tile = state.map[r][c]; if (!tile.building) return { ok: false, reason: 'Hier steht kein Gebäude.' }; + // Klick auf eine Teilfläche eines großen Gebäudes → auf den Anker umlenken + if (tile.buildingRef) { ({ c, r } = tile.buildingRef); tile = state.map[r][c]; } const def = BUILDINGS[tile.building]; + const size = def.size || 1; const refund = Math.round(def.cost * 0.3); state.money += refund; state.nature = Math.min(100, state.nature + 1); @@ -338,8 +362,15 @@ export function demolish(state, c, r) { state.pistes = state.pistes.filter(p => !(p.owner.c === c && p.owner.r === r)); state.trails = state.trails.filter(t => !(t.owner.c === c && t.owner.r === r)); const name = def.name; - tile.building = null; - tile.level = null; + for (let dr = 0; dr < size; dr++) { + for (let dc = 0; dc < size; dc++) { + const t = state.map[r + dr]?.[c + dc]; + if (!t) continue; + t.building = null; + t.level = null; + delete t.buildingRef; + } + } return { ok: true, refund, name }; } @@ -380,6 +411,9 @@ export function seasonFactor(type, season) { // Voller Bericht zu EINEM Gebäude: Werte, Auslastung, Tageskosten, Nutzen. // Grundlage der Inspektions-/Ausbaukarte (Lernprogramm: alles sichtbar). export function buildingReport(state, c, r) { + // Teilfläche eines großen Gebäudes → Anker + const ref = state.map[r]?.[c]?.buildingRef; + if (ref) ({ c, r } = ref); const b = state.buildings.find(x => x.c === c && x.r === r); if (!b) return null; const def = BUILDINGS[b.type]; @@ -430,6 +464,8 @@ export function upgradeCost(type, level) { } export function upgrade(state, c, r) { + const ref = state.map[r]?.[c]?.buildingRef; + if (ref) ({ c, r } = ref); const tile = state.map[r]?.[c]; const b = state.buildings.find(x => x.c === c && x.r === r); if (!tile?.building || !b) return { ok: false, reason: 'Hier steht kein Gebäude.' }; diff --git a/App/sims/tourismustal/tests/model-test.mjs b/App/sims/tourismustal/tests/model-test.mjs new file mode 100644 index 0000000..a9e72ff --- /dev/null +++ b/App/sims/tourismustal/tests/model-test.mjs @@ -0,0 +1,76 @@ +// Modell-Langzeittest ohne Browser: spielt eine typische Schüler-Strategie +// über 600 Tage (5 Jahre) durch – inkl. Freischalt-Reihenfolge – und prüft, +// ob Balancing und Meilensteine plausibel bleiben. +import { createState, simulateDay, canPlace, place, MAP_COLS, MAP_ROWS } from '../js/model.js'; +import { MILESTONES } from '../js/data.js'; + +const s = createState(); +// Bau-Wunschliste in sinnvoller Reihenfolge; gebaut wird, sobald +// freigeschaltet, leistbar und ein Platz gefunden ist. +const queue = ['restaurant', 'lift', 'access', 'hotel', 'staffhouse', + 'restaurant', 'lift', 'snow']; + +function tryBuildNext() { + const type = queue[0]; + if (!type) return; + for (let r = MAP_ROWS - 1; r >= 0; r--) { + for (let c = 0; c < MAP_COLS; c++) { + if (canPlace(s, type, c, r).ok) { + place(s, type, c, r); + queue.shift(); + console.log(` Tag ${s.day + 1}: ${type} gebaut @ ${c},${r}`); + return; + } + } + } +} + +let goalsDay = null; +const milestonesFired = new Set(); +for (let d = 1; d <= 600; d++) { + // Meilensteine wie im Spiel freischalten + for (const m of MILESTONES) { + if (!milestonesFired.has(m.id) && m.when(s)) { + milestonesFired.add(m.id); + s.unlocked.add(m.unlock); + console.log(` Tag ${d}: Meilenstein ${m.id} → ${m.unlock} frei`); + } + } + tryBuildNext(); + simulateDay(s); + if (s.goalsDone && goalsDay === null) goalsDay = d; + if (!Number.isFinite(s.money) || !Number.isFinite(s.nature)) { + console.error('NaN am Tag', d); process.exit(1); + } + if (d % 120 === 0) { + console.log(`Tag ${d} (${s.season} ${s.year}): Geld ${s.money} € · Gäste ${s.guestsToday} · maxGäste ${s.stats.maxGuests} · Zufr. ${s.satisfaction} % · Natur ${Math.round(s.nature)} %`); + } +} +console.log('---'); +console.log('Nicht gebaut:', queue.length ? queue : 'alles gebaut'); +console.log('Meilensteine:', [...milestonesFired].join(', ') || 'KEINE'); +console.log('Ziele erreicht an Tag:', goalsDay ?? 'NICHT erreicht'); +console.log('Endstand:', `${s.money} €`, `· maxGäste ${s.stats.maxGuests}`, `· Natur ${Math.round(s.nature)} %`, `· Zufriedenheit ${s.satisfaction} %`); + +// --- Kurzcheck der weiteren Szenen: Vorbauten + 240 Tage ohne Ausreißer --- +for (const scene of ['boom', 'klimawandel']) { + const st = createState(scene); + console.log(`\n[${scene}] Vorbauten:`, st.buildings.map(b => b.type).join(', '), + `· Start: ${st.money} € · Natur ${st.nature} % · freigeschaltet: ${[...st.unlocked].join(', ')}`); + // im Klimawandel-Szenario einen Panoramaweg dazubauen + if (scene === 'klimawandel') { + outer: + for (let r = MAP_ROWS - 1; r >= 0; r--) { + for (let c = 0; c < MAP_COLS; c++) { + if (canPlace(st, 'trail', c, r).ok) { place(st, 'trail', c, r); break outer; } + } + } + } + for (let d = 1; d <= 240; d++) { + simulateDay(st); + if (!Number.isFinite(st.money) || !Number.isFinite(st.nature)) { + console.error(`[${scene}] NaN am Tag`, d); process.exit(1); + } + } + console.log(`[${scene}] Tag 240 (${st.season} ${st.year}): Geld ${st.money} € · maxGäste ${st.stats.maxGuests} · maxSommer ${st.stats.maxSummerGuests} · Natur ${Math.round(st.nature)} % · Zufr. ${st.satisfaction} %`); +} diff --git a/App/sims/tourismustal/tests/strategien.mjs b/App/sims/tourismustal/tests/strategien.mjs new file mode 100644 index 0000000..b338fe4 --- /dev/null +++ b/App/sims/tourismustal/tests/strategien.mjs @@ -0,0 +1,107 @@ +// Strategie-Durchspiel: prüft, was das Modell belohnt und bestraft. +// Beantwortet: Funktioniert "Panzer-Rush" (alles sofort bauen)? Braucht man +// Rücklagen für die einmaligen Entscheidungs-Karten (Skirennen …)? Was +// passiert bei Preis-Extremen? Winter-only vs. Ganzjahr in Szene 3? +// node tests/strategien.mjs +import { createState, simulateDay, canPlace, place, MAP_COLS, MAP_ROWS } from '../js/model.js'; +import { BUILDINGS, MILESTONES, DECISIONS } from '../js/data.js'; + +function fireMilestones(s, fired) { + for (const m of MILESTONES) { + if (fired.has(m.id)) continue; + if (!m.when(s)) continue; + fired.add(m.id); + const unlocks = Array.isArray(m.unlock) ? m.unlock : [m.unlock]; + unlocks.forEach(u => s.unlocked.add(u)); + m.apply?.(s); + } +} + +// Entscheidungs-Karten wie im Spiel: einmalig, Ja nur wenn leistbar. +function handleDecisions(s, st) { + for (const d of DECISIONS) { + if (s.firedEvents.has(d.id)) continue; + if (!d.when(s)) continue; + s.firedEvents.add(d.id); + st.offered.push(d.id); + if (!d.yes.can || d.yes.can(s)) { d.yes.apply(s); st.taken.push(d.id); } + else { st.missed.push(`${d.id} (Tag ${s.day}, nur ${s.money} €)`); d.no.apply(s); } + } +} + +function findSpot(s, type) { + for (let r = MAP_ROWS - 1; r >= 0; r--) { + for (let c = 0; c < MAP_COLS; c++) { + if (canPlace(s, type, c, r).ok) return { c, r }; + } + } + return null; +} + +function run(name, sceneId, days, buildFn, priceFn) { + const s = createState(sceneId); + const fired = new Set(); + const st = { offered: [], taken: [], missed: [], minMoney: s.money, lossDays: 0 }; + for (let d = 1; d <= days; d++) { + fireMilestones(s, fired); + buildFn(s, d); + if (priceFn) s.price = priceFn(s, d); + simulateDay(s); + handleDecisions(s, st); + st.minMoney = Math.min(st.minMoney, s.money); + if (s.lastNet < 0) st.lossDays++; + if (!Number.isFinite(s.money)) { console.error(name, 'NaN Tag', d); process.exit(1); } + } + const goals = s.scenario.goals.filter(g => g.check(s)).length; + console.log( + `${name.padEnd(22)} Geld ${String(s.money).padStart(8)} € · min ${String(st.minMoney).padStart(7)} € ` + + `· maxG ${String(s.stats.maxGuests).padStart(3)} · maxSo ${String(s.stats.maxSummerGuests).padStart(3)} ` + + `· Zufr ${s.satisfaction} % · Natur ${Math.round(s.nature)} % · Ziele ${goals}/${s.scenario.goals.length} ` + + `· Verlusttage ${st.lossDays} · Geb. ${s.buildings.length}`); + if (st.missed.length) console.log(` ⚠️ verpasste Chancen: ${st.missed.join(' · ')}`); + return { s, st }; +} + +const QUEUE = ['restaurant', 'lift', 'access', 'hotel', 'staffhouse', + 'restaurant', 'lift', 'snow']; + +function queueBuilder(queue, reserve = 0) { + const q = [...queue]; + return s => { + if (!q.length) return; + const type = q[0]; + if (!BUILDINGS[type] || s.money - BUILDINGS[type].cost < reserve) return; + const spot = findSpot(s, type); + if (spot) { place(s, type, spot.c, spot.r); q.shift(); } + }; +} + +// Panzer-Rush: baut JEDEN Tag alles, was geht (freigeschaltet + leistbar). +function rushBuilder() { + return s => { + let built = true; + while (built) { + built = false; + for (const type of Object.keys(BUILDINGS)) { + if (!s.unlocked.has(type)) continue; + const spot = findSpot(s, type); + if (spot) { place(s, type, spot.c, spot.r); built = true; break; } + } + } + }; +} + +console.log('=== SZENE 1 · Pionierzeit, 600 Tage ==='); +run('Referenz (Plan)', 'pionierzeit', 600, queueBuilder(QUEUE)); +run('Panzer-Rush', 'pionierzeit', 600, rushBuilder()); +run('Rücklage 8000 €', 'pionierzeit', 600, queueBuilder(QUEUE, 8000)); +run('Geizhals (2 Geb.)', 'pionierzeit', 600, queueBuilder(['restaurant', 'lift'])); +run('Preis 45 € (Abzocke)', 'pionierzeit', 600, queueBuilder(QUEUE), () => 45); +run('Preis 15 € (Dumping)', 'pionierzeit', 600, queueBuilder(QUEUE), () => 15); + +console.log('\n=== SZENE 3 · Schneegrenze 2035, 600 Tage ==='); +run('Nur Winter ausbauen', 'klimawandel', 600, + queueBuilder(['gondola', 'snow', 'snow', 'staffhouse', 'hotel', 'restaurant'])); +run('Ganzjahres-Strategie', 'klimawandel', 600, + queueBuilder(['badesee', 'sled', 'trail', 'staffhouse', 'hotel', 'bikepark', 'playground', 'bikerental'])); +run('Rush Klimawandel', 'klimawandel', 600, rushBuilder());