diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index 5db8aaa..d4381c1 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -37,16 +37,26 @@ }, elder_repeat: { - start: 'again', + start: 'waiting', nodes: { - again: { - whenNot: ['quest_bells_done'], - next: 'waiting' - }, waiting: { speaker: 'Старейшина Ирвин', text: 'Цветы ждут у прудов, звонарь. А пепел не ждёт.' } } }, + // Сдача квеста: выбирается в talkTo, когда собрано достаточно цветов. + elder_hand_in: { + start: 'count', + nodes: { + count: { + speaker: 'Старейшина Ирвин', + text: 'Три цветка. Живые. Сажай у тропы, звонарь. Серая земля примет.', + next: 'accept' + }, + accept: { setFlags: ['quest_bells_done'], next: 'ring' }, + ring: { speaker: 'Звонарь', text: 'Пусть гудят. Это твой голос, Ирвин, — теперь в земле.' } + } + }, + trader_first: { start: 'stop', nodes: { @@ -68,5 +78,29 @@ give_cloth: { setFlags: ['met_mila', 'got_cloth'], next: 'reply' }, reply: { speaker: 'Звонарь', text: 'Спасибо, Мила. Верну и полотно, и голос — твой точно.' } } + }, + + // Повторная Мила: до сдачи квеста. + trader_repeat: { + start: 'quiet', + nodes: { + quiet: { + speaker: 'Торговка Мила', + text: '*колокольчик один раз* ...Звони тихо. Пепел просыпается от гулкого.', + end: true + } + } + }, + + // Мила после посадки: крючок акта 1. + trader_after: { + start: 'listen', + nodes: { + listen: { + speaker: 'Торговка Мила', + text: '*смотрит под ноги* ...Слышишь? Гул снизу. Это разъезд. Это Машина дышит.', + end: true + } + } } }; \ No newline at end of file diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts new file mode 100644 index 0000000..80c4de4 --- /dev/null +++ b/apps/game/src/data/quests.ts @@ -0,0 +1,40 @@ +import type { GameState } from '@rpg/engine'; + +/** + * Квест «Три цветка» (docs/world.md, акт 1): Ирвин просит собрать + * лунные колокольчики у Серых прудов и посадить их на лугу. + * Прогресс — во флагах/варах GameState, чтобы переживал сейвы. + */ +export const QUEST_FLOWERS = 3; + +/** Одна строка журнала квестов. */ +export interface QuestEntry { + done: boolean; + text: string; +} + +/** Журнал квестов по флагам GameState. */ +export function questLog(state: GameState): QuestEntry[] { + const entries: QuestEntry[] = []; + if (state.hasFlag('quest_bells_taken')) { + const n = Math.min(state.getNumber('flowers'), QUEST_FLOWERS); + entries.push({ + done: state.hasFlag('quest_bells_done'), + text: `Собрать лунные колокольчики у Серых прудов (${n}/${QUEST_FLOWERS})` + }); + } + if (state.hasFlag('quest_bells_done')) { + entries.push({ done: true, text: 'Цветы посажены у тропы. Поляна гудит.' }); + } + return entries; +} + +/** Предметы инвентаря по флагам/варам GameState. */ +export function inventoryItems(state: GameState): string[] { + const items: string[] = []; + if (state.hasFlag('got_cloth')) items.push('Вощёное полотно'); + const flowers = state.getNumber('flowers'); + if (flowers > 0) items.push(`Лунный колокольчик ×${flowers}`); + if (items.length === 0) items.push('— пусто —'); + return items; +} \ No newline at end of file diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index b7eaee6..bf17e8a 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -20,6 +20,7 @@ advance: ['Space', 'Enter'], attack: ['Space'], // вне диалога: тап — удар, удержание — резонанс menu: ['Escape'], + inventory: ['KeyI'], up: ['KeyW', 'ArrowUp'], down: ['KeyS', 'ArrowDown'], left: ['KeyA', 'ArrowLeft'], @@ -29,6 +30,7 @@ advance: [0], // A attack: [2], // X menu: [9], // Start + inventory: [3], // Y up: [12], down: [13], left: [14], diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts new file mode 100644 index 0000000..62652f8 --- /dev/null +++ b/apps/game/src/scenes/InventoryScene.ts @@ -0,0 +1,69 @@ +import { Container, Panel, PixelText, type Scene } from '@rpg/engine'; +import { Game } from '../Game'; +import { inventoryItems, questLog } from '../data/quests'; + +/** + * Сумка и журнал квестов (панель поверх локации, push/pop). + * Содержимое собирается из флагов/варов GameState при каждом открытии. + */ +export class InventoryScene implements Scene { + private view = new Container(); + + constructor(private game: Game, private onBack: () => void) { + const panel = new Panel({ width: 280, height: 180 }); + panel.position.set((480 - 280) / 2, (270 - 180) / 2); + + const title = new PixelText({ text: 'ЗВОНАРЬ', size: 14, color: 0xd8c79a }); + title.anchor.set(0.5); + title.position.set(140, 16); + panel.addChild(title); + + const addLine = (text: string, color: number, x: number, y: number): void => { + const t = new PixelText({ text, size: 9, color }); + t.position.set(x, y); + panel.addChild(t); + }; + + // Инвентарь + addLine('Сумка', 0x999988, 16, 36); + let y = 50; + for (const item of inventoryItems(this.game.state)) { + addLine(item, 0xd8c79a, 24, y); + y += 12; + } + + // Журнал квестов + addLine('Журнал', 0x999988, 16, y + 6); + y += 20; + for (const entry of questLog(this.game.state)) { + const mark = entry.done ? 'x' : '·'; + addLine(`[${mark}] ${entry.text}`, entry.done ? 0xd8c79a : 0xaaaaaa, 24, y); + y += 12; + } + + const hint = new PixelText({ text: 'Esc — назад', size: 8, color: 0x777788 }); + hint.anchor.set(0.5); + hint.position.set(140, 168); + panel.addChild(hint); + + this.view.addChild(panel); + this.game.renderer.uiRoot.addChild(this.view); + } + + 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') || input.isActionJustPressed('inventory')) { + void this.game.audio.play('sfx/ui_click'); + this.onBack(); + } + } +} \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index d0d1c41..5c21663 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -18,10 +18,12 @@ } from '@rpg/engine'; import { Game } from '../Game'; import { MenuScene, type SaveData } from './MenuScene'; +import { InventoryScene } from './InventoryScene'; import { TILES } from '../data/map'; import { locationOf, type LocationDef } from '../data/locations'; import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; +import { QUEST_FLOWERS } from '../data/quests'; import type { EnemyKindId } from '../data/enemies'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; import { DialogueSystem } from '../systems/DialogueSystem'; @@ -176,7 +178,7 @@ this.hint = new Container(); const text = new PixelText({ - text: 'клик — идти · клик по сгустку — атаковать · Space — удар (удержать — резонанс) · Esc — меню', + text: 'клик — идти · клик по сгустку — атаковать · Space — удар (удержать — резонанс) · I — сумка · Esc — меню', size: 8, color: 0x999988 }); @@ -218,6 +220,15 @@ return; } + if (input.isActionJustPressed('inventory')) { + void this.game.audio.play('sfx/ui_click'); + void this.game.scenes.push( + new InventoryScene(this.game, () => void this.game.scenes.pop()), + { duration: 0.2 } + ); + return; + } + // --- ввод боя: тап = короткий удар, удержание = заряд резонанса --- if (input.isActionJustPressed('attack')) { this.playerCombat.startCharge(); @@ -411,6 +422,12 @@ this.talkTo(npc.def); return; } + + // Клик по лунному колокольчику (пруды) — собрать цветок. + if (this.location.id === 'ponds' && this.tileId(clicked.x, clicked.y) === TILES.BELLFLOWER) { + this.collectFlower(clicked.x, clicked.y); + return; + } } // Клик по сгустку — выбрать цель (авто-подход и удар). @@ -428,15 +445,72 @@ this.player.onWorldClick(worldX, worldY); } + /** id тайла карты (для кликов по сборным объектам). */ + private tileId(x: number, y: number): number { + return this.map.data.tiles[y * this.map.data.width + x]; + } + + /** Сбор лунного колокольчика: тайл зеленеет, прогресс квеста — в vars. */ + private collectFlower(x: number, y: number): void { + this.map.setTile(x, y, TILES.GRASS); + const n = this.game.state.getNumber('flowers') + 1; + this.game.state.setVar('flowers', n); + this.game.engine.events.emit('quest:flower', { n }); + void this.game.audio.play('sfx/bell_hit', 0.6); + const c = this.tileCenter(x, y); + this.combatViews.hitBurst(c); + this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); + } + private talkTo(def: NpcDef): void { const met = this.game.state.hasFlag(def.flagKey); if (!met) this.game.state.setFlag(def.flagKey); - const id = met ? def.dialogueRepeat : def.dialogueFirst; + let id = met ? def.dialogueRepeat : def.dialogueFirst; + // Сюжетные ветки по прогрессу (квесты — по флагам GameState). + if (met && def.id === 'elder') { + const flowers = this.game.state.getNumber('flowers'); + if (!this.game.state.hasFlag('quest_bells_done') && flowers >= QUEST_FLOWERS) { + id = 'elder_hand_in'; + } + } + if (met && def.id === 'trader' && this.game.state.hasFlag('quest_bells_done')) { + id = 'trader_after'; + } this.dialogue.start(DIALOGUES[id], id); } - private onDialogueFinished(_id: string): void { - // Триггеры сюжета вешаются на id завершённого диалога (квесты — по флагам GameState). + private onDialogueFinished(id: string): void { + if (id === 'elder_hand_in') this.plantFlowers(); + } + + /** Посадка цветов у тропы: тайлы гудят колокольчиками (крючок акта 1). */ + private plantFlowers(): void { + const left = this.game.state.getNumber('flowers') - QUEST_FLOWERS; + this.game.state.setVar('flowers', Math.max(0, left)); + for (const [tx, ty] of [ + [15, 13], + [16, 14], + [15, 15] + ]) { + this.map.setTile(tx, ty, TILES.BELLFLOWER); + this.combatViews.hitBurst(this.tileCenter(tx, ty)); + } + void this.game.audio.play('sfx/bell_low', 0.8); + this.showToast('Гул снизу стал громче... Машина дышит.'); + } + + /** Всплывающая подсказка: появляется и растворяется над UI. */ + private showToast(text: string): void { + const toast = new PixelText({ text, size: 9, color: 0xd8c79a }); + toast.anchor.set(0.5); + toast.position.set(240, 40); + toast.alpha = 0; + this.game.renderer.uiRoot.addChild(toast); + const tweens = this.game.engine.tweens; + tweens.to(toast, { alpha: 1 }, { duration: 0.25 }); + tweens.delay(1.6, () => { + tweens.to(toast, { alpha: 0, y: 32 }, { duration: 0.5, onDone: () => toast.destroy() }); + }); } private makeNpcView(def: NpcDef): Container { diff --git a/packages/engine/src/input/InputManager.ts b/packages/engine/src/input/InputManager.ts index fdcd797..dae25e6 100644 --- a/packages/engine/src/input/InputManager.ts +++ b/packages/engine/src/input/InputManager.ts @@ -28,7 +28,8 @@ private keysDown = new Set(); private keysPressed = new Set(); private keysReleased = new Set(); - private keyActions = new Map(); + /** code -> действия (одна клавиша может маппиться на несколько действий). */ + private keyActions = new Map(); // --- геймпад (стандартная карта кнопок) --- /** action -> индексы кнопок стандартной карты. */ @@ -75,7 +76,9 @@ bindActions(map: Record): void { for (const [action, codes] of Object.entries(map)) { for (const code of codes) { - this.keyActions.set(code, action); + const actions = this.keyActions.get(code) ?? []; + if (!actions.includes(action)) actions.push(action); + this.keyActions.set(code, actions); } } } @@ -134,8 +137,8 @@ /** Действие активно (зажато): клавиатура или геймпад. */ isActionActive(action: string): boolean { - for (const [code, a] of this.keyActions) { - if (a === action && this.keysDown.has(code)) return true; + for (const [code, actions] of this.keyActions) { + if (actions.includes(action) && this.keysDown.has(code)) return true; } const padButtons = this.padActions.get(action); if (padButtons) { @@ -149,7 +152,7 @@ /** Действие нажато именно в этом тике. */ isActionJustPressed(action: string): boolean { for (const code of this.keysPressed) { - if (this.keyActions.get(code) === action) return true; + if (this.keyActions.get(code)?.includes(action)) return true; } const padButtons = this.padActions.get(action); if (padButtons) { @@ -163,7 +166,7 @@ /** Действие отпущено именно в этом тике (клавиатура или геймпад). */ isActionJustReleased(action: string): boolean { for (const code of this.keysReleased) { - if (this.keyActions.get(code) === action) return true; + if (this.keyActions.get(code)?.includes(action)) return true; } const padButtons = this.padActions.get(action); if (padButtons) { diff --git a/packages/engine/src/input/__tests__/InputManager.test.ts b/packages/engine/src/input/__tests__/InputManager.test.ts index a5ae251..bfe8a5c 100644 --- a/packages/engine/src/input/__tests__/InputManager.test.ts +++ b/packages/engine/src/input/__tests__/InputManager.test.ts @@ -70,6 +70,16 @@ expect(input.isActionJustReleased('advance')).toBe(false); }); + it('одна клавиша маппится на несколько действий', () => { + const kd = env.windowListeners.get('keydown')!; + input.bindActions({ advance: ['Space'] }); // Space уже занят attack + kd(keyEvent('Space')); + expect(input.isActionJustPressed('attack')).toBe(true); + expect(input.isActionJustPressed('advance')).toBe(true); + expect(input.isActionActive('attack')).toBe(true); + expect(input.isActionActive('advance')).toBe(true); + }); + it('указатель: downTicks копится, justPressed сбрасывается в endTick', () => { const pd = env.canvasListeners.get('pointerdown')!; const pu = env.canvasListeners.get('pointerup')!; diff --git a/packages/engine/src/map/IsometricTileMap.ts b/packages/engine/src/map/IsometricTileMap.ts index 49cb841..267f6b0 100644 --- a/packages/engine/src/map/IsometricTileMap.ts +++ b/packages/engine/src/map/IsometricTileMap.ts @@ -29,13 +29,23 @@ tall?: Record; } +/** Спрайты одной ячейки — для перерисовки тайла без перестроения карты. */ +interface CellSprites { + ground?: Sprite; + object?: Sprite; +} + export class IsometricTileMap implements Grid { readonly data: TileMapData; readonly iso: IsoLayout; readonly view: Container; + private textures: Map; private blockedSet: Set; private tallSpecs: Map; + private groundLayer: Container; + private objectsLayer: Container; + private cells = new Map(); get width(): number { return this.data.width; @@ -48,6 +58,7 @@ constructor(data: TileMapData, textures: Map, iso: IsoLayout = DEFAULT_ISO) { this.data = data; this.iso = iso; + this.textures = textures; this.blockedSet = new Set(data.blocked); this.tallSpecs = new Map( Object.entries(data.tall ?? {}).map(([id, spec]) => [ @@ -59,60 +70,88 @@ const ground = new Container(); const objects = new Container(); + this.groundLayer = ground; + this.objectsLayer = objects; this.view.addChild(ground, objects); // Сортировка глубины по диагонали (tx + ty) — классика изометрии. - const cells: { tx: number; ty: number; id: number }[] = []; + const cells: { tx: number; ty: number }[] = []; for (let ty = 0; ty < data.height; ty++) { for (let tx = 0; tx < data.width; tx++) { - cells.push({ tx, ty, id: data.tiles[ty * data.width + tx] }); + cells.push({ tx, ty }); } } cells.sort((a, b) => a.tx + a.ty - (b.tx + b.ty)); for (const cell of cells) { - const tall = this.tallSpecs.get(cell.id); - const p = isoToScreen(cell.tx, cell.ty, iso); + this.drawCell(cell.tx, cell.ty); + } + } - // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер). - const groundId = tall?.ground ?? cell.id; - const groundTex = textures.get(groundId); - if (groundTex) { - const s = new Sprite(groundTex); - s.anchor.set(0.5, 0); - s.position.set(p.x, p.y); - ground.addChild(s); + /** + * Изменить тайл: обновляет данные и перерисовывает одну ячейку + * (сбор предметов, посадка цветов, разрушаемые стены и т.п.). + */ + setTile(x: number, y: number, id: number): void { + if (x < 0 || y < 0 || x >= this.data.width || y >= this.data.height) return; + this.data.tiles[y * this.data.width + x] = id; + this.drawCell(x, y); + } + + /** Нарисовать (или перерисовать после setTile) одну ячейку карты. */ + private drawCell(tx: number, ty: number): void { + const iso = this.iso; + const id = this.data.tiles[ty * this.data.width + tx]; + const key = ty * this.data.width + tx; + const old = this.cells.get(key); + if (old?.ground) old.ground.destroy(); + if (old?.object) old.object.destroy(); + const fresh: CellSprites = {}; + this.cells.set(key, fresh); + + const tall = this.tallSpecs.get(id); + const p = isoToScreen(tx, ty, iso); + + // Земля: под высоким объектом рисуем его ground-тайл (или плейсхолдер). + const groundId = tall?.ground ?? id; + const groundTex = this.textures.get(groundId); + if (groundTex) { + const s = new Sprite(groundTex); + s.anchor.set(0.5, 0); + s.position.set(p.x, p.y); + this.groundLayer.addChild(s); + fresh.ground = s; + } else { + // Нет текстуры — плейсхолдер-ромб: зелёный (проходимо) или коричневый (блок). + const g = new Graphics(); + const hw = iso.tileW / 2; + const hh = iso.tileH / 2; + g.poly([p.x, p.y, p.x + hw, p.y + hh, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]); + g.fill(this.blockedSet.has(id) ? 0x4a3b2a : 0x2d5a27); + this.groundLayer.addChild(g); + } + + // Высокий объект: спрайт с якорем в центре ромба, иначе колонна. + if (tall) { + const objTex = this.textures.get(id); + if (objTex) { + const s = new Sprite(objTex); + s.anchor.set(0.5, 1); + s.position.set(p.x, p.y + iso.tileH / 2); + this.objectsLayer.addChild(s); + fresh.object = s; } else { - // Нет текстуры — плейсхолдер-ромб: зелёный (проходимо) или коричневый (блок). - const g = new Graphics(); + const block = new Graphics(); + const h = tall.height; const hw = iso.tileW / 2; const hh = iso.tileH / 2; - g.poly([p.x, p.y, p.x + hw, p.y + hh, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]); - g.fill(this.blockedSet.has(cell.id) ? 0x4a3b2a : 0x2d5a27); - ground.addChild(g); - } - - // Высокий объект: спрайт с якорем в центре ромба, иначе колонна. - if (tall) { - const objTex = textures.get(cell.id); - if (objTex) { - const s = new Sprite(objTex); - s.anchor.set(0.5, 1); - s.position.set(p.x, p.y + iso.tileH / 2); - objects.addChild(s); - } else { - const block = new Graphics(); - const h = tall.height; - const hw = iso.tileW / 2; - const hh = iso.tileH / 2; - block.poly([p.x, p.y - h, p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x - hw, p.y + hh - h]); - block.fill(0x6b5a44); - block.poly([p.x - hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]); - block.fill(0x4a3b2a); - block.poly([p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x + hw, p.y + hh]); - block.fill(0x352a1e); - objects.addChild(block); - } + block.poly([p.x, p.y - h, p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x - hw, p.y + hh - h]); + block.fill(0x6b5a44); + block.poly([p.x - hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x - hw, p.y + hh]); + block.fill(0x4a3b2a); + block.poly([p.x + hw, p.y + hh - h, p.x, p.y + iso.tileH - h, p.x, p.y + iso.tileH, p.x + hw, p.y + hh]); + block.fill(0x352a1e); + this.objectsLayer.addChild(block); } } } diff --git a/packages/engine/src/map/__tests__/isometricMap.test.ts b/packages/engine/src/map/__tests__/isometricMap.test.ts new file mode 100644 index 0000000..d90fd48 --- /dev/null +++ b/packages/engine/src/map/__tests__/isometricMap.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { IsometricTileMap, type TileMapData } from '../IsometricTileMap'; + +function makeData(): TileMapData { + return { + width: 4, + height: 4, + tiles: new Array(16).fill(0), + blocked: [2], + tall: { 3: { height: 24, ground: 0 } } + }; +} + +describe('IsometricTileMap.setTile', () => { + it('обновляет данные и проходимость ячейки', () => { + const map = new IsometricTileMap(makeData(), new Map()); + expect(map.isWalkable(1, 1)).toBe(true); + + map.setTile(1, 1, 3); // высокий объект + expect(map.data.tiles[1 * 4 + 1]).toBe(3); + expect(map.isWalkable(1, 1)).toBe(true); // 3 не в blocked + + map.setTile(1, 1, 2); // блокирующий id + expect(map.data.tiles[1 * 4 + 1]).toBe(2); + expect(map.isWalkable(1, 1)).toBe(false); + }); + + it('игнорирует координаты за границей карты', () => { + const map = new IsometricTileMap(makeData(), new Map()); + expect(() => map.setTile(-1, 0, 2)).not.toThrow(); + expect(() => map.setTile(4, 0, 2)).not.toThrow(); + expect(() => map.setTile(0, 4, 2)).not.toThrow(); + // данные не изменились + expect(map.data.tiles.every((t) => t === 0)).toBe(true); + }); +}); \ No newline at end of file diff --git a/tools/smoke-quest.mjs b/tools/smoke-quest.mjs new file mode 100644 index 0000000..4880d58 --- /dev/null +++ b/tools/smoke-quest.mjs @@ -0,0 +1,52 @@ +/** + * Смоук квестовой рамки: новая игра → диалог с Ирвином (задание) → + * сумка с журналом (I). Запуск: node tools/smoke-quest.mjs [url] [скриншот] + */ +import puppeteer from 'puppeteer-core'; + +const url = process.argv[2] ?? 'http://localhost:5199/'; +const shot = process.argv[3] ?? '/tmp/rpg_quest.png'; + +const browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium', + headless: true, + 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 ?? ''}`)); + +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)); + +// «Новая игра» +await page.mouse.click(480, 283); +await new Promise((r) => setTimeout(r, 2500)); + +// Клик по Ирвину (тайл (13,15), герой в (14,14)) — диалог. +await page.mouse.click(416, 270); +await new Promise((r) => setTimeout(r, 1000)); + +// Листаем реплики до конца. +for (let i = 0; i < 5; i++) { + await page.keyboard.press('Space'); + await new Promise((r) => setTimeout(r, 500)); +} + +// Открываем сумку с журналом. +await page.keyboard.press('KeyI'); +await new Promise((r) => setTimeout(r, 800)); + +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +await browser.close(); \ No newline at end of file