diff --git a/apps/game/src/scenes/MapScene.ts b/apps/game/src/scenes/MapScene.ts index 54d39f2..6a5df3f 100644 --- a/apps/game/src/scenes/MapScene.ts +++ b/apps/game/src/scenes/MapScene.ts @@ -6,7 +6,8 @@ Sprite, Texture, renderMinimap, - type MinimapMarker + type MinimapImage, + type TileMapData } from '@rpg/engine'; import { Game } from '../Game'; import { TILES } from '../data/map'; @@ -51,6 +52,46 @@ const HERO_COLOR = 0xf2d49a; const TRANSITION_COLOR = 0xb2b2bb; const NPC_COLOR = 0xf2b45a; +/** Цвет дорог-линий на листе мира (тропа). */ +const ROAD_COLOR = 0x77634f; // T1 + +/** Часть листа мира: область и её оффсет в тайлах листа. */ +interface WorldPart { + id: string; + name: string; + x: number; + y: number; + map: TileMapData; +} + +/** + * Раскладка листа мира «Низина Звенца» (оффсеты в тайлах, cell = 1): + * пруды на северо-западе, луга в центре, восточнее — деревня, роща, разъезд. + * Интерьеры домов и зал Машины на карте мира не показаны. + */ +function worldParts(game: Game): WorldPart[] { + const defs: [id: string, name: string, x: number, y: number][] = [ + ['ponds', 'Серые пруды', 0, 0], + ['meadows', 'Выжженные луга', 53, 10], + ['zvenets', 'Звенец', 114, 20], + ['rust_grove', 'Ржавая роща', 167, 20], + // Разъезд сдвинут вправо: подписи соседних областей не слипаются. + ['old_siding', 'Старый разъезд', 231, 20] + ]; + return defs.flatMap(([id, name, x, y]) => { + const map = game.mapFiles.get(id); + return map ? [{ id, name, x, y, map }] : []; + }); +} + +/** Дороги мира: пары тайлов областей (связки переходов, тропы и колодцы). */ +const WORLD_ROADS: { a: [string, number, number]; b: [string, number, number] }[] = [ + { a: ['ponds', 2, 2], b: ['meadows', 2, 2] }, // тропа на пруды + { a: ['meadows', 54, 28], b: ['zvenets', 2, 20] }, // тропа в Звенец + { a: ['meadows', 44, 40], b: ['zvenets', 16, 13] }, // колодцы (телепорт) + { a: ['zvenets', 54, 20], b: ['rust_grove', 1, 20] }, // дорога в рощу + { a: ['rust_grove', 46, 20], b: ['old_siding', 1, 20] } // дорога на разъезд +]; /** RGBA-картинка движка → пиксельная текстура (nearest, без размытия). */ function textureFromMinimap(img: { width: number; height: number; rgba: Uint8Array }): Texture { @@ -65,9 +106,30 @@ } /** - * Карта окрестностей (предмет map_scroll): схема текущей области поверх - * локации, push/pop как у сумки. Тайлы — клетки палитры; переходы и NPC — - * маркеры; герой — мигающая точка. Esc/E/клавиша сумки — назад. + * Склеить миниатюры областей в лист мира: каждая область рендерится + * renderMinimap (cell = 1), строки копируются в общий RGBA по оффсету. + * Промежутки между областями остаются прозрачными (alpha 0). + */ +function composeWorldSheet(parts: WorldPart[]): MinimapImage { + const width = Math.max(...parts.map((p) => p.x + p.map.width)); + const height = Math.max(...parts.map((p) => p.y + p.map.height)); + const rgba = new Uint8Array(width * height * 4); // всё прозрачено + for (const p of parts) { + const img = renderMinimap(p.map, { colors: MAP_COLORS, cell: 1 }); + for (let row = 0; row < img.height; row++) { + const src = row * img.width * 4; + const dst = ((p.y + row) * width + p.x) * 4; + rgba.set(img.rgba.subarray(src, src + img.width * 4), dst); + } + } + return { width, height, rgba }; +} + +/** + * Карта мира «Низина Звенца» (предмет map_scroll): композит миниатюр всех + * внешних областей на одном листе, дороги между переходами, маркеры текущей + * области и мигающая точка героя. Интерьеры и зал Машины не показаны. + * Esc/E/клавиша сумки — назад. */ export class MapScene extends MenuSceneBase { /** Мигающий маркер героя. */ @@ -85,47 +147,46 @@ } protected build(): void { - const location = this.game.activeLocation; - const areaId = location?.areaId; - const map = areaId ? this.game.mapFiles.get(areaId) : undefined; - if (!location || !map) return; // карта открыта вне локации — не бывает - - const area = areaOf(areaId); - const markers: MinimapMarker[] = []; - // Переходы — светлые клетки (в т.ч. двери/назад). - for (const t of area.transitions) { - markers.push({ x: t.tile.x, y: t.tile.y, color: TRANSITION_COLOR }); - } - // NPC — тёплые клетки (тайл привязки вычисляется, считаем из pos). - for (const npc of area.npcs) { - markers.push({ x: Math.floor(npc.pos.x), y: Math.floor(npc.pos.y), color: NPC_COLOR }); - } + const parts = worldParts(this.game); + if (parts.length === 0) return; // карты ещё не загружены — не бывает const panel = new Panel({ width: 300, height: 222 }); panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2); - const title = new PixelText({ text: area.name, size: 14, color: 0xd8c79a }); + const title = new PixelText({ text: 'Низина Звенца', size: 14, color: 0xd8c79a }); title.anchor.set(0.5); title.position.set(150, 14); panel.addChild(title); - const img = renderMinimap(map, { colors: MAP_COLORS, markers, cell: 3 }); - const sprite = new Sprite(textureFromMinimap(img)); - // Целочисленный масштаб: картинка целиком в панели. - const scale = Math.max(1, Math.floor(Math.min(270 / img.width, 168 / img.height))); - sprite.scale.set(scale); - sprite.position.set(150 - (img.width * scale) / 2, 40 + (168 - img.height * scale) / 2); + const sheet = composeWorldSheet(parts); + const sprite = new Sprite(textureFromMinimap(sheet)); + sprite.position.set(150 - sheet.width / 2, 46); panel.addChild(sprite); - // Герой — клетка-маркер поверх (мигает в update). - const heroTile = location.heroTile(); - this.hero = new Graphics(); - this.hero.rect(0, 0, 3 * scale, 3 * scale).fill(HERO_COLOR); - this.hero.position.set( - sprite.position.x + heroTile.x * 3 * scale, - sprite.position.y + heroTile.y * 3 * scale - ); - panel.addChild(this.hero); + // Дороги между областями: тропы и колодцы, по связкам переходов. + const roads = new Graphics(); + roads.lineStyle(1, ROAD_COLOR, 1); + for (const road of WORLD_ROADS) { + const a = parts.find((p) => p.id === road.a[0]); + const b = parts.find((p) => p.id === road.b[0]); + if (!a || !b) continue; + roads.moveTo(sprite.position.x + a.x + road.a[1] + 0.5, sprite.position.y + a.y + road.a[2] + 0.5); + roads.lineTo(sprite.position.x + b.x + road.b[1] + 0.5, sprite.position.y + b.y + road.b[2] + 0.5); + } + panel.addChild(roads); + + // Подписи областей под миниатюрами. + for (const p of parts) { + const label = new PixelText({ text: p.name, size: 8, color: 0xb2b2bb }); + label.anchor.set(0.5, 0); + label.position.set( + sprite.position.x + p.x + p.map.width / 2, + sprite.position.y + p.y + p.map.height + 2 + ); + panel.addChild(label); + } + + this.addMarkers(panel, sprite.position.x, sprite.position.y, parts); const hint = new PixelText({ text: 'Esc — назад', size: 9, color: 0xf2d49a }); hint.position.set(16, 204); @@ -135,6 +196,37 @@ this.game.renderer.uiRoot.addChild(this.view); } + /** Маркеры текущей области: переходы, NPC и мигающий герой. */ + private addMarkers(panel: Panel, sheetX: number, sheetY: number, parts: WorldPart[]): void { + const areaId = this.game.activeLocation?.areaId; + const part = areaId ? parts.find((p) => p.id === areaId) : undefined; + if (!part) return; // интерьер/подземелье: на карте мира маркеров нет + const area = areaOf(part.id); + const g = new Graphics(); + const at = (x: number, y: number): { x: number; y: number } => ({ + x: sheetX + part.x + x - 1, + y: sheetY + part.y + y - 1 + }); + // Переходы — светлые клетки, NPC — тёплые. + for (const t of area.transitions) { + const p = at(t.tile.x, t.tile.y); + g.rect(p.x, p.y, 3, 3).fill(TRANSITION_COLOR); + } + for (const npc of area.npcs) { + const p = at(Math.floor(npc.pos.x), Math.floor(npc.pos.y)); + g.rect(p.x, p.y, 3, 3).fill(NPC_COLOR); + } + panel.addChild(g); + + // Герой — мигающая клетка поверх (update). + const heroTile = this.game.activeLocation!.heroTile(); + this.hero = new Graphics(); + this.hero.rect(0, 0, 4, 4).fill(HERO_COLOR); + const hp = at(heroTile.x, heroTile.y); + this.hero.position.set(hp.x - 0.5, hp.y - 0.5); + panel.addChild(this.hero); + } + update(dt: number): void { super.update(dt); // Мигание маркера героя (локация не тикается — мерцает сама карта).