Files
fishing-game/tests/gen-screenshots.js
T
andrey a1afe224e6 fix: корректная генерация английских скриншотов
setLang(SCRIPT_LANG) вызывается после page.reload(), потому что
forceLang сбрасывается между reload-ами и resolveLang ставил LANG='ru'.
Жёсткая проверка 'Бросков' в дневнике сделана языкозависимой.
Переименована LANG -> SCRIPT_LANG для избежания конфликта с глобальной LANG.
Перегенерированы screenshots-wiki (ru) и screenshots-wiki-en (en) — по 30 кадров.
2026-09-09 18:30:21 +03:00

261 lines
12 KiB
JavaScript

'use strict';
/**
* Генерация скриншотов «Рыбалки» — витринный набор (страница вики «Скриншоты»,
* материалы для Яндекс Игр).
*
* Снимает 10 состояний игры (меню, лавка, достижения, дневник, ожидание дня,
* дождь, ночь, Озёра, вываживание, карточка улова) на трёх вьюпортах:
* - 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
* и 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', 'reel', 'card'];
// ---------- 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
await page.evaluate(() => { save.locations = [0, 1]; save.location = 1; setDayTime(0.3); });
await waitPaint(page);
await shoot(page, `${vp.key}-lake.png`);
await page.evaluate(() => { save.location = 0; save.locations = [0]; });
// вываживание: 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`);
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); });