cf0afc5c02
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>
394 lines
14 KiB
JavaScript
394 lines
14 KiB
JavaScript
// 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);
|