diff --git a/docs/engine/maps.md b/docs/engine/maps.md index 1e39e88..bf889be 100644 --- a/docs/engine/maps.md +++ b/docs/engine/maps.md @@ -237,4 +237,29 @@ Требования к карте в Tiled: ориентация **isometric**, размер тайла 32×16, tilesets один с известным `firstgid`. GID из Tiled смещаются на `firstgid`, -чтобы id шли с 0; верхние слои заполняют пустые (0) клетки нижних. \ No newline at end of file +чтобы id шли с 0; верхние слои заполняют пустые (0) клетки нижних. + +## Миникарта (renderMinimap) + +Чистый рендер `TileMapData` в RGBA-картинку «вид сверху» — без Pixi; движок +не знает про текстуры, картинку в текстуру превращает игра (через свой +PNG/текстурный слой). Полезно для карт-предметов, оверлеев, отладочных вью. + +```ts +import { renderMinimap, type TileMapData } from '@rpg/engine'; + +const img = renderMinimap(map.data, { + colors: { 1: 0x3a3f35, 2: 0x4a4e42 }, // палитра: id тайла → 0xRRGGBB + fallbackColor: 0x1a1a20, // тайлы без записи в colors + cell: 3, // размер клетки в пикселях (2–3) + markers: [ // точки интереса (тайловые коорд.) + { x: 12, y: 8, color: 0xf2b45a }, // герой + { x: 26, y: 10, color: 0x8ab4d6 }, // переход + ] +}); +// img: { width, height, rgba } — RGBA, 4 байта на пиксель. +``` + +Маркер рисуется закрашенной клеткой с тёмной рамкой — читается на любом +фоне; маркеры вне карты молча пропускаются. Цвет клетки задаёт вызывающая +сторона: движок не связывает id тайлов с конкретной палитрой. \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index ca3219a..53bdcce 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -125,6 +125,12 @@ type MapFileV1 } from './map/mapFormat'; export { fromTiledIso, type TiledMap } from './map/tiled'; +export { + renderMinimap, + type MinimapImage, + type MinimapMarker, + type MinimapOptions +} from './map/minimap'; // render export { Renderer, type RendererOptions } from './render/Renderer'; diff --git a/packages/engine/src/map/__tests__/minimap.test.ts b/packages/engine/src/map/__tests__/minimap.test.ts new file mode 100644 index 0000000..1b609a7 --- /dev/null +++ b/packages/engine/src/map/__tests__/minimap.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { TileMapData } from '../IsometricTileMap'; +import { renderMinimap } from '../minimap'; + +/** Карта 3×2: два разных тайла + заблокированная клетка. */ +function tinyMap(): TileMapData { + return { + width: 3, + height: 2, + tiles: [1, 2, 1, 2, 1, 2], + blocked: [0, 0, 1, 0, 0, 0], + }; +} + +const px = (img: { width: number; rgba: Uint8Array }, x: number, y: number): [number, number, number] => { + const i = (y * img.width + x) * 4; + return [img.rgba[i]!, img.rgba[i + 1]!, img.rgba[i + 2]!]; +}; + +describe('renderMinimap', () => { + it('размер картинки = карта × cell', () => { + const img = renderMinimap(tinyMap(), { colors: { 1: 0x101010, 2: 0x202020 }, cell: 3 }); + expect(img.width).toBe(9); + expect(img.height).toBe(6); + expect(img.rgba.length).toBe(9 * 6 * 4); + }); + + it('клетка целиком красится цветом своего тайла', () => { + const img = renderMinimap(tinyMap(), { colors: { 1: 0xff0000, 2: 0x00ff00 }, cell: 2 }); + // Тайл (0,0) = id 1, тайл (1,0) = id 2. + expect(px(img, 0, 0)).toEqual([255, 0, 0]); + expect(px(img, 1, 1)).toEqual([255, 0, 0]); + expect(px(img, 2, 0)).toEqual([0, 255, 0]); + expect(px(img, 5, 3)).toEqual([0, 255, 0]); + }); + + it('незаданный тайл — fallbackColor', () => { + const img = renderMinimap(tinyMap(), { colors: { 1: 0xff0000 }, fallbackColor: 0x0a0b0c, cell: 2 }); + expect(px(img, 2, 0)).toEqual([10, 11, 12]); + // И без явного fallback — тёмный по умолчанию. + const plain = renderMinimap(tinyMap(), { colors: { 1: 0xff0000 }, cell: 2 }); + expect(px(plain, 2, 0)[0]).toBeLessThan(64); + }); + + it('маркер перекрашивает свою клетку с тёмной рамкой', () => { + const img = renderMinimap(tinyMap(), { + colors: { 1: 0x101010, 2: 0x101010 }, + cell: 3, + markers: [{ x: 1, y: 1, color: 0xffff00 }], + }); + // Центр клетки — цвет маркера, угол — рамка. + expect(px(img, 4, 4)).toEqual([255, 255, 0]); + expect(px(img, 3, 3)[0]).toBeLessThan(64); + // Соседняя клетка не задета. + expect(px(img, 7, 4)).toEqual([16, 16, 16]); + }); + + it('маркер вне карты молча пропускается', () => { + const img = renderMinimap(tinyMap(), { + colors: { 1: 0x101010, 2: 0x101010 }, + cell: 2, + markers: [{ x: 9, y: 9, color: 0xffff00 }], + }); + expect(px(img, 0, 0)).toEqual([16, 16, 16]); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/map/minimap.ts b/packages/engine/src/map/minimap.ts new file mode 100644 index 0000000..77ec112 --- /dev/null +++ b/packages/engine/src/map/minimap.ts @@ -0,0 +1,78 @@ +import type { TileMapData } from './IsometricTileMap'; + +/** + * Миникарта: чистый рендер TileMapData в RGBA-картинку (без Pixi — движок + * не знает про текстуры; в пиксельную текстуру картинку превращает игра). + * Схема «вид сверху»: клетка сетки = тайл, цвет — из палитры вызывающей + * стороны (Record). Незаданные тайлы — fallbackColor. + */ + +export interface MinimapMarker { + /** Тайловые координаты точки интереса. */ + x: number; + y: number; + /** Цвет маркера (0xRRGGBB). */ + color: number; +} + +export interface MinimapOptions { + /** Цвет клетки по id тайла (0xRRGGBB). */ + colors: Record; + /** Цвет тайла без записи в colors (по умолчанию тёмный). */ + fallbackColor?: number; + /** Размер клетки в пикселях (2–3 читаются лучше всего). */ + cell?: number; + /** Точки интереса поверх карты (герой, переходы, NPC). */ + markers?: MinimapMarker[]; +} + +/** Результат рендера: RGBA-байты, 4 на пиксель. */ +export interface MinimapImage { + width: number; + height: number; + rgba: Uint8Array; +} + +/** 0xRRGGBB → [r, g, b]. */ +function rgbOf(color: number): [number, number, number] { + return [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]; +} + +/** + * Отрисовать карту в пиксельную картинку: сетка width×height клеток + * (cell px каждая), маркеры закрашивают свою клетку поверх. + */ +export function renderMinimap(data: TileMapData, opts: MinimapOptions): MinimapImage { + const cell = opts.cell ?? 3; + const width = data.width * cell; + const height = data.height * cell; + const rgba = new Uint8Array(width * height * 4); + const put = (x: number, y: number, [r, g, b]: [number, number, number]): void => { + const i = (y * width + x) * 4; + rgba[i] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = 255; + }; + for (let ty = 0; ty < data.height; ty++) { + for (let tx = 0; tx < data.width; tx++) { + const id = data.tiles[ty * data.width + tx]!; + const color = rgbOf(opts.colors[id] ?? opts.fallbackColor ?? 0x1a1a20); + for (let dy = 0; dy < cell; dy++) { + for (let dx = 0; dx < cell; dx++) put(tx * cell + dx, ty * cell + dy, color); + } + } + } + // Маркеры: закрашенная клетка с рамкой цвета фона — читается на любом поле. + for (const m of opts.markers ?? []) { + if (m.x < 0 || m.y < 0 || m.x >= data.width || m.y >= data.height) continue; + const [r, g, b] = rgbOf(m.color); + for (let dy = 0; dy < cell; dy++) { + for (let dx = 0; dx < cell; dx++) { + const border = dx === 0 || dy === 0 || dx === cell - 1 || dy === cell - 1; + put(m.x * cell + dx, m.y * cell + dy, border ? [20, 20, 24] : [r, g, b]); + } + } + } + return { width, height, rgba }; +} \ No newline at end of file