diff --git a/apps/game/assets/maps/meadows.map b/apps/game/assets/maps/meadows.map new file mode 100644 index 0000000..494a635 --- /dev/null +++ b/apps/game/assets/maps/meadows.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":28,"height":28,"blocked":[2,3],"tall":{"3":{"height":48,"ground":0}},"encoding":"rle","tiles":[[0,1],[3,2],[4,1],[0,1],[4,3],[0,1],[4,3],[0,1],[4,1],[0,4],[4,8],[3,1],[4,1],[0,1],[1,1],[0,3],[4,3],[0,2],[4,1],[0,2],[4,1],[0,4],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[3,1],[1,2],[0,2],[4,2],[0,3],[4,4],[0,4],[4,1],[0,1],[4,2],[0,1],[4,4],[3,1],[0,2],[1,2],[0,1],[4,2],[0,6],[4,9],[0,1],[4,2],[3,1],[4,2],[3,1],[0,2],[1,2],[0,1],[4,2],[0,5],[4,3],[0,1],[4,2],[0,1],[4,1],[0,1],[4,1],[0,1],[4,2],[0,1],[4,1],[3,1],[0,1],[1,3],[0,8],[4,5],[5,1],[0,5],[4,1],[3,1],[4,2],[0,4],[1,2],[0,1],[4,3],[0,1],[4,1],[0,2],[4,2],[0,1],[4,1],[0,11],[3,1],[0,3],[1,3],[4,2],[0,1],[4,1],[0,1],[4,5],[0,1],[2,4],[0,7],[4,1],[0,1],[4,1],[0,1],[4,1],[1,2],[4,2],[0,2],[4,3],[0,1],[4,1],[0,1],[2,4],[0,3],[3,1],[0,3],[4,1],[3,1],[0,3],[4,1],[1,1],[4,3],[0,1],[4,5],[0,1],[2,2],[0,1],[5,1],[0,7],[3,1],[0,1],[4,1],[0,3],[1,2],[4,2],[0,1],[4,3],[0,8],[3,1],[0,3],[4,1],[3,1],[0,4],[4,3],[1,1],[0,3],[4,5],[0,6],[3,1],[0,2],[3,1],[0,1],[4,1],[3,1],[0,3],[4,3],[1,1],[0,4],[4,5],[0,1],[4,5],[0,1],[4,2],[0,2],[3,1],[0,4],[4,2],[1,1],[4,1],[0,5],[4,3],[0,1],[4,7],[3,1],[0,1],[4,1],[0,1],[3,1],[0,4],[4,1],[1,2],[4,2],[0,3],[4,3],[0,1],[4,6],[3,1],[4,1],[3,2],[4,1],[0,5],[4,1],[0,1],[1,1],[0,1],[4,1],[0,4],[4,1],[0,2],[4,7],[3,2],[0,1],[4,1],[3,1],[0,2],[2,2],[0,2],[1,3],[4,1],[0,6],[4,1],[0,1],[4,4],[0,1],[4,1],[3,1],[0,1],[3,1],[4,3],[2,4],[5,1],[0,1],[1,1],[4,1],[0,13],[3,1],[0,1],[3,1],[4,5],[2,3],[4,2],[1,1],[4,3],[0,4],[4,1],[0,3],[3,1],[0,4],[3,1],[4,1],[3,1],[0,1],[4,4],[0,2],[5,1],[1,2],[4,2],[0,3],[4,3],[0,13],[4,4],[0,1],[4,1],[1,1],[0,5],[4,2],[0,4],[4,2],[0,1],[3,3],[0,6],[4,1],[0,2],[4,1],[1,3],[0,9],[4,1],[0,5],[3,1],[0,6],[4,4],[0,1],[1,1],[0,8],[4,3],[0,1],[3,1],[0,1],[3,1],[0,3],[4,4],[0,1],[4,1],[0,1],[4,1],[0,1],[1,1],[0,10],[3,1],[0,2],[3,3],[0,2],[4,1],[0,2],[4,5],[0,1],[1,1],[0,9],[4,2],[0,2],[3,1],[0,4],[4,2],[0,7],[1,10],[4,2],[3,1],[0,24],[4,4],[0,2],[3,1],[0,21],[3,4]]} \ No newline at end of file diff --git a/apps/game/assets/maps/ponds.map b/apps/game/assets/maps/ponds.map new file mode 100644 index 0000000..4d95be1 --- /dev/null +++ b/apps/game/assets/maps/ponds.map @@ -0,0 +1 @@ +{"format":"rpg-map","version":1,"width":24,"height":24,"blocked":[2,3],"tall":{"3":{"height":48,"ground":0}},"encoding":"rle","tiles":[[4,4],[0,1],[4,13],[0,3],[3,1],[4,2],[3,2],[0,4],[4,1],[0,1],[4,2],[0,1],[4,9],[0,3],[4,1],[3,2],[1,1],[0,3],[4,1],[0,1],[4,2],[0,1],[4,9],[0,1],[3,1],[4,3],[3,1],[4,1],[1,1],[0,1],[4,2],[0,2],[4,1],[0,2],[4,8],[0,1],[3,1],[0,2],[3,1],[0,5],[4,1],[0,2],[4,1],[0,2],[4,5],[0,1],[4,2],[0,1],[3,1],[0,2],[4,1],[0,2],[4,1],[1,1],[0,1],[4,1],[0,1],[4,1],[0,3],[4,4],[0,2],[4,3],[3,1],[0,1],[4,2],[3,1],[4,1],[0,1],[1,1],[0,1],[4,2],[0,6],[4,2],[5,1],[0,4],[4,1],[3,1],[0,1],[4,1],[0,1],[4,2],[1,2],[0,1],[4,1],[2,1],[0,1],[2,1],[4,1],[2,1],[4,1],[2,1],[0,1],[4,2],[0,1],[4,1],[0,2],[3,1],[4,3],[0,3],[1,1],[0,1],[4,1],[2,7],[4,1],[2,1],[4,1],[0,1],[4,1],[0,1],[4,1],[3,1],[4,2],[3,1],[0,2],[4,1],[1,3],[2,11],[0,3],[4,3],[3,2],[0,2],[2,2],[1,1],[2,10],[4,1],[2,1],[4,1],[0,1],[4,1],[3,2],[0,1],[4,1],[0,1],[4,2],[2,1],[1,2],[2,10],[4,1],[0,2],[4,2],[3,3],[0,2],[2,3],[1,1],[2,10],[0,2],[3,1],[4,2],[0,4],[4,1],[2,3],[1,1],[2,12],[4,1],[3,1],[4,2],[0,1],[3,1],[0,1],[4,1],[2,3],[1,1],[2,10],[4,5],[0,2],[4,4],[2,2],[1,2],[2,9],[4,2],[3,1],[4,1],[3,1],[4,1],[3,1],[0,2],[4,2],[2,2],[4,1],[1,1],[2,7],[4,1],[5,1],[4,2],[3,2],[4,4],[0,2],[4,1],[0,1],[5,1],[2,1],[1,1],[4,1],[2,2],[4,1],[2,1],[4,1],[2,2],[4,6],[3,1],[4,3],[0,2],[4,3],[1,7],[4,5],[3,1],[4,1],[0,1],[4,2],[3,1],[0,1],[4,2],[0,2],[4,13],[0,3],[4,1],[3,1],[4,2],[0,1],[4,1],[0,1],[4,4],[0,1],[5,1],[0,1],[4,7],[0,1],[4,1],[0,1],[4,1],[3,1],[4,3],[0,2],[4,4],[0,2],[4,10],[3,1],[4,11],[0,3],[4,21],[0,1],[4,10],[3,2]]} \ No newline at end of file diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 97249b3..26a734c 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -5,8 +5,10 @@ AudioManager, GameState, Settings, + parseMap, type Renderer, - type SceneManager + type SceneManager, + type TileMapData } from '@rpg/engine'; // Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL. import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'; @@ -73,11 +75,27 @@ this.audio.setBusVolume('master', this.settings.data.master); this.audio.setBusVolume('music', this.settings.data.music); this.audio.setBusVolume('sfx', this.settings.data.sfx); + // Хук для смоук-тестов (tools/smoke*.mjs): доступ к контексту из страницы. + if (import.meta.env.DEV) { + (window as unknown as { __game?: Game }).__game = this; + } } /** URL пиксельного шрифта (VT323 идёт вместе с движком, OFL). */ static readonly FONT_URL: string = fontUrl; + /** Карты локаций, распарсенные из assets/maps/.map (формат rpg-map). */ + readonly mapFiles = new Map(); + + /** Загрузить и распарсить карту локации (вызывается в BootScene). */ + async loadMap(id: string): Promise { + const res = await fetch(`${import.meta.env.BASE_URL}maps/${id}.map`); + if (!res.ok) throw new Error(`Карта ${id}: HTTP ${res.status}`); + const data = parseMap(await res.json()); + this.mapFiles.set(id, data); + return data; + } + get scenes(): SceneManager { return this.engine.scenes; } diff --git a/apps/game/src/data/enemies.ts b/apps/game/src/data/enemies.ts index 6e5e095..0ee6a87 100644 --- a/apps/game/src/data/enemies.ts +++ b/apps/game/src/data/enemies.ts @@ -74,11 +74,4 @@ } }; -/** Точки спавна на лугах (тайловые координаты). */ -export const ENEMY_SPAWNS_MEADOWS: { kind: EnemyKindId; tile: { x: number; y: number } }[] = [ - { kind: 'crawler', tile: { x: 8, y: 9 } }, - { kind: 'crawler', tile: { x: 10, y: 7 } }, - { kind: 'spitter', tile: { x: 20, y: 8 } }, - { kind: 'crawler', tile: { x: 22, y: 21 } }, - { kind: 'heavy', tile: { x: 6, y: 22 } } -]; \ No newline at end of file +/** Точки спавна врагов задаются в data/locations.ts (LOCATIONS[*].enemies). */ \ No newline at end of file diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts new file mode 100644 index 0000000..e4b42fe --- /dev/null +++ b/apps/game/src/data/locations.ts @@ -0,0 +1,77 @@ +import type { NpcDef } from './npcs'; +import type { EnemyKindId } from './enemies'; +import { NPCS } from './npcs'; + +/** + * Локации демо-среза. Карты хранятся файлами assets/maps/.map + * (формат rpg-map, RLE) и загружаются через parseMap в BootScene. + */ +export type LocationId = 'meadows' | 'ponds'; + +export interface LocationExit { + /** Тайл-триггер (наступил — переход). */ + tile: { x: number; y: number }; + to: LocationId; + /** Куда поставить героя в новой локации. */ + entry: { x: number; y: number }; +} + +export interface LocationDef { + id: LocationId; + name: string; + /** Музыка локации (кроссфейд при переходе). */ + music: string; + /** Стартовый тайл (новая игра / респаун). */ + spawn: { x: number; y: number }; + npcs: NpcDef[]; + enemies: { kind: EnemyKindId; tile: { x: number; y: number } }[]; + exits: LocationExit[]; + /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов. */ + ambience: 'ash' | 'fog'; +} + +export const LOCATIONS: Record = { + meadows: { + id: 'meadows', + name: 'Выжженные луга', + music: 'music/meadows', + spawn: { x: 14, y: 14 }, + npcs: NPCS, + enemies: [ + { kind: 'crawler', tile: { x: 8, y: 9 } }, + { kind: 'crawler', tile: { x: 10, y: 7 } }, + { kind: 'spitter', tile: { x: 20, y: 8 } }, + { kind: 'crawler', tile: { x: 22, y: 21 } }, + { kind: 'heavy', tile: { x: 6, y: 22 } } + ], + exits: [ + // Северо-западный угол: тропа на Серые пруды. + { tile: { x: 2, y: 2 }, to: 'ponds', entry: { x: 3, y: 3 } } + ], + ambience: 'ash' + }, + ponds: { + id: 'ponds', + name: 'Серые пруды', + music: 'music/ponds', + spawn: { x: 3, y: 3 }, + npcs: [], + enemies: [ + { kind: 'crawler', tile: { x: 5, y: 12 } }, + { kind: 'crawler', tile: { x: 9, y: 18 } }, + { kind: 'spitter', tile: { x: 16, y: 11 } }, + { kind: 'spitter', tile: { x: 18, y: 18 } }, + { kind: 'heavy', tile: { x: 12, y: 15 } } + ], + exits: [ + // Обратно на луга. + { tile: { x: 2, y: 2 }, to: 'meadows', entry: { x: 3, y: 3 } } + ], + ambience: 'fog' + } +}; + +/** Определение локации по id (с фолбэком на луга для старых сейвов). */ +export function locationOf(id: string | null | undefined): LocationDef { + return LOCATIONS[(id as LocationId) ?? 'meadows'] ?? LOCATIONS.meadows; +} \ No newline at end of file diff --git a/apps/game/src/data/map.ts b/apps/game/src/data/map.ts index 031b725..96b319c 100644 --- a/apps/game/src/data/map.ts +++ b/apps/game/src/data/map.ts @@ -106,6 +106,103 @@ } } + // Тропа к Серым прудам: северо-западный угол (переход между локациями). + // Вокруг тайлов перехода расчищаем поляну — рощи не должны запирать выход. + for (let dy = -1; dy <= 2; dy++) { + for (let dx = -1; dx <= 2; dx++) { + const nx = 2 + dx; + const ny = 2 + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height) { + tiles[at(nx, ny)] = dx === dy || dx + 1 === dy ? TILES.PATH : TILES.GRASS; + } + } + } + tiles[at(2, 2)] = TILES.PATH; + tiles[at(3, 3)] = TILES.PATH; + + return { + width, + height, + tiles, + blocked: [...BLOCKED], + tall: TALL_OBJECTS + }; +} + +/** + * Локация «Серые пруды»: опасная зона — большая гладь воды, туман, + * много пепла и сгустков. Тропа начинается в северо-западном углу + * (обратный переход на луга). + */ +export function buildPondsMap(width = 24, height = 24): TileMapData { + const rng = mulberry32(20260906); + const tiles: number[] = new Array(width * height).fill(TILES.ASH); + const at = (x: number, y: number) => y * width + x; + + // Пятна чахлой травы. + for (let i = 0; i < 24; i++) { + const x = Math.floor(rng() * width); + const y = Math.floor(rng() * height); + const r = 1 + Math.floor(rng() * 2); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng() < 0.6) { + tiles[at(nx, ny)] = TILES.GRASS; + } + } + } + } + + // Большая гладь в центре с рваными берегами. + const cx = Math.floor(width / 2); + const cy = Math.floor(height / 2); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const d = Math.hypot((x - cx) / 7.5, (y - cy) / 5.5); + if (d + (rng() - 0.5) * 0.35 < 1) tiles[at(x, y)] = TILES.WATER; + } + } + + // Лунные колокольчики у берегов (квестовые цветы растут только здесь). + for (const [fx, fy] of [ + [5, 8], + [16, 6], + [18, 16], + [7, 17], + [12, 20] + ]) { + if (tiles[at(fx, fy)] !== TILES.WATER) tiles[at(fx, fy)] = TILES.BELLFLOWER; + } + + // Тропа от северо-западного угла к берегу. + let px = 3; + let py = 3; + while (py < height - 6 || px < width - 8) { + tiles[at(px, py)] = TILES.PATH; + if (rng() < 0.45 && px < width - 8) px++; + else if (py < height - 6) py++; + else px++; + } + + // Сухие деревья по краям. + for (let i = 0; i < 46; i++) { + const edge = rng() < 0.5; + const x = edge ? Math.floor(rng() * 3) : width - 1 - Math.floor(rng() * 3); + const y = Math.floor(rng() * height); + if (tiles[at(x, y)] !== TILES.WATER) tiles[at(x, y)] = TILES.TREE; + } + + // Поляна у входа свободна; тайлы перехода — тропа. + for (let dy = 0; dy <= 1; dy++) { + for (let dx = 0; dx <= 1; dx++) { + tiles[at(3 + dx, 3 + dy)] = dy + dx === 0 ? TILES.PATH : TILES.GRASS; + } + } + tiles[at(2, 2)] = TILES.PATH; + tiles[at(3, 3)] = TILES.PATH; + return { width, height, diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index bd133fa..09877ed 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -1,5 +1,6 @@ import { Container, Graphics, PixelText, ensurePixelFont, type Scene } from '@rpg/engine'; import { Game } from '../Game'; +import { LOCATIONS } from '../data/locations'; import { MenuScene } from './MenuScene'; /** @@ -39,6 +40,12 @@ }) .then(async () => await this.game.assets.loadAtlas('chars/hero_sheet.json')) .then(async () => await this.game.assets.loadAtlas('chars/clumps_sheet.json')) + .then(async () => { + // Карты локаций: файлы .map (rpg-map, RLE) -> parseMap. + for (const id of Object.keys(LOCATIONS)) { + await this.game.loadMap(id); + } + }) .then(() => ensurePixelFont(Game.FONT_URL)) // Звуки: декодируем заранее, чтобы первый play не тормозил. .then(async () => { @@ -49,6 +56,7 @@ }) .then(() => { this.progress = 1; + console.log('[boot] ассеты загружены, меню...'); // маяк для смоук-тестов void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); }) .catch((err) => { diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 42db640..d0d1c41 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -18,10 +18,11 @@ } from '@rpg/engine'; import { Game } from '../Game'; import { MenuScene, type SaveData } from './MenuScene'; -import { buildMeadowsMap, TILES } from '../data/map'; -import { NPCS, type NpcDef } from '../data/npcs'; +import { TILES } from '../data/map'; +import { locationOf, type LocationDef } from '../data/locations'; +import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; -import { ENEMY_SPAWNS_MEADOWS, type EnemyKindId } from '../data/enemies'; +import type { EnemyKindId } from '../data/enemies'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; import { DialogueSystem } from '../systems/DialogueSystem'; import { CombatWorld } from '../systems/combat/CombatWorld'; @@ -42,6 +43,7 @@ private actors = new IsoDepthLayer(); private hint: Container; private ash: ParticleEmitter; + private locationLabel: PixelText; private camera: Camera; // --- бой --- @@ -57,14 +59,20 @@ /** Таймер мигания при неуязвимости (свой, не боевой). */ private blinkT = 0; - constructor(private game: Game, save: SaveData | null) { + constructor( + private game: Game, + save: SaveData | null, + private location: LocationDef, + /** Точка входа при переходе из другой локации (приоритетнее save). */ + entry?: { x: number; y: number } + ) { this.camera = game.engine.camera; - const data = buildMeadowsMap(); + const data = this.game.mapFiles.get(location.id)!; this.map = new IsometricTileMap(data, this.tileTextures(), DEFAULT_ISO); this.world.addChild(this.map.view, this.actors); this.game.renderer.worldRoot.addChild(this.world); - const startTile = save?.pos ?? { x: 14, y: 14 }; + const startTile = entry ?? save?.pos ?? location.spawn; if (save?.state) this.game.state.load(save.state); this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { void this.game.audio.play('sfx/step', 0.35); @@ -72,7 +80,7 @@ // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). this.actors.add(this.player.view, startTile.x, startTile.y); - for (const def of NPCS) { + for (const def of location.npcs) { const view = this.makeNpcView(def); this.actors.add(view, def.tile.x, def.tile.y); this.npcs.push({ def, view }); @@ -93,7 +101,7 @@ this.game.state.setVar('motes', this.game.state.getNumber('motes') + kind.motes); } ); - for (const s of ENEMY_SPAWNS_MEADOWS) { + for (const s of location.enemies) { this.combat.spawnEnemy(s.kind, this.tileCenter(s.tile.x, s.tile.y)); } this.combatViews = new CombatViews(this.combat, this.actors, this.enemyTextures(), this.world); @@ -124,19 +132,37 @@ this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id); this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5); - // Пепел над лугами: медленные серые точки в воздухе. - this.ash = new ParticleEmitter({ - color: 0x666677, - rate: 5, - lifetime: [4, 9], - velocity: { x: [-9, -3], y: [-2, 2] }, - size: 1, - spawnArea: { width: 520, height: 300 }, - seed: 20260905 - }); + // Атмосфера локации: пепел на лугах, туман над прудами. + this.ash = + location.ambience === 'fog' + ? new ParticleEmitter({ + color: 0x7a7a88, + rate: 6, + lifetime: [5, 10], + velocity: { x: [-3, 3], y: [-1, 1] }, + size: 2, + spawnArea: { width: 560, height: 340 }, + seed: 20260906 + }) + : new ParticleEmitter({ + color: 0x666677, + rate: 5, + lifetime: [4, 9], + velocity: { x: [-9, -3], y: [-2, 2] }, + size: 1, + spawnArea: { width: 520, height: 300 }, + seed: 20260905 + }); this.ash.position.set(240, 120); this.game.renderer.worldRoot.addChild(this.ash); + // Название локации в правом верхнем углу. + const name = new PixelText({ text: location.name, size: 10, color: 0x999988 }); + name.anchor.set(1, 0); + name.position.set(474, 4); + this.game.renderer.uiRoot.addChild(name); + this.locationLabel = name; + // Камера: следим за героем; ромб карты уходит в минус по X — // границы начинаются от его западного угла. const size = this.map.screenSize; @@ -160,8 +186,9 @@ } enter(): void { + console.log('[location]', this.location.id); // маяк для смоук-тестов // Амбиент локации (кроссфейд; тот же трек не перезапускается). - this.game.playMusic('music/meadows'); + this.game.playMusic(this.location.music); } exit(): void { @@ -171,6 +198,7 @@ this.ash.destroy(); this.healthBar.destroy({ children: true }); this.hint.destroy({ children: true }); + this.locationLabel.destroy({ children: true }); } update(dt: number): void { @@ -220,6 +248,17 @@ const tile = this.player.currentTile(); this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); + this.checkExits(tile); + } + + /** Переход между локациями: герой наступил на тайл-триггер. */ + private checkExits(tile: { x: number; y: number }): void { + const exit = this.location.exits.find((e) => e.tile.x === tile.x && e.tile.y === tile.y); + if (!exit) return; + void this.game.scenes.replace( + new LocationScene(this.game, null, locationOf(exit.to), exit.entry), + { duration: 0.4 } + ); } render(): void {} @@ -295,8 +334,8 @@ if (this.playerCombat.dead) { this.game.state.setVar('deaths', this.game.state.getNumber('deaths') + 1); this.playerCombat.revive(); - // Респаун на стартовом тайле - this.player.teleportTo({ x: 14, y: 14 }); + // Респаун на стартовом тайле локации + this.player.teleportTo(this.location.spawn); this.healthBar.setHp(this.playerCombat.hp); } } @@ -421,6 +460,7 @@ const pos = this.player.currentTile(); this.game.state.setVar('hp', this.playerCombat.hp); this.game.saves.save('autosave', { + location: this.location.id, pos, state: this.game.state.serialize(), savedAt: Date.now() diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index 2b4771b..7a436b2 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -1,6 +1,8 @@ import { Container, MenuList, PixelText, type GameStateData, type Scene } from '@rpg/engine'; import type { Game } from '../Game'; +import { locationOf } from '../data/locations'; import { LocationScene } from './LocationScene'; +import { SaveSlotsScene } from './SaveSlotsScene'; import { SettingsScene } from './SettingsScene'; /** @@ -35,7 +37,25 @@ const items = [ { label: 'Новая игра', - onSelect: () => this.game.audio.play('sfx/ui_click').then(() => this.start(null)) + // Звук не блокирует навигацию: в suspended-контексте play может не дойти. + onSelect: () => { + void this.game.audio.play('sfx/ui_click'); + this.game.state.reset(); + this.start(null); + } + }, + { + label: 'Сейвы', + onSelect: () => { + void this.game.audio.play('sfx/ui_click'); + void this.game.scenes.push( + new SaveSlotsScene( + this.game, + (save) => void this.game.scenes.pop().then(() => this.start(save)), + () => void this.game.scenes.pop() + ) + ); + } }, { label: 'Настройки', @@ -79,7 +99,9 @@ } private start(save: SaveData | null): void { - void this.game.scenes.replace(new LocationScene(this.game, save), { duration: 0.4 }); + void this.game.scenes.replace(new LocationScene(this.game, save, locationOf(save?.location)), { + duration: 0.4 + }); } update(_dt: number): void { @@ -97,8 +119,10 @@ } } -/** Формат автосейва: позиция героя + сериализованное состояние прохождения. */ +/** Формат сейва: локация, позиция героя и сериализованное состояние прохождения. */ export interface SaveData { + /** В какой локации герой (фолбэк 'meadows' для старых сейвов). */ + location: string; pos: { x: number; y: number }; state: GameStateData; savedAt: number; diff --git a/apps/game/src/scenes/SaveSlotsScene.ts b/apps/game/src/scenes/SaveSlotsScene.ts new file mode 100644 index 0000000..d6b0371 --- /dev/null +++ b/apps/game/src/scenes/SaveSlotsScene.ts @@ -0,0 +1,101 @@ +import { Container, MenuList, Panel, PixelText, type Scene } from '@rpg/engine'; +import type { Game } from '../Game'; +import type { SaveData } from './MenuScene'; + +/** + * Меню сейвов: autosave (только загрузка) + 3 именованных слота + * (загрузка/удаление). Панель поверх меню (push/pop). + */ +export class SaveSlotsScene implements Scene { + private view = new Container(); + private menu: MenuList; + + constructor( + private game: Game, + private onPick: (save: SaveData) => void, + private onBack: () => void + ) { + const panel = new Panel({ width: 240, height: 150 }); + panel.position.set((480 - 240) / 2, (270 - 150) / 2); + + const title = new PixelText({ text: 'СЕЙВЫ', size: 14, color: 0xd8c79a }); + title.anchor.set(0.5); + title.position.set(120, 14); + panel.addChild(title); + + this.menu = new MenuList({ width: 200, height: 13, gap: 3, size: 9 }); + this.menu.position.set(20, 36); + panel.addChild(this.menu); + + this.view.addChild(panel); + this.game.renderer.uiRoot.addChild(this.view); + this.rebuild(); + } + + enter(): void {} + + exit(): void { + this.view.destroy({ children: true }); + } + + render(): void {} + + update(_dt: number): void { + if (this.game.scenes.transitioning) return; + const input = this.game.engine.input; + if (input.isActionJustPressed('menu')) { + this.back(); + return; + } + if (input.isActionJustPressed('up')) this.menu.moveCursor(-1); + if (input.isActionJustPressed('down')) this.menu.moveCursor(1); + if (input.isActionJustPressed('advance')) this.menu.activate(); + // Delete на выбранном именованном слоте — стереть + if (input.isActionJustPressed('attack')) this.deleteAt(this.menu.index); + } + + /** Пересобрать список слотов из SaveManager (listSlots + load). */ + private rebuild(): void { + const items: { label: string; onSelect: () => void }[] = []; + const slots = this.game.saves.listSlots(); + for (const slot of ['autosave', 'slot1', 'slot2', 'slot3']) { + const exists = slots.includes(slot); + if (!exists) { + items.push({ label: `${this.slotTitle(slot)} — пусто —`, onSelect: () => {} }); + continue; + } + const save = this.game.saves.load(slot)!; + const loc = save.location === 'ponds' ? 'пруды' : 'луга'; + const date = new Date(save.savedAt); + const when = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + items.push({ + label: `${this.slotTitle(slot)} ${loc} ${when}`, + onSelect: () => { + void this.game.audio.play('sfx/ui_click'); + this.onPick(save); + } + }); + } + items.push({ label: 'Назад', onSelect: () => this.back() }); + this.menu.setItems(items); + } + + /** Удалить сейв в выбранной строке (именованные слоты; autosave защищён). */ + private deleteAt(index: number): void { + const slot = ['autosave', 'slot1', 'slot2', 'slot3'][index]; + if (!slot || slot === 'autosave' || !this.game.saves.listSlots().includes(slot)) return; + this.game.saves.delete(slot); + void this.game.audio.play('sfx/ui_click'); + this.rebuild(); + } + + private slotTitle(slot: string): string { + if (slot === 'autosave') return 'Авто'; + return slot.replace('slot', 'Слот '); + } + + private back(): void { + void this.game.audio.play('sfx/ui_click'); + this.onBack(); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index a3b8092..526b4e8 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -2,6 +2,7 @@ IsometricTileMap, findPath, isoToScreen, + screenToIso, screenToIsoExact, moveTo, FrameAnimation, @@ -78,16 +79,23 @@ } } - /** Кликом по миру выбран тайл — строим путь A* и начинаем движение. */ + /** Кликом по миру выбран тайл — строим путь A* и начинаем движение. + * Клик за краем карты ведёт к ближайшему краевому тайлу (камера прижата границей). */ onWorldClick(worldX: number, worldY: number): void { - const tile = screenToIsoExact( + let tile = screenToIsoExact( worldX, worldY, this.map.data.width, this.map.data.height, DEFAULT_ISO ); - if (!tile) return; + if (!tile) { + const approx = screenToIso(worldX, worldY, DEFAULT_ISO); + tile = { + x: Math.min(this.map.data.width - 1, Math.max(0, approx.x)), + y: Math.min(this.map.data.height - 1, Math.max(0, approx.y)) + }; + } const path = findPath(this.map, this.currentTile(), tile, false); if (path && path.length > 0) { this.path = path; diff --git a/package.json b/package.json index 5814600..549cbc2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "npm run dev --workspace @rpg/game", "build": "npm run build --workspace @rpg/game", "art": "node tools/pixelart/gen.mjs", + "maps": "vitest run tools/maps/gen.test.ts", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit -p packages/engine && tsc --noEmit -p apps/game" diff --git a/tools/maps/gen.test.ts b/tools/maps/gen.test.ts new file mode 100644 index 0000000..69f17db --- /dev/null +++ b/tools/maps/gen.test.ts @@ -0,0 +1,52 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { encodeMap, parseMap } from '../../packages/engine/src/map/mapFormat'; +import { buildMeadowsMap, buildPondsMap } from '../../apps/game/src/data/map'; + +/** + * Генератор карт-файлов: `npm run maps` (vitest run tools/maps/gen.test.ts). + * Карты строятся из data/map.ts (единый источник истины), пакуются в RLE + * через движковый encodeMap и пишутся в apps/game/assets/maps/*.map. + * Здесь же проверяется обратная распаковка: parseMap должен вернуть карту + * попиксельно равную исходной. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(here, '../../apps/game/assets/maps'); + +const MAPS: Record> = { + meadows: buildMeadowsMap(), + ponds: buildPondsMap() +}; + +describe('генерация карт-файлов (rpg-map, RLE)', () => { + for (const [id, data] of Object.entries(MAPS)) { + it(`${id}: encode -> файл -> parse восстанавливает карту`, () => { + const file = encodeMap(data); + const back = parseMap(JSON.parse(JSON.stringify(file))); + + expect(back.width).toBe(data.width); + expect(back.height).toBe(data.height); + expect(back.blocked).toEqual(data.blocked); + expect(back.tiles).toEqual(data.tiles); + expect(back.tall).toEqual(data.tall); + + mkdirSync(outDir, { recursive: true }); + const path = resolve(outDir, `${id}.map`); + writeFileSync(path, JSON.stringify(file)); + }); + } + + it('RLE реально сжимает карты', () => { + for (const [id, data] of Object.entries(MAPS)) { + const file = encodeMap(data); + const pairs = file.tiles as [number, number][]; + // Серий меньше, чем тайлов: RLE что-то ужал, несмотря на пятнистость + expect(pairs.length).toBeLessThan(data.tiles.length); + expect(file.encoding).toBe('rle'); + expect(id.length).toBeGreaterThan(0); + } + }); +}); \ No newline at end of file diff --git a/tools/smoke-ponds.mjs b/tools/smoke-ponds.mjs new file mode 100644 index 0000000..827f71b --- /dev/null +++ b/tools/smoke-ponds.mjs @@ -0,0 +1,84 @@ +/** + * Смоук перехода луга -> Серые пруды: герой идёт кликами в северо-западный + * угол карты, где триггер (2,2). Запуск: node tools/smoke-ponds.mjs [url] [скриншот] + */ +import puppeteer from 'puppeteer-core'; + +const url = process.argv[2] ?? 'http://localhost:5199/'; +const shot = process.argv[3] ?? '/tmp/rpg_ponds.png'; + +const browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium', + headless: true, + // autoplay-policy: в headless ctx.resume() без флага может не резолвиться, + // и навигация, завязанная на звук, зависает. + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] +}); +const page = await browser.newPage(); +await page.setViewport({ width: 960, height: 540 }); + +page.on('console', (msg) => console.log(`[консоль] ${msg.text()}`)); +page.on('pageerror', (err) => console.log(`[ошибка страницы] ${err.message}\n${err.stack ?? ''}`)); + +// Ждём маяк BootScene: без него клики уходят «в загрузку». +const booted = new Promise((resolve) => { + page.on('console', (msg) => { + if (msg.text().includes('[boot] ассеты загружены')) resolve(); + }); +}); + +await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); +await booted; +await new Promise((r) => setTimeout(r, 1500)); // fade перехода в меню + +// «Новая игра» (первый пункт в свежем профиле; канвас 480x270 растянут x2) +await page.mouse.click(480, 283); +await new Promise((r) => setTimeout(r, 2500)); + +// Шагаем к переходу (2,2): в изометрии он строго «вверх экрана» от старта +// (герой идёт по диагонали tx=ty, экранная X неизменна). Экран->мир зависит +// от камеры, поэтому точку клика вычисляем в странице: на 2 тайла выше ног +// героя в сторону триггера. Каждый клик подтягивает героя к (2,2). +async function stepClick() { + return page.evaluate(() => { + const g = window.__game; + const sc = g.scenes.current; + const tile = sc.player.currentTile(); + const canvas = document.querySelector('canvas'); + const rect = canvas.getBoundingClientRect(); + const wr = g.renderer.worldRoot.position; + const halfH = 8; // DEFAULT_ISO: tileH 16 + const wx = 0; // диагональ tx=ty — экранная X равна 0 + const wy = (tile.x + tile.y) * halfH + halfH - 32; // на 2 тайла выше ног + return { + x: rect.left + ((wx + wr.x) / 480) * rect.width, + y: rect.top + ((wy + wr.y) / 270) * rect.height + }; + }).then((p) => page.mouse.click(p.x, p.y)); +} +for (let i = 0; i < 18; i++) { + const near = await page.evaluate(() => { + const sc = window.__game.scenes.current; + return sc && Math.max(sc.player.currentTile().x, sc.player.currentTile().y) <= 5; + }); + if (near) { + // Триггер уже на экране — кликаем точно в его мировую точку. + const p = await page.evaluate(() => { + const g = window.__game; + const rect = document.querySelector('canvas').getBoundingClientRect(); + const wr = g.renderer.worldRoot.position; + const wy = 4 * 8 + 8; // центр ромба (2,2) + return { x: rect.left + (wr.x / 480) * rect.width, + y: rect.top + ((wy + wr.y) / 270) * rect.height }; + }); + await page.mouse.click(p.x, p.y); + } else { + await stepClick(); + } + await new Promise((r) => setTimeout(r, 700)); +} + +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +await browser.close(); \ No newline at end of file diff --git a/tools/smoke.mjs b/tools/smoke.mjs index bab5e00..e562b99 100644 --- a/tools/smoke.mjs +++ b/tools/smoke.mjs @@ -11,7 +11,10 @@ const browser = await puppeteer.launch({ executablePath: '/usr/bin/chromium', headless: true, - args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', '--window-size=960,540'] + // autoplay-policy: в headless ctx.resume() без флага может не резолвиться, + // и навигация, завязанная на звук, зависает. + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] }); const page = await browser.newPage(); await page.setViewport({ width: 960, height: 540 });