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