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:
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Tourismustal – Sprite-Editor</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<style>
|
||||
#edPanel {
|
||||
position: fixed; top: 10px; left: 10px; z-index: 90;
|
||||
width: min(340px, 92vw); max-height: calc(100dvh - 20px); overflow-y: auto;
|
||||
padding: 14px 16px; font-size: 14px;
|
||||
}
|
||||
#edPanel h2 { margin: 0 0 10px; font-size: 16px; }
|
||||
#edPanel label { display: block; font-weight: 700; font-size: 12px; margin: 10px 0 3px; color: #4a4f52; }
|
||||
#edPanel select, #edPanel textarea { width: 100%; font-size: 14px; padding: 6px; border-radius: 8px; border: 1px solid #ccc; }
|
||||
#edPanel textarea { height: 110px; font-family: monospace; font-size: 11px; }
|
||||
.ed-row { display: flex; align-items: center; gap: 8px; }
|
||||
.ed-row input[type=range] { flex: 1; }
|
||||
.ed-row .val { width: 44px; text-align: right; font-family: monospace; font-size: 12px; }
|
||||
.ed-btns { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.ed-btns button { flex: 1; padding: 8px 6px; border-radius: 8px; border: 1px solid #bbb; background: #f4f1e6; cursor: pointer; font-size: 13px; }
|
||||
.ed-btns button.primary { background: #e8892b; color: #fff; border-color: #d07a20; font-weight: 700; }
|
||||
.ed-lvl { display: flex; gap: 4px; }
|
||||
.ed-lvl button { flex: 1; padding: 6px 0; border-radius: 8px; border: 1px solid #bbb; background: #fff; cursor: pointer; }
|
||||
.ed-lvl button.active { background: #e8892b; color: #fff; border-color: #d07a20; font-weight: 700; }
|
||||
.ed-check { display: flex; gap: 14px; margin-top: 8px; font-size: 13px; }
|
||||
.ed-hint { font-size: 11px; color: #8a8a8a; margin-top: 4px; line-height: 1.4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="map"></canvas>
|
||||
|
||||
<div id="edPanel" class="panel">
|
||||
<h2>🛠️ Sprite-Editor</h2>
|
||||
|
||||
<label>Gebäude</label>
|
||||
<select id="edType"></select>
|
||||
|
||||
<label>Ausbaustufe</label>
|
||||
<div class="ed-lvl" id="edLevels">
|
||||
<button data-lvl="1" class="active">I</button>
|
||||
<button data-lvl="2">II</button>
|
||||
<button data-lvl="3">III</button>
|
||||
<button data-lvl="4">IV</button>
|
||||
</div>
|
||||
|
||||
<div class="ed-check">
|
||||
<label style="margin:0;font-weight:400"><input type="checkbox" id="edMega"> 🏛️ Mega (2×2)</label>
|
||||
<label style="margin:0;font-weight:400"><input type="checkbox" id="edEco" checked> 🌱 Öko (Solar)</label>
|
||||
</div>
|
||||
|
||||
<label>Sprite-Breite <span style="color:#8a8a8a">(w)</span></label>
|
||||
<div class="ed-row"><input type="range" id="edW" min="30" max="180" step="1"><span class="val" id="edWv"></span></div>
|
||||
|
||||
<label>Boden-Anker <span style="color:#8a8a8a">(bottom · größer = tiefer)</span></label>
|
||||
<div class="ed-row"><input type="range" id="edB" min="-20" max="60" step="1"><span class="val" id="edBv"></span></div>
|
||||
|
||||
<label>☀️ Solar: horizontal (dx)</label>
|
||||
<div class="ed-row"><input type="range" id="edSX" min="-60" max="60" step="1"><span class="val" id="edSXv"></span></div>
|
||||
|
||||
<label>☀️ Solar: vertikal (dy)</label>
|
||||
<div class="ed-row"><input type="range" id="edSY" min="-80" max="20" step="1"><span class="val" id="edSYv"></span></div>
|
||||
|
||||
<label>☀️ Solar: Größe (s)</label>
|
||||
<div class="ed-row"><input type="range" id="edSS" min="0.4" max="3" step="0.05"><span class="val" id="edSSv"></span></div>
|
||||
|
||||
<div class="ed-btns">
|
||||
<button id="edReset">↩︎ Typ zurücksetzen</button>
|
||||
<button id="edCopy" class="primary">📋 Export kopieren</button>
|
||||
</div>
|
||||
|
||||
<label>Export (an Atlas schicken → wird fest eingebaut)</label>
|
||||
<textarea id="edOut" readonly></textarea>
|
||||
<div class="ed-hint">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.</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/editor-tune.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
+262
-27
@@ -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();
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -79,10 +79,14 @@ export class UI {
|
||||
el.className = 'build-item';
|
||||
el.dataset.type = type;
|
||||
el.innerHTML = `
|
||||
<span class="emoji">${emoji}</span>
|
||||
<span class="info"><b>${name}</b><small>${desc}</small></span>
|
||||
<span class="cost">${cost}</span>
|
||||
<button class="bi-menu-info" title="Alles über: ${name}">ℹ️</button>`;
|
||||
<div class="bi-head">
|
||||
<span class="bi-title"><b>${name}</b><button class="bi-menu-info" title="Alles über: ${name}">ℹ️</button></span>
|
||||
<span class="cost">${cost}</span>
|
||||
</div>
|
||||
<div class="bi-body">
|
||||
<span class="emoji">${emoji}</span>
|
||||
<small class="desc">${desc}</small>
|
||||
</div>`;
|
||||
el.addEventListener('click', () => this._toggleBuild(type, el));
|
||||
el.querySelector('.bi-menu-info').addEventListener('click', e => {
|
||||
e.stopPropagation(); // nicht in den Baumodus wechseln
|
||||
|
||||
Reference in New Issue
Block a user