2076 lines
79 KiB
JavaScript
2076 lines
79 KiB
JavaScript
/**
|
||
* 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}`;
|
||
}
|
||
}
|