Files
andrey 7f545347b4 Этап 45: локация «Волшебное болото» (id 4, 90 000 🪙) — светящиеся рыбы, лягушки, кувшинки/лилии. (1) Данные: LOCATIONS[4] (тёмно-моховая вода 6 hex, слабая рябь; в массиве ПОСЛЕДНИЙ, по цене — между Озёрами 65536 и Морем 131072); 5 новых видов строго в КОНЕЦ SPECIES (32→37, по одному на редкость r0–r4, у каждого glow:'#hex' — 5 разных цветов): glowfin Светлячок #5ff2ff 8🪙, moonfish Лунная рыбка #b9c8ff 13, emberfin Изумрудный светляк #57ffa8 20, violetlure Фиалковый огонёк #d17bff 38, spiritfin Дух болота #ffe07a 70; SWAMP_IDS/SWAMP_DAY_WEIGHTS/SWAMP_NIGHT_WEIGHTS/SWAMP_SPECIES(_NIGHT) — паттерн LAKE/SEA/OCEAN без пересечений; NIGHT_WEIGHTS — ключи всем 37 id. (2) pickSpecies(): вложенный тернарник → табличный доступ SPECIES_BY_LOCATION(_NIGHT) по save.location + guard (вне 0..4 → Река); роллы 0..3 бит-в-идентичны (lc3/lc4/lc4b/lc4c + речные §9j/M4/M6 не тронуты). (3) Свечение (только ночь): nightMix()>0.35 — тот же порог, что у фонаря; drawGlowAura() (радиальный градиент + 'lighter', save/restore, кэш GLOW_RGBA) — фоновые рыбы болота (AMBIENT_FISH[4] — 7 рыб + palette из 4 цветов, цвет стабильно по индексу), зацепившаяся/вываживаемая (fishGlow — вид с glow), боссовая золотая аура сохранена; карточка — класс glow-card + --glow (только normal-вариант, drop-shadow, не конфликтует с фильтрами вариантов), дневник — drop-shadow на SVG. (4) Декорации: initScenery() ветка i===4 (bank+reed+snag+pine в болотной гамме), ГЛОБАЛЬНЫЕ pads[] (8–14 кувшинок с вырезом, 45% с лилиями — жёлтые/белые/розовые) и frogs[] (3–5, sit→jump по параболе на соседнюю кувшинку, 3–7 с) — очищаются/пересобираются в initScenery() (resize + обе ветки shopClick); updateDecor(dt) — покачивание/прыжки; drawSwampDecor() — ОТДЕЛЬНЫЙ слой после drawWater/drawAmbientFish и до drawBoat (дusk берега не гасит; локальное притухание ночью). (5) Босс: BOSS_BY_LOCATION[4]='spiritfin' (Дух болота, 20–50 кг, ×5 — паттерн Этапа 37). (6) Магазин/i18n/ачивки: тост welcome_swamp (ru/en, тернарник → if/else), ачивки 34→36: swamp_first (bonus:{coinPct:0.02}, locations-based) + swamp_boss (bonus:{}, bosses.spiritfin) → пин achBonus().coinPct 0.16→0.18; species_8 = все 37. (7) Баланс: SWAMP_DAY_WEIGHTS {glowfin:3,moonfish:4,emberfin:28,violetlure:13,spiritfin:4.5}, SWAMP_NIGHT_WEIGHTS {glowfin:2.5,moonfish:2.5,emberfin:20,violetlure:30,spiritfin:4} — Болото 240.3/328.9 🪙 (ночь/день ×1.37), порядок по цене Озёра 194.1/249.0 < Болото < Море 297.3/476.7 в ОБЕ фазы; пины остальных вод не изменились (Река 65.1/125.9 · Озёра 194.1/249.0 · Море 297.3/476.7 · Океан 329.1/552.7). (8) tools/balance.mjs: 8→10 таблиц, +SWAMP_SPECIES(_NIGHT) в return, сводка/пары отношений — сортировка ПО ЦЕНЕ (болото встает между Озёрами и Морем), пины в шапке. (9) Портреты: 5 flat-SVG (каждый — отдельным художником) assets/fish/fish-{glowfin,moonfish,emberfin,violetlure,spiritfin}.svg (viewBox 0 0 160 100, var(--f1..f4,hsl-fallback)); embed-fish.mjs IDS 32→37 (в конец), 37 <symbol> в index.html; fish-preview.html 32→37 карточек. (10) Тесты (тот же коммит): lc1 (37/5 локаций/90000/names/sprites 37/oceanOrder slice(23,32)/swampOrder/swampGlow/swampRar), lc2 (+SWAMP-таблицы, пересечения, nightW 37), lc4d (новый: 3000+3000 роллов болота — все 5 ≥1, чужих нет), lc5 (+location:4 не сбрасывается, location:5→0), lc8w (новый: покупка 90000 + welcome_swamp), lc9 (37), lc10 (mid29), lc11 (новый: glow #rrggbb, 5 цветов различны, веса покрывают id, NIGHT_WEIGHTS — все 37), a22d (36), a36loc (+swamp_first), пин 0.18, §9k2 (+{loc:4, spiritfin, swamp_boss}). 198→204 PASS, exit 0. (11) Скриншоты: gen-screenshots.js +кадры swamp/swamp-night (12→14 × 3 вьюпорта, dayTime 0.3/0.85) — 42 RU + 42 EN PNG (screenshots-wiki/, screenshots-wiki-en/). (12) Доки: AGENTS.md (локации/босс/баланс/баланс-скрипт/награды/портреты/скриншоты), README (5 локаций, 37 видов, 36 ачивок, 204 теста, 14 кадров, mobp-swamp*), KODA.md (SWAMP_*, SPECIES_BY_LOCATION, drawGlowAura/fishGlow/GLOW_RGBA, drawSwampDecor, pads/frogs), DEVLOG (Этап 45), PLAN-SWAMP.md (статус «реализован»). Проверено: 204/204 PASS exit 0, check.mjs OK, balance.mjs — пины без изменений + Болото в цели, emoji-font --check OK (52 глифа, новых emoji нет), ревью @oracle — критичных замечаний нет (.gitignore + .opencode/, fish-preview, осколок комментария species_8 — закрыты в этом же коммите). Миграция сейва не тронута (граница из LOCATIONS.length; location=4 покрыт lc5(f)).
2026-09-21 22:59:15 +03:00

