diff --git a/App/sims/tourismusregion/api/save_map.php b/App/sims/tourismusregion/api/save_map.php
new file mode 100644
index 0000000..127ef60
--- /dev/null
+++ b/App/sims/tourismusregion/api/save_map.php
@@ -0,0 +1,46 @@
+ false, 'error' => 'invalid or empty json']);
+ exit;
+}
+
+$target = __DIR__ . '/../js/maps-custom.json';
+$backupDir = __DIR__ . '/mapbackups';
+
+// vorhandene, nicht-leere Datei zuerst sichern
+if (file_exists($target)) {
+ $old = trim((string)file_get_contents($target));
+ if ($old !== '' && $old !== '{}') {
+ if (!is_dir($backupDir)) @mkdir($backupDir, 0777, true);
+ @copy($target, $backupDir . '/maps-custom-' . date('Ymd-His') . '.json');
+ @copy($target, $backupDir . '/maps-custom.prev.json'); // schneller Griff aufs letzte
+ $files = glob($backupDir . '/maps-custom-*.json'); // max. 40 Backups halten
+ if ($files && count($files) > 40) {
+ sort($files);
+ foreach (array_slice($files, 0, count($files) - 40) as $f) @unlink($f);
+ }
+ }
+}
+
+$ok = file_put_contents($target, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+echo json_encode(['ok' => (bool)$ok, 'file' => 'js/maps-custom.json', 'backed_up' => is_dir($backupDir)]);
diff --git a/App/sims/tourismusregion/editor.html b/App/sims/tourismusregion/editor.html
new file mode 100644
index 0000000..93af221
--- /dev/null
+++ b/App/sims/tourismusregion/editor.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+Karten-Editor – Tourismusregion
+
+
+
+
+
+
🛠️ Karten-Editor
+
Karte
+
+
Modus
+
+ 📍 Pfad
+ 🏗️ Bauslots
+
+
+ Route
+
+
+
+ Zone für neue Slots
+
+ Standard (alle Gebäude)
+ Straße (Parkplatz/Bus)
+ Seeufer (Badesteg)
+ Hang (Skilift)
+ Berg (Almhütte/Aussicht)
+
+
+
Aktionen
+
↩︎ Rückgängig
+
🗑 Route leeren
+
♻︎ Original laden
+
💾 Speichern (ins Spiel)
+
+
+
+ Klick = Punkt/Slot setzen · Ziehen = verschieben ·
+ Doppelklick = löschen · Rechte Maus/2 Finger = Karte schieben ·
+ Mausrad = Zoom.
+ Die orange Linie ist die geglättete Kurve, auf der die Figuren laufen.
+ Speichern schreibt js/maps-custom.json – das Spiel lädt sie
+ beim nächsten Start automatisch.
+ Jede Änderung wird sofort lokal im Browser gesichert und beim Öffnen
+ wiederhergestellt – deine Arbeit geht nicht verloren.
+
+
+
+
+
diff --git a/App/sims/tourismusregion/js/editor.js b/App/sims/tourismusregion/js/editor.js
new file mode 100644
index 0000000..7b53728
--- /dev/null
+++ b/App/sims/tourismusregion/js/editor.js
@@ -0,0 +1,393 @@
+// Karten-Editor: legt Pfad-Kurven (Klickpunkte → Catmull-Rom-Glättung) und
+// Bauslots über die Karten-Artworks. Speichert nach js/maps-custom.json
+// (api/save_map.php, XAMPP) – das Spiel lädt die Datei beim Start.
+// Bedienung: Klick = setzen · Ziehen = verschieben · Doppelklick = löschen ·
+// rechte Maustaste/zweiter Finger = Karte schieben · Mausrad = Zoom.
+
+import { MAPS, MAP_ORDER } from './maps.js';
+
+const $ = id => document.getElementById(id);
+const cv = $('cv');
+const ctx = cv.getContext('2d');
+
+let mapId = MAP_ORDER[0];
+let mode = 'path';
+let routeId = null;
+let edit = null; // {routes: {id: [[x,y],…]}, meta: {id:{entry,exit,lake}}, slots: [{x,y,zone}]}
+let history = [];
+let cam = { x: 0, y: 0, scale: 0.7 };
+let drag = null; // {kind:'point'|'slot'|'pan', idx, moved}
+let mapImg = new Image();
+let slotImg = new Image();
+slotImg.src = 'assets/sprites/slot.png';
+
+const ZONE_COLORS = {
+ standard: '#2f6ea0', road: '#8c6239', lake: '#2e8b8b', hang: '#c9531f', berg: '#6d4fb8',
+};
+
+// ---------- Laden ----------
+async function loadMap(id) {
+ mapId = id;
+ const def = MAPS[id];
+ mapImg = new Image();
+ mapImg.src = def.image;
+ // vorhandene Overrides zuerst, sonst eingebaute Daten
+ let ov = null;
+ try {
+ const r = await fetch('js/maps-custom.json', { cache: 'no-store' });
+ if (r.ok) ov = (await r.json())[id];
+ } catch { /* keine Overrides */ }
+ edit = { routes: {}, meta: {}, slots: [] };
+ for (const [rk, r] of Object.entries(def.routes)) {
+ if (r.reverseOf) continue; // Gegenrichtungen entstehen automatisch
+ edit.meta[rk] = { entry: r.entry, exit: r.exit, lake: !!r.lake };
+ const ctrl = ov?.ctrl?.[rk];
+ edit.routes[rk] = (ctrl || r.points).map(p => [p[0], p[1]]);
+ }
+ edit.slots = (ov?.slots || def.slots).map(s => ({ x: s.x, y: s.y, zone: s.zone }));
+ // Lokaler Zwischenstand geht IMMER vor (verlustsicher gegen Datei-Resets):
+ // Der Editor sichert jede Änderung im Browser – so ist Arbeit nie weg.
+ const local = loadLocal(id);
+ if (local) {
+ for (const rk of Object.keys(edit.routes)) if (local.routes[rk]) edit.routes[rk] = local.routes[rk];
+ if (local.slots) edit.slots = local.slots;
+ setStatus('↩︎ Lokaler Bearbeitungsstand wiederhergestellt.');
+ } else {
+ setStatus('');
+ }
+ routeId = Object.keys(edit.routes)[0];
+ buildRouteSel();
+ history = [];
+ fitCamera();
+}
+
+const LKEY = id => `tt_editor_${id}`;
+function persistLocal() {
+ try { localStorage.setItem(LKEY(mapId), JSON.stringify({ routes: edit.routes, slots: edit.slots, ts: Date.now() })); }
+ catch { /* Speicher voll/Privatmodus – dann ohne Autosave */ }
+}
+function loadLocal(id) {
+ try {
+ const raw = localStorage.getItem(LKEY(id));
+ return raw ? JSON.parse(raw) : null;
+ } catch { return null; }
+}
+
+function snapshot() {
+ history.push(JSON.stringify({ routes: edit.routes, slots: edit.slots }));
+ if (history.length > 60) history.shift();
+}
+
+function undo() {
+ const prev = history.pop();
+ if (!prev) return;
+ const o = JSON.parse(prev);
+ edit.routes = o.routes;
+ edit.slots = o.slots;
+ persistLocal();
+}
+
+// ---------- Catmull-Rom-Glättung ----------
+export function smoothCurve(pts, step = 20) {
+ if (pts.length < 3) return pts.map(p => [Math.round(p[0]), Math.round(p[1])]);
+ const out = [];
+ const P = i => pts[Math.max(0, Math.min(pts.length - 1, i))];
+ for (let i = 0; i < pts.length - 1; i++) {
+ const p0 = P(i - 1), p1 = P(i), p2 = P(i + 1), p3 = P(i + 2);
+ const segLen = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
+ const k = Math.max(2, Math.round(segLen / step));
+ for (let j = 0; j < k; j++) {
+ const t = j / k, t2 = t * t, t3 = t2 * t;
+ out.push([
+ 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3),
+ 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3),
+ ]);
+ }
+ }
+ out.push([...pts[pts.length - 1]]);
+ return out.map(p => [Math.round(p[0]), Math.round(p[1])]);
+}
+
+// ---------- Speichern ----------
+async function save() {
+ // bestehende Overrides anderer Karten erhalten
+ let all = {};
+ try {
+ const r = await fetch('js/maps-custom.json', { cache: 'no-store' });
+ if (r.ok) all = await r.json();
+ } catch { /* neu anlegen */ }
+ const routesOut = {};
+ for (const [rk, pts] of Object.entries(edit.routes)) {
+ if (pts.length >= 2) routesOut[rk] = smoothCurve(pts);
+ }
+ all[mapId] = {
+ routes: routesOut, // geglättet – fürs Spiel
+ ctrl: JSON.parse(JSON.stringify(edit.routes)), // Kontrollpunkte – für den Editor
+ slots: edit.slots,
+ };
+ try {
+ const res = await fetch('api/save_map.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(all),
+ });
+ const j = res.ok ? await res.json() : { ok: false };
+ if (j.ok) { persistLocal(); setStatus('✅ Gespeichert (+ lokal gesichert) – Spiel neu laden.'); return; }
+ throw new Error('kein PHP');
+ } catch {
+ // Fallback ohne PHP: JSON zum manuellen Speichern anzeigen
+ const out = $('out');
+ out.style.display = 'block';
+ out.value = JSON.stringify(all, null, 1);
+ out.select();
+ setStatus('⚠️ Kein PHP – JSON manuell als js/maps-custom.json speichern.');
+ }
+}
+
+function setStatus(msg) { $('status').textContent = msg; }
+
+// ---------- Kamera / Koordinaten ----------
+function fitCamera() {
+ const def = MAPS[mapId];
+ cam.scale = Math.min(innerWidth / def.w, innerHeight / def.h) * 0.95;
+ cam.x = (innerWidth - def.w * cam.scale) / 2;
+ cam.y = (innerHeight - def.h * cam.scale) / 2;
+}
+const toWorld = (sx, sy) => ({ x: (sx - cam.x) / cam.scale, y: (sy - cam.y) / cam.scale });
+
+// ---------- Interaktion ----------
+function hitPoint(w) {
+ const pts = edit.routes[routeId] || [];
+ const r = 14 / cam.scale;
+ for (let i = pts.length - 1; i >= 0; i--) {
+ if (Math.hypot(pts[i][0] - w.x, pts[i][1] - w.y) < r) return i;
+ }
+ return -1;
+}
+function hitSlot(w) {
+ const r = 26 / cam.scale + 14;
+ for (let i = edit.slots.length - 1; i >= 0; i--) {
+ if (Math.hypot(edit.slots[i].x - w.x, edit.slots[i].y - w.y) < r) return i;
+ }
+ return -1;
+}
+
+const pointers = new Map();
+cv.addEventListener('pointerdown', e => {
+ cv.setPointerCapture(e.pointerId);
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (e.button === 2 || pointers.size === 2) { drag = { kind: 'pan' }; return; }
+ const w = toWorld(e.clientX, e.clientY);
+ if (mode === 'path') {
+ const idx = hitPoint(w);
+ drag = idx >= 0 ? { kind: 'point', idx, moved: 0 } : { kind: 'maybeAdd', moved: 0 };
+ } else {
+ const idx = hitSlot(w);
+ drag = idx >= 0 ? { kind: 'slot', idx, moved: 0 } : { kind: 'maybeAdd', moved: 0 };
+ }
+});
+cv.addEventListener('pointermove', e => {
+ const p = pointers.get(e.pointerId);
+ if (!p || !drag) return;
+ const dx = e.clientX - p.x, dy = e.clientY - p.y;
+ p.x = e.clientX; p.y = e.clientY;
+ drag.moved = (drag.moved || 0) + Math.abs(dx) + Math.abs(dy);
+ if (drag.kind === 'pan' || (drag.kind === 'maybeAdd' && drag.moved > 8)) {
+ if (drag.kind === 'maybeAdd') drag.kind = 'pan';
+ cam.x += dx; cam.y += dy;
+ } else if (drag.kind === 'point' && drag.moved > 3) {
+ if (!drag.snap) { snapshot(); drag.snap = true; }
+ const w = toWorld(e.clientX, e.clientY);
+ edit.routes[routeId][drag.idx] = [Math.round(w.x), Math.round(w.y)];
+ } else if (drag.kind === 'slot' && drag.moved > 3) {
+ if (!drag.snap) { snapshot(); drag.snap = true; }
+ const w = toWorld(e.clientX, e.clientY);
+ edit.slots[drag.idx].x = Math.round(w.x);
+ edit.slots[drag.idx].y = Math.round(w.y);
+ }
+});
+cv.addEventListener('pointerup', e => {
+ pointers.delete(e.pointerId);
+ if (drag?.kind === 'maybeAdd' && (drag.moved || 0) < 8) {
+ const w = toWorld(e.clientX, e.clientY);
+ snapshot();
+ if (mode === 'path') {
+ // an das nähere Ende anfügen bzw. in das nächste Segment einfügen
+ const pts = edit.routes[routeId];
+ insertPoint(pts, [Math.round(w.x), Math.round(w.y)]);
+ } else {
+ edit.slots.push({ x: Math.round(w.x), y: Math.round(w.y), zone: $('zoneSel').value });
+ }
+ }
+ if (drag && drag.kind !== 'pan') persistLocal(); // jede Setzung/Bewegung sichern
+ drag = null;
+});
+cv.addEventListener('dblclick', e => {
+ const w = toWorld(e.clientX, e.clientY);
+ if (mode === 'path') {
+ const idx = hitPoint(w);
+ if (idx >= 0 && edit.routes[routeId].length > 2) { snapshot(); edit.routes[routeId].splice(idx, 1); persistLocal(); }
+ } else {
+ const idx = hitSlot(w);
+ if (idx >= 0) { snapshot(); edit.slots.splice(idx, 1); persistLocal(); }
+ }
+});
+cv.addEventListener('contextmenu', e => e.preventDefault());
+cv.addEventListener('wheel', e => {
+ e.preventDefault();
+ const f = e.deltaY < 0 ? 1.12 : 0.89;
+ const next = Math.min(3, Math.max(0.25, cam.scale * f));
+ const real = next / cam.scale;
+ cam.x = e.clientX - (e.clientX - cam.x) * real;
+ cam.y = e.clientY - (e.clientY - cam.y) * real;
+ cam.scale = next;
+}, { passive: false });
+
+// neuen Punkt sinnvoll einfügen: ans nähere Ende oder mitten ins nächste Segment
+function insertPoint(pts, p) {
+ if (pts.length < 2) { pts.push(p); return; }
+ let bestSeg = -1, bestD = 40; // nur einfügen, wenn nah an einem Segment
+ for (let i = 0; i < pts.length - 1; i++) {
+ const d = distToSegment(p, pts[i], pts[i + 1]);
+ if (d < bestD) { bestD = d; bestSeg = i; }
+ }
+ if (bestSeg >= 0) { pts.splice(bestSeg + 1, 0, p); return; }
+ const dStart = Math.hypot(p[0] - pts[0][0], p[1] - pts[0][1]);
+ const dEnd = Math.hypot(p[0] - pts[pts.length - 1][0], p[1] - pts[pts.length - 1][1]);
+ if (dStart < dEnd) pts.unshift(p); else pts.push(p);
+}
+function distToSegment(p, a, b) {
+ const dx = b[0] - a[0], dy = b[1] - a[1];
+ const L2 = dx * dx + dy * dy || 1;
+ const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L2));
+ return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
+}
+
+// ---------- UI ----------
+for (const id of MAP_ORDER) {
+ const o = document.createElement('option');
+ o.value = id;
+ o.textContent = `${MAPS[id].icon} ${MAPS[id].name}`;
+ $('mapSel').appendChild(o);
+}
+$('mapSel').addEventListener('change', () => loadMap($('mapSel').value));
+function buildRouteSel() {
+ const sel = $('routeSel');
+ sel.innerHTML = '';
+ for (const rk of Object.keys(edit.routes)) {
+ const o = document.createElement('option');
+ o.value = rk;
+ const m = edit.meta[rk];
+ o.textContent = `${rk} (${m.entry} → ${m.exit})`;
+ sel.appendChild(o);
+ }
+ sel.value = routeId;
+}
+$('routeSel').addEventListener('change', () => { routeId = $('routeSel').value; });
+$('modePath').addEventListener('click', () => setMode('path'));
+$('modeSlot').addEventListener('click', () => setMode('slot'));
+function setMode(m2) {
+ mode = m2;
+ $('modePath').classList.toggle('active', m2 === 'path');
+ $('modeSlot').classList.toggle('active', m2 === 'slot');
+ $('pathTools').style.display = m2 === 'path' ? '' : 'none';
+ $('slotTools').style.display = m2 === 'slot' ? '' : 'none';
+}
+$('undoBtn').addEventListener('click', undo);
+$('clearBtn').addEventListener('click', () => {
+ if (mode === 'path') { snapshot(); edit.routes[routeId] = []; }
+ else { snapshot(); edit.slots = []; }
+ persistLocal();
+});
+// „Original laden" – lokalen Stand dieser Karte verwerfen und Defaults holen
+$('resetBtn')?.addEventListener('click', () => {
+ if (!confirm('Lokalen Bearbeitungsstand dieser Karte verwerfen und die '
+ + 'gespeicherte/eingebaute Version laden?')) return;
+ try { localStorage.removeItem(LKEY(mapId)); } catch {}
+ loadMap(mapId);
+});
+$('saveBtn').addEventListener('click', save);
+window.addEventListener('resize', resize);
+function resize() {
+ const dpr = window.devicePixelRatio || 1;
+ cv.width = innerWidth * dpr;
+ cv.height = innerHeight * dpr;
+}
+resize();
+
+// ---------- Zeichnen ----------
+function draw() {
+ const dpr = window.devicePixelRatio || 1;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, innerWidth, innerHeight);
+ ctx.fillStyle = '#e7dcc4';
+ ctx.fillRect(0, 0, innerWidth, innerHeight);
+ if (!edit) { requestAnimationFrame(draw); return; }
+ ctx.save();
+ ctx.translate(cam.x, cam.y);
+ ctx.scale(cam.scale, cam.scale);
+ const def = MAPS[mapId];
+ if (mapImg.complete && mapImg.naturalWidth) ctx.drawImage(mapImg, 0, 0, def.w, def.h);
+
+ // Slots
+ for (let i = 0; i < edit.slots.length; i++) {
+ const s = edit.slots[i];
+ if (slotImg.complete && slotImg.naturalWidth) {
+ const w = 78;
+ const h = w * (slotImg.naturalHeight / slotImg.naturalWidth);
+ ctx.drawImage(slotImg, s.x - w / 2, s.y - h / 2, w, h);
+ }
+ ctx.strokeStyle = ZONE_COLORS[s.zone] || '#2f6ea0';
+ ctx.lineWidth = mode === 'slot' ? 3 : 1.5;
+ ctx.setLineDash([6, 5]);
+ ctx.beginPath();
+ ctx.ellipse(s.x, s.y, 34, 21, 0, 0, 7);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.fillStyle = ZONE_COLORS[s.zone] || '#2f6ea0';
+ ctx.font = 'bold 13px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(s.zone, s.x, s.y - 26);
+ }
+
+ // Routen: andere blass, aktive mit Punkten + geglätteter Kurve
+ for (const [rk, pts] of Object.entries(edit.routes)) {
+ if (pts.length < 2) continue;
+ const active = rk === routeId && mode === 'path';
+ const sm = smoothCurve(pts);
+ ctx.strokeStyle = active ? 'rgba(232,110,20,0.95)' : 'rgba(47,62,70,0.30)';
+ ctx.lineWidth = active ? 5 : 3;
+ ctx.beginPath();
+ sm.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])));
+ ctx.stroke();
+ if (active) {
+ // Richtungspfeile
+ ctx.fillStyle = 'rgba(232,110,20,0.95)';
+ for (let i = 20; i < sm.length; i += 40) {
+ const a = Math.atan2(sm[i][1] - sm[i - 1][1], sm[i][0] - sm[i - 1][0]);
+ ctx.save();
+ ctx.translate(sm[i][0], sm[i][1]);
+ ctx.rotate(a);
+ ctx.beginPath();
+ ctx.moveTo(8, 0); ctx.lineTo(-4, -6); ctx.lineTo(-4, 6);
+ ctx.closePath(); ctx.fill();
+ ctx.restore();
+ }
+ // Kontrollpunkte
+ for (let i = 0; i < pts.length; i++) {
+ ctx.fillStyle = i === 0 ? '#2e8b2e' : i === pts.length - 1 ? '#c0392b' : '#fff';
+ ctx.strokeStyle = '#2f3e46';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(pts[i][0], pts[i][1], 9, 0, 7);
+ ctx.fill();
+ ctx.stroke();
+ }
+ }
+ }
+ ctx.restore();
+ requestAnimationFrame(draw);
+}
+
+loadMap(mapId);
+requestAnimationFrame(draw);