/** * MapEditor.js - 2D canvas-based map editor for a Three.js RPG game. * Renders all map objects as 2D shapes (top-down view) and provides * interactive editing: select, move, add, delete, and modify properties. * * Coordinate system: X = horizontal (left-right), Z = vertical on canvas * (positive Z = up/north in the game world, which maps to negative Y on canvas). */ export class MapEditor { constructor(canvas, listPanel, propsPanel, statusBar) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.listPanel = listPanel; this.propsPanel = propsPanel; this.statusBar = statusBar; // Camera state this.camera = { x: 0, z: 0, zoom: 2.5 }; this.minZoom = 0.5; this.maxZoom = 10; // Object storage this.objects = []; this.selectedObject = null; this.nextId = 1; // Interaction state this.isDragging = false; this.isPanning = false; this.dragStartWorld = null; this.dragStartObjPos = null; this.panStart = null; this.panCameraStart = null; this.mouseWorld = { x: 0, z: 0 }; // Settings this.snapStep = 1; this.showGrid = true; this.showLabels = true; this.showRoutes = true; // Category collapse state this.collapsedCategories = {}; // Structure display names (Russian) this.structureLabels = { park: 'Парк', shop: 'Магазин', shelter: 'Приют', hospital: 'Больница', church: 'Церковь', market: 'Рынок', construction: 'Стройка', busStop: 'Остановка', parking: 'Парковка', fountain: 'Фонтан', phoneBooth: 'Телефон', jobBoard: 'Доска вакансий', campSpot: 'Лагерь' }; // Category display names (Russian) this.categoryLabels = { roads: 'Дороги', buildings: 'Здания', structures: 'Структуры', interactables: 'Интерактивные', decorations: 'Декорации', npcs: 'NPC', vehicles: 'Транспорт' }; // Category icons this.categoryIcons = { roads: '\u2550', buildings: '\u2302', structures: '\u2736', interactables: '\u2699', decorations: '\u2600', npcs: '\u263A', vehicles: '\u2708' }; this._setupEventListeners(); this._resizeCanvas(); this._startRenderLoop(); } // ========================================================================= // COORDINATE CONVERSION // ========================================================================= screenToWorld(sx, sy) { const cx = this.canvas.width / 2; const cy = this.canvas.height / 2; const wx = (sx - cx) / this.camera.zoom + this.camera.x; const wz = -(sy - cy) / this.camera.zoom + this.camera.z; return { x: wx, z: wz }; } worldToScreen(wx, wz) { const cx = this.canvas.width / 2; const cy = this.canvas.height / 2; const sx = (wx - this.camera.x) * this.camera.zoom + cx; const sy = -(wz - this.camera.z) * this.camera.zoom + cy; return { x: sx, y: sy }; } // ========================================================================= // EVENT LISTENERS // ========================================================================= _setupEventListeners() { this.canvas.addEventListener('mousedown', (e) => this._onMouseDown(e)); this.canvas.addEventListener('mousemove', (e) => this._onMouseMove(e)); this.canvas.addEventListener('mouseup', (e) => this._onMouseUp(e)); this.canvas.addEventListener('wheel', (e) => this._onWheel(e)); this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); window.addEventListener('keydown', (e) => this._onKeyDown(e)); window.addEventListener('resize', () => this._resizeCanvas()); } _resizeCanvas() { this.canvas.width = this.canvas.clientWidth; this.canvas.height = this.canvas.clientHeight; } _onMouseDown(e) { const rect = this.canvas.getBoundingClientRect(); const sx = e.clientX - rect.left; const sy = e.clientY - rect.top; const world = this.screenToWorld(sx, sy); if (e.button === 2) { // Right click: start panning this.isPanning = true; this.panStart = { x: sx, y: sy }; this.panCameraStart = { x: this.camera.x, z: this.camera.z }; return; } if (e.button === 0) { // Left click: select or start drag const hit = this.hitTest(world.x, world.z); if (hit) { this.selectedObject = hit; this.isDragging = true; this.dragStartWorld = { x: world.x, z: world.z }; this.dragStartObjPos = { x: hit.x, z: hit.z }; } else { this.selectedObject = null; } this.updateObjectList(); this.updatePropsPanel(); } } _onMouseMove(e) { const rect = this.canvas.getBoundingClientRect(); const sx = e.clientX - rect.left; const sy = e.clientY - rect.top; const world = this.screenToWorld(sx, sy); this.mouseWorld = world; if (this.isPanning && this.panStart) { const dx = (sx - this.panStart.x) / this.camera.zoom; const dy = (sy - this.panStart.y) / this.camera.zoom; this.camera.x = this.panCameraStart.x - dx; this.camera.z = this.panCameraStart.z + dy; } if (this.isDragging && this.selectedObject && this.dragStartWorld) { const dx = world.x - this.dragStartWorld.x; const dz = world.z - this.dragStartWorld.z; let newX = this.dragStartObjPos.x + dx; let newZ = this.dragStartObjPos.z + dz; if (this.snapStep > 0) { newX = Math.round(newX / this.snapStep) * this.snapStep; newZ = Math.round(newZ / this.snapStep) * this.snapStep; } this.selectedObject.x = newX; this.selectedObject.z = newZ; this.updatePropsPanel(); } this._updateStatusBar(); } _onMouseUp(e) { if (e.button === 2) { this.isPanning = false; this.panStart = null; this.panCameraStart = null; } if (e.button === 0) { this.isDragging = false; this.dragStartWorld = null; this.dragStartObjPos = null; } } _onWheel(e) { e.preventDefault(); const rect = this.canvas.getBoundingClientRect(); const sx = e.clientX - rect.left; const sy = e.clientY - rect.top; const worldBefore = this.screenToWorld(sx, sy); const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; this.camera.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.camera.zoom * zoomFactor)); const worldAfter = this.screenToWorld(sx, sy); this.camera.x += worldBefore.x - worldAfter.x; this.camera.z += worldBefore.z - worldAfter.z; } _onKeyDown(e) { if (e.key === 'Delete' && this.selectedObject) { this.deleteObject(this.selectedObject); } } // ========================================================================= // HIT TESTING // ========================================================================= hitTest(worldX, worldZ) { // Check in reverse order so top-rendered objects are hit first for (let i = this.objects.length - 1; i >= 0; i--) { const obj = this.objects[i]; if (this._hitTestObject(obj, worldX, worldZ)) { return obj; } } return null; } _hitTestObject(obj, wx, wz) { switch (obj.type) { case 'road': { let rw, rh; if (obj.rotation && Math.abs(obj.rotation) > 0.1) { rw = obj.height; rh = obj.width; } else { rw = obj.width; rh = obj.height; } return Math.abs(wx - obj.x) <= rw / 2 && Math.abs(wz - obj.z) <= rh / 2; } case 'building': { const bw = obj.w || 10; const bd = obj.d || 8; return Math.abs(wx - obj.x) <= bw / 2 && Math.abs(wz - obj.z) <= bd / 2; } case 'structure': return this._hitTestStructure(obj, wx, wz); case 'dumpster': return Math.abs(wx - obj.x) <= 1.5 && Math.abs(wz - obj.z) <= 1; case 'bench': return Math.abs(wx - obj.x) <= 2 && Math.abs(wz - obj.z) <= 1; case 'trashPile': return Math.hypot(wx - obj.x, wz - obj.z) <= 1.5; case 'lamp': return Math.hypot(wx - obj.x, wz - obj.z) <= 1.5; case 'hydrant': return Math.hypot(wx - obj.x, wz - obj.z) <= 1; case 'bin': return Math.hypot(wx - obj.x, wz - obj.z) <= 1; case 'npc': return Math.hypot(wx - obj.x, wz - obj.z) <= 2; case 'vehicleRoute': { const route = obj; if (route.axis === 'x') { const minX = Math.min(route.start, route.end); const maxX = Math.max(route.start, route.end); return wx >= minX && wx <= maxX && Math.abs(wz - route.lane) <= 3; } else { const minZ = Math.min(route.start, route.end); const maxZ = Math.max(route.start, route.end); return wz >= minZ && wz <= maxZ && Math.abs(wx - route.lane) <= 3; } } case 'passerbyRoute': { if (!obj.waypoints || obj.waypoints.length < 2) return false; for (let j = 0; j < obj.waypoints.length - 1; j++) { const ax = obj.waypoints[j][0], az = obj.waypoints[j][1]; const bx = obj.waypoints[j + 1][0], bz = obj.waypoints[j + 1][1]; const dist = this._pointToSegmentDist(wx, wz, ax, az, bx, bz); if (dist < 3) return true; } return false; } default: return Math.abs(wx - obj.x) <= 3 && Math.abs(wz - obj.z) <= 3; } } _hitTestStructure(obj, wx, wz) { const st = obj.structureType; // For structures with radius (park, construction, fountain) if (obj.radius) { return Math.hypot(wx - obj.x, wz - obj.z) <= obj.radius; } // For structures with w/d dimensions if (obj.w && obj.d) { return Math.abs(wx - obj.x) <= obj.w / 2 && Math.abs(wz - obj.z) <= obj.d / 2; } // Fallback defaults per type switch (st) { case 'park': return Math.hypot(wx - obj.x, wz - obj.z) <= 13; case 'construction': return Math.hypot(wx - obj.x, wz - obj.z) <= 12; case 'fountain': return Math.hypot(wx - obj.x, wz - obj.z) <= 2; case 'campSpot': return Math.abs(wx - obj.x) + Math.abs(wz - obj.z) <= 3; default: return Math.abs(wx - obj.x) <= 3 && Math.abs(wz - obj.z) <= 3; } } _pointToSegmentDist(px, pz, ax, az, bx, bz) { const dx = bx - ax; const dz = bz - az; const lenSq = dx * dx + dz * dz; if (lenSq === 0) return Math.hypot(px - ax, pz - az); let t = ((px - ax) * dx + (pz - az) * dz) / lenSq; t = Math.max(0, Math.min(1, t)); const cx = ax + t * dx; const cz = az + t * dz; return Math.hypot(px - cx, pz - cz); } // ========================================================================= // RENDERING // ========================================================================= _startRenderLoop() { const loop = () => { this.render(); requestAnimationFrame(loop); }; requestAnimationFrame(loop); } render() { const ctx = this.ctx; const w = this.canvas.width; const h = this.canvas.height; ctx.clearRect(0, 0, w, h); // Background ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, w, h); // Draw ground this._drawGround(); // Draw grid if (this.showGrid) this._drawGrid(); // Draw coordinate axes this._drawAxes(); // Draw objects in layers this._drawRoads(); this._drawBuildings(); this._drawStructures(); this._drawInteractables(); this._drawDecorations(); if (this.showRoutes) { this._drawVehicleRoutes(); this._drawPasserbyRoutes(); } this._drawNPCs(); // Draw selection highlight if (this.selectedObject) { this._drawSelectionHighlight(this.selectedObject); } } _drawGround() { const ctx = this.ctx; const topLeft = this.worldToScreen(-100, 100); const botRight = this.worldToScreen(100, -100); ctx.fillStyle = '#2d5a2d'; ctx.fillRect(topLeft.x, topLeft.y, botRight.x - topLeft.x, botRight.y - topLeft.y); } _drawGrid() { const ctx = this.ctx; const w = this.canvas.width; const h = this.canvas.height; const topLeftWorld = this.screenToWorld(0, 0); const botRightWorld = this.screenToWorld(w, h); const minX = Math.floor(topLeftWorld.x / 10) * 10 - 10; const maxX = Math.ceil(botRightWorld.x / 10) * 10 + 10; const minZ = Math.floor(botRightWorld.z / 10) * 10 - 10; const maxZ = Math.ceil(topLeftWorld.z / 10) * 10 + 10; // Light grid every 10 units ctx.lineWidth = 0.5; for (let x = minX; x <= maxX; x += 10) { const isMajor = x % 50 === 0; ctx.strokeStyle = isMajor ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.05)'; ctx.lineWidth = isMajor ? 1 : 0.5; const s1 = this.worldToScreen(x, minZ); const s2 = this.worldToScreen(x, maxZ); ctx.beginPath(); ctx.moveTo(s1.x, s1.y); ctx.lineTo(s2.x, s2.y); ctx.stroke(); } for (let z = minZ; z <= maxZ; z += 10) { const isMajor = z % 50 === 0; ctx.strokeStyle = isMajor ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.05)'; ctx.lineWidth = isMajor ? 1 : 0.5; const s1 = this.worldToScreen(minX, z); const s2 = this.worldToScreen(maxX, z); ctx.beginPath(); ctx.moveTo(s1.x, s1.y); ctx.lineTo(s2.x, s2.y); ctx.stroke(); } } _drawAxes() { const ctx = this.ctx; const w = this.canvas.width; const h = this.canvas.height; // X axis (red) const xStart = this.worldToScreen(-200, 0); const xEnd = this.worldToScreen(200, 0); ctx.strokeStyle = 'rgba(255, 80, 80, 0.6)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(xStart.x, xStart.y); ctx.lineTo(xEnd.x, xEnd.y); ctx.stroke(); // Z axis (blue) const zStart = this.worldToScreen(0, -200); const zEnd = this.worldToScreen(0, 200); ctx.strokeStyle = 'rgba(80, 80, 255, 0.6)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(zStart.x, zStart.y); ctx.lineTo(zEnd.x, zEnd.y); ctx.stroke(); // Axis labels ctx.font = '12px monospace'; ctx.fillStyle = '#ff5050'; const xLabel = this.worldToScreen(195, 2); ctx.fillText('X', xLabel.x, xLabel.y); ctx.fillStyle = '#5050ff'; const zLabel = this.worldToScreen(2, 195); ctx.fillText('Z', zLabel.x, zLabel.y); } _drawRoads() { const ctx = this.ctx; const roads = this.objects.filter(o => o.type === 'road'); for (const road of roads) { const isNS = road.rotation && Math.abs(road.rotation) > 0.1; let rw, rh; if (isNS) { rw = road.height; rh = road.width; } else { rw = road.width; rh = road.height; } // Sidewalks const sw = road.sidewalkWidth ?? 3; if (sw > 0) { ctx.fillStyle = 'rgba(120, 120, 120, 0.4)'; if (isNS) { // N-S road: sidewalks east/west const halfW = road.width / 2; const halfLen = road.height / 2; // East sidewalk const eTl = this.worldToScreen(road.x + halfW, road.z + halfLen); const eBr = this.worldToScreen(road.x + halfW + sw, road.z - halfLen); ctx.fillRect(eTl.x, eTl.y, eBr.x - eTl.x, eBr.y - eTl.y); // West sidewalk const wTl = this.worldToScreen(road.x - halfW - sw, road.z + halfLen); const wBr = this.worldToScreen(road.x - halfW, road.z - halfLen); ctx.fillRect(wTl.x, wTl.y, wBr.x - wTl.x, wBr.y - wTl.y); } else { // E-W road: sidewalks north/south const halfH = road.height / 2; const halfLen = road.width / 2; // North sidewalk const nTl = this.worldToScreen(road.x - halfLen, road.z + halfH + sw); const nBr = this.worldToScreen(road.x + halfLen, road.z + halfH); ctx.fillRect(nTl.x, nTl.y, nBr.x - nTl.x, nBr.y - nTl.y); // South sidewalk const sTl = this.worldToScreen(road.x - halfLen, road.z - halfH); const sBr = this.worldToScreen(road.x + halfLen, road.z - halfH - sw); ctx.fillRect(sTl.x, sTl.y, sBr.x - sTl.x, sBr.y - sTl.y); } } // Road surface const tl = this.worldToScreen(road.x - rw / 2, road.z + rh / 2); const br = this.worldToScreen(road.x + rw / 2, road.z - rh / 2); ctx.fillStyle = 'rgba(80, 80, 80, 0.8)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); // Road center line ctx.strokeStyle = 'rgba(200, 200, 50, 0.3)'; ctx.lineWidth = 1; ctx.setLineDash([4, 6]); if (isNS) { const cv1 = this.worldToScreen(road.x, road.z - rh / 2); const cv2 = this.worldToScreen(road.x, road.z + rh / 2); ctx.beginPath(); ctx.moveTo(cv1.x, cv1.y); ctx.lineTo(cv2.x, cv2.y); ctx.stroke(); } else { const c1 = this.worldToScreen(road.x - rw / 2, road.z); const c2 = this.worldToScreen(road.x + rw / 2, road.z); ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke(); } ctx.setLineDash([]); // Label if (this.camera.zoom > 1.5 && road.name) { const center = this.worldToScreen(road.x, road.z); ctx.font = `${Math.max(9, 11 * this.camera.zoom / 2.5)}px sans-serif`; ctx.fillStyle = 'rgba(255, 255, 200, 0.7)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(road.name, center.x, center.y); } } } _drawBuildings() { const ctx = this.ctx; const buildings = this.objects.filter(o => o.type === 'building'); for (const bld of buildings) { const bw = bld.w || 10; const bd = bld.d || 8; const tl = this.worldToScreen(bld.x - bw / 2, bld.z + bd / 2); const br = this.worldToScreen(bld.x + bw / 2, bld.z - bd / 2); ctx.fillStyle = bld.color || '#888888'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); } } _drawStructures() { const ctx = this.ctx; const structures = this.objects.filter(o => o.type === 'structure'); for (const st of structures) { this._drawStructure(st); } } _drawStructure(obj) { const ctx = this.ctx; const st = obj.structureType; const sc = this.worldToScreen(obj.x, obj.z); const zoom = this.camera.zoom; const label = this.structureLabels[st] || st; switch (st) { case 'park': { const r = (obj.radius || 18) * zoom; ctx.fillStyle = 'rgba(50, 150, 50, 0.4)'; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(30, 120, 30, 0.6)'; ctx.lineWidth = 2; ctx.stroke(); break; } case 'shop': { const hw = (obj.w || 10) / 2, hd = (obj.d || 8) / 2; const tl = this.worldToScreen(obj.x - hw, obj.z + hd); const br = this.worldToScreen(obj.x + hw, obj.z - hd); ctx.fillStyle = 'rgba(60, 100, 200, 0.7)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = '#3355aa'; ctx.lineWidth = 1.5; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); break; } case 'shelter': { const hw = (obj.w || 8) / 2, hd = (obj.d || 6) / 2; const tl = this.worldToScreen(obj.x - hw, obj.z + hd); const br = this.worldToScreen(obj.x + hw, obj.z - hd); ctx.fillStyle = 'rgba(90, 60, 30, 0.7)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = '#5a3a1a'; ctx.lineWidth = 1.5; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); break; } case 'hospital': { const hw = (obj.w || 12) / 2, hd = (obj.d || 10) / 2; const tl = this.worldToScreen(obj.x - hw, obj.z + hd); const br = this.worldToScreen(obj.x + hw, obj.z - hd); ctx.fillStyle = 'rgba(240, 240, 240, 0.8)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = '#cccccc'; ctx.lineWidth = 1.5; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); // Red cross const center = this.worldToScreen(obj.x, obj.z); const crossSize = 3 * zoom; ctx.strokeStyle = '#cc0000'; ctx.lineWidth = Math.max(2, zoom); ctx.beginPath(); ctx.moveTo(center.x - crossSize, center.y); ctx.lineTo(center.x + crossSize, center.y); ctx.moveTo(center.x, center.y - crossSize); ctx.lineTo(center.x, center.y + crossSize); ctx.stroke(); break; } case 'church': { const hw = (obj.w || 10) / 2, hd = (obj.d || 14) / 2; const tl = this.worldToScreen(obj.x - hw, obj.z + hd); const br = this.worldToScreen(obj.x + hw, obj.z - hd); ctx.fillStyle = 'rgba(120, 60, 160, 0.7)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = '#6a2a9a'; ctx.lineWidth = 1.5; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); break; } case 'market': { const hw = (obj.w || 14) / 2, hd = (obj.d || 10) / 2; const tl = this.worldToScreen(obj.x - hw, obj.z + hd); const br = this.worldToScreen(obj.x + hw, obj.z - hd); ctx.fillStyle = 'rgba(220, 140, 40, 0.7)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = '#cc8820'; ctx.lineWidth = 1.5; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); break; } case 'construction': { const r = (obj.radius || 12) * zoom; ctx.strokeStyle = 'rgba(220, 200, 40, 0.7)'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); break; } case 'busStop': { const hw = (obj.w || 5) / 2, hd = (obj.d || 2) / 2; const tl = this.worldToScreen(obj.x - hw, obj.z + hd); const br = this.worldToScreen(obj.x + hw, obj.z - hd); ctx.fillStyle = 'rgba(0, 200, 220, 0.7)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = '#00aabb'; ctx.lineWidth = 1; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); break; } case 'parking': { const hw = (obj.w || 20) / 2, hd = (obj.d || 15) / 2; const tl = this.worldToScreen(obj.x - hw, obj.z + hd); const br = this.worldToScreen(obj.x + hw, obj.z - hd); ctx.fillStyle = 'rgba(100, 100, 100, 0.5)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = 'rgba(150, 150, 150, 0.7)'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]); ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.setLineDash([]); break; } case 'fountain': { const r = (obj.radius || 2) * zoom; ctx.fillStyle = 'rgba(80, 140, 255, 0.7)'; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#4488dd'; ctx.lineWidth = 1.5; ctx.stroke(); break; } case 'phoneBooth': { const hw = ((obj.w || 1.2) / 2) * zoom; const hd = ((obj.d || 1.2) / 2) * zoom; ctx.fillStyle = 'rgba(0, 160, 140, 0.8)'; ctx.fillRect(sc.x - hw, sc.y - hd, hw * 2, hd * 2); ctx.strokeStyle = '#009988'; ctx.lineWidth = 1; ctx.strokeRect(sc.x - hw, sc.y - hd, hw * 2, hd * 2); break; } case 'jobBoard': { const hw = ((obj.w || 1.2) / 2) * zoom; const hd = ((obj.d || 0.8) / 2) * zoom; ctx.fillStyle = 'rgba(60, 160, 60, 0.8)'; ctx.fillRect(sc.x - hw, sc.y - hd, hw * 2, hd * 2); ctx.strokeStyle = '#338833'; ctx.lineWidth = 1; ctx.strokeRect(sc.x - hw, sc.y - hd, hw * 2, hd * 2); break; } case 'campSpot': { const size = 3 * zoom; ctx.fillStyle = 'rgba(50, 180, 50, 0.6)'; ctx.beginPath(); ctx.moveTo(sc.x, sc.y - size); ctx.lineTo(sc.x + size, sc.y); ctx.lineTo(sc.x, sc.y + size); ctx.lineTo(sc.x - size, sc.y); ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#228822'; ctx.lineWidth = 1.5; ctx.stroke(); break; } } // Label if (this.camera.zoom > 1.5) { const fontSize = Math.max(9, 11 * zoom / 2.5); ctx.font = `bold ${fontSize}px sans-serif`; ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; const labelPos = this.worldToScreen(obj.x, obj.z); let offsetY = -6; if (st === 'park') offsetY = -(obj.radius || 18) * zoom - 4; if (st === 'construction') offsetY = -(obj.radius || 12) * zoom - 4; ctx.fillText(label, labelPos.x, labelPos.y + offsetY); } } _drawInteractables() { const ctx = this.ctx; // Dumpsters const dumpsters = this.objects.filter(o => o.type === 'dumpster'); for (const d of dumpsters) { const tl = this.worldToScreen(d.x - 0.75, d.z + 0.5); const br = this.worldToScreen(d.x + 0.75, d.z - 0.5); ctx.fillStyle = 'rgba(40, 100, 40, 0.8)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = '#1a5a1a'; ctx.lineWidth = 1; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); } // Benches const benches = this.objects.filter(o => o.type === 'bench'); for (const b of benches) { const isRotated = b.rot && Math.abs(b.rot) > 0.5; const bw = isRotated ? 0.5 : 2; const bh = isRotated ? 2 : 0.5; const tl = this.worldToScreen(b.x - bw / 2, b.z + bh / 2); const br = this.worldToScreen(b.x + bw / 2, b.z - bh / 2); ctx.fillStyle = 'rgba(140, 90, 40, 0.8)'; ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); ctx.strokeStyle = '#6a4020'; ctx.lineWidth = 1; ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); } // Trash piles const trashPiles = this.objects.filter(o => o.type === 'trashPile'); for (const t of trashPiles) { const sc = this.worldToScreen(t.x, t.z); const r = 1.5 * this.camera.zoom; ctx.fillStyle = 'rgba(100, 110, 50, 0.7)'; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#5a5a30'; ctx.lineWidth = 1; ctx.stroke(); } } _drawDecorations() { const ctx = this.ctx; // Lamps const lamps = this.objects.filter(o => o.type === 'lamp'); for (const l of lamps) { const sc = this.worldToScreen(l.x, l.z); const r = 0.8 * this.camera.zoom; ctx.fillStyle = 'rgba(255, 230, 80, 0.8)'; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.fill(); // Glow effect ctx.fillStyle = 'rgba(255, 230, 80, 0.15)'; ctx.beginPath(); ctx.arc(sc.x, sc.y, r * 3, 0, Math.PI * 2); ctx.fill(); } // Hydrants const hydrants = this.objects.filter(o => o.type === 'hydrant'); for (const h of hydrants) { const sc = this.worldToScreen(h.x, h.z); const r = 0.5 * this.camera.zoom; ctx.fillStyle = 'rgba(220, 50, 50, 0.8)'; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.fill(); } // Bins const bins = this.objects.filter(o => o.type === 'bin'); for (const b of bins) { const sc = this.worldToScreen(b.x, b.z); const r = 0.4 * this.camera.zoom; ctx.fillStyle = 'rgba(150, 150, 150, 0.8)'; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.fill(); } } _drawNPCs() { const ctx = this.ctx; const npcs = this.objects.filter(o => o.type === 'npc'); for (const npc of npcs) { // Draw patrol route if (npc.patrol && npc.patrol.length > 1) { ctx.strokeStyle = 'rgba(80, 120, 255, 0.4)'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); ctx.beginPath(); for (let i = 0; i < npc.patrol.length; i++) { const ps = this.worldToScreen(npc.patrol[i][0], npc.patrol[i][1]); if (i === 0) ctx.moveTo(ps.x, ps.y); else ctx.lineTo(ps.x, ps.y); } // Close loop const first = this.worldToScreen(npc.patrol[0][0], npc.patrol[0][1]); ctx.lineTo(first.x, first.y); ctx.stroke(); ctx.setLineDash([]); // Patrol waypoints for (const wp of npc.patrol) { const wps = this.worldToScreen(wp[0], wp[1]); ctx.fillStyle = 'rgba(80, 120, 255, 0.5)'; ctx.beginPath(); ctx.arc(wps.x, wps.y, 2, 0, Math.PI * 2); ctx.fill(); } } // NPC circle const sc = this.worldToScreen(npc.x, npc.z); const r = 1.5 * this.camera.zoom; ctx.fillStyle = npc.color || '#3355aa'; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)'; ctx.lineWidth = 1.5; ctx.stroke(); // Name label if (this.camera.zoom > 1.0) { const fontSize = Math.max(9, 11 * this.camera.zoom / 2.5); ctx.font = `bold ${fontSize}px sans-serif`; ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(npc.name || 'NPC', sc.x, sc.y - r - 3); } } } _drawVehicleRoutes() { const ctx = this.ctx; const routes = this.objects.filter(o => o.type === 'vehicleRoute'); for (const route of routes) { ctx.strokeStyle = 'rgba(220, 140, 40, 0.5)'; ctx.lineWidth = 2; let sx1, sy1, sx2, sy2; if (route.axis === 'x') { const s1 = this.worldToScreen(route.start, route.lane); const s2 = this.worldToScreen(route.end, route.lane); sx1 = s1.x; sy1 = s1.y; sx2 = s2.x; sy2 = s2.y; } else { const s1 = this.worldToScreen(route.lane, route.start); const s2 = this.worldToScreen(route.lane, route.end); sx1 = s1.x; sy1 = s1.y; sx2 = s2.x; sy2 = s2.y; } ctx.beginPath(); ctx.moveTo(sx1, sy1); ctx.lineTo(sx2, sy2); ctx.stroke(); // Arrow at end const angle = Math.atan2(sy2 - sy1, sx2 - sx1); const arrowSize = 8; ctx.fillStyle = 'rgba(220, 140, 40, 0.7)'; ctx.beginPath(); ctx.moveTo(sx2, sy2); ctx.lineTo(sx2 - arrowSize * Math.cos(angle - 0.4), sy2 - arrowSize * Math.sin(angle - 0.4)); ctx.lineTo(sx2 - arrowSize * Math.cos(angle + 0.4), sy2 - arrowSize * Math.sin(angle + 0.4)); ctx.closePath(); ctx.fill(); } } _drawPasserbyRoutes() { const ctx = this.ctx; const routes = this.objects.filter(o => o.type === 'passerbyRoute'); for (const route of routes) { if (!route.waypoints || route.waypoints.length < 2) continue; ctx.strokeStyle = 'rgba(100, 200, 100, 0.3)'; ctx.lineWidth = 1.5; ctx.setLineDash([3, 5]); ctx.beginPath(); for (let i = 0; i < route.waypoints.length; i++) { const wp = route.waypoints[i]; const sc = this.worldToScreen(wp[0], wp[1]); if (i === 0) ctx.moveTo(sc.x, sc.y); else ctx.lineTo(sc.x, sc.y); } ctx.stroke(); ctx.setLineDash([]); // Arrow at end const last = route.waypoints[route.waypoints.length - 1]; const prev = route.waypoints[route.waypoints.length - 2]; const sLast = this.worldToScreen(last[0], last[1]); const sPrev = this.worldToScreen(prev[0], prev[1]); const angle = Math.atan2(sLast.y - sPrev.y, sLast.x - sPrev.x); const arrowSize = 6; ctx.fillStyle = 'rgba(100, 200, 100, 0.5)'; ctx.beginPath(); ctx.moveTo(sLast.x, sLast.y); ctx.lineTo(sLast.x - arrowSize * Math.cos(angle - 0.4), sLast.y - arrowSize * Math.sin(angle - 0.4)); ctx.lineTo(sLast.x - arrowSize * Math.cos(angle + 0.4), sLast.y - arrowSize * Math.sin(angle + 0.4)); ctx.closePath(); ctx.fill(); } } _drawSelectionHighlight(obj) { const ctx = this.ctx; ctx.strokeStyle = '#ffff00'; ctx.lineWidth = 2.5; ctx.setLineDash([5, 3]); switch (obj.type) { case 'road': { let rw, rh; if (obj.rotation && Math.abs(obj.rotation) > 0.1) { rw = obj.height; rh = obj.width; } else { rw = obj.width; rh = obj.height; } const pad = 1; const tl = this.worldToScreen(obj.x - rw / 2 - pad, obj.z + rh / 2 + pad); const br = this.worldToScreen(obj.x + rw / 2 + pad, obj.z - rh / 2 - pad); ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); break; } case 'building': { const bw = obj.w || 10; const bd = obj.d || 8; const pad = 1; const tl = this.worldToScreen(obj.x - bw / 2 - pad, obj.z + bd / 2 + pad); const br = this.worldToScreen(obj.x + bw / 2 + pad, obj.z - bd / 2 - pad); ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); break; } case 'structure': this._drawStructureSelectionHighlight(obj); break; case 'npc': { const sc = this.worldToScreen(obj.x, obj.z); const r = (1.5 + 0.5) * this.camera.zoom; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.stroke(); break; } case 'vehicleRoute': { if (obj.axis === 'x') { const pad = 2; const tl = this.worldToScreen(Math.min(obj.start, obj.end) - pad, obj.lane + pad); const br = this.worldToScreen(Math.max(obj.start, obj.end) + pad, obj.lane - pad); ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); } else { const pad = 2; const tl = this.worldToScreen(obj.lane - pad, Math.max(obj.start, obj.end) + pad); const br = this.worldToScreen(obj.lane + pad, Math.min(obj.start, obj.end) - pad); ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); } break; } case 'passerbyRoute': { if (obj.waypoints && obj.waypoints.length > 1) { ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)'; ctx.lineWidth = 3; ctx.beginPath(); for (let i = 0; i < obj.waypoints.length; i++) { const sc = this.worldToScreen(obj.waypoints[i][0], obj.waypoints[i][1]); if (i === 0) ctx.moveTo(sc.x, sc.y); else ctx.lineTo(sc.x, sc.y); } ctx.stroke(); } break; } default: { const sc = this.worldToScreen(obj.x, obj.z); const r = 3 * this.camera.zoom; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.stroke(); break; } } ctx.setLineDash([]); } _drawStructureSelectionHighlight(obj) { const ctx = this.ctx; const pad = 1; // Круглые структуры (park, construction, fountain) if (obj.radius) { const sc = this.worldToScreen(obj.x, obj.z); const r = (obj.radius + pad) * this.camera.zoom; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.stroke(); return; } // Прямоугольные структуры с размерами из конфига if (obj.w && obj.d) { const hw = obj.w / 2, hd = obj.d / 2; const tl = this.worldToScreen(obj.x - hw - pad, obj.z + hd + pad); const br = this.worldToScreen(obj.x + hw + pad, obj.z - hd - pad); ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); return; } // campSpot — ромб if (obj.structureType === 'campSpot') { const sc = this.worldToScreen(obj.x, obj.z); const size = (3 + pad) * this.camera.zoom; ctx.beginPath(); ctx.moveTo(sc.x, sc.y - size); ctx.lineTo(sc.x + size, sc.y); ctx.lineTo(sc.x, sc.y + size); ctx.lineTo(sc.x - size, sc.y); ctx.closePath(); ctx.stroke(); return; } // Fallback — круг const sc = this.worldToScreen(obj.x, obj.z); const r = 4 * this.camera.zoom; ctx.beginPath(); ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); ctx.stroke(); } // ========================================================================= // CONFIG LOADING / EXPORTING // ========================================================================= async loadConfigFromFile(path) { try { const response = await fetch(path); const json = await response.json(); this.loadConfig(json); } catch (err) { console.error('Failed to load map config:', err); } } loadConfig(json) { this.objects = []; this.nextId = 1; this.selectedObject = null; // Roads if (json.roads) { for (const road of json.roads) { this.objects.push({ type: 'road', category: 'roads', id: this.nextId++, roadId: road.id || '', name: road.name || '', x: road.x, z: road.z, width: road.width, height: road.height, rotation: road.rotation || 0, sidewalkWidth: road.sidewalkWidth ?? 3 }); } } // Buildings if (json.buildings) { for (let i = 0; i < json.buildings.length; i++) { const bld = json.buildings[i]; this.objects.push({ type: 'building', category: 'buildings', id: this.nextId++, x: bld.x, z: bld.z, w: bld.w, h: bld.h, d: bld.d, color: bld.color || '#888888' }); } } // Structures if (json.structures) { for (const [key, data] of Object.entries(json.structures)) { this.objects.push({ type: 'structure', category: 'structures', id: this.nextId++, structureType: key, x: data.x, z: data.z, radius: data.radius, w: data.w, h: data.h, d: data.d, rotation: data.rotation }); } } // Interactables - dumpsters if (json.interactables && json.interactables.dumpsters) { for (const d of json.interactables.dumpsters) { this.objects.push({ type: 'dumpster', category: 'interactables', id: this.nextId++, x: d.x, z: d.z, rot: d.rot || 0 }); } } // Interactables - benches if (json.interactables && json.interactables.benches) { for (const b of json.interactables.benches) { this.objects.push({ type: 'bench', category: 'interactables', id: this.nextId++, x: b.x, z: b.z, rot: b.rot || 0 }); } } // Interactables - trash piles if (json.interactables && json.interactables.trashPiles) { for (const t of json.interactables.trashPiles) { this.objects.push({ type: 'trashPile', category: 'interactables', id: this.nextId++, x: t.x, z: t.z }); } } // Decorations if (json.decorations) { if (json.decorations.lamps) { for (const l of json.decorations.lamps) { this.objects.push({ type: 'lamp', category: 'decorations', id: this.nextId++, x: l[0], z: l[1] }); } } if (json.decorations.hydrants) { for (const h of json.decorations.hydrants) { this.objects.push({ type: 'hydrant', category: 'decorations', id: this.nextId++, x: h[0], z: h[1] }); } } if (json.decorations.bins) { for (const b of json.decorations.bins) { this.objects.push({ type: 'bin', category: 'decorations', id: this.nextId++, x: b[0], z: b[1] }); } } } // NPCs if (json.npcs) { for (const npc of json.npcs) { this.objects.push({ type: 'npc', category: 'npcs', id: this.nextId++, name: npc.name, x: npc.x, z: npc.z, npcType: npc.type, color: npc.color || '#3355aa', patrol: npc.patrol ? npc.patrol.map(p => [...p]) : null }); } } // Vehicle routes if (json.vehicles && json.vehicles.routes) { for (const route of json.vehicles.routes) { // For vehicle routes, x/z represent center for dragging purposes let cx, cz; if (route.axis === 'x') { cx = (route.start + route.end) / 2; cz = route.lane; } else { cx = route.lane; cz = (route.start + route.end) / 2; } this.objects.push({ type: 'vehicleRoute', category: 'vehicles', id: this.nextId++, x: cx, z: cz, axis: route.axis, lane: route.lane, start: route.start, end: route.end, dir: route.dir }); } } // Passerby routes if (json.passerbyRoutes) { for (const route of json.passerbyRoutes) { const wps = route.waypoints || []; let cx = 0, cz = 0; if (wps.length > 0) { for (const wp of wps) { cx += wp[0]; cz += wp[1]; } cx /= wps.length; cz /= wps.length; } this.objects.push({ type: 'passerbyRoute', category: 'vehicles', id: this.nextId++, x: cx, z: cz, waypoints: wps.map(w => [...w]) }); } } this.updateObjectList(); this.updatePropsPanel(); } exportConfig() { const config = { roads: [], buildings: [], structures: {}, interactables: { dumpsters: [], benches: [], trashPiles: [] }, decorations: { lamps: [], hydrants: [], bins: [] }, npcs: [], vehicles: { routes: [] }, passerbyRoutes: [] }; for (const obj of this.objects) { switch (obj.type) { case 'road': config.roads.push({ id: obj.roadId || obj.id.toString(), name: obj.name || '', x: obj.x, z: obj.z, width: obj.width, height: obj.height, rotation: obj.rotation || 0, sidewalkWidth: obj.sidewalkWidth ?? 3 }); break; case 'building': config.buildings.push({ x: obj.x, z: obj.z, w: obj.w, h: obj.h, d: obj.d, color: obj.color }); break; case 'structure': { const data = { x: obj.x, z: obj.z }; if (obj.radius !== undefined && obj.radius !== null) data.radius = obj.radius; if (obj.w !== undefined && obj.w !== null) data.w = obj.w; if (obj.h !== undefined && obj.h !== null) data.h = obj.h; if (obj.d !== undefined && obj.d !== null) data.d = obj.d; if (obj.rotation !== undefined && obj.rotation !== null) data.rotation = obj.rotation; config.structures[obj.structureType] = data; break; } case 'dumpster': config.interactables.dumpsters.push({ x: obj.x, z: obj.z, rot: obj.rot || 0 }); break; case 'bench': config.interactables.benches.push({ x: obj.x, z: obj.z, rot: obj.rot || 0 }); break; case 'trashPile': config.interactables.trashPiles.push({ x: obj.x, z: obj.z }); break; case 'lamp': config.decorations.lamps.push([obj.x, obj.z]); break; case 'hydrant': config.decorations.hydrants.push([obj.x, obj.z]); break; case 'bin': config.decorations.bins.push([obj.x, obj.z]); break; case 'npc': { const npcData = { name: obj.name, x: obj.x, z: obj.z, type: obj.npcType || 'citizen', color: obj.color }; if (obj.patrol) npcData.patrol = obj.patrol.map(p => [...p]); config.npcs.push(npcData); break; } case 'vehicleRoute': config.vehicles.routes.push({ axis: obj.axis, lane: obj.lane, start: obj.start, end: obj.end, dir: obj.dir }); break; case 'passerbyRoute': config.passerbyRoutes.push({ waypoints: obj.waypoints ? obj.waypoints.map(w => [...w]) : [] }); break; } } return config; } saveToFile() { const config = this.exportConfig(); const json = JSON.stringify(config, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'map-config.json'; a.click(); URL.revokeObjectURL(url); } loadFromFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const json = JSON.parse(ev.target.result); this.loadConfig(json); } catch (err) { console.error('Failed to parse JSON:', err); alert('Ошибка при загрузке файла: ' + err.message); } }; reader.readAsText(file); }; input.click(); } // ========================================================================= // OBJECT MANAGEMENT // ========================================================================= addObject(type, props = {}) { const centerWorld = this.screenToWorld(this.canvas.width / 2, this.canvas.height / 2); const baseX = props.x !== undefined ? props.x : Math.round(centerWorld.x); const baseZ = props.z !== undefined ? props.z : Math.round(centerWorld.z); let obj = null; switch (type) { case 'road': obj = { type: 'road', category: 'roads', id: this.nextId++, roadId: props.roadId || 'road_' + this.nextId, name: props.name || 'Новая дорога', x: baseX, z: baseZ, width: props.width || 100, height: props.height || 8, rotation: props.rotation || 0, sidewalkWidth: props.sidewalkWidth ?? 3 }; break; case 'building': obj = { type: 'building', category: 'buildings', id: this.nextId++, x: baseX, z: baseZ, w: props.w || 10, h: props.h || 15, d: props.d || 8, color: props.color || '#888888' }; break; case 'structure': obj = { type: 'structure', category: 'structures', id: this.nextId++, structureType: props.structureType || 'shop', x: baseX, z: baseZ, radius: props.radius, w: props.w, h: props.h, d: props.d, rotation: props.rotation }; break; case 'dumpster': obj = { type: 'dumpster', category: 'interactables', id: this.nextId++, x: baseX, z: baseZ, rot: props.rot || 0 }; break; case 'bench': obj = { type: 'bench', category: 'interactables', id: this.nextId++, x: baseX, z: baseZ, rot: props.rot || 0 }; break; case 'trashPile': obj = { type: 'trashPile', category: 'interactables', id: this.nextId++, x: baseX, z: baseZ }; break; case 'lamp': obj = { type: 'lamp', category: 'decorations', id: this.nextId++, x: baseX, z: baseZ }; break; case 'hydrant': obj = { type: 'hydrant', category: 'decorations', id: this.nextId++, x: baseX, z: baseZ }; break; case 'bin': obj = { type: 'bin', category: 'decorations', id: this.nextId++, x: baseX, z: baseZ }; break; case 'npc': obj = { type: 'npc', category: 'npcs', id: this.nextId++, name: props.name || 'Новый NPC', x: baseX, z: baseZ, npcType: props.npcType || 'citizen', color: props.color || '#3355aa', patrol: props.patrol || null }; break; case 'vehicleRoute': obj = { type: 'vehicleRoute', category: 'vehicles', id: this.nextId++, x: baseX, z: baseZ, axis: props.axis || 'x', lane: props.lane || baseZ, start: props.start || (baseX - 50), end: props.end || (baseX + 50), dir: props.dir || 1 }; break; case 'passerbyRoute': obj = { type: 'passerbyRoute', category: 'vehicles', id: this.nextId++, x: baseX, z: baseZ, waypoints: props.waypoints || [ [baseX - 30, baseZ], [baseX, baseZ], [baseX + 30, baseZ] ] }; break; default: console.warn('Unknown object type:', type); return null; } if (obj) { this.objects.push(obj); this.selectedObject = obj; this.updateObjectList(); this.updatePropsPanel(); } return obj; } deleteObject(obj) { const idx = this.objects.indexOf(obj); if (idx !== -1) { this.objects.splice(idx, 1); if (this.selectedObject === obj) { this.selectedObject = null; } this.updateObjectList(); this.updatePropsPanel(); } } // ========================================================================= // OBJECT LIST PANEL (left sidebar) // ========================================================================= updateObjectList() { if (!this.listPanel) return; const categories = [ { key: 'roads', types: ['road'] }, { key: 'buildings', types: ['building'] }, { key: 'structures', types: ['structure'] }, { key: 'interactables', types: ['dumpster', 'bench', 'trashPile'] }, { key: 'decorations', types: ['lamp', 'hydrant', 'bin'] }, { key: 'npcs', types: ['npc'] }, { key: 'vehicles', types: ['vehicleRoute', 'passerbyRoute'] } ]; let html = ''; for (const cat of categories) { const items = this.objects.filter(o => cat.types.includes(o.type)); const isCollapsed = this.collapsedCategories[cat.key] || false; const icon = this.categoryIcons[cat.key] || ''; const label = this.categoryLabels[cat.key] || cat.key; html += `