Скриншоты: витринный набор под требования Яндекс Игр (tests/gen-screenshots.js).
18 кадров: desk 1280x720@1x, mobp 360x640@2x -> 720x1280 (9:16), mobl 800x450@1.6x -> 1280x720 (16:9). Все — 24-битный PNG без альфа-канала (конверсия ffmpeg/ImageMagick), длинная сторона 1280 (треб. каталога: 1280..2560). Ландшафт — 800x450 CSS, не 640x360: мобильная 'низкая' ветка @media(max-height:620px) та же, но карточка улова с RV-кнопкой (Y2) целиком влезает, а на 360px .panel скроллится и дно уезжает за экран. Детерминизм: чистый сейв на каждом вьюпорте (localStorage.clear), поклёвка — forceBite, карточка — всегда золотой осётр 12.5 кг (SHINY_CHANCE=1, rnd цены шпилится в 0.5 -> ровно +2438 во всех сетках), закрытие карточки — реальный клик + DOM-фолбэк + жёсткое ожидание (лавка не снимается поверх карточки), тосты стухают (1.6 с) до кадра карточки. Выпуск — screenshots-wiki/ (18 png). Запуск: node tests/gen-screenshots.js [каталог-сборки] [папка-вывода]; нужны Chromium, Noto Color Emoji, ffmpeg или ImageMagick.
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,230 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Генерация скриншотов «Рыбалки» — витринный набор (страница вики «Скриншоты»,
|
||||
* материалы для Яндекс Игр).
|
||||
*
|
||||
* Снимает 6 состояний игры (меню, ожидание клёва, поклёвка, вываживание,
|
||||
* карточка улова, лавка) на трёх вьюпортах:
|
||||
* - desk: 1280x720 @1x (десктоп, 16:9)
|
||||
* - mobp: 360x640 @2x -> 720x1280 (мобильный, портрет, 9:16)
|
||||
* - mobl: 800x450 @1.6x -> 1280x720 (мобильный, ландшафт, 16:9;
|
||||
* CSS 800x450: мобильная «низкая» ветка @media(max-height:620px), но
|
||||
* карточка улова целиком влезает — на 640x360@2x дно уезжает за экран)
|
||||
*
|
||||
* Требования Яндекс Игр: пропорции 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
|
||||
* Зависимость: puppeteer-core (cd tests && npm install).
|
||||
*
|
||||
* Переменные окружения:
|
||||
* CHROMIUM — путь к бинарнику Chromium/Chrome (по умолчанию /usr/bin/chromium-browser).
|
||||
*
|
||||
* Требования окружения:
|
||||
* - системный Chromium/Chrome;
|
||||
* - цветной эмодзи-шрифт (Noto Color Emoji) — иначе эмодзи в игре «квадратики»;
|
||||
* - ffmpeg или ImageMagick (для 24-битного PNG без альфа).
|
||||
*
|
||||
* Детерминизм:
|
||||
* - новый сейв на каждом вьюпорте (localStorage.clear) — кадры согласованы;
|
||||
* - поклёвка форсится тест-глобалом forceBite;
|
||||
* - карточка улова — всегда золотой осётр 12.5 кг (SHINY_CHANCE=1);
|
||||
* цена catchFish() жмитится фактором (0.95+0.1·rnd) — rnd шпилится в 0.5,
|
||||
* фактор = ровно 1.0, итог +2438 во всех трёх сетках;
|
||||
* - закрытие карточки: реальный клик + DOM-фолбэк + жёсткое ожидание
|
||||
* (без «проглоченных» таймаутов — лавка не снимается поверх карточки).
|
||||
*
|
||||
* Зависит от тест-глобалов игры (AGENTS.md, «Подводные камни»):
|
||||
* state, forceBite, biteTimer, reel, catchFish, SHINY_CHANCE, SPECIES
|
||||
* и DOM-кнопок: #btnPlay, #btnCardOk, #btnShop, #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 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', 'wait', 'bite', 'fight', 'card', 'shop'];
|
||||
|
||||
// ---------- 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' });
|
||||
// чистый сейв на каждом вьюпорте: кадры согласованы, без переноса прогресса
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
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');
|
||||
|
||||
// заброс: удержание заряжает бросок
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.mouse.down();
|
||||
await sleep(1500);
|
||||
await page.mouse.up();
|
||||
await waitState(page, 'WAIT');
|
||||
await sleep(1200);
|
||||
await waitPaint(page);
|
||||
await shoot(page, `${vp.key}-wait.png`);
|
||||
|
||||
// поклёвка (тест-форс: рыба идёт на крючок, поплавок тонет)
|
||||
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
|
||||
await waitState(page, 'BITE', 4000);
|
||||
await sleep(300);
|
||||
await waitPaint(page);
|
||||
await shoot(page, `${vp.key}-bite.png`);
|
||||
|
||||
// подсечка -> бой; эффектная рыба (осётр 12.5 кг, фазы детерминированы)
|
||||
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}-fight.png`);
|
||||
await page.mouse.up();
|
||||
|
||||
// карточка улова: золотой осётр 12.5 кг (детерминированно);
|
||||
// цена catchFish() жмитится (0.95+0.1·rnd) — rnd шпилится в 0.5 -> фактор 1.0
|
||||
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);
|
||||
// ждём, пока стихнут тосты (улов/ачивка/квест; toast() гаснет через 1.6 с)
|
||||
await sleep(2200);
|
||||
await waitPaint(page);
|
||||
console.log(' [card]', await page.evaluate(() =>
|
||||
state + ' card=' + document.getElementById('card').classList.contains('hidden')
|
||||
+ ' shop=' + document.getElementById('shop').classList.contains('hidden')));
|
||||
await shoot(page, `${vp.key}-card.png`);
|
||||
|
||||
// закрытие карточки: реальный клик + DOM-фолбэк + жёсткое ожидание
|
||||
await page.click('#btnCardOk');
|
||||
const cardHidden = () => page.waitForFunction(
|
||||
() => document.getElementById('card').classList.contains('hidden'),
|
||||
{ timeout: 2500, polling: 50 }).then(() => true).catch(() => false);
|
||||
if (!(await cardHidden())) {
|
||||
console.log(' btnCardOk: клик мимо, DOM-клик');
|
||||
await page.evaluate(() => document.getElementById('btnCardOk').click());
|
||||
await page.waitForFunction(
|
||||
() => document.getElementById('card').classList.contains('hidden'),
|
||||
{ timeout: 2500, polling: 50 });
|
||||
}
|
||||
await waitState(page, 'IDLE', 5000);
|
||||
|
||||
// лавка рыбака
|
||||
await page.click('#btnShop');
|
||||
await page.waitForFunction(
|
||||
() => !document.getElementById('shop').classList.contains('hidden'),
|
||||
{ timeout: 3000, polling: 50 });
|
||||
await sleep(300);
|
||||
await waitPaint(page);
|
||||
console.log(' [shop]', await page.evaluate(() =>
|
||||
state + ' card=' + document.getElementById('card').classList.contains('hidden')
|
||||
+ ' shop=' + document.getElementById('shop').classList.contains('hidden')));
|
||||
await shoot(page, `${vp.key}-shop.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);
|
||||
await captureSet(browser, vp);
|
||||
n += FRAMES.length;
|
||||
}
|
||||
await browser.close();
|
||||
console.log('DONE:', n, 'кадров ->', OUT);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||