From cf0afc5c02b0def69c19eebac0840e18d6f68c84 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 12 Jul 2026 16:55:36 +0200 Subject: [PATCH] Tourismusregion: Karten-Editor reaktiviert (mit Lehrer-Guard) editor.html + js/editor.js aus der Quelle uebernommen. save_map.php schreibt js/maps-custom.json (mit Auto-Backups) - jetzt abgesichert: nur eingeloggte Lehrpersonen duerfen speichern. Das Spiel laedt die Overrides beim Start (applyMapOverrides). Thomas fixt damit Routen- Punkte und Bauplaetze; die Datei wird danach ins Repo uebernommen. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/sims/tourismusregion/api/save_map.php | 46 +++ App/sims/tourismusregion/editor.html | 75 +++++ App/sims/tourismusregion/js/editor.js | 393 ++++++++++++++++++++++ 3 files changed, 514 insertions(+) create mode 100644 App/sims/tourismusregion/api/save_map.php create mode 100644 App/sims/tourismusregion/editor.html create mode 100644 App/sims/tourismusregion/js/editor.js 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

+ + + +
+ + +
+
+ + +
+ + + + + + +
+ +
+ 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);