Files
geograsim/App/sims/tourismusregion/js/editor.js
T
Adminator cf0afc5c02 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>
2026-07-12 16:55:36 +02:00

394 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);