'use strict'; /** * Генерация скриншотов «Рыбалки» — витринный набор (страница вики «Скриншоты», * материалы для Яндекс Игр). * * Снимает 15 состояний игры (меню, лавка, достижения, дневник, ожидание дня, * дождь, ночь, Озёра, Море, Океан, Волшебное болото — день и ночь, вываживание, * карточка улова, аквариум) на трёх вьюпортах: * - desk: 1280x720 @1x (десктоп, 16:9) * - mobp: 360x640 @2x -> 720x1280 (мобильный, портрет, 9:16) * - mobl: 800x450 @1.6x -> 1280x720 (мобильный, ландшафт, 16:9) * * Требования Яндекс Игр: пропорции 16:9 / 9:16, длинная сторона 1280..2560, * JPEG или 24-разрядный PNG без альфа-канала. Скрипт пишет именно 24-битный * PNG (без альфа): после съёмки каждый кадр конвертируется ffmpeg или * ImageMagick (что найдётся первым). * * Использование (путь к скрипту: tests/gen-screenshots.js): * node tests/gen-screenshots.js [каталог-сборки] [папка-вывода] [язык] * каталог-сборки — папка с index.html (по умолчанию корень репозитория) * папка-вывода — по умолчанию <каталог-сборки>/screenshots-wiki * (если язык en, добавляется -en: screenshots-wiki-en) * язык — 'ru' (дефолт) или 'en' — для английских скриншотов * Зависимость: puppeteer-core (cd tests && npm install). * * Переменные окружения: * CHROMIUM — путь к бинарнику Chromium/Chrome (по умолчанию /usr/bin/chromium-browser). * * Требования окружения: * - системный Chromium/Chrome; * - цветной эмодзи-шрифт (Noto Color Emoji) — иначе эмодзи в игре «квадратики»; * - ffmpeg или ImageMagick (для 24-битного PNG без альфа). * * Детерминизм: * - новый сейв на каждом вьюпорте (localStorage.clear) — кадры согласованы; * - карточка улова — всегда золотой осётр 12.5 кг (SHINY_CHANCE=1); * цена catchFish() жмитится фактором (0.95+0.1·rnd) — rnd шпилится в 0.5, * фактор = ровно 1.0, итог +2438 во всех трёх сетках; * * Зависит от тест-глобалов игры (AGENTS.md, «Подводные камни»): * state, forceBite, forceWeather, biteTimer, reel, catchFish, SHINY_CHANCE, * SPECIES, setDayTime, save, dayTime, dayPhase, forceLang, setLang, initFish (Этап 43), * initScenery (Этап 44 — силуэты берега в кадрах lake/sea/ocean; Этап 45 — swamp/swamp-night), * openAquarium (Этап 46 — кадр aquarium засевает save.aqFish и открывает сцену банки) * и DOM-кнопок: #btnPlay, #btnShop, #tabAch, #tabJournal, #btnCloseShop, * #btnCardOk, #card, #shop, #menu. */ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const puppeteer = require('puppeteer-core'); const GAME_DIR = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(__dirname, '..'); const OUT = process.argv[3] ? path.resolve(process.argv[3]) : path.join(GAME_DIR, 'screenshots-wiki'); const GAME = 'file://' + path.join(GAME_DIR, 'index.html'); const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium-browser'; const SCRIPT_LANG = (process.argv[4] === 'en') ? 'en' : 'ru'; const VIEWPORTS = [ { key: 'desk', w: 1280, h: 720, dsf: 1 }, { key: 'mobp', w: 360, h: 640, dsf: 2 }, { key: 'mobl', w: 800, h: 450, dsf: 1.6 }, ]; const FRAMES = ['menu', 'shop', 'ach', 'journal', 'wait', 'weather-rain', 'night', 'lake', 'sea', 'ocean', 'swamp', 'swamp-night', 'reel', 'card', 'aquarium']; // Этап 43: +кадры Море/Океан (визуальное различение локаций); Этап 45: +кадры Волшебного болота (день/ночь со свечением); Этап 46: +кадр аквариума (сцена коллекции) // ---------- 24-битный PNG (без альфа): ffmpeg предпочтительнее, иначе ImageMagick function findBin(name) { const r = spawnSync('sh', ['-c', `command -v ${name}`], { encoding: 'utf8' }); return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null; } const FFMPEG = findBin('ffmpeg'); const CONVERT = findBin('convert'); function to24bit(src, dst) { if (FFMPEG) { const r = spawnSync(FFMPEG, ['-y', '-loglevel', 'error', '-i', src, '-c:v', 'png', '-pix_fmt', 'rgb24', dst]); if (r.status !== 0) throw new Error('ffmpeg: ' + (r.stderr || '').toString().trim()); return; } if (CONVERT) { const r = spawnSync(CONVERT, [src, '-type', 'TrueColor', dst]); if (r.status !== 0) throw new Error('convert: ' + (r.stderr || '').toString().trim()); return; } throw new Error('нет ни ffmpeg, ни ImageMagick (convert) — нужны для 24-битного PNG без альфа'); } // ---------- утилиты ожидания const sleep = ms => new Promise(r => setTimeout(r, ms)); async function waitState(page, st, timeout = 10000) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { const s = await page.evaluate(() => state); if (s === st) return; await sleep(100); } throw new Error('timeout waiting for state ' + st + ' (now: ' + (await page.evaluate(() => state)) + ')'); } // Фактическая отрисовка: два подряд requestAnimationFrame. const waitPaint = page => page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); // Скриншот: сырой RGBA -> 24-битный PNG без альфа (файл в OUT — финальный). async function shoot(page, name) { const tmp = path.join(OUT, name + '.tmp.png'); await page.screenshot({ path: tmp, type: 'png' }); to24bit(tmp, path.join(OUT, name)); fs.unlinkSync(tmp); console.log(' saved', name); } // ---------- один набор кадров на вьюпорте async function captureSet(browser, vp) { const cx = Math.round(vp.w / 2), cy = Math.round(vp.h / 2); const page = await browser.newPage(); await page.setViewport({ width: vp.w, height: vp.h, deviceScaleFactor: vp.dsf }); await page.goto(GAME, { waitUntil: 'load' }); // язык: forceLang + setLang (обновляет UI на en/ru) await page.evaluate((lang) => { forceLang = (lang === 'en') ? 'en' : null; setLang(lang); }, SCRIPT_LANG); // чистый сейв на каждом вьюпорте: кадры согласованы, без переноса прогресса await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'load' }); // после reload forceLang/resolveLang сбрасываются на ru — ставим язык заново await page.evaluate((lang) => setLang(lang), SCRIPT_LANG); await page.waitForFunction( () => !document.getElementById('menu').classList.contains('hidden'), { timeout: 8000, polling: 50 }); await waitPaint(page); await shoot(page, `${vp.key}-menu.png`); // старт await page.click('#btnPlay'); await waitState(page, 'IDLE'); // лавка рыбака — вкладка «Лавка» (tackle) await page.click('#btnShop'); await page.waitForFunction( () => !document.getElementById('shop').classList.contains('hidden'), { timeout: 3000, polling: 50 }); await sleep(300); await waitPaint(page); await shoot(page, `${vp.key}-shop.png`); // достижения — вкладка #tabAch await page.click('#tabAch'); await page.waitForFunction( () => document.getElementById('tabAch').classList.contains('active'), { timeout: 3000, polling: 50 }); await waitPaint(page); await shoot(page, `${vp.key}-ach.png`); // дневник — вкладка #tabJournal await page.click('#tabJournal'); await page.waitForFunction( () => { const text = document.getElementById('shopBody').textContent; if (LANG === 'en') return text.indexOf('Casts') >= 0; return text.indexOf('Бросков') >= 0; }, { timeout: 3000, polling: 50 }); await waitPaint(page); await shoot(page, `${vp.key}-journal.png`); await page.click('#btnCloseShop'); await waitState(page, 'IDLE', 3000); // ожидание днём — заброс + WAIT await page.mouse.move(cx, cy); await page.mouse.down(); await waitState(page, 'CHARGE', 3000); await sleep(650); await waitPaint(page); await page.mouse.up(); await waitState(page, 'WAIT'); await sleep(1200); await waitPaint(page); await shoot(page, `${vp.key}-wait.png`); // дождь — forceWeather='rain': полоски дождя + пилюля 🌧️ в HUD await page.evaluate(() => { forceWeather = 'rain'; }); await sleep(500); await waitPaint(page); await shoot(page, `${vp.key}-weather-rain.png`); await page.evaluate(() => { forceWeather = null; }); // ночь — dayTime 0.85 (фаза night) await page.evaluate(() => { setDayTime(0.85); }); await waitPaint(page); await shoot(page, `${vp.key}-night.png`); await page.evaluate(() => { setDayTime(0.3); }); // Озёра — save.location=1, день, dayTime 0.3 (Этап 43: initFish() — рыбы по воде; // Этап 44: initScenery() — силуэты новой воды, без них кадр покажет лес с Реки) await page.evaluate(() => { save.locations = [0, 1]; save.location = 1; initFish(); initScenery(); setDayTime(0.3); }); await waitPaint(page); await shoot(page, `${vp.key}-lake.png`); // Море — save.location=2, день, dayTime 0.3 (Этап 43/44) await page.evaluate(() => { save.locations = [0, 1, 2]; save.location = 2; initFish(); initScenery(); setDayTime(0.3); }); await waitPaint(page); await shoot(page, `${vp.key}-sea.png`); // Океан — save.location=3, день, dayTime 0.3 (Этап 43/44: силуэтов нет — открытый горизонт) await page.evaluate(() => { save.locations = [0, 1, 2, 3]; save.location = 3; initFish(); initScenery(); setDayTime(0.3); }); await waitPaint(page); await shoot(page, `${vp.key}-ocean.png`); // Волшебное болото — save.location=4, день, dayTime 0.3 (Этап 45: мрачная моховая вода, // камыш/кривые деревья, кувшинки/лилии, прыгающие лягушки; свечения нет — nightMix()<=0.35) await page.evaluate(() => { save.locations = [0, 1, 2, 3, 4]; save.location = 4; initFish(); initScenery(); setDayTime(0.3); }); await waitPaint(page); await shoot(page, `${vp.key}-swamp.png`); // Волшебное болото — ночь, dayTime 0.85 (Этап 45: светящиеся рыбы — nightMix()>0.35, // ауры цветом glow каждого вида/палитры) await page.evaluate(() => { setDayTime(0.85); }); await waitPaint(page); await shoot(page, `${vp.key}-swamp-night.png`); // сброс на Реку, день (dayTime возвращаем после ночного болотного кадра) await page.evaluate(() => { save.location = 0; save.locations = [0]; initFish(); initScenery(); setDayTime(0.3); }); // вываживание: recall → заброс + клёв + подсечка → REEL // biteTimer сбрасывается при завершении CAST, поэтому ставим его после заброса await page.evaluate(() => { forceBite = true; recall(); }); await page.mouse.move(cx, cy); await page.mouse.down(); await waitState(page, 'CHARGE', 3000); await sleep(650); await page.mouse.up(); await waitState(page, 'WAIT'); await page.evaluate(() => { biteTimer = 0.05; }); await waitState(page, 'BITE', 4000); await page.mouse.down(); await page.mouse.up(); await waitState(page, 'REEL'); await page.evaluate(() => { const r = reel; r.sp = SPECIES.find(s => s.id === 'sturge'); r.w = 12.5; r.ov = 4; r.aggr = 0.8; r.st = 0.95; r.tired = false; r.d = Math.max(r.d, 90); r.d0 = Math.max(r.d0, 132); r.tension = 25; r.escape = 0; r.phase = 'calm'; r.phaseT = 2.0; r.line = Math.max(r.line, 300); r.boss = false; }); await sleep(400); await page.mouse.down(); await sleep(1200); await waitPaint(page); await shoot(page, `${vp.key}-reel.png`); await page.mouse.up(); // карточка улова: золотой осётр 12.5 кг (детерминированно); await page.evaluate(() => { SHINY_CHANCE = 1; if (reel) { reel.w = 12.5; reel.boss = false; } if (state === 'REEL') { const __rnd = Math.random; Math.random = () => 0.5; try { catchFish(reel.sp); } finally { Math.random = __rnd; } } }); await waitState(page, 'CARD', 4000); await sleep(2200); await waitPaint(page); console.log(' [card]', await page.evaluate(() => state + ' card=' + document.getElementById('card').classList.contains('hidden'))); await shoot(page, `${vp.key}-card.png`); // аквариум (Этап 46): сцена коллекции вместо озера — засеваем save.aqFish // (карась, щука-серебро, золотая-радуга, светлячок Болота), openAquarium() и снимок await page.evaluate(() => { document.getElementById('card').classList.add('hidden'); state = 'IDLE'; save.aqSlots = 6; save.aqFish = [ { id: 'carass', v: 'normal', w: 0.6 }, { id: 'pike', v: 'silver', w: 7.4 }, { id: 'gold', v: 'rainbow', w: 0.8 }, { id: 'glowfin', v: 'normal', w: 2.4 }, ]; openAquarium(); }); await waitPaint(page); await sleep(300); await waitPaint(page); await shoot(page, `${vp.key}-aquarium.png`); await page.close(); } (async () => { if (!fs.existsSync(path.join(GAME_DIR, 'index.html'))) { console.error('Не найден index.html в', GAME_DIR, '— укажите каталог сборки: node tests/gen-screenshots.js /path/to/fishing-game [папка-вывода] [язык]'); process.exit(1); } fs.mkdirSync(OUT, { recursive: true }); const browser = await puppeteer.launch({ executablePath: CHROMIUM, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage', '--mute-audio', '--font-render-hinting=none', '--force-color-profile=srgb'], }); let n = 0; for (const vp of VIEWPORTS) { console.log('=== viewport', vp.key, vp.w + 'x' + vp.h + '@' + vp.dsf, '(lang=' + SCRIPT_LANG + ')'); await captureSet(browser, vp); n += FRAMES.length; } await browser.close(); console.log('DONE:', n, 'кадров ->', OUT); })().catch(e => { console.error(e); process.exit(1); });