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) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 23:32:55 +02:00
parent d195b085d9
commit cfdfa4bb28
7 changed files with 575 additions and 51 deletions
+262 -27
View File
@@ -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();