From cfdfa4bb28ec24c0206a16770c4bb0543b19ad9d Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 11 Jul 2026 23:32:55 +0200 Subject: [PATCH] Tourismustal: Sprite-Editor, Bikepark-Umbau, Animationen, Ambiente, Baumenue-Layout - Justier-Editor (editor.html): jedes Gebaeude in allen Stufen, 1x1 und Mega-2x2, Oeko an/aus - Sprite-Breite, Boden-Anker und Solarpanel- Position (dx/dy/s pro Sprite-Key) live auf echten Tiles tunen. Werte wirken sofort via localStorage-Override, Export-JSON zum Einbacken. - Bikepark umgebaut aufs Rodelbahn-Prinzip: 1x1-Station, Flowtrail- Strecke 5 Felder talwaerts mit Schwuengen, Platz wird SCHON BEIM BAU geprueft und reserviert (tile.bikeTrail blockiert Bebauung), fahrende Raeder mit Kurvenneigung (mehr Biker je Ausbaustufe), Ziel-Bogen. - Animationen: Spielplatz (Schaukel + Rutscher), Hochseilgarten (Zipline-Gleiter), Aussichtsplattform (Foto-Blitz), Fahrradverleih (drehendes Werkstatt-Rad), Gasthaus (Kamin-Dampf). - Ambiente: Wolkenschatten ziehen uebers Tal; Schafe auf Almen und Kuehe auf Talwiesen (grasen, deterministische Herdenplaetze, nicht im Winter). - Baumenue-Karten: oben Titel+Info links / Preis rechts, darunter zweispaltig Icon und Beschreibung. - Solar-Position pro Gebaeudetyp justierbar (SOLAR_POS). Co-Authored-By: Claude Opus 4.8 (1M context) --- App/sims/tourismustal/css/style.css | 21 +- App/sims/tourismustal/editor.html | 83 +++++++ App/sims/tourismustal/js/data.js | 4 +- App/sims/tourismustal/js/editor-tune.js | 186 +++++++++++++++ App/sims/tourismustal/js/iso.js | 289 +++++++++++++++++++++--- App/sims/tourismustal/js/model.js | 31 ++- App/sims/tourismustal/js/ui.js | 12 +- 7 files changed, 575 insertions(+), 51 deletions(-) create mode 100644 App/sims/tourismustal/editor.html create mode 100644 App/sims/tourismustal/js/editor-tune.js diff --git a/App/sims/tourismustal/css/style.css b/App/sims/tourismustal/css/style.css index f7480b2..2a447c5 100644 --- a/App/sims/tourismustal/css/style.css +++ b/App/sims/tourismustal/css/style.css @@ -173,24 +173,27 @@ html, body { } #buildmenu h2 { margin: 0 0 10px; font-size: 16px; } .build-item { - display: flex; - gap: 10px; - align-items: center; - padding: 10px; + display: block; + padding: 9px 10px; border-radius: 12px; background: #fff; margin-bottom: 8px; border: 2px solid transparent; cursor: pointer; - min-height: 56px; } .build-item.active { border-color: var(--orange); background: #fff7ee; } .build-item.disabled { opacity: 0.45; } -.build-item .emoji { font-size: 26px; } -.build-item .info { flex: 1; } -.build-item .info b { display: block; font-size: 14px; } -.build-item .info small { color: var(--muted); font-size: 11.5px; line-height: 1.25; display: block; } +/* Zeile 1: Titel + ℹ links, Preis rechts */ +.build-item .bi-head { + display: flex; justify-content: space-between; align-items: center; gap: 8px; +} +.build-item .bi-title { display: flex; align-items: center; gap: 2px; min-width: 0; } +.build-item .bi-title b { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .build-item .cost { font-size: 13px; font-weight: 700; color: var(--green); white-space: nowrap; } +/* Zeile 2: zweispaltig Icon | Beschreibung */ +.build-item .bi-body { display: flex; gap: 10px; align-items: flex-start; margin-top: 4px; } +.build-item .emoji { font-size: 24px; line-height: 1.1; flex-shrink: 0; } +.build-item .desc { color: var(--muted); font-size: 11.5px; line-height: 1.3; } /* Graphs (bottom) */ #graphs { diff --git a/App/sims/tourismustal/editor.html b/App/sims/tourismustal/editor.html new file mode 100644 index 0000000..f2ce346 --- /dev/null +++ b/App/sims/tourismustal/editor.html @@ -0,0 +1,83 @@ + + + + + +Tourismustal – Sprite-Editor + + + + + + + +
+

