diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 5178642..e5d7fc3 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -44,10 +44,17 @@ readonly settings = new Settings(window.localStorage); /** - * Активная локация (для автосейва при закрытии/скрытии вкладки). - * null — вне локации; сумка/оверлеи поверх не мешают: локация зарегистрирована. + * Активная локация (для автосейва при закрытии/скрытии вкладки и для + * оверлея карты: область + тайл героя). null — вне локации; сумка/оверлеи + * поверх не мешают: локация зарегистрирована. */ - activeLocation: { autosave(): void } | null = null; + activeLocation: { + autosave(): void; + /** id области текущей локации (карта из Game.mapFiles). */ + readonly areaId: string; + /** Текущий тайл героя (мигающий маркер на карте). */ + heroTile(): { x: number; y: number }; + } | null = null; /** Текстуры тайлов и одиночные спрайты NPC. */ static readonly ASSET_KEYS = [ diff --git a/apps/game/src/data/dialogues/elder_first.json b/apps/game/src/data/dialogues/elder_first.json index 1b32b10..7fddc97 100644 --- a/apps/game/src/data/dialogues/elder_first.json +++ b/apps/game/src/data/dialogues/elder_first.json @@ -23,9 +23,9 @@ }, "give": { "setFlags": ["met_elder", "quest_bells_taken"], - "do": [{ "kind": "giveItem", "id": "clock" }], + "do": [{ "kind": "giveItem", "id": "clock" }, { "kind": "giveItem", "id": "map_scroll" }], "speaker": "Старейшина Ирвин", - "text": "Держи старые медные часы. Стрелка не врёт: темнота по ней приходит.", + "text": "Держи старые медные часы — стрелка не врёт, темнота по ней приходит. И набросок окрестностей: я чертил его для звонарей. Тропы помнит, дома помнит.", "next": "ring" }, "ring": { diff --git a/apps/game/src/data/items.ts b/apps/game/src/data/items.ts index 755b717..066ec08 100644 --- a/apps/game/src/data/items.ts +++ b/apps/game/src/data/items.ts @@ -8,7 +8,7 @@ desc: string; } -export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask' | 'mote' | 'clock'; +export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask' | 'mote' | 'clock' | 'map_scroll'; export const ITEMS: Record = { clock: { @@ -40,6 +40,11 @@ id: 'mote', name: 'Пепельный мот', desc: 'Сгусток серой земли с тёплой искрой — то, что колокольчик вытянул из пепла. Мила меняет моты на товар.' + }, + map_scroll: { + id: 'map_scroll', + name: 'Карта окрестностей', + desc: 'Набросок Ирвина: тропы, дома, дальние края. Местность тут меняется быстрее чернил.' } }; diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts index 3965077..ad8e033 100644 --- a/apps/game/src/scenes/InventoryScene.ts +++ b/apps/game/src/scenes/InventoryScene.ts @@ -3,6 +3,7 @@ import { Game } from '../Game'; import { questLog } from '../data/quests'; import { itemName, useItemLine } from '../data/items'; +import { MapScene } from './MapScene'; /** * Сумка и журнал квестов (панель поверх локации, push/pop). @@ -84,6 +85,15 @@ /** Применить предмет: юза́бельные дают строку-результат, остальные молчат. */ private useItem(id: string, result: PixelText): void { + // Карта окрестностей — не строка, а оверлей поверх сумки. + if (id === 'map_scroll') { + playSfx(this.game.audio, 'sfx/ui_click'); + void this.game.scenes.push( + new MapScene(this.game, () => void this.game.scenes.pop()), + { duration: 0.2 } + ); + return; + } const line = useItemLine(id, { label: this.game.clock.label }); if (line === null) return; playSfx(this.game.audio, 'sfx/ui_click'); diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 1f97806..60d3a8f 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -512,6 +512,16 @@ this.game.activeLocation = this; } + /** id области — для оверлея карты (Game.activeLocation). */ + get areaId(): string { + return this.area.id; + } + + /** Текущий тайл героя — для мигающего маркера на карте. */ + heroTile(): { x: number; y: number } { + return this.player.currentTile(); + } + enter(): void { console.log('[location]', this.area.id); // маяк для смоук-тестов // Амбиент локации: день/ночь по текущим часам (кроссфейд; '' — тишина). diff --git a/apps/game/src/scenes/MapScene.ts b/apps/game/src/scenes/MapScene.ts new file mode 100644 index 0000000..842add2 --- /dev/null +++ b/apps/game/src/scenes/MapScene.ts @@ -0,0 +1,156 @@ +import { + Graphics, + MenuSceneBase, + Panel, + PixelText, + Sprite, + Texture, + renderMinimap, + type MinimapMarker +} from '@rpg/engine'; +import { Game } from '../Game'; +import { TILES } from '../data/map'; +import { areaOf } from '../data/locations'; +import { playSfx } from '../data/sfxSpecs'; + +/** + * Палитра миникарты: id тайла → цвет (0xRRGGBB, из арт-палитры). Варианты + * тайлов красятся как базовый. Незаданный — тёмный fallback движка. + */ +const MAP_COLORS: Record = { + [TILES.GRASS]: 0x4a5340, // G1 — трава + [TILES.GRASS_V1]: 0x4a5340, + [TILES.GRASS_V2]: 0x4a5340, + [TILES.PATH]: 0x77634f, // T1 — протоптанная земля + [TILES.PATH_V1]: 0x77634f, + [TILES.WATER]: 0x3a4356, // C0 — вода + [TILES.TREE]: 0x343b2c, // G0 — тёмная крона + [TILES.TREE_V1]: 0x343b2c, + [TILES.TREE_V2]: 0x343b2c, + [TILES.ASH]: 0x3e3e48, // P3 — пепел + [TILES.ASH_V1]: 0x3e3e48, + [TILES.BELLFLOWER]: 0xf2b45a, // F1 — цветок (тёплый) + [TILES.TOWER]: 0x26262e, // M0 — высокая башня + [TILES.HOUSE]: 0x5a4a3c, // T0 — крыши + [TILES.HOUSE_V1]: 0x5a4a3c, + [TILES.HOUSE_V2]: 0x5a4a3c, + [TILES.FLOOR]: 0x44454e, // M1 — полы интерьеров + [TILES.WALL]: 0x26262e, // M0 — стены + [TILES.WELL]: 0x8a6a2e, // B0 — латунный колодец + [TILES.WIRE]: 0x5c3b2e, // R1 — бухты кабеля + [TILES.RAIL]: 0x767681, // P5 — металл рельсов + [TILES.STAIRS]: 0x77634f, + [TILES.MACHINE]: 0x26262e +}; + +/** Цвета маркеров: герой (мигает), переходы, NPC. */ +const HERO_COLOR = 0xf2d49a; +const TRANSITION_COLOR = 0xb2b2bb; +const NPC_COLOR = 0xf2b45a; + +/** RGBA-картинка движка → пиксельная текстура (nearest, без размытия). */ +function textureFromMinimap(img: { width: number; height: number; rgba: Uint8Array }): Texture { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d')!; + ctx.putImageData(new ImageData(new Uint8ClampedArray(img.rgba), img.width, img.height), 0, 0); + const tex = Texture.from(canvas); + tex.source.scaleMode = 'nearest'; + return tex; +} + +/** + * Карта окрестностей (предмет map_scroll): схема текущей области поверх + * локации, push/pop как у сумки. Тайлы — клетки палитры; переходы и NPC — + * маркеры; герой — мигающая точка. Esc/E/клавиша сумки — назад. + */ +export class MapScene extends MenuSceneBase { + /** Мигающий маркер героя. */ + private hero: Graphics | null = null; + private blinkT = 0; + + constructor( + private game: Game, + private onBack: () => void + ) { + super( + { input: game.engine.input, inputBlocked: () => game.scenes.transitioning }, + { up: 'up', down: 'down', confirm: 'advance', cancel: 'menu', extra: ['inventory'] } + ); + } + + 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 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 }); + 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); + 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 hint = new PixelText({ text: 'Esc — назад', size: 9, color: 0xf2d49a }); + hint.position.set(16, 204); + panel.addChild(hint); + + this.view.addChild(panel); + this.game.renderer.uiRoot.addChild(this.view); + } + + update(dt: number): void { + super.update(dt); + // Мигание маркера героя (локация не тикается — мерцает сама карта). + if (this.hero) { + this.blinkT += dt; + this.hero.visible = Math.floor(this.blinkT / 0.4) % 2 === 0; + } + } + + /** Esc или клавиша сумки — назад (в сумку, если карта открыта из неё). */ + protected onCancel(): void { + this.close(); + } + + protected onAction(): void { + this.close(); + } + + private close(): void { + playSfx(this.game.audio, 'sfx/ui_click'); + this.onBack(); + } +} \ No newline at end of file