Files
Hommie_RPG_Game/js/editor/MapEditor.js
Maxim Dolgolyov fb5f09212b Initial commit: 3D Hommie RPG game
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:04:09 +03:00

2076 lines
79 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.
/**
* 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 += `<div class="editor-category">`;
html += `<div class="editor-category-header" data-category="${cat.key}">`;
html += `<span class="editor-collapse-icon">${isCollapsed ? '\u25B6' : '\u25BC'}</span>`;
html += `<span class="editor-category-icon">${icon}</span>`;
html += `<span class="editor-category-label">${label}</span>`;
html += `<span class="editor-category-count">(${items.length})</span>`;
html += `<button class="editor-add-btn" data-add-type="${cat.types[0]}" title="Добавить">+</button>`;
html += `</div>`;
if (!isCollapsed) {
html += `<div class="editor-category-items">`;
for (const item of items) {
const isSelected = this.selectedObject && this.selectedObject.id === item.id;
const itemName = this._getObjectDisplayName(item);
const coords = `(${Math.round(item.x)}, ${Math.round(item.z)})`;
html += `<div class="editor-item${isSelected ? ' selected' : ''}" data-id="${item.id}">`;
html += `<span class="editor-item-type">${this._getTypeIcon(item.type)}</span>`;
html += `<span class="editor-item-name">${itemName}</span>`;
html += `<span class="editor-item-coords">${coords}</span>`;
html += `</div>`;
}
html += `</div>`;
}
html += `</div>`;
}
this.listPanel.innerHTML = html;
// Attach event listeners
const headers = this.listPanel.querySelectorAll('.editor-category-header');
for (const header of headers) {
header.addEventListener('click', (e) => {
// Don't toggle on add button click
if (e.target.classList.contains('editor-add-btn')) return;
const catKey = header.getAttribute('data-category');
this.collapsedCategories[catKey] = !this.collapsedCategories[catKey];
this.updateObjectList();
});
}
const addButtons = this.listPanel.querySelectorAll('.editor-add-btn');
for (const btn of addButtons) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const addType = btn.getAttribute('data-add-type');
this.addObject(addType);
});
}
const itemElements = this.listPanel.querySelectorAll('.editor-item');
for (const el of itemElements) {
el.addEventListener('click', () => {
const id = parseInt(el.getAttribute('data-id'));
const obj = this.objects.find(o => o.id === id);
if (obj) {
this.selectedObject = obj;
this.camera.x = obj.x;
this.camera.z = obj.z;
this.updateObjectList();
this.updatePropsPanel();
}
});
}
}
_getObjectDisplayName(obj) {
switch (obj.type) {
case 'road': return obj.name || obj.roadId || 'Дорога';
case 'building': return `Здание ${obj.color || ''}`;
case 'structure': return this.structureLabels[obj.structureType] || obj.structureType;
case 'dumpster': return 'Мусорный бак';
case 'bench': return 'Скамейка';
case 'trashPile': return 'Куча мусора';
case 'lamp': return 'Фонарь';
case 'hydrant': return 'Гидрант';
case 'bin': return 'Урна';
case 'npc': return obj.name || 'NPC';
case 'vehicleRoute': return `Маршрут ${obj.axis}:${obj.lane}`;
case 'passerbyRoute': return 'Маршрут прохожих';
default: return obj.type;
}
}
_getTypeIcon(type) {
switch (type) {
case 'road': return '\u2550';
case 'building': return '\u2302';
case 'structure': return '\u2736';
case 'dumpster': return '\u2612';
case 'bench': return '\u2587';
case 'trashPile': return '\u2622';
case 'lamp': return '\u2600';
case 'hydrant': return '\u2666';
case 'bin': return '\u25CF';
case 'npc': return '\u263A';
case 'vehicleRoute': return '\u2192';
case 'passerbyRoute': return '\u2026';
default: return '\u25A0';
}
}
// =========================================================================
// PROPERTIES PANEL (right sidebar)
// =========================================================================
updatePropsPanel() {
if (!this.propsPanel) return;
if (!this.selectedObject) {
this.propsPanel.innerHTML = '<div class="editor-props-empty">Объект не выбран</div>';
return;
}
const obj = this.selectedObject;
let html = '';
html += `<div class="editor-props-header">${this._getObjectDisplayName(obj)}</div>`;
html += `<div class="editor-props-type">Тип: ${obj.type}</div>`;
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Позиция</label>`;
html += this._propNumberField('x', 'X', obj.x);
html += this._propNumberField('z', 'Z', obj.z);
html += `</div>`;
switch (obj.type) {
case 'road':
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Параметры дороги</label>`;
html += this._propTextField('name', 'Название', obj.name || '');
html += this._propTextField('roadId', 'ID', obj.roadId || '');
html += this._propNumberField('width', 'Ширина', obj.width);
html += this._propNumberField('height', 'Толщина', obj.height);
html += this._propNumberField('rotation', 'Поворот', obj.rotation || 0, 0.01);
html += this._propNumberField('sidewalkWidth', 'Тротуар', obj.sidewalkWidth ?? 3, 0.5);
html += `</div>`;
break;
case 'building':
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Параметры здания</label>`;
html += this._propNumberField('w', 'Ширина (W)', obj.w);
html += this._propNumberField('h', 'Высота (H)', obj.h);
html += this._propNumberField('d', 'Глубина (D)', obj.d);
html += this._propColorField('color', 'Цвет', obj.color || '#888888');
html += `</div>`;
break;
case 'structure':
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Тип структуры: ${this.structureLabels[obj.structureType] || obj.structureType}</label>`;
if (obj.radius !== undefined && obj.radius !== null) {
html += this._propNumberField('radius', 'Радиус', obj.radius);
}
if (obj.w !== undefined && obj.w !== null) {
html += this._propNumberField('w', 'Ширина (W)', obj.w);
}
if (obj.h !== undefined && obj.h !== null) {
html += this._propNumberField('h', 'Высота (H)', obj.h);
}
if (obj.d !== undefined && obj.d !== null) {
html += this._propNumberField('d', 'Глубина (D)', obj.d);
}
if (obj.rotation !== undefined && obj.rotation !== null) {
html += this._propNumberField('rotation', 'Поворот', obj.rotation, 0.01);
}
html += `</div>`;
break;
case 'dumpster':
case 'bench':
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Параметры</label>`;
html += this._propNumberField('rot', 'Поворот', obj.rot || 0, 0.1);
html += `</div>`;
break;
case 'npc':
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Параметры NPC</label>`;
html += this._propTextField('name', 'Имя', obj.name || '');
html += this._propTextField('npcType', 'Тип', obj.npcType || 'citizen');
html += this._propColorField('color', 'Цвет', obj.color || '#3355aa');
html += `</div>`;
// Patrol points editor
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Маршрут патрулирования</label>`;
if (obj.patrol && obj.patrol.length > 0) {
for (let i = 0; i < obj.patrol.length; i++) {
html += `<div class="editor-patrol-point">`;
html += `<span class="editor-patrol-idx">${i + 1}.</span>`;
html += `<input type="number" class="editor-patrol-input" data-patrol-idx="${i}" data-patrol-axis="x" value="${obj.patrol[i][0]}" step="1">`;
html += `<input type="number" class="editor-patrol-input" data-patrol-idx="${i}" data-patrol-axis="z" value="${obj.patrol[i][1]}" step="1">`;
html += `<button class="editor-patrol-remove" data-patrol-idx="${i}" title="Удалить точку">x</button>`;
html += `</div>`;
}
} else {
html += `<div class="editor-props-note">Нет маршрута</div>`;
}
html += `<button class="editor-patrol-add">+ Добавить точку</button>`;
html += `</div>`;
break;
case 'vehicleRoute':
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Параметры маршрута</label>`;
html += this._propTextField('axis', 'Ось', obj.axis);
html += this._propNumberField('lane', 'Полоса', obj.lane);
html += this._propNumberField('start', 'Начало', obj.start);
html += this._propNumberField('end', 'Конец', obj.end);
html += this._propNumberField('dir', 'Направление', obj.dir, 1);
html += `</div>`;
break;
case 'passerbyRoute':
html += `<div class="editor-props-section">`;
html += `<label class="editor-prop-label">Точки маршрута</label>`;
if (obj.waypoints && obj.waypoints.length > 0) {
for (let i = 0; i < obj.waypoints.length; i++) {
html += `<div class="editor-patrol-point">`;
html += `<span class="editor-patrol-idx">${i + 1}.</span>`;
html += `<input type="number" class="editor-waypoint-input" data-wp-idx="${i}" data-wp-axis="x" value="${obj.waypoints[i][0]}" step="1">`;
html += `<input type="number" class="editor-waypoint-input" data-wp-idx="${i}" data-wp-axis="z" value="${obj.waypoints[i][1]}" step="1">`;
html += `<button class="editor-waypoint-remove" data-wp-idx="${i}" title="Удалить точку">x</button>`;
html += `</div>`;
}
}
html += `<button class="editor-waypoint-add">+ Добавить точку</button>`;
html += `</div>`;
break;
}
// Delete button
html += `<div class="editor-props-actions">`;
html += `<button class="editor-delete-btn">Удалить</button>`;
html += `</div>`;
this.propsPanel.innerHTML = html;
// Attach event listeners
this._attachPropsListeners();
}
_propNumberField(propName, label, value, step = 1) {
return `<div class="editor-prop-row">
<span class="editor-prop-name">${label}:</span>
<input type="number" class="editor-prop-input" data-prop="${propName}" value="${value}" step="${step}">
</div>`;
}
_propTextField(propName, label, value) {
return `<div class="editor-prop-row">
<span class="editor-prop-name">${label}:</span>
<input type="text" class="editor-prop-input" data-prop="${propName}" value="${value}">
</div>`;
}
_propColorField(propName, label, value) {
return `<div class="editor-prop-row">
<span class="editor-prop-name">${label}:</span>
<input type="color" class="editor-prop-color" data-prop="${propName}" value="${value}">
</div>`;
}
_attachPropsListeners() {
if (!this.propsPanel || !this.selectedObject) return;
// Number and text inputs
const inputs = this.propsPanel.querySelectorAll('.editor-prop-input');
for (const input of inputs) {
input.addEventListener('change', () => {
const prop = input.getAttribute('data-prop');
if (input.type === 'number') {
this.selectedObject[prop] = parseFloat(input.value);
} else {
this.selectedObject[prop] = input.value;
}
this.updateObjectList();
});
}
// Color inputs
const colorInputs = this.propsPanel.querySelectorAll('.editor-prop-color');
for (const input of colorInputs) {
input.addEventListener('input', () => {
const prop = input.getAttribute('data-prop');
this.selectedObject[prop] = input.value;
});
}
// Delete button
const deleteBtn = this.propsPanel.querySelector('.editor-delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
if (this.selectedObject) {
this.deleteObject(this.selectedObject);
}
});
}
// Patrol point inputs (NPC)
const patrolInputs = this.propsPanel.querySelectorAll('.editor-patrol-input');
for (const input of patrolInputs) {
input.addEventListener('change', () => {
const idx = parseInt(input.getAttribute('data-patrol-idx'));
const axis = input.getAttribute('data-patrol-axis');
if (this.selectedObject.patrol && this.selectedObject.patrol[idx]) {
if (axis === 'x') {
this.selectedObject.patrol[idx][0] = parseFloat(input.value);
} else {
this.selectedObject.patrol[idx][1] = parseFloat(input.value);
}
}
});
}
// Remove patrol point buttons
const patrolRemoveBtns = this.propsPanel.querySelectorAll('.editor-patrol-remove');
for (const btn of patrolRemoveBtns) {
btn.addEventListener('click', () => {
const idx = parseInt(btn.getAttribute('data-patrol-idx'));
if (this.selectedObject.patrol) {
this.selectedObject.patrol.splice(idx, 1);
if (this.selectedObject.patrol.length === 0) {
this.selectedObject.patrol = null;
}
this.updatePropsPanel();
}
});
}
// Add patrol point button
const patrolAddBtn = this.propsPanel.querySelector('.editor-patrol-add');
if (patrolAddBtn) {
patrolAddBtn.addEventListener('click', () => {
if (!this.selectedObject.patrol) {
this.selectedObject.patrol = [];
}
this.selectedObject.patrol.push([
Math.round(this.selectedObject.x),
Math.round(this.selectedObject.z)
]);
this.updatePropsPanel();
});
}
// Waypoint inputs (passerby route)
const wpInputs = this.propsPanel.querySelectorAll('.editor-waypoint-input');
for (const input of wpInputs) {
input.addEventListener('change', () => {
const idx = parseInt(input.getAttribute('data-wp-idx'));
const axis = input.getAttribute('data-wp-axis');
if (this.selectedObject.waypoints && this.selectedObject.waypoints[idx]) {
if (axis === 'x') {
this.selectedObject.waypoints[idx][0] = parseFloat(input.value);
} else {
this.selectedObject.waypoints[idx][1] = parseFloat(input.value);
}
}
});
}
// Remove waypoint buttons
const wpRemoveBtns = this.propsPanel.querySelectorAll('.editor-waypoint-remove');
for (const btn of wpRemoveBtns) {
btn.addEventListener('click', () => {
const idx = parseInt(btn.getAttribute('data-wp-idx'));
if (this.selectedObject.waypoints) {
this.selectedObject.waypoints.splice(idx, 1);
this.updatePropsPanel();
}
});
}
// Add waypoint button
const wpAddBtn = this.propsPanel.querySelector('.editor-waypoint-add');
if (wpAddBtn) {
wpAddBtn.addEventListener('click', () => {
if (!this.selectedObject.waypoints) {
this.selectedObject.waypoints = [];
}
this.selectedObject.waypoints.push([
Math.round(this.selectedObject.x),
Math.round(this.selectedObject.z)
]);
this.updatePropsPanel();
});
}
}
// =========================================================================
// STATUS BAR
// =========================================================================
_updateStatusBar() {
if (!this.statusBar) return;
const mx = this.mouseWorld.x.toFixed(1);
const mz = this.mouseWorld.z.toFixed(1);
const count = this.objects.length;
const zoom = this.camera.zoom.toFixed(1);
this.statusBar.textContent = `Координаты: X=${mx} Z=${mz} | Масштаб: ${zoom}x | Объектов: ${count}`;
}
}