🛠️ Sprite-Editor

+ + + + + +
+ + + + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+ + + +
Werte wirken sofort – auch im echten Spiel auf DIESEM Gerät + (localStorage). Endgültig werden sie erst, wenn Atlas den Export in den Code übernimmt. + Karte: ziehen/zoomen wie im Spiel.
+
+ + + + diff --git a/App/sims/tourismustal/js/data.js b/App/sims/tourismustal/js/data.js index de17b5a..b1c2071 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: 3, 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.', + zone: 'mid', staff: 2, summerCap: 160, trailLen: 5, + desc: 'Flowtrail-Strecke den Hang hinunter: bis 160 Sommergäste. Braucht 5 freie Felder talwärts für die Strecke – und eine Bahn in der Nähe (max. 4 Felder).', }, ropes: { name: 'Hochseilgarten', emoji: '🧗', cost: 6500, upkeep: 45, diff --git a/App/sims/tourismustal/js/editor-tune.js b/App/sims/tourismustal/js/editor-tune.js new file mode 100644 index 0000000..2d67cf1 --- /dev/null +++ b/App/sims/tourismustal/js/editor-tune.js @@ -0,0 +1,186 @@ +// Sprite-Editor: Gebäude in allen Ausbaustufen (1×1 und 2×2/Mega) auf echten +// Tiles justieren – Sprite-Breite, Boden-Anker und Solarpanel-Position. +// Werte landen live in localStorage (tt_sprite_tuning) und wirken sofort im +// Spiel auf diesem Gerät; der Export wird von Atlas fest in iso.js übernommen. + +import { createState, place } from './model.js'; +import { IsoRenderer, SPRITE_DEFS, SOLAR_POS } from './iso.js'; +import { BUILDINGS } from './data.js'; + +const $ = id => document.getElementById(id); + +// ---------- Test-Welt: flach geräumte Bühne ---------- +const state = createState('boom'); +state.stageRow = 0; +state.season = 'Sommer'; +state.money = 9e9; +state.guestsToday = 0; +for (const row of state.map) { + for (const t of row) { + t.building = null; t.level = null; + delete t.buildingRef; delete t.mega; delete t.eco; + } +} +state.buildings = []; state.pistes = []; state.trails = []; + +// Bühne: ebene 2×2-Fläche nahe der Kartenmitte suchen +let SPOT = { c: 18, r: 24 }; +outer: +for (let r = 20; r < 34; r++) { + for (let c = 10; c < 28; c++) { + const ok = [[0, 0], [1, 0], [0, 1], [1, 1]].every(([dc, dr]) => { + const t = state.map[r + dr]?.[c + dc]; + return t && !t.road && t.terrain !== 'water' && t.terrain !== 'village' + && t.terrain !== 'rock' && t.elev >= 1 && t.elev <= 3; + }); + if (ok) { SPOT = { c, r }; break outer; } + } +} + +const renderer = new IsoRenderer(document.getElementById('map'), state, { onTileTap: () => {} }); + +// ---------- Tuning-Store ---------- +let tuned = { sprites: {}, solar: {} }; +try { tuned = Object.assign(tuned, JSON.parse(localStorage.getItem('tt_sprite_tuning') || '{}')); } catch {} +function saveTuning() { + localStorage.setItem('tt_sprite_tuning', JSON.stringify(tuned)); + $('edOut').value = JSON.stringify(tuned, null, 2); +} + +// ---------- Auswahl ---------- +const sel = $('edType'); +for (const [type, def] of Object.entries(BUILDINGS)) { + if (def.action) continue; // Aufforsten etc. + const o = document.createElement('option'); + o.value = type; + o.textContent = `${def.emoji} ${def.name}`; + sel.appendChild(o); +} +let curLevel = 1; + +function spriteKey() { + const type = sel.value; + if ($('edMega').checked && SPRITE_DEFS[`mega_${type}`]) return `mega_${type}`; + if (type === 'hotel' && curLevel >= 2) return 'hotel2'; + return type; +} + +// ---------- Bühne neu bestücken ---------- +function rebuild() { + for (const row of state.map) { + for (const t of row) { + t.building = null; t.level = null; + delete t.buildingRef; delete t.mega; delete t.eco; + } + } + state.buildings = []; state.pistes = []; state.trails = []; + const type = sel.value; + const def = BUILDINGS[type]; + const mega = $('edMega').checked && (def.size || 1) === 1; + const { c, r } = SPOT; + if (mega) { + // 4 gleiche im Quadrat → verschmilzt automatisch (falls merge-fähig), + // sonst von Hand zum 2×2 zusammensetzen. + place(state, type, c, r); place(state, type, c + 1, r); + place(state, type, c, r + 1); place(state, type, c + 1, r + 1); + let b = state.buildings.find(x => x.mega); + if (!b && state.buildings.length) { + b = state.buildings[0]; + state.buildings = [b]; + b.size = 2; b.mega = true; + for (let dr = 0; dr < 2; dr++) for (let dc = 0; dc < 2; dc++) { + const t = state.map[r + dr][c + dc]; + t.building = type; + if (dr || dc) t.buildingRef = { c, r }; + else { delete t.buildingRef; t.mega = true; } + } + } + } else { + place(state, type, c, r); + } + const b = state.buildings[0]; + if (b) { + b.level = curLevel; + state.map[b.r][b.c].level = curLevel; + if ($('edEco').checked) { b.eco = true; state.map[b.r][b.c].eco = true; } + } + syncSliders(); +} + +// ---------- Slider ↔ Werte ---------- +function syncSliders() { + const key = spriteKey(); + const sd = SPRITE_DEFS[key] || { w: 60, bottom: 8 }; + const sp = SOLAR_POS[key] + || ($('edMega').checked || (BUILDINGS[sel.value].size || 1) > 1 + ? SOLAR_POS.defaultBig : SOLAR_POS.default); + $('edW').value = sd.w; $('edWv').textContent = sd.w; + $('edB').value = sd.bottom; $('edBv').textContent = sd.bottom; + $('edSX').value = sp.dx ?? 0; $('edSXv').textContent = sp.dx ?? 0; + $('edSY').value = sp.dy ?? -27; $('edSYv').textContent = sp.dy ?? -27; + $('edSS').value = sp.s ?? 1; $('edSSv').textContent = (sp.s ?? 1).toFixed(2); +} + +function applySprite() { + const key = spriteKey(); + const w = +$('edW').value, bottom = +$('edB').value; + SPRITE_DEFS[key] = Object.assign(SPRITE_DEFS[key] || {}, { w, bottom }); + tuned.sprites[key] = { w, bottom }; + $('edWv').textContent = w; $('edBv').textContent = bottom; + saveTuning(); +} +function applySolar() { + const key = spriteKey(); + const v = { dx: +$('edSX').value, dy: +$('edSY').value, s: +$('edSS').value }; + SOLAR_POS[key] = v; + tuned.solar[key] = v; + $('edSXv').textContent = v.dx; $('edSYv').textContent = v.dy; + $('edSSv').textContent = v.s.toFixed(2); + saveTuning(); +} + +['edW', 'edB'].forEach(id => $(id).addEventListener('input', applySprite)); +['edSX', 'edSY', 'edSS'].forEach(id => $(id).addEventListener('input', applySolar)); + +sel.addEventListener('change', rebuild); +$('edMega').addEventListener('change', rebuild); +$('edEco').addEventListener('change', rebuild); +document.querySelectorAll('#edLevels button').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#edLevels button').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + curLevel = +btn.dataset.lvl; + rebuild(); + }); +}); + +$('edReset').addEventListener('click', () => { + const key = spriteKey(); + delete tuned.sprites[key]; + delete tuned.solar[key]; + saveTuning(); + location.reload(); // Original-Werte aus dem Code laden +}); + +$('edCopy').addEventListener('click', async () => { + $('edOut').value = JSON.stringify(tuned, null, 2); + try { + await navigator.clipboard.writeText($('edOut').value); + $('edCopy').textContent = '✅ kopiert!'; + } catch { + $('edOut').select(); + $('edCopy').textContent = '⬇︎ unten markiert'; + } + setTimeout(() => { $('edCopy').textContent = '📋 Export kopieren'; }, 1800); +}); + +// ---------- Loop ---------- +rebuild(); +$('edOut').value = JSON.stringify(tuned, null, 2); +let last = performance.now(); +function frame(now) { + const dt = now - last; last = now; + renderer.draw(now, dt, 1); + requestAnimationFrame(frame); +} +requestAnimationFrame(frame); diff --git a/App/sims/tourismustal/js/iso.js b/App/sims/tourismustal/js/iso.js index 9874895..ec54fcd 100644 --- a/App/sims/tourismustal/js/iso.js +++ b/App/sims/tourismustal/js/iso.js @@ -25,7 +25,7 @@ const TERRAIN_COLORS = { // KI-generierte Sprites (Nordic-Flat, transparent). Fehlt eine Datei, // greift automatisch die Vektor-Zeichnung als Fallback. // w = Zeichenbreite in px, bottom = Abstand Unterkante Motiv → Kachelmitte -const SPRITE_DEFS = { +export const SPRITE_DEFS = { restaurant: { w: 78, bottom: 10 }, hotel: { w: 88, bottom: 10 }, hotel2: { w: 104, bottom: 11 }, @@ -35,10 +35,10 @@ const SPRITE_DEFS = { access: { w: 52, bottom: 8 }, rescue: { w: 60, bottom: 9 }, snow: { w: 44, bottom: 7 }, - // 2×2-Areale: Mitte des Sprites = Mitte der 4 Kacheln; w/bottom so - // gewählt, dass der Fuß bis zur vorderen Kachel-Spitze reicht (~40 px - // unter dem Zentrum: bottom + 0.12·w ≈ 38). - bikepark: { w: 116, bottom: 24 }, + bikepark: { w: 58, bottom: 8 }, // Basisstation; Strecke via _drawBikeParks + // 2×2-Areale (Megabauwerke): Mitte des Sprites = Mitte der 4 Kacheln; + // w/bottom so, dass der Fuß bis zur vorderen Kachel-Spitze reicht + // (~40 px unter dem Zentrum: bottom + 0.12·w ≈ 38). // Megabauwerke: eigene Motive (mehr Elemente statt vergrößerter Fenster) mega_restaurant: { w: 116, bottom: 24 }, mega_hotel: { w: 118, bottom: 24 }, @@ -62,6 +62,25 @@ const SPRITE_DEFS = { broadleaf: { w: 44, bottom: 6 }, }; +// Solarpanel-Position (Öko-Umbau) je Sprite-Key; Fallback default/defaultBig. +// dx/dy relativ zum Gebäude-Ankerpunkt, s = Skalierung. +export const SOLAR_POS = { + default: { dx: -3, dy: -27, s: 1 }, + defaultBig: { dx: -5, dy: -36, s: 1.6 }, +}; + +// Feinjustierung aus dem Editor (editor.html): überschreibt SPRITE_DEFS- +// und SOLAR_POS-Werte lokal. Finale Werte werden in den Code übernommen. +try { + const tune = JSON.parse(localStorage.getItem('tt_sprite_tuning') || 'null'); + if (tune?.sprites) { + for (const [k, v] of Object.entries(tune.sprites)) { + SPRITE_DEFS[k] = Object.assign(SPRITE_DEFS[k] || {}, v); + } + } + if (tune?.solar) Object.assign(SOLAR_POS, tune.solar); +} catch { /* kaputte Overrides ignorieren */ } + export class IsoRenderer { constructor(canvas, state, callbacks) { this.canvas = canvas; @@ -937,6 +956,7 @@ export class IsoRenderer { } this._drawTracks(winter); this._drawSledRuns(); + this._drawBikeParks(); if (this.state.stageRow > 0) this._drawFog(); this._drawPreview(); this._drawBus(); @@ -946,8 +966,10 @@ export class IsoRenderer { this._drawConstruction(); this._drawRiders(); this._drawStaff(); + this._drawAnimals(winter); this._drawGuests(winter); this._drawHeli(); + this._drawCloudShadows(); this._drawOccupancy(); this._drawFloats(); ctx.restore(); @@ -1113,22 +1135,7 @@ export class IsoRenderer { } const isPiste = !!piste; - // Bike-Trail (Bikepark): brauner Singletrail, der sich talwärts schlängelt - if (this.state.trails?.some(t => t.c === tile.c && t.r === tile.r) && !tile.building) { - ctx.strokeStyle = 'rgba(122,90,58,0.75)'; - ctx.lineWidth = 2.4; - ctx.beginPath(); - ctx.moveTo(x - 11, y - 5); - ctx.quadraticCurveTo(x + 7, y - 3, x - 4, y + 5); - ctx.stroke(); - ctx.strokeStyle = 'rgba(122,90,58,0.35)'; - ctx.lineWidth = 4.5; - ctx.beginPath(); - ctx.moveTo(x - 11, y - 5); - ctx.quadraticCurveTo(x + 7, y - 3, x - 4, y + 5); - ctx.stroke(); - ctx.lineWidth = 1; - } + // (Bikepark-Strecke wird als durchgehende Bahn in _drawBikeParks gezeichnet) if (tile.terrain === 'forest' && !tile.building && !isPiste) { this._drawTree(x, y, snowy, tile.tint); @@ -1281,6 +1288,163 @@ export class IsoRenderer { } } + // Wolkenschatten: weiche Flecken, die gemächlich über das Tal ziehen + _drawCloudShadows() { + const ctx = this.ctx; + for (let i = 0; i < 3; i++) { + const t = (this.simTime / (110000 + i * 30000) + i * 0.37) % 1; + const c = -8 + t * (MAP_COLS + 16); + const r = 8 + i * 11 + Math.sin(t * 6.3 + i * 2) * 3; + const p = this.tileToWorld(c, r, 0); + ctx.fillStyle = 'rgba(47,62,70,0.05)'; + ctx.beginPath(); + ctx.ellipse(p.x, p.y, 140 + i * 45, 55 + i * 15, 0, 0, 7); + ctx.ellipse(p.x + 95, p.y + 22, 95, 38, 0, 0, 7); + ctx.fill(); + } + } + + // Weidetiere: Schafe auf den Almen, Kühe auf den Talwiesen. Feste Herden- + // Plätze (deterministisch aus der Karte), leichtes Grasen/Wandern. + _drawAnimals(winter) { + if (winter) return; // im Winter sind die Tiere im Stall + if (!this.herds) { + this.herds = []; + const s = this.state; + for (let r = 2; r < MAP_ROWS - 2; r++) { + for (let c = 2; c < MAP_COLS - 2; c++) { + if ((c * 7 + r * 13) % 41 !== 0) continue; // deterministisch dünn streuen + const t = s.map[r][c]; + if (t.road || t.building) continue; + if (t.terrain === 'alm' && this.herds.filter(h => h.kind === 'sheep').length < 5) { + this.herds.push({ c, r, kind: 'sheep', n: 3 + (c + r) % 3 }); + } else if (t.terrain === 'meadow' && t.elev <= 2 + && this.herds.filter(h => h.kind === 'cow').length < 3) { + this.herds.push({ c, r, kind: 'cow', n: 2 + (c + r) % 2 }); + } + } + } + } + const ctx = this.ctx; + for (const h of this.herds) { + const tile = this.state.map[h.r][h.c]; + if (tile.building || tile.bikeTrail) continue; // inzwischen bebaut + for (let i = 0; i < h.n; i++) { + const drift = Math.sin(this.simTime / 5200 + i * 2.4 + h.c) * 0.9; + const gx = h.c + 0.25 + (i % 3) * 0.28 + drift * 0.12; + const gy = h.r + 0.3 + Math.floor(i / 3) * 0.35 + Math.cos(this.simTime / 6100 + i) * 0.1; + const p = this.tileToWorld(gx, gy, this.elevAt(gx, gy)); + const graze = Math.sin(this.simTime / 900 + i * 1.7) > 0.55; // Kopf unten? + if (h.kind === 'sheep') { + this._shadow(p.x, p.y + 1, 2.6, 1.1); + ctx.fillStyle = '#f2efe6'; + ctx.beginPath(); ctx.ellipse(p.x, p.y - 2.2, 2.6, 1.8, 0, 0, 7); ctx.fill(); + ctx.fillStyle = '#4a4540'; + ctx.beginPath(); + ctx.arc(p.x + 2.4, p.y - (graze ? 1.2 : 2.6), 1, 0, 7); + ctx.fill(); + } else { + this._shadow(p.x, p.y + 1, 3.6, 1.4); + ctx.fillStyle = '#8a6a4f'; + ctx.beginPath(); ctx.ellipse(p.x, p.y - 2.8, 3.7, 2.3, 0, 0, 7); ctx.fill(); + ctx.fillStyle = '#f2efe6'; // Flecken + ctx.beginPath(); + ctx.ellipse(p.x - 1.2, p.y - 3.2, 1.3, 0.9, 0.4, 0, 7); + ctx.ellipse(p.x + 1.4, p.y - 2.2, 1, 0.7, -0.3, 0, 7); + ctx.fill(); + ctx.fillStyle = '#6e523c'; + ctx.beginPath(); + ctx.arc(p.x + 3.6, p.y - (graze ? 1.4 : 3.4), 1.3, 0, 7); + ctx.fill(); + // Beine + ctx.strokeStyle = '#6e523c'; ctx.lineWidth = 0.9; + ctx.beginPath(); + ctx.moveTo(p.x - 2, p.y - 0.8); ctx.lineTo(p.x - 2, p.y + 1); + ctx.moveTo(p.x + 2, p.y - 0.8); ctx.lineTo(p.x + 2, p.y + 1); + ctx.stroke(); + } + } + } + } + + // Bikepark: Flowtrail-Strecke talwärts (Rodelbahn-Prinzip) mit fahrenden + // Rädern. Die Strecken-Kacheln sind im Modell reserviert (tile.bikeTrail). + _drawBikeParks() { + const ctx = this.ctx; + for (const b of this.state.buildings) { + if (b.type !== 'bikepark') continue; + const tile = this.state.map[b.r][b.c]; + const segs = BUILDINGS.bikepark.trailLen || 5; + const level = tile.level || 1; + const pts = []; + for (let i = 0; i <= segs; i++) { + const rr = Math.min(MAP_ROWS - 1, b.r + i); + const cc = b.c + Math.sin(i * 1.7 + tile.tint) * 0.55; // Flow-Schwünge + const tt = this.state.map[rr]?.[Math.round(Math.max(0, Math.min(MAP_COLS - 1, cc)))]; + pts.push(this.tileToWorld(cc, rr, tt ? tt.elev : 0)); + } + // Erdige Trail-Bahn mit hellen Berm-Kanten + ctx.lineCap = 'round'; + ctx.strokeStyle = 'rgba(122,90,58,0.85)'; + ctx.lineWidth = 5; + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y - 2); + for (let i = 1; i <= segs; i++) ctx.lineTo(pts[i].x, pts[i].y - 2); + ctx.stroke(); + ctx.strokeStyle = 'rgba(214,196,158,0.8)'; + ctx.lineWidth = 1.4; + for (const off of [-3, 3]) { + ctx.beginPath(); + ctx.moveTo(pts[0].x + off, pts[0].y - 2); + for (let i = 1; i <= segs; i++) ctx.lineTo(pts[i].x + off, pts[i].y - 2); + ctx.stroke(); + } + ctx.lineCap = 'butt'; + ctx.lineWidth = 1; + // Ziel-Bogen am unteren Ende + ctx.strokeStyle = '#e8892b'; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.arc(pts[segs].x, pts[segs].y - 6, 6, Math.PI, 0); + ctx.stroke(); + ctx.lineWidth = 1; + + // Fahrende Räder (mit Kurvenneigung); höhere Stufe = mehr Biker + const drawBiker = (phase, hue) => { + const pr = ((this.simTime / (2000 * segs)) + phase) % 1; + const fi = pr * segs; + const i0 = Math.min(segs - 1, Math.floor(fi)); + const f = fi - i0; + const bx = pts[i0].x + (pts[i0 + 1].x - pts[i0].x) * f; + const by = pts[i0].y + (pts[i0 + 1].y - pts[i0].y) * f - 4; + const ang = Math.atan2(pts[i0 + 1].y - pts[i0].y, pts[i0 + 1].x - pts[i0].x); + ctx.save(); + ctx.translate(bx, by); + ctx.rotate(ang * 0.45); + // Räder + ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1.1; + ctx.beginPath(); ctx.arc(-3.2, 1.5, 2.1, 0, 7); ctx.stroke(); + ctx.beginPath(); ctx.arc(3.2, 1.5, 2.1, 0, 7); ctx.stroke(); + // Rahmen + ctx.beginPath(); + ctx.moveTo(-3.2, 1.5); ctx.lineTo(0, -1.5); ctx.lineTo(3.2, 1.5); + ctx.moveTo(0, -1.5); ctx.lineTo(-1, 1.2); + ctx.stroke(); + // Fahrer:in (gebückt) + Helm + ctx.fillStyle = hue; + ctx.beginPath(); ctx.ellipse(0.4, -3.4, 2.4, 1.9, -0.5, 0, 7); ctx.fill(); + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); ctx.arc(2.2, -4.6, 1.4, 0, 7); ctx.fill(); + ctx.fillStyle = '#c9531f'; + ctx.beginPath(); ctx.arc(2.2, -5.3, 1.2, Math.PI, 0); ctx.fill(); + ctx.restore(); + }; + drawBiker(0, '#2f6ea0'); + if (level >= 2) drawBiker(0.4, '#3f8f6b'); + if (level >= 3) drawBiker(0.7, '#a34a8e'); + } + } + // Skispuren im Schnee – verblassen nach ~90 Spielsekunden _drawTracks(winter) { const ctx = this.ctx; @@ -1757,7 +1921,77 @@ export class IsoRenderer { ctx.fill(); } } - if (tile.eco) this._drawSolar(x, y, !!tile.mega || (BUILDINGS[type]?.size || 1) > 1); + // Leben in den Funktions-Gebäuden (dezente Dauer-Animationen) + if (type === 'playground') { + // Schaukel pendelt + alle paar Sekunden rutscht jemand + const sw = Math.sin(this.simTime / 420) * 0.55; + ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x + 12, y - 16); + ctx.lineTo(x + 12 + Math.sin(sw) * 9, y - 16 + Math.cos(sw) * 9); + ctx.stroke(); + ctx.fillStyle = '#2f6ea0'; + ctx.beginPath(); + ctx.arc(x + 12 + Math.sin(sw) * 9, y - 15 + Math.cos(sw) * 9, 1.8, 0, 7); + ctx.fill(); + const sp = (this.simTime % 4200) / 4200; + if (sp < 0.35) { + const q = sp / 0.35; + ctx.fillStyle = '#a34a8e'; + ctx.beginPath(); ctx.arc(x - 10 + q * 9, y - 15 + q * 8, 1.8, 0, 7); ctx.fill(); + } + } + if (type === 'ropes') { + // Zipline-Gleiter pendelt zwischen den Bäumen (mit Seildurchhang) + const zp = (this.simTime % 5200) / 5200; + const q = zp < 0.5 ? zp * 2 : (1 - zp) * 2; + const zx = x - 16 + q * 30; + const zy = y - 26 + Math.sin(q * Math.PI) * 5; + ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(zx, zy); ctx.lineTo(zx, zy + 4.5); ctx.stroke(); + ctx.fillStyle = '#c9531f'; + ctx.beginPath(); ctx.arc(zx, zy + 6, 1.9, 0, 7); ctx.fill(); + } + if (type === 'platform') { + // Ausflugsgäste fotografieren: kurzer Blitz alle ~4 s + const fp = this.simTime % 4300; + if (fp < 260) { + const a = 1 - fp / 260; + ctx.strokeStyle = `rgba(255,255,240,${a.toFixed(2)})`; + ctx.lineWidth = 1.4; + for (let i = 0; i < 4; i++) { + const ang = i * Math.PI / 2 + 0.4; + ctx.beginPath(); + ctx.moveTo(x + 8 + Math.cos(ang) * 2, y - 22 + Math.sin(ang) * 2); + ctx.lineTo(x + 8 + Math.cos(ang) * (4 + a * 4), y - 22 + Math.sin(ang) * (4 + a * 4)); + ctx.stroke(); + } + } + } + if (type === 'bikerental') { + // Werkstatt: rotierendes Vorderrad am Ständer + const wa = this.simTime / 260; + ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(x + 15, y - 6, 3, 0, 7); ctx.stroke(); + for (let i = 0; i < 3; i++) { + const a2 = wa + i * (Math.PI * 2 / 3); + ctx.beginPath(); + ctx.moveTo(x + 15, y - 6); + ctx.lineTo(x + 15 + Math.cos(a2) * 3, y - 6 + Math.sin(a2) * 3); + ctx.stroke(); + } + } + if (type === 'restaurant') { + // Kamin dampft – es wird gekocht + ctx.fillStyle = 'rgba(240,238,230,0.5)'; + for (let i = 0; i < 3; i++) { + const p = (this.simTime / 1600 + i / 3) % 1; + ctx.beginPath(); + ctx.arc(x + 9 + Math.sin(p * 5 + i) * 2, y - 30 - p * 12, 1.2 + p * 2.2, 0, 7); + ctx.fill(); + } + } + if (tile.eco) this._drawSolar(spriteName, x, y, !!tile.mega || (BUILDINGS[type]?.size || 1) > 1); return; } switch (type) { @@ -1961,16 +2195,17 @@ export class IsoRenderer { break; } } - if (tile.eco) this._drawSolar(x, y, !!tile.mega); + if (tile.eco) this._drawSolar(type, x, y, !!tile.mega); } // Solardach: Zeichen des Öko-Umbaus – dunkles Panel mit weißen Stegen - // und kleinem Glanzpunkt, stilisiert aufs Dach gesetzt. - _drawSolar(x, y, big = false) { + // und kleinem Glanzpunkt. Position pro Sprite-Key justierbar (SOLAR_POS). + _drawSolar(key, x, y, big = false) { const ctx = this.ctx; - const s = big ? 1.6 : 1; + const pos = SOLAR_POS[key] || (big ? SOLAR_POS.defaultBig : SOLAR_POS.default); + const s = pos.s ?? 1; ctx.save(); - ctx.translate(x - 3 * s, y - (big ? 36 : 27)); + ctx.translate(x + (pos.dx ?? 0), y + (pos.dy ?? 0)); ctx.scale(s, s); ctx.fillStyle = '#2d4a66'; ctx.beginPath(); diff --git a/App/sims/tourismustal/js/model.js b/App/sims/tourismustal/js/model.js index ea3d839..f5efe62 100644 --- a/App/sims/tourismustal/js/model.js +++ b/App/sims/tourismustal/js/model.js @@ -260,6 +260,7 @@ export function canPlace(state, type, c, r) { 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.bikeTrail) return { ok: false, reason: 'Hier verläuft die Bikepark-Strecke.' }; 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 }; @@ -308,6 +309,17 @@ export function canPlace(state, type, c, r) { if (type === 'forest' && !['meadow', 'alm', 'field'].includes(tile.terrain)) { return { ok: false, reason: 'Aufforsten geht nur auf freien Wiesen, Almen oder Feldern.' }; } + if (def.trailLen) { + // Die Flowtrail-Strecke braucht ihren Platz SCHON BEIM BAU: trailLen + // freie Felder direkt talwärts unter der Station. + for (let i = 1; i <= def.trailLen; i++) { + const t = state.map[r + i]?.[c]; + if (!t || t.building || t.bikeTrail || t.road + || ['water', 'rock', 'village'].includes(t.terrain)) { + return { ok: false, reason: `${def.name} braucht ${def.trailLen} freie Felder talwärts (direkt unterhalb) für die Strecke.` }; + } + } + } if (type === 'access') { let nearRoad = false; for (let rr = r - 1; rr <= r + 1 && !nearRoad; rr++) { @@ -358,16 +370,11 @@ export function place(state, type, c, r) { } } if (def.trailLen) { - // Bike-Trails ziehen sich hangAUFwärts über der Basisstation – gleiche - // Richtung wie die Lift-Pisten (die Bahn bringt die Bikes hinauf, die - // Trails führen zurück herunter). Sie kosten extra Natur. - let cc = c; + // Flowtrail-Strecke talwärts (wie die Rodelbahn): Kacheln werden + // reserviert – dort kann nicht mehr gebaut werden. Kostet extra Natur. for (let i = 1; i <= def.trailLen; i++) { - cc += (i % 2 ? 1 : -1); - const t = state.map[r - i]?.[Math.max(1, Math.min(MAP_COLS - 2, cc))]; - if (t && t.terrain !== 'water' && !t.building) { - state.trails.push({ c: t.c, r: t.r, owner: { c, r } }); - } + const t = state.map[r + i]?.[c]; + if (t) t.bikeTrail = { c, r }; } state.nature = Math.max(15, state.nature - 2); } @@ -391,6 +398,12 @@ export function demolish(state, c, r) { state.buildings = state.buildings.filter(b => !(b.c === c && b.r === 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)); + if (def.trailLen) { + for (let i = 1; i <= def.trailLen; i++) { + const t = state.map[r + i]?.[c]; + if (t?.bikeTrail?.c === c && t?.bikeTrail?.r === r) delete t.bikeTrail; + } + } const name = def.name; for (let dr = 0; dr < size; dr++) { for (let dc = 0; dc < size; dc++) { diff --git a/App/sims/tourismustal/js/ui.js b/App/sims/tourismustal/js/ui.js index 5a90239..333eacd 100644 --- a/App/sims/tourismustal/js/ui.js +++ b/App/sims/tourismustal/js/ui.js @@ -79,10 +79,14 @@ export class UI { el.className = 'build-item'; el.dataset.type = type; el.innerHTML = ` - ${emoji} - ${name}${desc} - ${cost} - `; +
+ ${name} + ${cost} +
+
+ ${emoji} + ${desc} +
`; el.addEventListener('click', () => this._toggleBuild(type, el)); el.querySelector('.bi-menu-info').addEventListener('click', e => { e.stopPropagation(); // nicht in den Baumodus wechseln