/
RaskolnickOFF
/
3D
Обзор
Документация
Войти
/
RaskolnickOFF
/
3D
Код
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/source/ui/MiniMap.js
541 строка
23 KB
RaskolnickOFF
fixes
19 июн 2026, 19:05
19 июн 2026, 19:05
84fe909
Код
Авторство
О чём код?
// js/source/ui/MiniMap.js import { TileHeightmap } from '../systems/world/tiles/TileHeightmap.js'; import SETTINGS from 'SETTINGS'; import { Components } from '../core/Components.js'; import { GameEvents } from '../core/GameEvents.js'; export class MiniMap { constructor({ terrainSystem, worldMapData, SETTINGS, EVENTBUS, canvasSize = 200 }) { // Единый источник правды для новой карты высот this.tileHeightmap = new TileHeightmap(); this._tilesPrerendered = new Map(); this.worldMapData = worldMapData; this.eventBus = EVENTBUS; this.canvasSize = canvasSize; this.worldSize = SETTINGS.terrain.size; // (64) this.windowSize = SETTINGS.tiles.default; // (5) this.cacheRadius = Math.floor((this.windowSize - 1) / 2); this.renderRadius = 1; this.scale = SETTINGS.ui?.minimapScale || 2; // ФИКС: Явно объявляем разрешение текстуры одного чанка до её использования холстом this.tileTextureSize = 64; // Выделяем ОДИН буферный массив пикселей на всю жизнь игры. Защита от Out of Memory. this._pixelBufferSize = this.tileTextureSize * this.tileTextureSize * 4; this._pixelBuffer = new Uint8ClampedArray(this._pixelBufferSize); // ОДИН буферный холст на всю жизнь миникарты для защиты от Out of Memory this._prerenderCanvas = document.createElement('canvas'); this._prerenderCanvas.width = this.tileTextureSize; this._prerenderCanvas.height = this.tileTextureSize; this._prerenderCtx = this._prerenderCanvas.getContext('2d'); this._viewX = 0; this._viewZ = 0; this._showTerrain = true; this._dragging = false; this._dragStart = { x: 0, y: 0 }; this._viewStart = { x: 0, z: 0 }; this._lastUpdate = 0; this._updateInterval = 750; this._sortedStops = SETTINGS.terrain.levelColors.slice().sort((a, b) => a.height - b.height); this.waterLevel = SETTINGS.terrain.waterLevel || 0.0; this._buildDOM(); this._setupDrag(); this.isBigMapOpen = false; if (this.eventBus) { this.eventBus.on(GameEvents.TILE_SHIFT, this._handleWorldShift.bind(this)); this.eventBus.on('MAP_TOGGLE', this._toggleBigMap.bind(this)); } // Рендерим стартовую порцию this._initFirstTiles(); } _buildDOM() { this.container = document.createElement('div'); this.container.className = 'mini-map'; this.canvas = document.createElement('canvas'); this.canvas.width = this.canvasSize; this.canvas.height = this.canvasSize; this.canvas.style.width = '100%'; this.canvas.style.height = '100%'; this.container.appendChild(this.canvas); document.body.appendChild(this.container); this.ctx = this.canvas.getContext('2d'); } _setupDrag() { const canvas = this.canvas; canvas.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; this._dragging = true; this._dragStart = { x: e.clientX, y: e.clientY }; this._viewStart = { x: this._viewX, z: this._viewZ }; canvas.setPointerCapture(e.pointerId); }); canvas.addEventListener('pointermove', (e) => { if (!this._dragging) return; const rect = canvas.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const viewRegionSize = this.worldSize * this.scale; const scaleX = viewRegionSize / rect.width; const scaleY = viewRegionSize / rect.height; const deltaX = e.clientX - this._dragStart.x; const deltaY = e.clientY - this._dragStart.y; this._viewX = this._viewStart.x - deltaX * scaleX; this._viewZ = this._viewStart.z - deltaY * scaleY; this._lastUpdate = 0; }); const stopDrag = (e) => { if (this._dragging) { this._dragging = false; canvas.releasePointerCapture(e.pointerId); } }; canvas.addEventListener('pointerup', stopDrag); canvas.addEventListener('pointercancel', stopDrag); canvas.addEventListener('contextmenu', (e) => { e.preventDefault(); this._showTerrain = !this._showTerrain; this._renderTerrainOrClear(); }); } update(engine) { if (!this._dragging) { const players = engine.with(Components.PLAYER); if (players.length > 0) { const playerEntity = players[0]; // Берем самого игрока const transform = playerEntity.get(Components.TRANSFORM); if (transform) { this._viewX = transform.x; this._viewZ = transform.z; } // ЕДИНЫЙ ИСТОЧНИК ПРАВДЫ: Забираем тайл нахождения const tileComp = playerEntity.get(Components.CURRENT_TILE); if (tileComp) { this._currentTx = tileComp.tx; this._currentTz = tileComp.tz; } } } const now = performance.now(); if (!this._lastUpdate) this._lastUpdate = now; if (this._dragging || (now - this._lastUpdate >= this._updateInterval)) { this._lastUpdate = now; this._render(engine); } } _render(engine) { this._renderTerrainOrClear(); this._renderEntities(engine); } _renderTerrainOrClear() { if (this._showTerrain) { this._renderTerrainByTiles(); } else { this.ctx.clearRect(0, 0, this.canvasSize, this.canvasSize); } } _heightToColor(y) { const stops = this._sortedStops; if (!stops || stops.length < 2) { if (y < this.waterLevel) return { r: 77, g: 178, b: 255 }; if (y < 0) return { r: 255, g: 232, b: 148 }; if (y < 2) return { r: 133, g: 213, b: 52 }; if (y < 5) return { r: 191, g: 189, b: 141 }; return { r: 255, g: 255, b: 255 }; } if (y <= stops[0].height) return this._hexToRgb(stops[0].color); if (y >= stops[stops.length - 1].height) return this._hexToRgb(stops[stops.length - 1].color); for (let i = 0; i < stops.length - 1; i++) { const a = stops[i]; const b = stops[i + 1]; if (y >= a.height && y <= b.height) { const t = (y - a.height) / (b.height - a.height || 0.001); const ca = this._hexToRgb(a.color); const cb = this._hexToRgb(b.color); return this._mixColors(ca, cb, t); } } return { r: 128, g: 128, b: 128 }; } _hexToRgb(hex) { const h = hex.replace('#', ''); return { r: parseInt(h.substring(0, 2), 16), g: parseInt(h.substring(2, 4), 16), b: parseInt(h.substring(4, 6), 16), }; } _mixColors(c1, c2, t) { return { r: Math.round(c1.r + (c2.r - c1.r) * t), g: Math.round(c1.g + (c2.g - c1.g) * t), b: Math.round(c1.b + (c2.b - c1.b) * t), }; } _renderEntities(engine) { const { ctx, canvasSize, worldSize, scale, _viewX, _viewZ } = this; const viewRegionSize = worldSize * scale; const halfView = viewRegionSize / 2; const players = engine.with(Components.PLAYER); if (players.length > 0) { const transform = players[0].get(Components.TRANSFORM); if (transform) { const px = canvasSize / 2; const py = canvasSize / 2; ctx.font = '16px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.beginPath(); ctx.arc(px, py, 50, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(35, 35, 80, 0.15)'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.arc(px, py, 25, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(35, 35, 80, 0.15)'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.arc(px, py, 75, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(35, 35, 80, 0.15)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = '#000000'; ctx.fillText('🧭', px, py); } } const enemies = engine.with(Components.ENEMY); ctx.font = '16px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (const enemy of enemies) { const transform = enemy.get(Components.TRANSFORM); if (!transform) continue; const relX = transform.x - _viewX; const relZ = transform.z - _viewZ; if (Math.abs(relX) > halfView || Math.abs(relZ) > halfView) continue; const px = (relX / viewRegionSize + 0.5) * canvasSize; const py = (relZ / viewRegionSize + 0.5) * canvasSize; ctx.beginPath(); ctx.arc(px, py, 15, 0, Math.PI * 2); ctx.strokeStyle = '#f443367b'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.beginPath(); ctx.arc(px, py, 50, 0, Math.PI * 2); ctx.strokeStyle = '#f7ff047a'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.fillStyle = '#000000'; ctx.fillText('🐺', px, py); } const items = []; for (const entity of engine.entities.values()) { if ( entity.has(Components.MESH) && entity.has(Components.ZONE_OWNED) && !entity.has(Components.PLAYER) && !entity.has(Components.ENEMY) && !entity.has(Components.PENDING_DESTROY) ) { items.push(entity); } } ctx.font = '16px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (const item of items) { const transform = item.get(Components.TRANSFORM); if (!transform) continue; const relX = transform.x - _viewX; const relZ = transform.z - _viewZ; if (Math.abs(relX) > halfView || Math.abs(relZ) > halfView) continue; const px = (relX / viewRegionSize + 0.5) * canvasSize; const py = (relZ / viewRegionSize + 0.5) * canvasSize; let emoji = '📦'; if (item.has(Components.TREE)) { emoji = '🌲'; } else if (item.has(Components.BERRY_BUSH)) { emoji = '🍓'; } ctx.fillText(emoji, px, py); } } /** * ЛОВИМ СДВИГ: Вычисляем центральный тайл по чистой математической сетке */ _handleWorldShift() { const tileSize = this.worldSize; const viewRadius = this.viewRadius; // Прямое деление без смещений const currentTx = Math.floor(this._viewX / tileSize); const currentTz = Math.floor(this._viewZ / tileSize); // Обновляем экстремумы высот для новой области видимости if (typeof this.tileHeightmap.gatherGlobalMinMax === 'function') { this.tileHeightmap.gatherGlobalMinMax(currentTx, currentTz); } const activeKeys = new Set(); // Держим в памяти сетку чанков вокруг игрока for (let dz = -viewRadius; dz <= viewRadius; dz++) { for (let dx = -viewRadius; dx <= viewRadius; dx++) { const tx = currentTx + dx; const tz = currentTz + dz; const key = `${tx}_${tz}`; activeKeys.add(key); if (!this._tilesPrerendered.has(key)) { this._prerenderTile(tx, tz); } } } // Чистим память: убираем старые картинки, ушедшие далеко for (const cachedKey of this._tilesPrerendered.keys()) { if (!activeKeys.has(cachedKey)) { this._tilesPrerendered.delete(cachedKey); const [tx, tz] = cachedKey.split('_').map(Number); this.tileHeightmap.deleteTile(tx, tz); } } } /** * Первичная загрузка картинок при старте игры */ _initFirstTiles() { const tileSize = this.worldSize; const viewRadius = this.viewRadius; const currentTx = Math.floor(this._viewX / tileSize); const currentTz = Math.floor(this._viewZ / tileSize); if (typeof this.tileHeightmap.gatherGlobalMinMax === 'function') { this.tileHeightmap.gatherGlobalMinMax(currentTx, currentTz); } for (let dz = -viewRadius; dz <= viewRadius; dz++) { for (let dx = -viewRadius; dx <= viewRadius; dx++) { this._prerenderTile(currentTx + dx, currentTz + dz); } } } /** * Отрисовка ландшафта: выводит на экран СТРОГО 3х3 тайла вокруг ИСТОЧНИКА ПРАВДЫ игрока */ _renderTerrainByTiles() { if (!this._showTerrain) { this.ctx.clearRect(0, 0, this.canvasSize, this.canvasSize); return; } const { ctx, canvasSize, worldSize, scale, _viewX, _viewZ, renderRadius, cacheRadius } = this; ctx.clearRect(0, 0, canvasSize, canvasSize); const viewRegionSize = worldSize * scale; const pixelsPerMeter = canvasSize / viewRegionSize; const displayTileSize = worldSize * pixelsPerMeter; const overlapSize = displayTileSize + 1.2; // Нахлест против швов // Границы текущего существующего в игре окна 5х5 (чтобы не заглядывать за край и не плодить дубли) const minAllowedTx = this._currentTx - cacheRadius; const maxAllowedTx = this._currentTx + cacheRadius; const minAllowedTz = this._currentTz - cacheRadius; const maxAllowedTz = this._currentTz + cacheRadius; // Рисуем сетку 3х3 вокруг подтвержденного чанка нахождения игрока в ECS const centerTx = this._currentTx; const centerTz = this._currentTz; for (let dz = -renderRadius; dz <= renderRadius; dz++) { for (let dx = -renderRadius; dx <= renderRadius; dx++) { const tx = centerTx + dx; const tz = centerTz + dz; // Если камера миникарты из-за масштаба пытается выйти за рамки созданного окна 5х5 — рисуем пустоту if (tx < minAllowedTx || tx > maxAllowedTx || tz < minAllowedTz || tz > maxAllowedTz) { continue; } const key = `${tx}_${tz}`; // Ленивый дорендер, если чанк легитимен, но картинки еще почему-то нет if (!this._tilesPrerendered.has(key)) { this._prerenderTile(tx, tz); } const tileImg = this._tilesPrerendered.get(key); if (!tileImg) continue; // Мировые координаты левого верхнего угла тайла (согласовано с генератором высот) const tileWorldX = tx * worldSize - worldSize / 2; const tileWorldZ = tz * worldSize - worldSize / 2; // Скроллинг остается идеально плавным на каждый пиксель движения игрока const px = Math.floor((tileWorldX - _viewX) * pixelsPerMeter + canvasSize / 2); const py = Math.floor((tileWorldZ - _viewZ) * pixelsPerMeter + canvasSize / 2); ctx.drawImage(tileImg, px, py, overlapSize, overlapSize); } } } /** * ПРЕРЕНДЕР КАРТИНКИ ТАЙЛА: Считает пиксели по центрам выборок, убирая цветовые швы */ _prerenderTile(tx, tz) { const key = `${tx}_${tz}`; if (this._tilesPrerendered.has(key)) return; // Запрашиваем генерацию чистых данных ландшафта this.tileHeightmap.generateTileData(tx, tz); const tSize = this.tileTextureSize; const data = this._pixelBuffer; // Мировые координаты левого верхнего угла этого тайла const tileLeftX = tx * this.worldSize; const tileTopZ = tz * this.worldSize; for (let py = 0; py < tSize; py++) { for (let px = 0; px < tSize; px++) { // ИСПРАВЛЕНИЕ ШВОВ: Берем центры пикселей (+0.5) вместо деления на (tSize - 1), // чтобы выборка никогда не наступала на проблемные граничные стыки тайлов const localX = ((px + 0.5) / tSize) * this.worldSize; const localZ = ((py + 0.5) / tSize) * this.worldSize; // Абсолютные мировые координаты точки const worldX = tileLeftX + localX; const worldZ = tileTopZ + localZ; const height = this.tileHeightmap.getHeightAt(worldX, worldZ); const color = this._heightToColor(height); const index = (py * tSize + px) * 4; data[index] = color.r; data[index + 1] = color.g; data[index + 2] = color.b; data[index + 3] = 255; } } const imgData = new ImageData(data, tSize, tSize); this._prerenderCtx.putImageData(imgData, 0, 0); const tileImage = document.createElement('canvas'); tileImage.width = tSize; tileImage.height = tSize; tileImage.getContext('2d').drawImage(this._prerenderCanvas, 0, 0); this._tilesPrerendered.set(key, tileImage); } /** * Динамически генерирует и склеивает один большой монолитный canvas всей карты мира. * Завязано строго на windowSize и cacheRadius из настроек. * @returns {HTMLCanvasElement} Готовый холст со склеенным ландшафтом */ generateFullWorldCanvas() { // 1. Очищаем старый холст полной карты, если он почему-то завис в памяти this.destroyFullWorldCanvas(); const cacheRadius = this.cacheRadius; const tileSize = this.worldSize; // 64 метра const texSize = this.tileTextureSize; // 64 пикселя разрешение одной картинки // Вычисляем, сколько всего тайлов по ширине и высоте (для windowSize: 5 это 5 тайлов) const totalTilesCount = this.windowSize; // Создаем новый тяжелый холст под полную карту мира this._fullWorldCanvas = document.createElement('canvas'); this._fullWorldCanvas.className = 'full-world-map'; this._fullWorldCanvas.width = totalTilesCount * texSize; this._fullWorldCanvas.height = totalTilesCount * texSize; const fullCtx = this._fullWorldCanvas.getContext('2d'); // Вычисляем жесткие границы текущего игрового окна (вокруг текущего положения игрока) const minTx = this._currentTx - cacheRadius; const maxTx = this._currentTx + cacheRadius; const minTz = this._currentTz - cacheRadius; const maxTz = this._currentTz + cacheRadius; // Обновляем экстремумы высот один раз для всей этой огромной области if (typeof this.tileHeightmap.gatherGlobalMinMax === 'function') { this.tileHeightmap.gatherGlobalMinMax(this._currentTx, this._currentTz); } // Двойной цикл сборки: перебираем всю сетку тайлов от верхнего левого угла к правому нижнему for (let tz = minTz; tz <= maxTz; tz++) { for (let tx = minTx; tx <= maxTx; tx++) { const key = `${tx}_${tz}`; // Если картинки этого чанка по какой-то причине нет в кэше — принудительно пререндерим её if (!this._tilesPrerendered.has(key)) { this._prerenderTile(tx, tz); } const tileImg = this._tilesPrerendered.get(key); if (!tileImg) continue; // Находим локальные пиксельные координаты, куда вставить эту плитку на большом холсте // Переводим индексы (например от -2 до +2) в массив индексов от 0 до 4 const localTileX = tx - minTx; const localTileZ = tz - minTz; const destX = localTileX * texSize; const destY = localTileZ * texSize; // Рисуем плитку на холст полной карты мира (1 к 1 без растягивания и швов) fullCtx.drawImage(tileImg, destX, destY, texSize, texSize); } } document.body.appendChild(this._fullWorldCanvas); console.log(`[Full Map Generator] Успешно собран монолитный холст мира. Размер: ${this._fullWorldCanvas.width}x${this._fullWorldCanvas.height}px`); return this._fullWorldCanvas; } /** * Безжалостно уничтожает холст полной карты мира и очищает ссылки, защищая вкладку от падений (OOM) */ destroyFullWorldCanvas() { if (this._fullWorldCanvas) { this._fullWorldCanvas.remove(); } } /** * Реактивно открывает или закрывает полную карту мира по сигналу из шины событий */ _toggleBigMap() { if (this.isBigMapOpen) { // Если открыта — закрываем и выгружаем из памяти this.destroyFullWorldCanvas(); this.isBigMapOpen = false; } else { // Если закрыта — генерируем и выводим в body this.generateFullWorldCanvas(); this.isBigMapOpen = true; } } }