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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 16:55:36 +02:00
parent 6ca643f628
commit cf0afc5c02
3 changed files with 514 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
<?php
// Speichert Editor-Daten (Routen-Kurven + Bauslots) nach js/maps-custom.json.
// Das Spiel lädt diese Datei beim Start und überschreibt damit die
// eingebauten Karten-Daten. Nur lokal (XAMPP) gedacht.
//
// WICHTIG: Vor jedem Überschreiben wird die bisherige Datei automatisch als
// Zeitstempel-Backup nach api/mapbackups/ gesichert so geht nichts verloren.
// Ein leeres {} wird abgelehnt, damit versehentliche Resets nicht speichern.
header('Content-Type: application/json; charset=utf-8');
// Plattform-Guard: Schreiben nur für eingeloggte Lehrpersonen.
// Pfad funktioniert lokal (App/sims/...) UND auf Prod (Top-Level sims/...).
require_once __DIR__ . '/../../../php/config/app.php';
require_once __DIR__ . '/../../../php/lib/Database.php';
require_once __DIR__ . '/../../../php/lib/Response.php';
require_once __DIR__ . '/../../../php/lib/Session.php';
Session::requireTeacher();
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if (!is_array($data) || count($data) === 0) {
http_response_code(400);
echo json_encode(['ok' => 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)]);
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Karten-Editor Tourismusregion</title>
<style>
:root { --green:#4f7f5e; --beige:#ede6d6; --orange:#e8892b; --ink:#2f3e46; }
* { box-sizing: border-box; }
html, body { margin:0; height:100%; overflow:hidden;
font-family:-apple-system,"Segoe UI",Roboto,sans-serif; color:var(--ink); }
#cv { position:fixed; inset:0; width:100%; height:100%; background:#e7dcc4; touch-action:none; }
#panel { position:fixed; top:10px; left:10px; width:265px; background:rgba(253,251,244,.95);
border-radius:14px; box-shadow:0 6px 20px rgba(47,62,70,.2); padding:12px 14px; z-index:10; }
h1 { font-size:15px; margin:0 0 8px; }
label { display:block; font-size:11px; color:#6b7a70; margin:8px 0 2px; text-transform:uppercase; letter-spacing:.03em; }
select, button { width:100%; padding:8px; border-radius:9px; border:1.5px solid #d9cfb4;
background:#fff; font-size:13px; font-family:inherit; margin-bottom:2px; }
button { cursor:pointer; }
button.primary { background:var(--orange); color:#fff; border:none; font-weight:700; }
button.mini { width:49%; display:inline-block; }
.mode { display:flex; gap:6px; }
.mode button { flex:1; }
.mode button.active { background:var(--green); color:#fff; border-color:var(--green); }
#hint { font-size:11px; line-height:1.45; color:#6b7a70; margin-top:8px; }
#status { font-size:12px; margin-top:6px; min-height:16px; font-weight:600; }
#out { width:100%; height:70px; font-size:10px; margin-top:6px; display:none; }
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="panel">
<h1>🛠️ Karten-Editor</h1>
<label>Karte</label>
<select id="mapSel"></select>
<label>Modus</label>
<div class="mode">
<button id="modePath" class="active">📍 Pfad</button>
<button id="modeSlot">🏗️ Bauslots</button>
</div>
<div id="pathTools">
<label>Route</label>
<select id="routeSel"></select>
</div>
<div id="slotTools" style="display:none">
<label>Zone für neue Slots</label>
<select id="zoneSel">
<option value="standard">Standard (alle Gebäude)</option>
<option value="road">Straße (Parkplatz/Bus)</option>
<option value="lake">Seeufer (Badesteg)</option>
<option value="hang">Hang (Skilift)</option>
<option value="berg">Berg (Almhütte/Aussicht)</option>
</select>
</div>
<label>Aktionen</label>
<button id="undoBtn" class="mini">↩︎ Rückgängig</button>
<button id="clearBtn" class="mini">🗑 Route leeren</button>
<button id="resetBtn" class="mini">♻︎ Original laden</button>
<button id="saveBtn" class="primary">💾 Speichern (ins Spiel)</button>
<div id="status"></div>
<textarea id="out" readonly></textarea>
<div id="hint">
<b>Klick</b> = Punkt/Slot setzen · <b>Ziehen</b> = verschieben ·
<b>Doppelklick</b> = löschen · <b>Rechte Maus/2 Finger</b> = Karte schieben ·
<b>Mausrad</b> = Zoom.<br>
Die orange Linie ist die geglättete Kurve, auf der die Figuren laufen.
Speichern schreibt <code>js/maps-custom.json</code> das Spiel lädt sie
beim nächsten Start automatisch.<br>
<b>Jede Änderung wird sofort lokal im Browser gesichert</b> und beim Öffnen
wiederhergestellt deine Arbeit geht nicht verloren.
</div>
</div>
<script type="module" src="js/editor.js"></script>
</body>
</html>
+393
View File
@@ -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);