139 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
// tools/embed-fish.mjs — встраивает 37 SVG-портретов рыб (assets/fish/fish-<id>.svg)
// в index.html как СКРЫТЫЙ SVG-спрайт из <symbol> (id="fish-<id>", viewBox 0 0 160 100).
//
// Запуск: node tools/embed-fish.mjs (из каталога репо или откуда угодно — пути от репо)
// - идемпотентен: если маркеры FISH-SVG:BEGIN/END уже есть — заменяет ВСЁ между ними;
// если нет — вставляет блок сразу ПОСЛЕ открывающего тега <body> (в начале файла).
// - после записи — самопроверка: 37 вхождений id="fish-, оба маркера, затем node tools/check.mjs.
// Чистый Node, без npm-зависимостей. Детерминирован: одинаковый ввод → одинаковый index.html.
//
// ВАЖНО: это НЕ подключение UI. Дневник/карточка/JS-логику НЕ трогаем —
// <use href="#fish-<id>"> подключит следующий этап.
import { readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const INDEX = join(ROOT, 'index.html');
const ASSETS = join(ROOT, 'assets', 'fish');
// Порядок = порядок SPECIES в index.html (карасс → кит → дух болота).
const IDS = [
'carass', 'perch', 'bream', 'carp', 'pike', 'catfish', 'sturge', 'gold',
'zander', 'chuch', 'crayfish', 'bleak', 'roundel', 'eelpout', 'tench', 'navaga',
'rudd', 'cisco', 'herring', 'mackerel', 'flounder', 'tuna', 'swordfish',
'moray', 'grouper', 'turtle', 'angler', 'octopus', 'manta', 'shark', 'squid', 'whale',
'glowfin', 'moonfish', 'emberfin', 'violetlure', 'spiritfin',
];
const BEGIN = '<!-- FISH-SVG:BEGIN (генерируется tools/embed-fish.mjs — не править руками) -->';
const END = '<!-- FISH-SVG:END -->';
const VIEWBOX = 'viewBox="0 0 160 100"';
function fail(msg) {
console.error(`embed-fish: ОШИБКА: ${msg}`);
process.exit(1);
}
// Чтение и валидация одного fish-<id>.svg → ВНУТРЕННЕЕ содержимое (между '>' корневого
// <svg ...> и '</svg>'). Корневой тег (width/height) в symbol не нужен — только viewBox.
function loadInner(id) {
const file = `fish-${id}.svg`;
let s;
try {
s = readFileSync(join(ASSETS, file), 'utf8');
} catch (e) {
fail(`${file} (вид ${id}): не удалось прочитать: ${e.message}`);
}
// BOM допустим
if (s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
// Перед <svg допустимы пробельные символы и XML-комментарии (в файлах — поясняющий комментарий вида).
let i = 0;
for (;;) {
const ws = /^[ \t\r\n]+/.exec(s.slice(i));
if (ws) { i += ws[0].length; continue; }
const cm = /^<!--[\s\S]*?-->/.exec(s.slice(i));
if (cm) { i += cm[0].length; continue; }
break;
}
// Корневой тег: <svg, за ним пробел (атрибуты) или '>'
if (!/^<svg([ >])/.test(s.slice(i))) {
fail(`${file} (вид ${id}): файл не начинается с <svg (после BOM/пробелов/комментариев)`);
}
if (!s.includes(VIEWBOX)) {
fail(`${file} (вид ${id}): не найдено ${VIEWBOX}`);
}
if (!s.trimEnd().endsWith('</svg>')) {
fail(`${file} (вид ${id}): файл не заканчивается на </svg>`);
}
const openEnd = s.indexOf('>', i); // закрывающая '>' корневого <svg ...>
const closeStart = s.lastIndexOf('</svg>'); // финальное </svg>
if (openEnd === -1 || closeStart === -1 || closeStart < openEnd) {
fail(`${file} (вид ${id}): нераспознана структура (корневой <svg> / </svg>)`);
}
return s.slice(openEnd + 1, closeStart);
}
// Сборка блока спрайта (строка на symbol; внутреннее содержимое — как в исходнике).
function buildBlock() {
const lines = [BEGIN, '<svg id="fishSprites" width="0" height="0" style="position:absolute" aria-hidden="true">'];
for (const id of IDS) {
lines.push(`<symbol id="fish-${id}" ${VIEWBOX}>${loadInner(id)}</symbol>`);
}
lines.push('</svg>', END);
return lines.join('\n');
}
// --- основная логика ---
let html;
try {
html = readFileSync(INDEX, 'utf8');
} catch (e) {
fail(`не удалось прочитать ${INDEX}: ${e.message}`);
}
const block = buildBlock();
const b = html.indexOf(BEGIN);
const e = html.indexOf(END);
if (b !== -1 || e !== -1) {
// Маркеры есть (хотя бы один) — заменяем ВСЁ между ними (идемпотентность повторного запуска).
if (b === -1 || e === -1 || e < b) fail('маркеры FISH-SVG:BEGIN/END найдены, но парно/порядком — index.html повреждён');
html = html.slice(0, b) + block + html.slice(e + END.length);
console.log('embed-fish: маркеры уже есть — блок заменён (повторный запуск)');
} else {
// Маркеров нет — вставляем сразу ПОСЛЕ открывающего тега <body> (с атрибутами, первый в файле).
const m = /<body(\s[^>]*)?>/.exec(html);
if (!m) fail('в index.html не найден открывающий тег <body>');
const pos = m.index + m[0].length;
html = html.slice(0, pos) + '\n' + block + html.slice(pos);
console.log(`embed-fish: блок вставлен после <body> (строка ${html.slice(0, pos).split('\n').length})`);
}
writeFileSync(INDEX, html);
// --- самопроверка: перечитываем файл и убеждаемся, что всё на месте ---
const check = readFileSync(INDEX, 'utf8');
const nSymbols = (check.match(/id="fish-/g) || []).length;
if (nSymbols !== IDS.length) fail(`самопроверка: ожидалось ${IDS.length} вхождений id="fish-, найдено ${nSymbols}`);
if (check.split(BEGIN).length - 1 !== 1) fail('самопроверка: маркер FISH-SVG:BEGIN не найден (или дублируется)');
if (check.split(END).length - 1 !== 1) fail('самопроверка: маркер FISH-SVG:END не найден (или дублируется)');
// Синтаксис JS в index.html (node --check на вынутых блоках) — как п.1 «Проверки» из AGENTS.md.
let out;
try {
out = execFileSync(process.execPath, [join(ROOT, 'tools', 'check.mjs')], { encoding: 'utf8', cwd: ROOT });
} catch (err) {
process.stdout.write(err.stdout || '');
process.stderr.write(err.stderr || String(err));
fail('node tools/check.mjs завершился с ошибкой');
}
process.stdout.write(out);
const blockBytes = Buffer.byteLength(block, 'utf8');
const blockLines = block.split('\n').length;
console.log(`embed-fish: OK — ${IDS.length} символов, блок ${blockBytes} байт / ${blockLines} строк, маркеры на месте, check.mjs пройден`);