11 Commits
Author SHA1 Message Date
Nikita 77e62189cf feat(v4): overtime, tutorial hour 0, soundtrack mixer, minimalist menu, immersion UI 2026-10-26 22:18:23 +03:00
Nikita 1c35a9e06c feat(v4): UDP third-try/no-slot, file lifespan and random icons, shop icons, grid/menu fixes 2026-10-26 21:25:41 +03:00
Nikita f0ba5fd2b9 feat(v4): per-hour request mix, UDP, quality gate, folder pricing, briefing, attack alert, UI polish 2026-10-26 20:39:45 +03:00
Nikita a2e7456362 chore(dev): seed demo file for visual QA 2026-10-26 19:11:52 +03:00
Nikita 417a352823 fix(v4): clear stale cells and dedupe file_ids on move 2026-10-26 19:05:42 +03:00
Nikita 6cb2639055 fix(v4): restore file shapes/visibility, detach refresh, reject purge 2026-10-26 18:57:34 +03:00
Nikita 44510e2926 fix(v4): folder focus after drop, reject cleanup, request-count heat, motion polish 2026-10-26 17:33:24 +03:00
Nikita ed1ecb7d1c feat(v4): persistent files, rate pacing, heat UI and motion polish 2026-10-26 16:14:41 +03:00
threenebula23 2191545629 feat(v3): TCP connection, named-file requests, 3-tier send games, rebalanced shop, Golos UI and pixel icons
CI / tests (push) Successful in 37s
2026-10-26 02:04:47 +03:00
threenebula23 9cb983a9b5 merge: Nikita v2 baseline (shop, polyomino grid, sorting table, 11 minigames) 2026-10-25 23:14:40 +03:00
Nikita 39846421bc feat(game): PEAK HOUR v2 - shop, polyomino grid, sorting table, 11 minigames
- 8 lots / 3 offers / rerolls / locks; start 50 USD, SLA 60/60, 1 slot
- 5x4..8x6 polyomino grid, reservations, 4x6 sorting table, save v2 + v1 migration
- 7 send minigames + 4 attack minigames without timers, dense B4 selection
- attacks as minigames, shield, SLA regen/defeat, 20 attacks per run
- balance simulator 240 seeds: all hours within +/-20%, PASS
- UI v2, old attack cards/connection strip removed
- tests: 26/26 PASS, docs v2.0
2026-10-25 21:38:18 +03:00
203 changed files with 44031 additions and 8897 deletions

No files matched your search

+197 -45
View File
@@ -1,4 +1,4 @@
# AI_REPORT — PEAK HOUR 97
# AI_REPORT — PEAK HOUR 97 (v3.0)
## 1. Инструменты и модели
@@ -13,66 +13,218 @@ test-writer, explorer, feis-regulator, oracle):
| **MiMo-V2.6-Flash** | аудио: SFX, ambient, фон |
| дефолтные модели субагентов | поиск, тесты, ревью |
MR-ревью — агент **oracle** (`opencode-go/deepseek-v4.1-flash`): dry-run
MR-ревью — агент **oracle** (opencode-go/deepseek-v4.1-flash): dry-run
74–90 с, комментарий в MR, без мержа.
## 2. Где применялся ИИ
## 2. Что изменилось в v3.0
- **Код:** `GameController`, `HourDirector`, `AttackDirector`, `Shop`,
`SaveManager`, UI, мини-игры.
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал.
- **Ambient:** 4 WAV (`audio/amb`, PSU/fan/room/seek), генератор
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость.
- **VHS:** процедурный `vhs_overlay.gd` (`RefCounted`) в `MainMenu._draw`;
скан-линии/зерно/виньетка, без shader/screen texture, ввод не блокирует,
текст резкий; только меню, чекбокс в настройках, HEADLESS без рендера.
- **Баланс/мета:** SLA, 10 апгрейдов, таймеры, канал, мета 3/5/9.
- **Аудит:** calm-фон, Esc итогов, уничтожение, тосты/fullscreen.
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; `docs/`;
18 SFX + `hum.wav` на Python.
- **Запросы:** 1–4 **конкретных именованных файла** вместо анонимных слотов;
имена на карточке 2×2, прикрепление по точному `file_id`, «ПОТЕРЯН» для
пропавшего файла, жёсткая резервация. Новые `request_mix` H1–H9 и
`request_cap`; H1-затравка и анти-голодание.
- **TCP:** возвращена полоска соединения (3 попадания, 440/340/280 px/с,
формула зоны, промах −4 SLA / 8 с, Esc 0 SLA / 4 с). TCP платит ×1.15,
UDP — ×0.85 и передаёт ×0.8.
- **Мини-игры:** 6 отправки + 4 атаки; игра с соединением проводов удалена;
у отправки 3 ступени (старые T2–T4), у атак 4. Выбор — по сумме
коэффициентов и таблице `rank_games` на 27 достижимых сумм.
- **Магазин:** 8 лотов, дерево **8 085 $**, SLA до **220**, ускорение ×0.90,
щит с 70 % награды, папки до 9×6, новая папка 180 $ (в v3.1 — 9 лотов и
8 825 $, см. §10).
- **UI:** Golos Text для тела текста, Press Start 2P только для логотипа и
цифр; шкала 11/13/17/26; пиксельные иконки `art/icons/`; карточка запроса
400×128 с сеткой файлов и прогнозом; пустое состояние отправки; «СЛОТЫ».
- **Туториал:** 5 шагов с событийными триггерами, пульсирующие рамки,
«ДАЛЕЕ», сохранение `tutorial_done`, повтор из главного меню.
- **Документация:** GDD/DECISIONS/README/ITCH_PAGE/UI_REFRESH/ASSETS
переписаны под фактические числа v3; старые решения помечены, а не удалены.
## 3. Приём: «враждебный критик»
## 3. Где применялся ИИ
- **Код:** HourDirector, FileRegistry, Economy, Shop, AttackDirector,
SaveManager, UI, соединение TCP и мини-игры.
- **UI v3:** Golos Text, пиксельные иконки, карточка запроса, туториал с
рамками, VHS главного меню.
- **Ambient:** 4 WAV (audio/amb), генератор generate_ambient.py, autoload
после первого жеста, mute/пауза/громкость.
- **Баланс:** цели дохода, дерево магазина 8 085 $ (в v3.1 — 8 825 $),
симулятор на 240 сидах.
- **Аудит:** B4 (дыры в выборе мини-игры) закрыт ещё в v2; в v3 выбор
перестроен на 27 сумм и 3 ступени. M8 (масштаб денег атак), M16
(soft-lock «нет денег/нет места») сохранены.
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; docs/;
18 SFX + hum.wav на Python.
## 4. Приём: «враждебный критик»
Промпт просил быть враждебным критиком: «найди блокеры, из-за которых
прототип нельзя собрать; не хвали». Нашлось **6 блокеров**. Один из них:
выбор мини-игры по самому сложному типу ломал смешанные запросы. Заменили на
средний коэффициент:
прототип нельзя собрать; не хвали». Главный дефект v2 — **B4**: выбор
мини-игры по `S−1` давал дыры и недостижимые строки. В v3 выбор перестроен:
```
difficulty = sum(coeff(required)) / n
coeff = {text: 1.0, sound: 2.0, photo: 3.0, video: 4.0}
```
ACHIEVABLE_SUMS = [1..22, 24, 25, 26, 28, 32] # 27 сумм для 1–4 файлов
rank = index of S in ACHIEVABLE_SUMS
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
id = RANK_GAMES[rank] # фиксированная таблица
Это изменило §8.4 и §10 GDD и решение D17; пороги `<1.5 / <2.5 / <3.5`
зафиксированы в `data/balance.json` и проверяются в `test_minigame.gd`.
Все 6 игр достижимы в каждой из 3 ступеней; S=32 → video/T3. Дополнительно
закрыты: H1-голодание запросов (анти-голодание + seed-файл), потерянные
required-файлы (подстановка того же типа), неоднозначность TCP-протокола
(блокировка после соединения). M16 (бесплатное уничтожение неприкреплённого
файла) сохранён, но файл, закреплённый за запросом, удалить нельзя.
## 4. Что сделано без ИИ
## 5. Что сделано без ИИ
Идея, логлайн, исходная спецификация, ключевые решения (1997 год, 9 часов,
4 атаки, экономика, тон 90-х) и финальная приёмка — человеком, автором
проекта. ИИ не придумывал игру: он помогал формулировать, писать код,
считать баланс и искать противоречия.
## 5. Ассеты и лицензии
## 6. Ассеты и лицензии
- **Handjet** и **Press Start 2P** — SIL OFL 1.1, тексты в
`art/fonts/OFL-Handjet.txt` и `OFL-PressStart2P.txt`.
- Pixelify Sans в теме не используется (нет глифов `О`/`П`); файл и OFL
оставлены в `art/fonts/`.
- UI, иконки и `icon.svg` — команда проекта, MIT.
- SFX, ambient и `hum.wav` — скрипты проекта, MIT/CC0.
- Код — MIT (`LICENSE`); OFL-шрифты совместимы с MIT при сохранении
лицензий. Подробности — в `ASSETS.md`.
- **Golos Text** — SIL OFL 1.1 (`art/fonts/OFL-GolosText.txt`), основной
шрифт UI с v3.
- **Press Start 2P** — SIL OFL 1.1 (`art/fonts/OFL-PressStart2P.txt`), только
логотип и цифры.
- **Handjet** — SIL OFL 1.1, с v3 в теме не используется и помечен как
legacy.
- Pixelify Sans в теме не используется (нет глифов О/П); файл и OFL
оставлены в art/fonts/.
- Пиксельные иконки `art/icons/*.png` сгенерированы `generate_icons.py`
(Pillow), команда проекта, **MIT**; исходный тайл-лист был недоступен,
поэтому набор перегенерирован с нуля.
- UI, маскот **БАЙТ** и icon.svg — команда проекта, MIT.
- SFX, ambient и hum.wav — скрипты проекта, MIT/CC0.
- Код — MIT (LICENSE); OFL-шрифты совместимы с MIT при сохранении лицензий.
Подробности — в ASSETS.md.
## 6. Проверки и ограничения
## 7. Проверки v3.1
- **22/22 PASS** (21 тест + parse). Core 543, hour 133, shop 116, minigame
51, slots 34, audio 37; fuzz 3249, integration 231, attacks 173,
controller 143.
- `test_full_playthrough` — 294 на сидах `1/42/777`: 3/3 victory, 9/9,
SLA 200; main 350, toast 14, fullscreen 31, ui 135, ambient 74.
- Web-экспорт — exit 0; Chromium **1920×1080**: меню → «Новый забег» →
подготовка, JS-ошибок нет.
- **Симулятор баланса:** `python tests/sim_balance.py`, 240 сидов, 9 часов,
`dt=0.25`, `q=0.85`, TCP. Отчёт `tests/SIM_BALANCE_REPORT.md`: средний итог
**7 205 $** в полосе **6 982–8 500 $**, расход магазина **3 964 $**
(**44,9 %** дерева **8 825 $**), **0 поражений из 240**, SLA в конце H9 —
**64,9**, запросов выполнено — **62,0**. Все 9 часов в ±20 %, exit code **0**,
вердикт `PASS`. H2, H4 и H5 идут близко к −20 % (H2 −16,4 %, H4 −15,3 %,
H5 −15,1 %), H1 — +14,1 % при цели **100 $**.
- **Тесты:** актуальный список — **полный набор + parse-check** в
`scripts/run_all_tests.sh` / `.ps1` (см. также `tests/`; счётчик наборов не
хардкодится). `test_economy` и `test_save` входят в runner; core-наборы
обновлены и зелёные; UI-наборы (`test_main_*`, `ui_smoke`) прогоняются
отдельно.
- **Web-пресет** `export_presets.cfg` существует: `thread_support=false`,
экспорт в `build/web/index.html`, просмотр только по HTTP.
Ограничения: туториал — маскот и подсказки; web-save — IndexedDB; плейтест,
громкость и масштаб — за человеком; remote push ещё не выполнен.
### Финальная проверка v3
> Замер предыдущей волны. После изменений баланса, каталога файлов и UI
> core-наборы перепрогнаны (зелёные), UI-наборы и E2E/веб-смоук требуют
> повторного прогона.
- **Headless:** полный набор + parse-check; список и статус — в
`scripts/run_all_tests.sh` / `.ps1`. Число наборов здесь не фиксируется.
- **Баланс:** симулятор — `PASS`, 240 сидов, все 9 часов в ±20 %, средний
итог **7 205 $**, магазин — **3 964 $ (44,9 %)** дерева **8 825 $**,
поражений **0/240** (`tests/SIM_BALANCE_REPORT.md`).
- **E2E на реальной сцене Main (1920×1080):** замер предыдущей волны,
**190/190**; после изменений темпа и «ЖАРЫ» требует повторного прогона:
меню → подготовка → час, приём пакета, прикрепление конкретного файла,
модалка TCP и промах/кулдаун, мини-игра отправки → завершение (файлы
**вернулись в папку**, деньги начислены), UDP-обход, конец часа при
открытом соединении, H1-голодание, очистка ghost-привязок при save/load,
защита ввода в модалках, шаг туториала «отправка»; повторный «НОВЫЙ ЗАБЕГ»
тоже получает seed-файл в подготовке.
- **Web (Godot 4.7.2, Web/no-threads; Chromium + SwiftShader):** сборка
загружается, WebGL2 OK, 0 ошибок/предупреждений в консоли; меню →
«Новый забег» → «НАЧАТЬ ЧАС» работает; drag пакета в папку и drag нужного
файла в чип запроса работают; модалка соединения открывается (палочка
промахивается сама, если не кликнуть за ~1,1 с, — так задумано).
- **Полоска TCP** — рефлекторная мини-игра: 3 попадания, зона 30 % на H1;
уход палочки за край = автопромах; промах = −4 SLA и кулдаун 8 с
(`Esc` = 4 с, без штрафа).
## 8. Известные отклонения
- `ПРОПУСТИТЬ` даёт `q=0`; `quality = 0.5`, то есть половина
quality-множителя, а не нулевая выплата. Осознанно.
- `speed` выплаты фиксируется на старте отправки, пока таймер был заморожен
мини-игрой; быстрый игрок видит ≈1.0.
- Лимит папок 6, новая папка 180 $.
- Бесплатное уничтожение любого неприкреплённого файла в подготовке
(review M16); файл, закреплённый за запросом, не удаляется.
- Деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
- H2 теоретически может выдать T2-запрос из трёх фото (S12); на практике
кривая H2 остаётся почти полностью T1.
- Расход магазина **44,9 %** — ниже проектной полосы 55–65 %; это известное
отклонение, не влияющее на exit code.
## 9. Ограничения
Туториал автоматически запускается только в первом часу первого забега;
повтор — из главного меню. Web-save — IndexedDB; плейтест, громкость и
масштаб — за человеком; remote push ещё не выполнен. Мета-ключ `search`
остаётся в данных, но UI-поиск не подключён. Скриншоты в `docs/screenshots/`
стоит пересобрать под v3 перед публикацией страницы.
## 10. Изменение v3.1: TCP-модель, UDP-лот и шрифты
Короткая запись о текущей волне — без переписывания истории выше.
- **TCP-соединение — отдельная сущность `TcpConnection`.** Оно живёт в
`HourDirector.active_connections`, занимает out-слот и хранит только клиента,
IP и `request_id`. До соединения файлы в запрос не прикрепляются (drag
отклоняется, backend режет). Кнопка `ПОДКЛЮЧИТЬСЯ` справа снизу запускает
полоску; успех больше не открывает мини-игру отправки.
- **Слот после отправки.** Успешная TCP-отправка удаляет запрос, но файлы
**не расходует**: они возвращаются в папку и доступны новым запросам. Слот
остаётся занятым (idle) до ручного `ОТКЛЮЧИТЬ`. Отключение
мгновенное; во время передачи оно прерывает её без выплаты и без расхода
файлов. `reject`/`timeout`/конец часа освобождают слоты. Слоты общие,
`1 + slot_level` (макс. 5); TCP может занять единственный слот и
заблокировать приём — это осознанно.
- **UDP-риск.** Новый лот `lot_udp` («UDP-повторитель», 4 уровня, цены
60/120/210/350) поднимает шанс с **40 %** до **80 %** шагом **+10 %**.
Ролл — в конце передачи: успех даёт обычную выплату UDP ×0.85; провал —
**−3 SLA**, без денег, файлы остаются, запрос жив, повтор через **4 с**,
слот освобождается, streak сбрасывается. Всего лотов **9**, дерево
**8 825 $**.
- **Шрифты.** Тело — **Kechusilka Regular** (нормализован под Golos,
x-height 0.530 em) с fallback **Golos Text**; крупный текст — **DisplayOTF**
(wordmark 46, «ПОБЕДА/ПОРАЖЕНИЕ» 34, титлбар 14, «РЕЗУЛЬТАТ» 18); цифры и
счётчики — **Press Start 2P**. Шкала 11/13/17/26.
- **Документация:** обновлены `docs/GDD.md`, `docs/DECISIONS.md` (D53–D61),
`docs/UI_REFRESH.md`, `docs/IMPLEMENTATION.md`, `docs/TEST_PLAN.md`,
`docs/ITCH_PAGE.md` и `README.md`. `ASSETS.md` не менялся — шрифтовые
лицензии уже зафиксированы там.
- **Проверки:** полный набор + parse-check; core-наборы обновлены и зелёные;
UI-наборы (`test_main_*`, `ui_smoke`) ещё идут. `tests/SIM_BALANCE_REPORT.md`
отражает текущий `data/balance.json`: **7 205 $**, магазин **44,9 %**,
**0/240** поражений, exit **0**.
## 11. v3.1: темп, каталог файлов и «ЖАРА»
Текущая волна; история выше не переписана.
- **Длительность:** часы **120 / 150 / 180 / 210 / 240 / 270 / 300 / 330 /
360 с** (`120 + 30×(h−1)`), забег **36 минут** + подготовка. H1
`income_target` — **100 $**. Окно спавна — **75 %** часа.
- **Файлы:** успешная отправка не удаляет файл; он остаётся в папке и снова
доступен пулу запросов. Общего лимита на забег нет; `hours[].files`
(**6,6,7,8,9,9,10,10,10**) — скорость, не жёсткий cap. Интервал —
`spawn_window / files`, переноса между часами нет (carry = 0); папки
докупаются, к финалу ~**5–6**.
- **Запросы:** `request_cap` удалён. Темп — `send_interval`
(**20,24,18,24,24,24,24,24,24**), лимит — только `active_requests` +
cutoff последних **10 с**. Анти-голодание — только первый запрос часа;
требования выбираются из файлов в папках и не связаны с приходом.
- **Атаки:** `start = clamp(0.25×duration, 20, 60)`,
`end = min(0.85×duration, duration−20)`; H2 **37,5–127,5 с**,
H9 **60–306 с**.
- **Визуал:** `TemperatureGauge` вместо 03:42 в меню-баре, `StressOverlay`
(heat-модель, alpha-only шейдер без screen texture), пульс SLA, тряска
панелей ≤2 px, `reduce_motion`, `UiMotion`, Lane B (drag/карточки/слоты),
headless-safe.
- **Баланс:** `tests/SIM_BALANCE_REPORT.md` — **7 205 $**, полоса
**6 982–8 500 $**, магазин **3 964 $ (44,9 %)**, **0/240** поражений,
exit **0**; H2, H4 и H5 близко к −20 % (H2 −16,4 %, H4 −15,3 %, H5 −15,1 %).
- **Тесты:** полный набор + parse-check; `test_economy` и `test_save` в
runner; точный счёт не фиксируется.
+175 -25
View File
@@ -5,23 +5,123 @@
## Шрифты
### Handjet — основной UI-шрифт
### Golos Text — fallback для редких символов (v3)
| Поле | Значение |
| --- | --- |
| Файл | `art/fonts/GolosText-VF.ttf` (variable; локальное имя для `GolosText[wght].ttf`) |
| Оси | `wght` 400–900; в теме — `wght 600` |
| Автор / проект | The Golos Text Project Authors — https://github.com/googlefonts/golos-text |
| Источник | https://github.com/google/fonts/tree/main/ofl/golostext |
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-GolosText.txt` |
| SHA-256 | `17bb58fb69aec2dfb047a2ebf52534023e9b688c97a6b7ac795b0a72912c2063` |
| Кириллица | полная (О/П/Ё/№), гуманистический рисунок, хорошо читается в 11–17 px |
| Использование | fallback для Kechusilka и DisplayOTF (`Theme90.FONT_FALLBACK`, `Font.fallbacks`): `№ – … · × © ± ≈ −` и стрелки. Прямо в разметке не используется |
Типографическая шкала: **11 / 13 / 17 / 26 px** (`FS_MICRO`, `FS_BODY`,
`FS_HEAD`, `FS_LOGO`); значения проверяет `ui_smoke.gd`, поэтому менять их
можно только вместе с проверкой. Крупнее 26 px размер передаётся на месте
вызова (24/28/34/46). Роли: тело текста — Kechusilka, крупный текст —
DisplayOTF (`display()`), цифры и счётчики — Press Start 2P (`logo()`),
страховка для редких символов — Golos Text.
### Kechusilka — основной шрифт тела (нормализованный)
| Поле | Значение |
| --- | --- |
| Файл | `art/fonts/Kechusilka-Regular.otf` — нормализованный (UPM 2048, 173 глифа) |
| Оригинал | `art/fonts/source/Kechusilka-Regular-original.otf` — только для сверки, к игре не подключён |
| Источник | архив `kechusilka-regular.zip` (загрузка пользователя), внутри `kechusilka-Regular_1.otf` и два PNG-превью |
| Версия | Version 1.000; 16.09.2026; FontCreator 15 |
| SHA-256 (нормализованный) | `e296eca059c05886da25808170accf6004619850dcac14200046199be04e10c8` |
| SHA-256 (оригинал) | `3f666b9b04dcbbfcfe3a31256d99e807553c9dd2efa3f360cc14422599917d36` |
| Лицензия | **подтверждена владельцем проекта** (license confirmed by project owner). Файла лицензии в архиве нет, строк copyright/license в шрифте тоже нет — поэтому текст лицензии здесь не приводится и её название не угадывается |
| Кириллица | полная: все 66 букв (включая Ё/ё), все цифры |
| Нет глифов | `№` U+2116, `–` U+2013, `…` U+2026, `·` `×` `← → ↑ ↓` `≤ ≥` `§` `°` `€` `•` `©` `±` `≈` `−` — закрываются fallback-шрифтом |
| Fallback | `GolosText-VF.ttf` через `Font.fallbacks` в `Theme90.ui()` |
| Использование | `FONT_UI`, `Theme90.ui()`; `default_font` темы `art/fonts/ui_theme.tres` |
**Нормализация.** У исходника x-height `0.312 em` против `0.530 em` у Golos
Text: в одном кегле текст был бы на 40 % мельче, и шкала 11/13/17/26 поехала
бы во всех карточках. Шрифт пересобран скриптом
`art/fonts/normalize_kechusilka.py`:
- коэффициент `scale = 0.530 × 2048 / 640 = 1.696`;
- масштабируются контуры (`TransformPen`), `hmtx` (advance + lsb),
`hhea`/`OS/2`/`head` и 150 значений `GPOS` — иначе кернинг стал бы на 40 %
слабее;
- результат: x-height `0.5298 em`, cap `0.7422 em`, линия `15.16 px` против
`15.60 px` у Golos при 13 px; ширина русских строк отличается от Golos
в пределах ±6 %, поэтому вёрстку пересчитывать не пришлось;
- хинтов в исходнике нет (0 stem-операторов), потерь при пересборке нет;
- сборка воспроизводима: повторный запуск даёт тот же файл и тот же SHA-256.
Оригинал не удалён — он лежит в `art/fonts/source/` и в теме не используется:
это точка сверки, если понадобится поменять коэффициент.
**Вес штриха.** Kechusilka светлее прежнего Golos Text `wght 600`: толщина
стойки `0.10 em` против `0.145 em`, то есть 1.35 px против 1.88 px при 13 px
(замер по растеризации `l`/`I`). На 11–13 px это читается, но на 9 px стойка
уходит в 0.94 px и текст «сереет». Поэтому подписи мельче 11 px лучше поднять
до `FS_MICRO`. Если после первого запуска в Godot захочется веса ближе к
прежнему, это делается одной строкой — `FontVariation.variation_embolden` в
`Theme90.ui()`; значение подбирать на месте, начиная с 0.1, потому что
масштаб эффекта зависит от версии TextServer.
### Kechusilka: чем закрыты отсутствующие символы
В шрифте нет 19 символов, которые встречаются в тексте проекта. Цепочка
`Kechusilka → Golos Text` закрывает все, кроме трёх: `Σ` U+03A3, `↔` U+2194
(только комментарии и тесты, на экран не попадают) и `▯` U+25AF — его нет ни в
одном шрифте проекта, включая прежний Golos, поэтому он и раньше рисовался
встроенным резервом Godot. Регрессии нет.
Проверено разбором `cmap` всех шрифтов проекта и всех строковых литералов
`.gd`/`.json`/`.tscn`, плюс рендер-симуляция подстановки: на 11–13 px стык
шрифтов не виден, потому что у Kechusilka и Golos совпадает x-height.
### DisplayOTF — крупный текст и логотип
| Поле | Значение |
| --- | --- |
| Файл | `art/fonts/DisplayOTF.otf` (статический, вариаций нет) |
| Автор | Denis A Serikov, 2003–2006 — `art/fonts/COPYRIGHT-DisplayOTF.txt` |
| Источник | архив `displayotf.zip` (загрузка пользователя; каталог `www.otlab.ru`), публичной ссылки нет |
| Лицензия | Freeware — текст соглашения `art/fonts/LICENSE-DisplayOTF.txt` (в архиве `license_131.txt` в CP1251, перекодирован в UTF-8 без правок текста) |
| SHA-256 | `c6578605ddc6992008044a1c7266cdd46dcf846a5d5f5dffbd391023e6ba0552` |
| Кириллица | полная: все 66 букв (включая Ё/ё), цифры, `№ % « » — – … ! ?` |
| Кернинг | таблиц `GPOS`/`kern` нет; есть `liga`, `salt`, `aalt` |
| Цифры | пропорциональные (`1` = 395/1000 em, остальные 553/1000) |
| Embedding | `fsType 0` — встраивание в игру разрешено |
| Использование | крупный текст: `theme90.gd → display()`; wordmark `PEAK HOUR 97`, «ПОБЕДА»/«ПОРАЖЕНИЕ», «РЕЗУЛЬТАТ», заголовки окон, вариация темы `LogoLabel` |
| Не использовать | тело текста и живые счётчики: мельче 16 px насечки сливаются, а пропорциональные цифры «дрожат» при смене значения |
Шрифт держит ту же «машинную» интонацию, что Press Start 2P, но с более
узким и характерным рисунком, поэтому занимает роль крупного текста, а Press
Start 2P остаётся на цифрах с моноширинной сеткой.
Условия лицензии, которые нужно соблюдать: файл распространяется «как есть» —
не переконвертировать в TTF, не сабсетить и не менять состав знаков без
разрешения автора; продавать шрифт отдельно нельзя.
### Handjet — legacy, в теме не используется
| Поле | Значение |
| --- | --- |
| Файл | `art/fonts/Handjet-VF.ttf` (variable; локальное имя для `Handjet[ELGR,ELSH,wght].ttf`) |
| Оси | `wght` 100–900, `ELGR` 1–2, `ELSH` 0–16; в теме — `wght 700, ELGR 2, ELSH 2` |
| Оси | `wght` 100–900, `ELGR` 1–2, `ELSH` 0–16 |
| Автор / проект | The Handjet Project Authors / Rosetta Type — https://github.com/google/fonts/tree/main/ofl/handjet |
| Прямая ссылка | https://raw.githubusercontent.com/google/fonts/main/ofl/handjet/Handjet%5BELGR%2CELSH%2Cwght%5D.ttf |
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-Handjet.txt` |
| SHA-256 | `9262749e8bb0b73ebcae0e20428689c3c59576eebeb6c4e1020300d2d41bdf4d` |
| Кириллица | полная (проверено по cmap: А–Я, а–я, Ё/ё, цифры; 1339 кодпоинтов) |
| Использование | `default_font` темы `art/fonts/ui_theme.tres`; все UI-тексты |
| Кириллица | полная (1339 кодпоинтов) |
| Использование | **нигде**: после перехода на Golos Text (D51) файл оставлен как исторический артефакт |
Handjet узкий (средняя ширина ≈0.36 em), поэтому в карточки 132 px влезают
имена файлов. Допустимые кегли: **10 / 12 / 16 / 24**.
TTF, `.import` и OFL можно удалить без влияния на игру; тема на Handjet не
ссылается. Узкий рисунок Handjet больше не нужен: Golos Text держит плотный
русский текст в карточках 400 px.
### Press Start 2P — логотип и крупные цифры
### Press Start 2P — цифры и числовые акценты
| Поле | Значение |
| --- | --- |
@@ -32,10 +132,12 @@ Handjet узкий (средняя ширина ≈0.36 em), поэтому в
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-PressStart2P.txt` |
| SHA-256 | `034c77f1f05ec89421e4a63f0e3a4ca1ecf852cc6d2bf611f126f275728e017d` |
| Кириллица | полная (656 кодпоинтов, включая Ё/ё и служебные `:/-`) |
| Использование | только логотип, заголовки и числовые строки (`theme90.gd → logo()`) |
| Использование | `theme90.gd → logo()`: цифры и счётчики (`num_line()`), часы, номера слотов, знак «?». Крупные слова и заголовки — уже на `display()` |
Шрифт слишком широкий для карточек (17 символов не влезают в 132 px), поэтому
основным текстом он не используется.
Шрифт слишком широкий для тела текста и карточек, поэтому основным текстом
он не используется (D51). Цифры у него ровно 1 em, поэтому счётчики и часы
не меняют ширину при смене значения — это главная причина держать его на
числах, а крупный текст отдавать DisplayOTF.
### Pixelify Sans — в теме не используется
@@ -54,22 +156,58 @@ Handjet узкий (средняя ширина ≈0.36 em), поэтому в
### Совместимость лицензий
Все три шрифта — **SIL OFL 1.1**. OFL разрешает встраивать шрифты в игру
(включая коммерческую), но требует сохранять текст лицензии и не продавать
шрифт отдельно. Для производных шрифтов действует Reserved Font Name
(у Press Start 2P — «Press Start 2P»). Это совместимо с MIT-лицензией кода:
Golos Text, Handjet, Press Start 2P и Pixelify Sans — **SIL OFL 1.1**.
OFL разрешает встраивать шрифты в игру (включая коммерческую), но требует
сохранять текст лицензии и не продавать шрифт отдельно. Для производных
шрифтов действует Reserved Font Name. Это совместимо с MIT-лицензией кода:
MIT не накладывает ограничений на шрифты, а OFL-тексты лежат рядом с файлами.
DisplayOTF — **freeware**, не OFL. Играть и встраивать в игру можно, текст
соглашения лежит рядом (`LICENSE-DisplayOTF.txt`); продавать сам шрифт
отдельно и менять состав знаков нельзя. Это не мешает MIT-коду игры, но при
смене лицензии проекта или платной сборке условия нужно перечитать.
Kechusilka — по шрифту лицензия **подтверждена владельцем проекта** (license
confirmed by project owner). Текста лицензии в архиве и в файле шрифта нет,
поэтому её название здесь не указано и условия не пересказываются: если
проект будет публиковаться или продаваться, текст нужно запросить у
владельца. Нормализация (масштаб контуров) — техническое изменение файла,
о котором владелец уведомлён.
## Графика
| Файл | Автор | Лицензия | Примечание |
| --- | --- | --- | --- |
| UI, иконки, фаски | команда проекта | MIT | рисуются кодом в `scripts/ui/theme90.gd`; внешних текстур нет |
| `art/icons/*.png` | команда проекта | MIT | 32×32 RGBA, пиксельные иконки файлов/папки/пакета; сгенерированы `art/icons/generate_icons.py` |
| UI, панели, маскот | команда проекта | MIT | рисуются кодом в `scripts/ui/theme90.gd`; внешних UI-текстур нет |
| `icon.svg` | команда проекта | MIT | иконка проекта, формы без текста (ThorVG) |
| `docs/screenshots/*.png` | команда проекта | MIT | кадры игры 1280×720, сняты из работающей сборки |
`art/icons/` и `art/ui/` пусты (`.gitkeep`): спрайтов, тайлсетов и сторонней
графики в проекте нет.
### Пиксельные иконки файлов
`art/icons/` содержит `file_text.png`, `file_sound.png`, `file_photo.png`,
`file_video.png`, `folder.png` и `packet.png` (все 32×32 RGBA). Иконки
показываются в сетке папки, drag-preview, чипах запроса и карточках пакетов;
в `Theme90.draw_file_icon()` они рисуются с `nearest`, поэтому остаются
резкими в 16/24/32 px.
- Генератор: `art/icons/generate_icons.py` (Python 3 + Pillow), палитра
Kaleidos, контур 1 px `#2C2A26`, без сглаживания.
- Лицензия: **MIT**, сгенерировано в команде проекта в стиле пиксельного
тайл-листа; сторонних спрайтов нет.
- Исходный тайл-лист/лист иконок был недоступен, поэтому набор **не вырезан**
из готового файла, а **перегенерирован** скриптом с нуля. Это позволяет
воспроизвести все PNG одной командой и не зависит от потерянного исходника.
Пересборка:
```bash
cd peak-hour
python3 art/icons/generate_icons.py
# превью: python3 art/icons/generate_icons.py --preview /tmp/opencode/icons_preview.png
```
`art/ui/` по-прежнему пуст (`.gitkeep`): отдельных UI-спрайтов нет.
## Аудио
@@ -108,21 +246,33 @@ python3 audio/generate_sfx.py
```bash
cd peak-hour/art/fonts
curl -L -o Handjet-VF.ttf \
"https://raw.githubusercontent.com/google/fonts/main/ofl/handjet/Handjet%5BELGR%2CELSH%2Cwght%5D.ttf"
# Golos Text: скачайте GolosText[wght].ttf из google/fonts/ofl/golostext
# и сохраните локально как GolosText-VF.ttf
curl -L -o PressStart2P-Regular.ttf \
"https://raw.githubusercontent.com/google/fonts/main/ofl/pressstart2p/PressStart2P-Regular.ttf"
sha256sum Handjet-VF.ttf PressStart2P-Regular.ttf
sha256sum GolosText-VF.ttf PressStart2P-Regular.ttf
# Kechusilka пересобирается из оригинала (нужен fontTools):
python3 normalize_kechusilka.py
```
Ожидаемые хеши — в таблицах выше. Тексты OFL уже лежат в `art/fonts/`; при
обновлении шрифта обновите и их. Pixelify Sans скачивается по ссылке из его
раздела, но для сборки не требуется.
обновлении шрифта обновите и их. Handjet и Pixelify Sans для сборки не
требуются.
DisplayOTF уже лежит в `art/fonts/` вместе с лицензией — публичной ссылки на
него нет, файл нужно сохранять как есть (не переконвертировать в TTF и не
сабсетить).
Kechusilka тоже в репозитории: `Kechusilka-Regular.otf` — результат
нормализации, оригинал лежит в `art/fonts/source/`. Правьте не результат, а
скрипт: он пересобирает файл из оригинала и даёт тот же SHA-256. Если
пересобрали — обновите хеш в таблице выше.
### Графика
Генерировать нечего: UI и иконки рисуются кодом, `icon.svg` — обычный SVG.
После клонирования достаточно импортировать проект в Godot 4.7.2.
Иконки пересобираются `python3 art/icons/generate_icons.py` (нужен Pillow).
`icon.svg` — обычный SVG. После клонирования достаточно импортировать проект
в Godot 4.7.2.
## Чего в проекте нет
+168 -69
View File
@@ -2,56 +2,102 @@
> Час пик. Одна смена. Ни одного свободного места.
Ночной диспетчер узла файлового обмена, 1997 год. Принимайте пакеты,
раскладывайте файлы, собирайте запросы клиентов, держите канал и SLA — в
интерфейсе, вдохновлённом Kaleidos: тёплая бумага, тонкие контуры и
бирюзовый акцент.
1997 год. Ночной диспетчер узла файлового обмена: принимайте пакеты,
раскладывайте файлы-полимино по папкам, собирайте контейнеры запросов,
проходите мини-игры и держите SLA. UI — «Kaleidos»: тёплая бумага, тонкие
контуры и бирюзовый акцент.
**Жанр:** менеджмент / тайм-менеджмент, retro UI.
**Платформа:** Godot **4.7.2** → **Web (HTML5, без threads)**, RU-only.
**Окно:** 1920×1080, логический viewport 1280×720, fractional scale.
**Забег:** 9 игровых часов по 5 минут (≈45 минут) + подготовка между часами.
**Забег:** 9 игровых часов — **120 / 150 / 180 / 210 / 240 / 270 / 300 / 330 /
360 с** (`120 + 30×(h−1)`, всего **36 минут**) + подготовка между часами.
## Статус v1.2
## Статус v3.0
Играбельный веб-прототип полного цикла: **меню → подготовка → час → итоги
часа → … → итоги забега**. Девять часов проходят на сидах `1`, `42`, `777`
без потери часов; SLA после магазина — 200.
часа → … → итоги забега**. Актуальная спецификация — [GDD v3.0](docs/GDD.md),
решения — [DECISIONS](docs/DECISIONS.md) (актуальны D45–D61, D01–D44 — история).
Реализовано:
- 4 типа файлов (text/sound/photo/video), буфер приёма, папки и секции.
- **10 апгрейдов** магазина, включая `SLA +24`; магазин открывается с часа 2
и скрыт во время часа.
- TCP-слоты и полоска соединения: три попадания; промах закрывает панель
без автоповтора.
- 4 атаки, но **час 1 проходит без атак**; junk/ddos — с часа 2,
phishing — с 3, ransomware — с 4.
- **SLA 128 / 200** на старте; потеря пакета: text 2 / sound 4 / photo 8 /
video 16.
- Мета-награды **постепенно по часам**: поиск после 3-го часа, щит после
5-го, `complete_run` после 9-го; итоги показывают «ОТКРЫТО: …», прогресс
живёт между забегами. `minigame_advanced` — резерв под будущий контент.
- Поиск по имени в текущей папке после открытия; заголовок «N ИЗ M ОБЪЕКТОВ».
- Автообновление папки/дерева, уникальные имена папок (`Имя`, `Имя 2`),
удаление пустых папок, attached-галочки, скролл приёма до 8 пакетов.
- «Уничтожить содержимое» папки удаляет файлы с подтверждением и −5 SLA;
пустая папка удаляется без штрафа.
- «Спокойный фон» — плоская бумага без dither; Esc на итогах забега ведёт
в главное меню.
- UI v2 «Kaleidos», маскот **БАЙТ** и короткий туториал первого часа.
- Ambient машинного зала: 4 WAV, автозапуск после первого жеста, mute/пауза/
громкость; генератор `audio/generate_ambient.py`.
- Процедурный VHS только в главном меню (настройки → «ЭКРАН»): скан-линии,
зерно, виньетка и полоса; рисуется кодом, не блокирует ввод, текст резкий.
Игровой UI остаётся чистым, без CRT на весь интерфейс.
- Handjet + Press Start 2P, 18 SFX + фоновый `hum`.
- **9 часов**, длительность **120 → 360 с** (+30 с за час), старт **50 $**,
**SLA 60/60** (кап +40 за уровень `lot_sla` до **220**), 1 слот (макс. 5),
канал 10 (макс. 46), буфер 6 (макс. 12), таймер запроса 30 с (макс. 65),
таймер пакета 20 с.
- **4 типа файлов-полимино:** текст 1, звук 2, фото 3, видео 4 клетки;
поворот `R`/ПКМ во время drag; сетка папки **5×4 → 6×4 → 7×5 → 8×5 →
8×6 → 9×6** (до 54 клеток). Новая папка — **180 $**, всего до 6.
- **Стол сортировки 4×6** в подготовке; файлы со стола нельзя прикрепить,
на старте часа они автоматически возвращаются в папки (если места нет —
час не начинается).
- **Запросы на 1–4 конкретных именованных файла:** имена видны на карточке
сеткой 2×2, прикрепление проверяется по точному `file_id`, потерянный файл
помечается «ПОТЕРЯН», обязательные файлы жёстко зарезервированы. H1
стартует с seed-файла и сразу с запроса на него. Отдельного `request_cap`
нет: одновременно живут не больше `active_requests` запросов, новые не
появляются в последние **10 с** часа; анти-голодание срабатывает только для
первого запроса часа, дальше темп задаёт `send_interval`.
- **Файлы — каталог, а не расходник:** успешная отправка только снимает
привязку к запросу, файл остаётся в папке и может быть затребован снова.
Приход задаётся только скоростью: окно спавна **75 %** часа, интервал —
`spawn_window / hours[].files` с джиттером ±20 %, без переноса и общего
лимита файлов (carry = 0).
- **9 лотов магазина**; ровно 3 предложения, рероллы 40/80/120 (макс. 3 за
подготовку), замок отдельного предложения. Магазин — с H2 и только в
подготовке. Полное дерево — **8 825 $** (было 8 085 $ с 8 лотами), новый
лот `lot_udp` — «UDP-повторитель».
- **6 мини-игр отправки** (`dots`, `sound`, `text`, `video`, `photo`, `maze`;
**3 ступени**) и **4 мини-игры атак** (`junk`, `ddos`, `phishing`,
`ransomware`; 4 ступени); без таймеров, с шапкой «название + цель», полосой
прогресса и экраном результата: имя игры, `q%`, полоса качества, вердикт,
детали и `ПРОДОЛЖИТЬ`/`Esc`.
- **TCP как отдельное соединение:** до `ПОДКЛЮЧИТЬСЯ` файлы в запрос не
прикрепляются. Успех (3 попадания) занимает out-слот, в слоте — имя клиента
и случайный IP; мини-игра отправки сама не открывается. `ОТПРАВИТЬ`
запускает её, только когда прикреплены все файлы. После успешной отправки
запрос исчезает, файлы возвращаются в папку и остаются доступны новым
запросам, выплата начисляется, но TCP-слот остаётся занятым (idle) до
ручного `ОТКЛЮЧИТЬ` на слоте. TCP платит **×1.15**.
- **UDP без соединения:** время передачи **×0.8**, выплата **×0.85**, но
успех — ролл в конце передачи: база **40 %**, +**10 %** за уровень
`lot_udp`, максимум **80 %** (цены 60/120/210/350). Провал: **−3 SLA**, без
выплаты и денежного штрафа, файлы не расходуются, запрос остаётся, повтор
через **4 с**, слот освобождается. Файлы не расходуются ни при успешной
отправке, ни при отмене/таймауте/отключении.
- **Слоты общие для приёма и отправки:** `1 + slot_level`, максимум 5.
TCP-соединение может занять единственный слот и заблокировать приём — это
осознанное требование. Открепление файла — двойной клик по чипу.
- **Шрифты:** тело — **Kechusilka Regular** (нормализован под Golos, fallback
Golos Text), крупный текст — **DisplayOTF**, цифры/счётчики — **Press Start
2P**; шкала 11/13/17/26.
- **«ЖАРА»:** в меню-баре вместо часов 03:42 — `TemperatureGauge`
(216×18, колба, деления, °C); поверх игры — `StressOverlay` с heat-моделью
и alpha-only шейдером без чтения экрана. Пульс SLA/CRIT, тряска панелей
≤2 px, «Снизить эффекты» (`reduce_motion`) отключает движение; в headless
слой скрыт. Анимации карточек, слотов и drag — через `UiMotion` (Lane B).
- **20 атак за забег** (`0,1,1,2,2,3,3,4,4`), 4 вида, 4 ступени; атака — это
мини-игра, которая ставит час на паузу. Щит «Заземление» гасит атаку
автоматически (**70 %** награды, без штрафа).
- **Пошаговый туториал v3:** 5 шагов (пакет → прикрепление → TCP-соединение →
мини-игра → SLA/атаки), пульсирующие рамки у цели и кнопка «ДАЛЕЕ».
Прохождение хранится в `Game.stats["tutorial_done"]`; кнопка «ОБУЧЕНИЕ» в
главном меню запускает повтор.
- **Save VERSION 2**: позиции только в `folder.files`/`table`, метаданные в
`files`; v1-сейвы мигрируют без потери файлов и без уменьшения сеток;
офферы/рероллы/замки, стол и `tutorial_done` сохраняются.
- Мета-награды после 3/5/9 часов, маскот **БАЙТ**, UI v3 «Kaleidos», ambient
машинного зала, VHS только в главном меню.
Ограничения:
- Туториал — маскот и несколько подсказок, без пошагового сценария.
- Сохранение в Web идёт через IndexedDB (`user://save.json`); проверяйте
продолжение после перезагрузки вкладки.
- Средний расход магазина в симуляторе — **44,9 %** дерева **8 825 $**;
итог **7 205 $** в полосе **6 982–8 500 $**, поражений **0/240**.
H2, H4 и H5 идут близко к нижней границе −20 % (−16,4 %, −15,3 %, −15,1 %),
поэтому дальше ужесточать темп нельзя. Подробности —
`tests/SIM_BALANCE_REPORT.md`.
- Remote push ещё не выполнен; сборка для itch.io готовится локально.
## Управление
@@ -60,20 +106,29 @@
| --- | --- |
| Выбор, меню, перетаскивание | ЛКМ |
| Контекстное меню | ПКМ |
| Пакет из «Приёма» → папка; файл → карточка запроса | drag&drop |
| Поиск по имени в текущей папке | после открытия «Поиска» |
| Мини-игра соединения: три попадания | ЛКМ или `Space` |
| Мини-игра подготовки: отправить | `Enter` или кнопка |
| Поворот фигуры во время drag | `R` или ПКМ |
| Пакет из «Приёма» → папка | drag&drop |
| Файл из папки → карточка запроса | drag&drop (TCP — только после `ПОДКЛЮЧИТЬСЯ`) |
| Файл ↔ стол сортировки | drag&drop |
| TCP-соединение | кнопка `ПОДКЛЮЧИТЬСЯ` справа снизу, 3 попадания в зелёную зону |
| Отправка запроса | кнопка `ОТПРАВИТЬ` слева снизу |
| Отклонить запрос | кнопка `ОТКЛОНИТЬ` слева снизу |
| Закрыть TCP-соединение | `ОТКЛЮЧИТЬ` на слоте (мгновенно) |
| Открепить файл от запроса | двойной клик по чипу |
| TCP/UDP | переключатель на карточке запроса до соединения |
| Мини-игра: действие | ЛКМ / `Space` / стрелки (по игре) |
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
| Пропустить мини-игру | `ПРОПУСТИТЬ`/`СДАТЬСЯ` + второй клик |
| Результат мини-игры | `ПРОДОЛЖИТЬ` или `Esc` |
| Магазин: закрыть | `ЗАКРЫТЬ` или `Esc` |
| Пауза | `Esc` |
| Панель соединения: отмена (слот свободен, запрос остаётся) | `Esc` |
## Запуск
Нужен Godot 4.7.2. В этом окружении:
Нужен Godot **4.7.2**. Команды выполняются из корня репозитория; ниже
`GODOT` — путь к бинарю Godot.
```bash
GODOT=/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64
# один раз после клона: импорт шрифтов и темы
"$GODOT" --headless --path . --import
@@ -84,6 +139,13 @@ GODOT=/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64
"$GODOT" -e --path .
```
В Windows удобно задать путь один раз:
```powershell
$env:GODOT_BIN = "D:\Apps\godot\Godot_v4.7.2-stable_win64_console.exe"
& $env:GODOT_BIN --path .
```
## Сборка Web
```bash
@@ -97,20 +159,37 @@ mkdir -p build/web
просмотр — только по HTTP:
```bash
python3 -m http.server 8000 --directory build/web
python -m http.server 8000 --directory build/web
# http://localhost:8000
```
Нужны export templates 4.7.2 (`web_nothreads_release.zip`).
Нужны export templates 4.7.2 (`web_nothreads_release.zip`). Каталог шаблонов
должен называться `4.7.2.stable`.
## Тесты
Полный прогон (21 набор + parse-check):
Актуальный список — **полный набор + parse-check** в
`scripts/run_all_tests.sh` / `scripts/run_all_tests.ps1` (см. также `tests/`;
число наборов и проверок может меняться между волнами, поэтому здесь не
хардкодится). Core-наборы (`test_hour`, `test_economy`, `test_save`,
`test_slots`, `test_controller`, `test_integration`, `test_fuzz`,
`test_full_playthrough`, `test_shop`, `test_shop_modal`) обновлены и зелёные;
UI-наборы (`test_main_*`, `ui_smoke`) прогоняются отдельно.
```powershell
# Windows
powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
```
```bash
# Linux / macOS
bash scripts/run_all_tests.sh
```
Windows-раннер сам ищет Godot (сначала `GODOT_BIN`, затем стандартный путь,
затем `godot4`/`godot` из `PATH`); путь можно передать явно:
`-GodotBin <path>`.
Точечно:
```bash
@@ -120,32 +199,49 @@ bash scripts/run_all_tests.sh
"$GODOT" --headless --path . --script scripts/ui/ui_smoke.gd
```
Фактический итог: **22/22 PASS** (21 набор + parse-check); ключевые
счётчики — `test_main_prep` 92/0, `ui_smoke` 135/0, `test_ambient` 74/0.
Подробности — в
[AI_REPORT](AI_REPORT.md).
Баланс (240 сидов, отчёт `tests/SIM_BALANCE_REPORT.md`):
```bash
python tests/sim_balance.py
```
Отчёт `tests/SIM_BALANCE_REPORT.md` (240 сидов, 9 часов, `q=0.85`, TCP):
средний итог **7 205 $** (payout 6 344 + `recv_fee` 177 + атаки 684) в полосе
**6 982–8 500 $**, расход магазина **3 964 $** (**44,9 %** дерева **8 825 $**),
**0 поражений из 240**, SLA в конце H9 — **64,9**, запросов выполнено — **62,0**,
вердикт `PASS`, exit code **0**. H1 `income_target` — **100 $** (2-минутный
час), H2, H4 и H5 близко к −20 %.
Подробности — в [AI_REPORT](AI_REPORT.md).
E2E-прогоны на реальной сцене `Main` и веб-смоук проводились в предыдущей
волне; после изменений TCP/UDP-модели и UI их нужно перепроверить перед
публикацией. Финальный счёт UI-наборов здесь не хардкодится.
## Структура
```
peak-hour/
Game-JAM/
├─ project.godot # 1920×1080 / 1280×720, GL Compatibility
├─ export_presets.cfg # Web (no threads)
├─ scenes/ # Main + dev-превью
├─ scenes/ # Main + UI/мини-игры
├─ scripts/
│ ├─ main.gd # UI, ввод, модалки, drag&drop, туториал
│ ├─ game_controller.gd # цикл menu → prep → hour → results
│ ├─ run_all_tests.sh # полный headless-прогон
│ ├─ run_all_tests.ps1/.sh # полный headless-прогон
│ ├─ autoload/ # Game, Data, Audio, Rng, Ambient
│ ├─ core/ # HourDirector, FileRegistry, SlotManager, Economy
│ ├─ entities/ # файлы, пакеты, запросы, папки, слоты
│ ├─ systems/ # атаки, магазин, сохранения
│ ├─ ui/ # Theme90 v2, модалки, маскот, VHS-оверлей
│ └─ minigames/ # ConnectionGame, MiniGame
│ ├─ entities/ # файлы, папки, секции, пакеты, запросы, слоты
│ ├─ systems/ # Shop, AttackDirector, SaveManager
│ ├─ ui/ # Theme90, FileGrid, SortingTable, ShopModal, VHS,
│ │ # TemperatureGauge, StressOverlay, UiMotion
│ └─ minigames/ # MiniGame, factory, connection_game, send/, attack/
├─ data/ # balance.json, strings_ru.json, names_ru.json
├─ art/fonts/ # Handjet, Press Start 2P + OFL
├─ art/fonts/ # Kechusilka, DisplayOTF, Golos fallback, Press Start 2P
├─ art/icons/ # пиксельные иконки + generate_icons.py
├─ audio/ # sfx/, music/, amb/, generate_*py
├─ tests/ # headless-наборы
├─ docs/ # GDD, DECISIONS, UI_REFRESH, screenshots
├─ shaders/ # vhs.gdshader (только главное меню)
├─ tests/ # headless-наборы, sim_balance.py
├─ docs/ # GDD, DECISIONS, UI_REFRESH, скриншоты
└─ build/ # артефакты экспорта (gitignored)
```
@@ -153,23 +249,26 @@ peak-hour/
- Godot **4.7.2** (GL Compatibility).
- Для Web: export templates 4.7.2, `web_nothreads_release.zip`.
- Python 3 — только для пересборки SFX.
- Python 3 — для баланса и пересборки SFX/ambient.
- Никаких секретов и внешних сервисов.
## Документация
- [GDD](docs/GDD.md) — правила и числа v1.2.
- [DECISIONS](docs/DECISIONS.md) — решения D01–D25.
- [GDD](docs/GDD.md) — правила и числа v3.0.
- [DECISIONS](docs/DECISIONS.md) — решения D01–D61 (актуальны D45–D61).
- [AI_REPORT](AI_REPORT.md) — модели, приёмы и проверки.
- [ASSETS](ASSETS.md) — шрифты, звук, лицензии и воспроизведение.
- [UI_REFRESH](docs/UI_REFRESH.md) — UI v2 «Kaleidos».
- [ASSETS](ASSETS.md) — шрифты, иконки, звук, лицензии и воспроизведение.
- [UI_REFRESH](docs/UI_REFRESH.md) — UI v3 «Kaleidos».
- [IMPLEMENTATION](docs/IMPLEMENTATION.md) и [TEST_PLAN](docs/TEST_PLAN.md) —
контракты и матрица тестов.
исторические контракты v1 и матрица тестов (v3-дельты — в начале
IMPLEMENTATION).
- [ITCH_PAGE](docs/ITCH_PAGE.md) — текст страницы itch.io.
- [screenshots](docs/screenshots/) — кадры меню, подготовки, часа и атак.
## Лицензии
Код — **MIT** ([LICENSE](LICENSE)). Шрифты — **SIL OFL 1.1**
(`art/fonts/OFL-*.txt`). UI и иконка — MIT. SFX и `hum.wav` — MIT/CC0,
сгенерированы `audio/generate_sfx.py`. Подробности — в [ASSETS.md](ASSETS.md).
Код — **MIT** ([LICENSE](LICENSE)). Шрифты: Golos Text и Press Start 2P —
**SIL OFL 1.1**, Kechusilka — лицензия подтверждена владельцем проекта,
DisplayOTF — **freeware/personal** (Denis A Serikov). UI, пиксельные иконки
(`art/icons/`) и иконка — MIT. SFX и `hum.wav` — MIT/CC0, сгенерированы
`audio/generate_sfx.py`. Подробности и тексты лицензий — в [ASSETS.md](ASSETS.md).
+1
View File
@@ -0,0 +1 @@
Copyright (c) Denis A Serikov, 2003-2006. All rights reserved. denis_box@mtu-net.ru
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://d26do8xk5bs24"
path="res://.godot/imported/DisplayOTF.otf-521c0bcaa41177fabd074bc7be7bb37f.fontdata"
[deps]
source_file="res://art/fonts/DisplayOTF.otf"
dest_files=["res://.godot/imported/DisplayOTF.otf-521c0bcaa41177fabd074bc7be7bb37f.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://cwms0uuyam0fk"
path="res://.godot/imported/GolosText-VF.ttf-af72b844d15035464cce46e3ecf9f3ea.fontdata"
[deps]
source_file="res://art/fonts/GolosText-VF.ttf"
dest_files=["res://.godot/imported/GolosText-VF.ttf-af72b844d15035464cce46e3ecf9f3ea.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://blvusebgit70d"
path="res://.godot/imported/Kechusilka-Regular.otf-b002f5d4ceda13c25fba42b2e2fc1ede.fontdata"
[deps]
source_file="res://art/fonts/Kechusilka-Regular.otf"
dest_files=["res://.godot/imported/Kechusilka-Regular.otf-b002f5d4ceda13c25fba42b2e2fc1ede.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+34
View File
@@ -0,0 +1,34 @@
-------------------------------------------------------------------------------------------------------------------------------------
Пожалуйста, перед использованием, прочтите данное соглашение!
-------------------------------------------------------------------------------------------------------------------------------------
====================================================================================
Лицензионное соглашение:
Используя этот шрифт, вы автоматически принимаете данное соглашение.
Шрифт является бесплатным (freeware) и предоставляется по принципу "как есть",
Следовательно, никаких гарантий со стороны производителя не прилагается
и не предусматривается. Вы используете этот шрифт на свой страх и риск.
Автор, не будет отвечать ни за какие потери или искажения данных, любую упущенную
выгоду в процессе использования, или не правильного использования этого шрифта.
Любая продажа шрифта или передача с целью получения дохода, запрещена.
Автор оставляет за собой право собственности на данный продукт и возможность
внесения любых изменений без предварительного уведомления и предупреждения.
Декомпиляция шрифта, изменение формата, и внесение изменений в состав
знаков - только с разрешения автора.
В случае возникновения вопросов, свяжитесь со мной по E-mail:denis_box@mtu-net.ru
====================================================================================
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
Следует помнить, что все логотипы и торговые марки и зарегистрированные торговые
марки принадлежат их законным владельцам.
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
====================================================================================
Этот шрифт был скачен с сайта www.otlab.ru
+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 The Golos Text Project Authors (https://github.com/googlefonts/golos-text)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+176 -174
View File
@@ -1,17 +1,15 @@
[gd_resource type="Theme" format=3]
[ext_resource type="FontFile" path="res://art/fonts/Handjet-VF.ttf" id="1_hjthv"]
[ext_resource type="FontFile" path="res://art/fonts/Kechusilka-Regular.otf" id="1_cg7jb"]
[ext_resource type="FontFile" path="res://art/fonts/GolosText-VF.ttf" id="1_fum4e"]
[ext_resource type="FontFile" path="res://art/fonts/PressStart2P-Regular.ttf" id="2_fum4e"]
[ext_resource type="FontFile" path="res://art/fonts/DisplayOTF.otf" id="3_dc2yr"]
[sub_resource type="FontVariation" id="FontVariation_fum4e"]
base_font = ExtResource("1_hjthv")
variation_opentype = {
1162626898: 2.0,
1162629960: 2.0,
2003265652: 700.0
}
[sub_resource type="FontVariation" id="FontVariation_6m2gx"]
fallbacks = Array[Font]([ExtResource("1_fum4e")])
base_font = ExtResource("1_cg7jb")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lr7a5"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_x3s7b"]
bg_color = Color(0.913725, 0.901961, 0.866667, 1)
border_width_left = 1
border_width_top = 1
@@ -24,7 +22,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cg7jb"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukyte"]
bg_color = Color(0, 0, 0, 0)
border_width_left = 1
border_width_top = 1
@@ -37,7 +35,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dc2yr"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wjr02"]
bg_color = Color(1, 1, 1, 1)
border_width_left = 1
border_width_top = 1
@@ -53,7 +51,7 @@ shadow_color = Color(0, 0, 0, 0.094)
shadow_size = 3
shadow_offset = Vector2(0, 2)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6m2gx"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4munx"]
bg_color = Color(0.968627, 0.960784, 0.933333, 1)
border_width_left = 1
border_width_top = 1
@@ -69,7 +67,7 @@ shadow_color = Color(0, 0, 0, 0.094)
shadow_size = 3
shadow_offset = Vector2(0, 2)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_x3s7b"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oc7r5"]
bg_color = Color(0.847059, 0.827451, 0.788235, 1)
border_width_left = 1
border_width_top = 1
@@ -82,33 +80,9 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="Image" id="Image_ukyte"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 14,
"mipmaps": false,
"width": 14
}
[sub_resource type="ImageTexture" id="ImageTexture_wjr02"]
image = SubResource("Image_ukyte")
[sub_resource type="Image" id="Image_4munx"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 14,
"mipmaps": false,
"width": 14
}
[sub_resource type="ImageTexture" id="ImageTexture_oc7r5"]
image = SubResource("Image_4munx")
[sub_resource type="Image" id="Image_y0w1v"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 14,
"mipmaps": false,
@@ -120,7 +94,7 @@ image = SubResource("Image_y0w1v")
[sub_resource type="Image" id="Image_dywbi"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 14,
"mipmaps": false,
@@ -130,38 +104,38 @@ data = {
[sub_resource type="ImageTexture" id="ImageTexture_47b07"]
image = SubResource("Image_dywbi")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_danrt"]
[sub_resource type="Image" id="Image_danrt"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 14,
"mipmaps": false,
"width": 14
}
[sub_resource type="ImageTexture" id="ImageTexture_1j4f6"]
image = SubResource("Image_danrt")
[sub_resource type="Image" id="Image_djhhc"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 14,
"mipmaps": false,
"width": 14
}
[sub_resource type="ImageTexture" id="ImageTexture_a0hrt"]
image = SubResource("Image_djhhc")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jvde3"]
bg_color = Color(0, 0, 0, 0)
border_color = Color(0, 0, 0, 0)
corner_detail = 4
[sub_resource type="Image" id="Image_1j4f6"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 9,
"mipmaps": false,
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_djhhc"]
image = SubResource("Image_1j4f6")
[sub_resource type="Image" id="Image_a0hrt"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 9,
"mipmaps": false,
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_jvde3"]
image = SubResource("Image_a0hrt")
[sub_resource type="Image" id="Image_372il"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 9,
"mipmaps": false,
@@ -173,7 +147,7 @@ image = SubResource("Image_372il")
[sub_resource type="Image" id="Image_11awt"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 9,
"mipmaps": false,
@@ -183,7 +157,31 @@ data = {
[sub_resource type="ImageTexture" id="ImageTexture_loh4e"]
image = SubResource("Image_11awt")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_a1mnj"]
[sub_resource type="Image" id="Image_a1mnj"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 9,
"mipmaps": false,
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_4uvxq"]
image = SubResource("Image_a1mnj")
[sub_resource type="Image" id="Image_petse"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 9,
"mipmaps": false,
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_j2rjp"]
image = SubResource("Image_petse")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7om8y"]
bg_color = Color(0.603922, 0.584314, 0.541176, 0.3)
border_color = Color(0, 0, 0, 0)
corner_radius_top_left = 2
@@ -192,7 +190,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4uvxq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_o55pb"]
bg_color = Color(0.184314, 0.498039, 0.560784, 0.55)
border_color = Color(0, 0, 0, 0)
corner_radius_top_left = 2
@@ -201,7 +199,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_petse"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wl44i"]
bg_color = Color(0.847059, 0.827451, 0.788235, 1)
border_width_left = 1
border_width_top = 1
@@ -214,33 +212,9 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="Image" id="Image_j2rjp"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 12,
"mipmaps": false,
"width": 12
}
[sub_resource type="ImageTexture" id="ImageTexture_7om8y"]
image = SubResource("Image_j2rjp")
[sub_resource type="Image" id="Image_o55pb"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 12,
"mipmaps": false,
"width": 12
}
[sub_resource type="ImageTexture" id="ImageTexture_wl44i"]
image = SubResource("Image_o55pb")
[sub_resource type="Image" id="Image_mni32"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 12,
"mipmaps": false,
@@ -250,7 +224,31 @@ data = {
[sub_resource type="ImageTexture" id="ImageTexture_8vjq1"]
image = SubResource("Image_mni32")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w06qd"]
[sub_resource type="Image" id="Image_w06qd"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 12,
"mipmaps": false,
"width": 12
}
[sub_resource type="ImageTexture" id="ImageTexture_barbq"]
image = SubResource("Image_w06qd")
[sub_resource type="Image" id="Image_dvi42"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 12,
"mipmaps": false,
"width": 12
}
[sub_resource type="ImageTexture" id="ImageTexture_foj1q"]
image = SubResource("Image_dvi42")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jyq6k"]
bg_color = Color(0.513726, 0.780392, 0.831373, 1)
border_color = Color(0, 0, 0, 0)
corner_radius_top_left = 2
@@ -259,7 +257,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_barbq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cjati"]
bg_color = Color(0.184314, 0.498039, 0.560784, 1)
border_color = Color(0, 0, 0, 0)
corner_radius_top_left = 2
@@ -268,7 +266,7 @@ corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dvi42"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m7381"]
bg_color = Color(1, 1, 1, 1)
border_width_left = 1
border_width_top = 1
@@ -281,7 +279,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_foj1q"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_of6nr"]
content_margin_left = 2.0
content_margin_top = 2.0
content_margin_right = 2.0
@@ -298,7 +296,11 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jyq6k"]
[sub_resource type="FontVariation" id="FontVariation_s7l1c"]
fallbacks = Array[Font]([ExtResource("1_fum4e")])
base_font = ExtResource("3_dc2yr")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7j4rs"]
bg_color = Color(0.968627, 0.960784, 0.933333, 1)
border_width_left = 1
border_width_top = 1
@@ -314,7 +316,7 @@ shadow_color = Color(0, 0, 0, 0.094)
shadow_size = 3
shadow_offset = Vector2(0, 2)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cjati"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_05v8i"]
content_margin_left = 2.0
content_margin_top = 2.0
content_margin_right = 2.0
@@ -327,7 +329,7 @@ corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m7381"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7ph3p"]
content_margin_left = 5.0
content_margin_top = 5.0
content_margin_right = 5.0
@@ -347,7 +349,7 @@ shadow_color = Color(0, 0, 0, 0.16)
shadow_size = 6
shadow_offset = Vector2(0, 3)
[sub_resource type="Image" id="Image_of6nr"]
[sub_resource type="Image" id="Image_2b66g"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -356,10 +358,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_s7l1c"]
image = SubResource("Image_of6nr")
[sub_resource type="ImageTexture" id="ImageTexture_1tdcs"]
image = SubResource("Image_2b66g")
[sub_resource type="Image" id="Image_7j4rs"]
[sub_resource type="Image" id="Image_hv5yg"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -368,10 +370,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_05v8i"]
image = SubResource("Image_7j4rs")
[sub_resource type="ImageTexture" id="ImageTexture_yjsxn"]
image = SubResource("Image_hv5yg")
[sub_resource type="Image" id="Image_7ph3p"]
[sub_resource type="Image" id="Image_s5gth"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -380,10 +382,10 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_2b66g"]
image = SubResource("Image_7ph3p")
[sub_resource type="ImageTexture" id="ImageTexture_bkd55"]
image = SubResource("Image_s5gth")
[sub_resource type="Image" id="Image_1tdcs"]
[sub_resource type="Image" id="Image_xoku7"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
@@ -392,74 +394,74 @@ data = {
"width": 9
}
[sub_resource type="ImageTexture" id="ImageTexture_hv5yg"]
image = SubResource("Image_1tdcs")
[sub_resource type="ImageTexture" id="ImageTexture_d1bwe"]
image = SubResource("Image_xoku7")
[resource]
default_font = SubResource("FontVariation_fum4e")
default_font_size = 12
default_font = SubResource("FontVariation_6m2gx")
default_font_size = 13
Button/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
Button/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
Button/colors/font_hover_color = Color(0.172549, 0.360784, 0.407843, 1)
Button/colors/font_pressed_color = Color(0.121569, 0.27451, 0.313726, 1)
Button/font_sizes/font_size = 12
Button/fonts/font = SubResource("FontVariation_fum4e")
Button/styles/disabled = SubResource("StyleBoxFlat_lr7a5")
Button/styles/focus = SubResource("StyleBoxFlat_cg7jb")
Button/styles/hover = SubResource("StyleBoxFlat_dc2yr")
Button/styles/normal = SubResource("StyleBoxFlat_6m2gx")
Button/styles/pressed = SubResource("StyleBoxFlat_x3s7b")
Button/font_sizes/font_size = 13
Button/fonts/font = SubResource("FontVariation_6m2gx")
Button/styles/disabled = SubResource("StyleBoxFlat_x3s7b")
Button/styles/focus = SubResource("StyleBoxFlat_ukyte")
Button/styles/hover = SubResource("StyleBoxFlat_wjr02")
Button/styles/normal = SubResource("StyleBoxFlat_4munx")
Button/styles/pressed = SubResource("StyleBoxFlat_oc7r5")
CheckBox/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
CheckBox/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
CheckBox/colors/font_hover_color = Color(0.172549, 0.360784, 0.407843, 1)
CheckBox/colors/font_pressed_color = Color(0.121569, 0.27451, 0.313726, 1)
CheckBox/icons/checked = SubResource("ImageTexture_wjr02")
CheckBox/icons/radio_checked = SubResource("ImageTexture_oc7r5")
CheckBox/icons/radio_unchecked = SubResource("ImageTexture_8m7xh")
CheckBox/icons/unchecked = SubResource("ImageTexture_47b07")
CheckBox/styles/focus = SubResource("StyleBoxFlat_danrt")
CheckBox/styles/hover = SubResource("StyleBoxFlat_danrt")
CheckBox/styles/normal = SubResource("StyleBoxFlat_danrt")
CheckBox/styles/pressed = SubResource("StyleBoxFlat_danrt")
HScrollBar/icons/decrement = SubResource("ImageTexture_djhhc")
HScrollBar/icons/decrement_highlight = SubResource("ImageTexture_jvde3")
HScrollBar/icons/increment = SubResource("ImageTexture_i4ifm")
HScrollBar/icons/increment_highlight = SubResource("ImageTexture_loh4e")
HScrollBar/styles/grabber = SubResource("StyleBoxFlat_a1mnj")
HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_4uvxq")
HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_4uvxq")
HScrollBar/styles/scroll = SubResource("StyleBoxFlat_petse")
HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_petse")
CheckBox/icons/checked = SubResource("ImageTexture_8m7xh")
CheckBox/icons/radio_checked = SubResource("ImageTexture_47b07")
CheckBox/icons/radio_unchecked = SubResource("ImageTexture_1j4f6")
CheckBox/icons/unchecked = SubResource("ImageTexture_a0hrt")
CheckBox/styles/focus = SubResource("StyleBoxFlat_jvde3")
CheckBox/styles/hover = SubResource("StyleBoxFlat_jvde3")
CheckBox/styles/normal = SubResource("StyleBoxFlat_jvde3")
CheckBox/styles/pressed = SubResource("StyleBoxFlat_jvde3")
HScrollBar/icons/decrement = SubResource("ImageTexture_i4ifm")
HScrollBar/icons/decrement_highlight = SubResource("ImageTexture_loh4e")
HScrollBar/icons/increment = SubResource("ImageTexture_4uvxq")
HScrollBar/icons/increment_highlight = SubResource("ImageTexture_j2rjp")
HScrollBar/styles/grabber = SubResource("StyleBoxFlat_7om8y")
HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_o55pb")
HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_o55pb")
HScrollBar/styles/scroll = SubResource("StyleBoxFlat_wl44i")
HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_wl44i")
HSlider/constants/grabber_offset = 0
HSlider/icons/grabber = SubResource("ImageTexture_7om8y")
HSlider/icons/grabber_disabled = SubResource("ImageTexture_wl44i")
HSlider/icons/grabber_highlight = SubResource("ImageTexture_8vjq1")
HSlider/styles/grabber_area = SubResource("StyleBoxFlat_w06qd")
HSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_barbq")
HSlider/styles/slider = SubResource("StyleBoxFlat_petse")
HSlider/icons/grabber = SubResource("ImageTexture_8vjq1")
HSlider/icons/grabber_disabled = SubResource("ImageTexture_barbq")
HSlider/icons/grabber_highlight = SubResource("ImageTexture_foj1q")
HSlider/styles/grabber_area = SubResource("StyleBoxFlat_jyq6k")
HSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_cjati")
HSlider/styles/slider = SubResource("StyleBoxFlat_wl44i")
HeadLabel/base_type = &"Label"
HeadLabel/font_sizes/font_size = 16
HeadLabel/fonts/font = SubResource("FontVariation_fum4e")
HeadLabel/font_sizes/font_size = 17
HeadLabel/fonts/font = SubResource("FontVariation_6m2gx")
Label/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
Label/font_sizes/font_size = 12
Label/fonts/font = SubResource("FontVariation_fum4e")
Label/font_sizes/font_size = 13
Label/fonts/font = SubResource("FontVariation_6m2gx")
LineEdit/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
LineEdit/colors/font_placeholder_color = Color(0.603922, 0.584314, 0.541176, 1)
LineEdit/font_sizes/font_size = 12
LineEdit/fonts/font = SubResource("FontVariation_fum4e")
LineEdit/styles/focus = SubResource("StyleBoxFlat_dvi42")
LineEdit/styles/normal = SubResource("StyleBoxFlat_foj1q")
LineEdit/font_sizes/font_size = 13
LineEdit/fonts/font = SubResource("FontVariation_6m2gx")
LineEdit/styles/focus = SubResource("StyleBoxFlat_m7381")
LineEdit/styles/normal = SubResource("StyleBoxFlat_of6nr")
LogoLabel/base_type = &"Label"
LogoLabel/colors/font_color = Color(0.172549, 0.360784, 0.407843, 1)
LogoLabel/font_sizes/font_size = 24
LogoLabel/fonts/font = ExtResource("2_fum4e")
LogoLabel/font_sizes/font_size = 26
LogoLabel/fonts/font = SubResource("FontVariation_s7l1c")
MicroLabel/base_type = &"Label"
MicroLabel/colors/font_color = Color(0.172549, 0.164706, 0.14902, 1)
MicroLabel/font_sizes/font_size = 10
MicroLabel/font_sizes/font_size = 11
NumLabel/base_type = &"Label"
NumLabel/font_sizes/font_size = 12
NumLabel/font_sizes/font_size = 13
NumLabel/fonts/font = ExtResource("2_fum4e")
Panel/styles/panel = SubResource("StyleBoxFlat_jyq6k")
Panel/styles/panel = SubResource("StyleBoxFlat_7j4rs")
PopupMenu/colors/font_accelerator_color = Color(0.603922, 0.584314, 0.541176, 1)
PopupMenu/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
PopupMenu/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
@@ -471,20 +473,20 @@ PopupMenu/constants/item_end_padding = 12
PopupMenu/constants/item_start_padding = 8
PopupMenu/constants/separator_outline_size = 0
PopupMenu/constants/v_separation = 3
PopupMenu/font_sizes/font_size = 12
PopupMenu/fonts/font = SubResource("FontVariation_fum4e")
PopupMenu/styles/hover = SubResource("StyleBoxFlat_cjati")
PopupMenu/styles/panel = SubResource("StyleBoxFlat_m7381")
PopupMenu/font_sizes/font_size = 13
PopupMenu/fonts/font = SubResource("FontVariation_6m2gx")
PopupMenu/styles/hover = SubResource("StyleBoxFlat_05v8i")
PopupMenu/styles/panel = SubResource("StyleBoxFlat_7ph3p")
TooltipLabel/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
TooltipLabel/font_sizes/font_size = 10
TooltipLabel/fonts/font = SubResource("FontVariation_fum4e")
TooltipPanel/styles/panel = SubResource("StyleBoxFlat_m7381")
VScrollBar/icons/decrement = SubResource("ImageTexture_s7l1c")
VScrollBar/icons/decrement_highlight = SubResource("ImageTexture_05v8i")
VScrollBar/icons/increment = SubResource("ImageTexture_2b66g")
VScrollBar/icons/increment_highlight = SubResource("ImageTexture_hv5yg")
VScrollBar/styles/grabber = SubResource("StyleBoxFlat_a1mnj")
VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_4uvxq")
VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_4uvxq")
VScrollBar/styles/scroll = SubResource("StyleBoxFlat_petse")
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_petse")
TooltipLabel/font_sizes/font_size = 11
TooltipLabel/fonts/font = SubResource("FontVariation_6m2gx")
TooltipPanel/styles/panel = SubResource("StyleBoxFlat_7ph3p")
VScrollBar/icons/decrement = SubResource("ImageTexture_1tdcs")
VScrollBar/icons/decrement_highlight = SubResource("ImageTexture_yjsxn")
VScrollBar/icons/increment = SubResource("ImageTexture_bkd55")
VScrollBar/icons/increment_highlight = SubResource("ImageTexture_d1bwe")
VScrollBar/styles/grabber = SubResource("StyleBoxFlat_7om8y")
VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_o55pb")
VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_o55pb")
VScrollBar/styles/scroll = SubResource("StyleBoxFlat_wl44i")
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_wl44i")
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://das8asb4b05gm"
path="res://.godot/imported/file_icons.png-aadd037746f865f0fe6757930f0f608c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_icons.png"
dest_files=["res://.godot/imported/file_icons.png-aadd037746f865f0fe6757930f0f608c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://djb7w10tao8ns"
path="res://.godot/imported/file_photo.png-b23698027d48c718a6fc500f302db87b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_photo.png"
dest_files=["res://.godot/imported/file_photo.png-b23698027d48c718a6fc500f302db87b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dw5oie5dxfky4"
path="res://.godot/imported/file_sound.png-ef9804b30db6fa708a74022e1489cfcd.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_sound.png"
dest_files=["res://.godot/imported/file_sound.png-ef9804b30db6fa708a74022e1489cfcd.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://gtofenp8bd1n"
path="res://.godot/imported/file_text.png-e2cb12a8276ae34819d095735a1e4828.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_text.png"
dest_files=["res://.godot/imported/file_text.png-e2cb12a8276ae34819d095735a1e4828.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c8yfny20jcs15"
path="res://.godot/imported/file_video.png-ffcbd0629f5e28094e97a5a051839521.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/file_video.png"
dest_files=["res://.godot/imported/file_video.png-ffcbd0629f5e28094e97a5a051839521.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cb7ge0n5gg8mu"
path="res://.godot/imported/folder.png-2d34cb1a6aa9dbae664638a5df628136.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/folder.png"
dest_files=["res://.godot/imported/folder.png-2d34cb1a6aa9dbae664638a5df628136.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Генерирует пиксельные иконки файлов для PEAK HOUR (32×32 RGBA PNG).
Стиль: плоские заливки из палитры Kaleidos, контур 1 px #2C2A26,
без сглаживания (nearest). Иконки читаются в размерах 16/24/32 px.
Запуск:
python3 art/icons/generate_icons.py
python3 art/icons/generate_icons.py --preview /tmp/opencode/icons_preview.png
"""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
SIZE = 32
# --- палитра Kaleidos ------------------------------------------------------
OUTLINE = (44, 42, 38, 255) # #2C2A26
NAVY = (44, 92, 104, 255) # #2C5C68
TEAL = (47, 127, 143, 255) # #2F7F8F
TEAL_LIGHT = (131, 199, 212, 255) # #83C7D4
TEAL_PALE = (230, 245, 247, 255) # #E6F5F7
WARN = (197, 138, 46, 255) # #C58A2E
SUCCESS = (63, 143, 94, 255) # #3F8F5E
ERROR = (194, 91, 74, 255) # #C25B4A
PAPER = (247, 245, 238, 255) # #F7F5EE
PAPER_LO = (216, 211, 201, 255) # #D8D3C9
WHITE = (255, 255, 255, 255)
def canvas() -> Image.Image:
return Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
def shape(d: ImageDraw.ImageDraw, pts: list[tuple[int, int]], fill, outline=OUTLINE) -> None:
"""Заливка многоугольника + контур 1 px по его периметру."""
d.polygon(pts, fill=fill)
if outline is not None:
d.line(pts + [pts[0]], fill=outline, width=1, joint="curve")
def box(d: ImageDraw.ImageDraw, xy: tuple[int, int, int, int], fill, outline=OUTLINE) -> None:
"""
Прямоугольник с координатами включительно (как у ImageDraw.rectangle).
"""
d.rectangle(xy, fill=fill)
if outline is not None:
d.rectangle(xy, outline=outline)
def file_text() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# страница с отрезанным верхним правым углом
page = [(7, 4), (19, 4), (25, 10), (25, 27), (7, 27)]
shape(d, page, PAPER)
# загнутый уголок
shape(d, [(19, 4), (25, 10), (19, 10)], PAPER_LO)
# три строки текста
d.rectangle((10, 13, 22, 14), fill=NAVY)
d.rectangle((10, 17, 22, 18), fill=NAVY)
d.rectangle((10, 21, 18, 22), fill=NAVY)
return img
def file_sound() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# янтарный бейдж со срезанными углами
badge = [(6, 3), (26, 3), (29, 6), (29, 26), (26, 29), (6, 29), (3, 26), (3, 6)]
shape(d, badge, WARN)
# восьмая нота: головка, штиль, флажок
head = [(9, 21), (11, 19), (15, 19), (17, 21), (17, 24), (15, 26), (11, 26), (9, 24)]
shape(d, head, OUTLINE)
d.rectangle((16, 8, 17, 21), fill=OUTLINE)
shape(d, [(17, 8), (23, 11), (23, 14), (20, 14), (20, 13), (17, 12)], OUTLINE)
return img
def file_photo() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# тёмная рамка
box(d, (3, 6, 28, 26), NAVY)
# светлое поле кадра
box(d, (6, 9, 25, 23), TEAL_PALE)
# дальняя и ближняя горы
shape(d, [(6, 23), (13, 12), (19, 23)], TEAL_LIGHT, outline=None)
shape(d, [(11, 23), (18, 14), (25, 23)], SUCCESS, outline=None)
# солнце
d.rectangle((20, 11, 22, 13), fill=WARN)
return img
def file_video() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# тело плёнки
box(d, (3, 8, 28, 24), NAVY)
# перфорация сверху и снизу
for x in (5, 9, 21, 25):
d.rectangle((x, 10, x + 1, 11), fill=PAPER)
d.rectangle((x, 21, x + 1, 22), fill=PAPER)
# play
shape(d, [(13, 11), (22, 16), (13, 21)], PAPER)
return img
def folder() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
body = [(3, 27), (3, 9), (13, 9), (16, 12), (29, 12), (29, 27)]
shape(d, body, WARN)
# светлая кромка передней стенки
d.line([(5, 25), (27, 25)], fill=(226, 175, 91, 255), width=1)
return img
def packet() -> Image.Image:
img = canvas()
d = ImageDraw.Draw(img)
# конверт
box(d, (3, 10, 28, 25), PAPER)
# клапан
shape(d, [(4, 11), (16, 19), (27, 11)], TEAL_PALE, outline=None)
d.line([(4, 11), (16, 19)], fill=NAVY, width=1)
d.line([(16, 19), (27, 11)], fill=NAVY, width=1)
# нижние сгибы
d.line([(4, 24), (12, 18)], fill=PAPER_LO, width=1)
d.line([(27, 24), (19, 18)], fill=PAPER_LO, width=1)
# входящий пакет: teal-бейдж со стрелкой вниз
shape(d, [(21, 19), (29, 19), (29, 27), (21, 27)], TEAL)
d.rectangle((24, 21, 25, 23), fill=WHITE)
d.polygon([(22, 23), (27, 23), (24, 26)], fill=WHITE)
return img
ICONS = {
"file_text": file_text,
"file_sound": file_sound,
"file_photo": file_photo,
"file_video": file_video,
"folder": folder,
"packet": packet,
}
# Проверка: только полностью прозрачные или полностью непрозрачные пиксели.
# Это гарантирует отсутствие сглаживания по краям.
def assert_hard_edges(img: Image.Image, name: str) -> None:
alpha = img.getchannel("A")
values = set(alpha.getdata())
assert values <= {0, 255}, f"{name}: найден полупрозрачный пиксель {values}"
def build_preview(path: Path) -> None:
"""Контактный лист: все иконки в 16/24/32 px + увеличение 32 px ×4."""
scale = 4
cell_w = 200
cell_h = 236
cols = 3
sheet = Image.new("RGBA", (cols * cell_w, 2 * cell_h), (233, 230, 221, 255))
d = ImageDraw.Draw(sheet)
try:
font = ImageFont.truetype("/usr/share/fonts/dejavu/DejaVuSans.ttf", 15)
small = ImageFont.truetype("/usr/share/fonts/dejavu/DejaVuSans.ttf", 11)
except OSError: # pragma: no cover - depends on system fonts
font = ImageFont.load_default()
small = font
for index, (name, factory) in enumerate(ICONS.items()):
col = index % cols
row = index // cols
x0 = col * cell_w
y0 = row * cell_h
d.rectangle((x0 + 4, y0 + 4, x0 + cell_w - 6, y0 + cell_h - 6),
fill=(247, 245, 238, 255), outline=(154, 149, 137, 255))
d.text((x0 + 12, y0 + 10), name, fill=(31, 30, 27, 255), font=font)
icon = factory()
# три реальных размера в ряд, каждый на белой «карточке»
cx = x0 + 12
for px in (16, 24, 32):
d.rectangle((cx - 2, y0 + 44, cx + px + 1, y0 + 47 + px),
fill=(255, 255, 255, 255), outline=(216, 211, 201, 255))
sheet.alpha_composite(icon.resize((px, px), Image.Resampling.NEAREST),
(cx, y0 + 46))
d.text((cx, y0 + 52 + px), f"{px}", fill=(90, 86, 78, 255), font=small)
cx += 42
# увеличение 32 px ×4 — чтобы видеть пиксели и контур
big = icon.resize((SIZE * scale, SIZE * scale), Image.Resampling.NEAREST)
sheet.alpha_composite(big, (x0 + 34, y0 + 92))
path.parent.mkdir(parents=True, exist_ok=True)
sheet.save(path)
print(f"preview saved: {path}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--preview", type=Path, default=None,
help="куда сохранить контактный лист (PNG)")
args = parser.parse_args()
out_dir = Path(__file__).resolve().parent
for name, factory in ICONS.items():
img = factory()
assert_hard_edges(img, name)
target = out_dir / f"{name}.png"
img.save(target)
print(f"saved {target.relative_to(out_dir.parent.parent)} {img.size}")
if args.preview is not None:
build_preview(args.preview)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dj6scq4olnonj"
path="res://.godot/imported/packet.png-2a847fa0ce4ca153b815c85ae5cddeab.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/packet.png"
dest_files=["res://.godot/imported/packet.png-2a847fa0ce4ca153b815c85ae5cddeab.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dyryfdgvj0s6m"
path="res://.godot/imported/_source_upgrades.png-5c2186a86af26623e1e1ba5a3e0ad2eb.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/_source_upgrades.png"
dest_files=["res://.godot/imported/_source_upgrades.png-5c2186a86af26623e1e1ba5a3e0ad2eb.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://05ucaefb4807"
path="res://.godot/imported/lot_channel.png-86f52ea0f61c04f6a60ada643ca85ccc.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_channel.png"
dest_files=["res://.godot/imported/lot_channel.png-86f52ea0f61c04f6a60ada643ca85ccc.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://brc1psekc17bc"
path="res://.godot/imported/lot_folder.png-dd754300f7e865cf73009868f7d66e6b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_folder.png"
dest_files=["res://.godot/imported/lot_folder.png-dd754300f7e865cf73009868f7d66e6b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0x7o4yo8dvps"
path="res://.godot/imported/lot_money.png-9b87cd53690d3369a84cabb7576571b2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_money.png"
dest_files=["res://.godot/imported/lot_money.png-9b87cd53690d3369a84cabb7576571b2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dapkbkpx18mgy"
path="res://.godot/imported/lot_shield.png-98b0b28b4edcfac112e9f4e5d43c5364.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_shield.png"
dest_files=["res://.godot/imported/lot_shield.png-98b0b28b4edcfac112e9f4e5d43c5364.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d1xbqwgle6adp"
path="res://.godot/imported/lot_sla.png-9730f109736cf5b78256df880bad17bc.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_sla.png"
dest_files=["res://.godot/imported/lot_sla.png-9730f109736cf5b78256df880bad17bc.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://drgl82ovu7vry"
path="res://.godot/imported/lot_slots.png-8d0c9f2a6fd82fdcf134c18f77d41a10.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_slots.png"
dest_files=["res://.godot/imported/lot_slots.png-8d0c9f2a6fd82fdcf134c18f77d41a10.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://stbfx7k5pt2s"
path="res://.godot/imported/lot_speed.png-17cc7beb1294023e976429ca8555aaaa.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_speed.png"
dest_files=["res://.godot/imported/lot_speed.png-17cc7beb1294023e976429ca8555aaaa.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cixxo6hxeisdl"
path="res://.godot/imported/lot_timer.png-9bb944960c84e22cd0cc36cadc18aa86.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_timer.png"
dest_files=["res://.godot/imported/lot_timer.png-9bb944960c84e22cd0cc36cadc18aa86.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dc1sclulaata4"
path="res://.godot/imported/lot_udp.png-111b745d551b21f3321882852d252022.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/shop/lot_udp.png"
dest_files=["res://.godot/imported/lot_udp.png-111b745d551b21f3321882852d252022.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d1vbo2277t4ix"
path="res://.godot/imported/tutor_cat.png-ad3ee184ce969bcd494931d604daab36.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/mascot/tutor_cat.png"
dest_files=["res://.godot/imported/tutor_cat.png-ad3ee184ce969bcd494931d604daab36.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://c5e6gil6gxxmp"
path="res://.godot/imported/defeat.mp3-7e698f993d537d59b939608a0abcf708.mp3str"
[deps]
source_file="res://audio/music/defeat.mp3"
dest_files=["res://.godot/imported/defeat.mp3-7e698f993d537d59b939608a0abcf708.mp3str"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://b6ua70k24xsp7"
path="res://.godot/imported/game.mp3-3720d552177761471b052a3420216bb1.mp3str"
[deps]
source_file="res://audio/music/game.mp3"
dest_files=["res://.godot/imported/game.mp3-3720d552177761471b052a3420216bb1.mp3str"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://b6vxk0i2pd0of"
path="res://.godot/imported/low_sla.mp3-a6f69b93336356d63c353431bcf1405a.mp3str"
[deps]
source_file="res://audio/music/low_sla.mp3"
dest_files=["res://.godot/imported/low_sla.mp3-a6f69b93336356d63c353431bcf1405a.mp3str"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://bxdgubpqsktv3"
path="res://.godot/imported/magazine.mp3-b6f09f5db14feb1428451d6c639c4105.mp3str"
[deps]
source_file="res://audio/music/magazine.mp3"
dest_files=["res://.godot/imported/magazine.mp3-b6f09f5db14feb1428451d6c639c4105.mp3str"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="mp3"
type="AudioStreamMP3"
uid="uid://dp3n8od21isy8"
path="res://.godot/imported/menu.mp3-c0c7e41802fae0234a014a5e97694acb.mp3str"
[deps]
source_file="res://audio/music/menu.mp3"
dest_files=["res://.godot/imported/menu.mp3-c0c7e41802fae0234a014a5e97694acb.mp3str"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+632 -206
View File
@@ -1,123 +1,652 @@
{
"_meta": {
"file": "balance.json",
"version": "1.1",
"rules": "docs/GDD.md (компактная сводка) + docs/DECISIONS.md (D01–D17)",
"note": "null = параметр не задан в постановке, уточнить до кода (см. _todo). Никаких выдуманных чисел.",
"_todo": [
"attacks.junk.spawn_probability — не задан в спецификации (явно null)",
"income_target_by_hour: часы 4,6,7,8 — интерполяция, уточнить",
"difficulty.h2 packet_timer/request_timer: формула даёт 26.325/50.15 (19.5×1.35 и 29.5×1.7 — у запросов отдельный grace)"
]
"version": "2.0",
"rules": "PEAK_HOUR_V2_SHOP_FILES_MINIGAMES.md §2, §3, §6, §8 + Appendix A",
"note": "v2: 8 lot families, 3 offers, rerolls, dense send-minigame selection. Legacy sections (difficulty/spawn/progression/timers/connections/channel) kept for the not-yet-rewritten HourDirector. New code reads constants/hours/grid/shop_rules/shop/minigames/attacks/payout/sla."
},
"constants": {
"hour_seconds": 300,
"run_hours": 9,
"sla_start": 128,
"sla_max": 200,
"money_start": 150,
"buffer_base": 4,
"buffer_per_upgrade": 2,
"buffer_max": 8,
"slots_start": 2,
"slots_max": 4,
"max_sections": 6,
"files_per_folder": 16,
"money_start": 50,
"sla_start": 60,
"sla_base": 60,
"sla_per_level": 40,
"sla_hard_cap": 220,
"sla_regen_per_hour": 15,
"slots_start": 1,
"slots_max": 5,
"channel_capacity": 10,
"channel_per_level": 6,
"channel_capacity_max": 46,
"buffer_base": 6,
"buffer_per_level": 1,
"buffer_max": 12,
"request_timer": 30,
"request_timer_per_level": 7,
"request_timer_max": 65,
"packet_timer": 20,
"folder_cols": 5,
"folder_rows": 4,
"folders_start": 2,
"folders_max": 6,
"folder_new_cost": 180,
"folder_price_start": 150,
"folder_price_step": 100,
"max_sections": 1,
"section_start": "C",
"folders_per_section": 12,
"files_per_folder": 48,
"send_window_visible_cards": 4,
"send_scroll_card_height_px": 56,
"required_name_char_limit": 12,
"file_cooldown_after_send_sec": 20,
"required_per_request": { "min": 1, "max": 3 }
"file_cooldown_after_send_sec": 0,
"required_per_request": { "min": 1, "max": 4 },
"spawn_window_ratio": 0.75,
"spawn_jitter": 0.2,
"buffer_per_upgrade": 1,
"udp_success_base": 0.40,
"udp_success_max": 0.80,
"udp_fail_sla": -3,
"udp_retry_cooldown_sec": 4,
"udp_request_ratio": 0.35,
"send_quality_min": 0.45,
"file_expire_base": 0.03,
"file_expire_step": 0.05,
"file_expire_cap": 0.55
},
"economy": {
"start_stats": {
"money": 50,
"sla": 60,
"sla_cap": 60,
"slots": 1,
"channel_capacity": 10,
"buffer": 6,
"request_timer": 30,
"packet_timer": 20,
"folder_grid": [5, 4],
"money_mult": 1.0,
"speed_mult": 1.0
},
"formulas": {
"max_sla": "clamp(60 + 40 * sla_level, 60, 220)",
"sla_regen": "sla = min(sla_cap, sla + 15) в начале каждого часа",
"slots": "1 + slot_level (max 5)",
"channel_capacity": "10 + 6 * channel_level",
"buffer": "min(12, 6 + channel_level)",
"request_timer": "min(65, 30 + 7 * timer_level)",
"packet_timer": "20 (не покупается)",
"speed": "pow(0.90, speed_level)",
"money_mult": "1 + 0.05 * money_level",
"folder_dims": "[[5,4],[6,4],[7,5],[8,5],[8,6],[9,6]][folder_level]",
"transfer_time": "base_time_sec * pow(0.90, speed_level) * max(1.0, load / capacity)",
"protocol_time": "UDP ×0.8, TCP ×1.0 (только отправка)"
}
},
"types": {
"text": {
"name_ru": "Текст",
"ext": ".txt",
"icon": "▤",
"recv_time": 1.0,
"send_time": 1.0,
"recv_fee": 2,
"base_pay": 20,
"weight": 6,
"minigame_coeff": 1.0,
"sla_loss": 2
"ext": "txt",
"icon": "text",
"size_kb": 64,
"cells": 1,
"base_time_sec": 3,
"base_pay": 18,
"recv_fee": 1,
"weight": 1,
"minigame_coeff": 1,
"sla_loss": 2,
"shapes": ["O1"]
},
"sound": {
"name_ru": "Звук",
"ext": ".wav",
"icon": "♪",
"recv_time": 1.5,
"send_time": 2.0,
"recv_fee": 3,
"base_pay": 35,
"weight": 10,
"minigame_coeff": 2.0,
"sla_loss": 4
"ext": "mp3",
"icon": "sound",
"size_kb": 3072,
"cells": 2,
"base_time_sec": 5,
"base_pay": 30,
"recv_fee": 2,
"weight": 2,
"minigame_coeff": 2,
"sla_loss": 4,
"shapes": ["I2H", "I2V"]
},
"photo": {
"name_ru": "Фото",
"ext": ".jpg",
"icon": "▣",
"recv_time": 2.0,
"send_time": 3.0,
"recv_fee": 5,
"base_pay": 55,
"weight": 14,
"minigame_coeff": 3.0,
"sla_loss": 8
"ext": "jpg",
"icon": "photo",
"size_kb": 5120,
"cells": 3,
"base_time_sec": 8,
"base_pay": 44,
"recv_fee": 4,
"weight": 4,
"minigame_coeff": 4,
"sla_loss": 8,
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"]
},
"video": {
"name_ru": "Видео",
"ext": ".mp4",
"icon": "▶",
"recv_time": 3.0,
"send_time": 5.0,
"recv_fee": 8,
"base_pay": 90,
"weight": 22,
"minigame_coeff": 4.0,
"sla_loss": 16
"ext": "mp4",
"icon": "video",
"size_kb": 20480,
"cells": 4,
"base_time_sec": 12,
"base_pay": 69,
"recv_fee": 6,
"weight": 8,
"minigame_coeff": 8,
"sla_loss": 16,
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
}
},
"hours": [
{
"hour": 1,
"duration": 120,
"files": 8,
"active_requests": 2,
"attacks": 0,
"income_target": 100,
"send_interval": 20,
"type_weights": { "text": 1.0, "sound": 0.0, "photo": 0.0, "video": 0.0 },
"request_mix": { "1": 1.0, "2": 0.0, "3": 0.0, "4": 0.0 },
"request_type_weights": { "text": 1.0, "sound": 0.0, "photo": 0.0, "video": 0.0 }
},
{
"hour": 2,
"duration": 150,
"files": 8,
"active_requests": 2,
"attacks": 1,
"income_target": 220,
"send_interval": 24,
"type_weights": { "text": 0.80, "sound": 0.20, "photo": 0.0, "video": 0.0 },
"request_mix": { "1": 0.70, "2": 0.30, "3": 0.0, "4": 0.0 },
"request_type_weights": { "text": 0.80, "sound": 0.20, "photo": 0.0, "video": 0.0 }
},
{
"hour": 3,
"duration": 180,
"files": 9,
"active_requests": 2,
"attacks": 1,
"income_target": 380,
"send_interval": 18,
"type_weights": { "text": 0.50, "sound": 0.50, "photo": 0.0, "video": 0.0 },
"request_mix": { "1": 0.20, "2": 0.80, "3": 0.0, "4": 0.0 },
"request_type_weights": { "text": 0.50, "sound": 0.50, "photo": 0.0, "video": 0.0 }
},
{
"hour": 4,
"duration": 210,
"files": 10,
"active_requests": 3,
"attacks": 2,
"income_target": 560,
"send_interval": 24,
"type_weights": { "text": 0.40, "sound": 0.30, "photo": 0.30, "video": 0.0 },
"request_mix": { "1": 0.0, "2": 0.70, "3": 0.30, "4": 0.0 },
"request_type_weights": { "text": 0.40, "sound": 0.30, "photo": 0.30, "video": 0.0 }
},
{
"hour": 5,
"duration": 240,
"files": 11,
"active_requests": 3,
"attacks": 2,
"income_target": 760,
"send_interval": 24,
"type_weights": { "text": 0.35, "sound": 0.35, "photo": 0.30, "video": 0.0 },
"request_mix": { "1": 0.0, "2": 0.55, "3": 0.45, "4": 0.0 },
"request_type_weights": { "text": 0.35, "sound": 0.35, "photo": 0.30, "video": 0.0 }
},
{
"hour": 6,
"duration": 270,
"files": 11,
"active_requests": 3,
"attacks": 3,
"income_target": 980,
"send_interval": 24,
"type_weights": { "text": 0.30, "sound": 0.30, "photo": 0.30, "video": 0.10 },
"request_mix": { "1": 0.0, "2": 0.25, "3": 0.60, "4": 0.15 },
"request_type_weights": { "text": 0.30, "sound": 0.30, "photo": 0.30, "video": 0.10 }
},
{
"hour": 7,
"duration": 300,
"files": 12,
"active_requests": 4,
"attacks": 3,
"income_target": 1200,
"send_interval": 24,
"type_weights": { "text": 0.22, "sound": 0.23, "photo": 0.30, "video": 0.25 },
"request_mix": { "1": 0.0, "2": 0.15, "3": 0.55, "4": 0.30 },
"request_type_weights": { "text": 0.22, "sound": 0.23, "photo": 0.30, "video": 0.25 }
},
{
"hour": 8,
"duration": 330,
"files": 12,
"active_requests": 4,
"attacks": 4,
"income_target": 1450,
"send_interval": 24,
"type_weights": { "text": 0.15, "sound": 0.25, "photo": 0.35, "video": 0.25 },
"request_mix": { "1": 0.0, "2": 0.10, "3": 0.45, "4": 0.45 },
"request_type_weights": { "text": 0.15, "sound": 0.25, "photo": 0.35, "video": 0.25 }
},
{
"hour": 9,
"duration": 360,
"files": 12,
"active_requests": 4,
"attacks": 4,
"income_target": 1700,
"send_interval": 24,
"type_weights": { "text": 0.10, "sound": 0.15, "photo": 0.30, "video": 0.45 },
"request_mix": { "1": 0.0, "2": 0.0, "3": 0.25, "4": 0.75 },
"request_type_weights": { "text": 0.10, "sound": 0.15, "photo": 0.30, "video": 0.45 }
}
],
"payout": {
"speed_base": 0.6,
"speed_bonus": 0.4,
"quality_base": 0.5,
"quality_bonus": 0.5,
"multi_step": 0.15,
"protocol_mult": { "TCP": 1.15, "UDP": 0.85 },
"udp_time_mult": 0.8,
"streak_step": 0.02,
"streak_cap": 10,
"money_mult_step": 0.05,
"formula": "payout = int(round(float(base_sum) * speed * quality * multi * protocol * streak * money_mult))",
"speed_formula": "speed = 0.6 + 0.4 * clamp(time_left / timer_total, 0, 1)",
"quality_formula": "quality = 0.5 + 0.5 * q",
"multi_formula": "multi = 1 + 0.15 * (n - 1)",
"streak_formula": "streak = 1 + 0.02 * min(10, streak)",
"money_mult_formula": "money_mult = 1 + 0.05 * money_level",
"note": "Только float-арифметика: никаких int/int и никакого ^ для степени."
},
"sla": {
"start": 60,
"regen_per_hour": 15,
"defeat_at_zero": true,
"timeout_fine_base": 10,
"timeout_fine_per_hour": 2,
"timeout_fine_formula": "-(10 + 2*hour) SLA",
"wrong_attach_fine": -2,
"lost_file_fine": -5,
"buffer_overflow_fine": -5,
"reject_money": -5,
"reject_sla": -5,
"folder_destroyed_fine": -5,
"sla_lose": "SLA <= 0 во время часа = поражение забега"
},
"grid": {
"gap": 6,
"cell_min": 56,
"cell_max": 104,
"view_w": 558,
"view_h": 470,
"folder_cols": 5,
"folder_rows": 4,
"folder_dims": [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]],
"table_cols": 4,
"table_rows": 6,
"cell_formula": "cell = clamp(floor(min((view_w - gap*(cols-1))/cols, (view_h - gap*(rows-1))/rows)), 56, 104)",
"shapes": {
"O1": { "cells": [[0, 0]], "w": 1, "h": 1 },
"I2H": { "cells": [[0, 0], [1, 0]], "w": 2, "h": 1 },
"I2V": { "cells": [[0, 0], [0, 1]], "w": 1, "h": 2 },
"I3H": { "cells": [[0, 0], [1, 0], [2, 0]], "w": 3, "h": 1 },
"I3V": { "cells": [[0, 0], [0, 1], [0, 2]], "w": 1, "h": 3 },
"L3a": { "cells": [[0, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"L3b": { "cells": [[0, 0], [1, 0], [0, 1]], "w": 2, "h": 2 },
"L3c": { "cells": [[1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"L3d": { "cells": [[0, 0], [1, 0], [1, 1]], "w": 2, "h": 2 },
"I4H": { "cells": [[0, 0], [1, 0], [2, 0], [3, 0]], "w": 4, "h": 1 },
"I4V": { "cells": [[0, 0], [0, 1], [0, 2], [0, 3]], "w": 1, "h": 4 },
"O4": { "cells": [[0, 0], [1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"T4a": { "cells": [[0, 0], [1, 0], [2, 0], [1, 1]], "w": 3, "h": 2 },
"T4b": { "cells": [[0, 1], [1, 1], [2, 1], [1, 0]], "w": 3, "h": 2 },
"T4c": { "cells": [[0, 0], [0, 1], [1, 1], [0, 2]], "w": 2, "h": 3 },
"T4d": { "cells": [[1, 0], [1, 1], [0, 1], [1, 2]], "w": 2, "h": 3 },
"L4a": { "cells": [[0, 0], [0, 1], [0, 2], [1, 2]], "w": 2, "h": 3 },
"L4b": { "cells": [[0, 0], [1, 0], [2, 0], [0, 1]], "w": 3, "h": 2 },
"L4c": { "cells": [[0, 0], [1, 0], [1, 1], [1, 2]], "w": 2, "h": 3 },
"L4d": { "cells": [[2, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
"J4a": { "cells": [[1, 0], [1, 1], [1, 2], [0, 2]], "w": 2, "h": 3 },
"J4b": { "cells": [[0, 0], [1, 0], [2, 0], [2, 1]], "w": 3, "h": 2 },
"J4c": { "cells": [[0, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
"J4d": { "cells": [[0, 0], [0, 1], [0, 2], [1, 0]], "w": 2, "h": 3 },
"S4": { "cells": [[1, 0], [2, 0], [0, 1], [1, 1]], "w": 3, "h": 2 },
"Z4": { "cells": [[0, 0], [1, 0], [1, 1], [2, 1]], "w": 3, "h": 2 }
},
"type_shapes": {
"text": ["O1"],
"sound": ["I2H", "I2V"],
"photo": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
"video": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
}
},
"shop_rules": {
"offers": 3,
"unlock_hour": 2,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
"note": "Веса берутся из shop[].weight_by_hour (9 значений, индекс = hour-1). Фазы H2-3/H4-6/H7-9 — таблица §3.1 — источник правды."
},
"shop": [
{
"id": "lot_slots",
"name_ru": "Порты коммутатора",
"desc_ru": "Ещё один порт: приём и отправка могут идти параллельно.",
"effect": "connection_slots",
"value": 1,
"max_level": 4,
"prices": [90, 200, 400, 700],
"level_names": ["Второй порт", "Третий порт", "Четвёртый порт", "Магистральный порт"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
},
{
"id": "lot_speed",
"name_ru": "Оптоволокно",
"desc_ru": "Скорость приёма и отправки +10% за уровень (умножается).",
"effect": "transfer_speed",
"value": 0.10,
"max_level": 6,
"prices": [55, 75, 100, 135, 180, 245],
"level_names": ["Витая пара", "Экранированная пара", "Оптоволокно", "Гигабитная линия", "Магистраль", "Тёмное волокно"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
},
{
"id": "lot_sla",
"name_ru": "Резервный ИБП",
"desc_ru": "Держит узел живым: кап SLA +40 и мгновенное восстановление +40.",
"effect": "sla_cap",
"value": 40,
"max_level": 4,
"prices": [80, 160, 270, 430],
"level_names": ["Резервный ИБП", "Горячий резерв", "Двойное питание", "Автономный контур"],
"weight_by_hour": [0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
},
{
"id": "lot_shield",
"name_ru": "Заземление",
"desc_ru": "Автоматически гасит атаку: 70% награды, без штрафа.",
"effect": "shield_charge",
"value": 1,
"max_level": 3,
"prices": [100, 220, 410],
"level_names": ["Заземление", "Громоотвод", "Защитный контур"],
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]
},
{
"id": "lot_money",
"name_ru": "Корпоративный контракт",
"desc_ru": "Оплата за контейнеры +5% за уровень.",
"effect": "money_mult",
"value": 0.05,
"max_level": 8,
"prices": [70, 95, 130, 170, 220, 285, 365, 465],
"level_names": ["Тарифный план", "Корпоративный контракт", "SLA-пакет", "Госзаказ", "Экспортный контракт", "Франшиза", "Монополия", "Золотая жила"],
"weight_by_hour": [0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2]
},
{
"id": "lot_folder",
"name_ru": "Складской стеллаж",
"desc_ru": "Больше места под фигуры: 20 → 24 клетки на первом уровне (до 54).",
"effect": "folder_cells",
"value": 1,
"max_level": 5,
"prices": [50, 100, 180, 290, 440],
"level_names": ["Стеллаж", "Секция стеллажа", "Складская линия", "Автоматизированный склад", "Ангар"],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]
},
{
"id": "lot_channel",
"name_ru": "Пропускная способность",
"desc_ru": "Канал +6 и буфер приёма +1 за уровень.",
"effect": "channel_capacity",
"value": 6,
"buffer_value": 1,
"max_level": 6,
"prices": [45, 70, 95, 130, 175, 230],
"level_names": ["Канал 56k", "ISDN", "ADSL", "Выделенная линия", "Оптика", "Магистраль"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9]
},
{
"id": "lot_timer",
"name_ru": "Регламент обработки",
"desc_ru": "Больше времени на поиск файлов и прикрепление: 30 → 37 с (до 65).",
"effect": "request_timer",
"value": 7,
"max_level": 5,
"prices": [45, 75, 115, 165, 230],
"level_names": ["Регламент", "Продление сессии", "Гибкий график", "Приоритетная обработка", "Выделенный менеджер"],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]
},
{
"id": "lot_udp",
"name_ru": "UDP-повторитель",
"desc_ru": "Надёжность UDP +10% за уровень: 40% → 80%.",
"effect": "udp_success",
"value": 0.10,
"max_level": 4,
"prices": [60, 120, 210, 350],
"weight_by_hour": [0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
}
],
"minigames": {
"selection": {
"order": ["dots", "sound", "text", "video", "photo", "maze"],
"achievable_sums": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32],
"max_index": 26,
"tier_max_rank": [7, 13],
"rank_games": ["text", "sound", "dots", "photo", "maze", "dots", "photo", "video", "dots", "text", "video", "maze", "photo", "sound", "video", "text", "maze", "sound", "video", "sound", "photo", "dots", "dots", "maze", "maze", "photo", "video"],
"formula": "S=sum(coeff по 1-4 файлам); rank=index of S in achievable_sums (или ближайшей меньшей); tier=1 при rank<=7, 2 при 8<=rank<=13, иначе 3; id=rank_games[rank]; S=0 → dots/T1",
"note": "6 игр отправки (wires удалена), 3 ступени, ровно 27 достижимых сумм коэффициентов для 1-4 файлов. Таблица rank_games задаёт игру по rank напрямую (без modulo)."
},
"games": {
"dots": {
"name_ru": "Одна линия",
"kind": "send",
"tiers": [
{ "grid": [4, 3], "obstacles": 2, "undo_penalty": 0.04 },
{ "grid": [4, 4], "obstacles": 4, "undo_penalty": 0.04 },
{ "grid": [5, 4], "obstacles": 6, "undo_penalty": 0.03 }
]
},
"sound": {
"name_ru": "Уровни",
"kind": "send",
"tiers": [
{ "channels": 4, "tolerance": 0.11, "drift": 0.0, "drift_hz": 0.0 },
{ "channels": 5, "tolerance": 0.08, "drift": 0.02, "drift_hz": 0.15 },
{ "channels": 6, "tolerance": 0.06, "drift": 0.03, "drift_hz": 0.25 }
]
},
"text": {
"name_ru": "Чужое слово",
"kind": "send",
"tiers": [
{ "words": 15, "aliens": 2, "lines": 2, "alien_kind": "foreign_language+typos" },
{ "words": 19, "aliens": 3, "lines": 3, "alien_kind": "foreign_language+typos+semantic" },
{ "words": 24, "aliens": 4, "lines": 3, "alien_kind": "foreign_language+typos+semantic+archaic" }
]
},
"video": {
"name_ru": "Раскадровка",
"kind": "send",
"tiers": [
{ "frames": 4, "slots": 4, "decoys": 0, "mirror": false },
{ "frames": 5, "slots": 4, "decoys": 1, "mirror": false },
{ "frames": 6, "slots": 5, "decoys": 2, "mirror": true }
]
},
"photo": {
"name_ru": "Кадр",
"kind": "send",
"tiers": [
{ "speed": 140, "frame_w": 180, "frame_h": 120, "shots": 4, "retarget_min": 0.6, "retarget_max": 1.0 },
{ "speed": 200, "frame_w": 150, "frame_h": 100, "shots": 5, "retarget_min": 0.5, "retarget_max": 0.8 },
{ "speed": 260, "frame_w": 120, "frame_h": 80, "shots": 6, "retarget_min": 0.35, "retarget_max": 0.65 }
]
},
"maze": {
"name_ru": "Лабиринт",
"kind": "send",
"tiers": [
{ "cols": 11, "rows": 4, "width": 440, "height": 160, "speed": 470, "collision_penalty": 0.05, "dead_end_penalty": 0.02 },
{ "cols": 13, "rows": 4, "width": 494, "height": 152, "speed": 520, "collision_penalty": 0.04, "dead_end_penalty": 0.02 },
{ "cols": 15, "rows": 5, "width": 520, "height": 180, "speed": 560, "collision_penalty": 0.04, "dead_end_penalty": 0.02 }
]
},
"junk": {
"name_ru": "Фильтр",
"kind": "attack",
"tiers": [
{ "cards": 9, "junk": 2, "hints": 1, "threshold": 0.6 },
{ "cards": 10, "junk": 3, "hints": 2, "threshold": 0.65 },
{ "cards": 11, "junk": 4, "hints": 2, "threshold": 0.7 },
{ "cards": 12, "junk": 5, "hints": 3, "threshold": 0.75 }
]
},
"ddos": {
"name_ru": "Щит",
"kind": "attack",
"tiers": [
{ "packets": 8, "speed": 110, "shield_width": 260, "interval": 0.55, "threshold": 0.6 },
{ "packets": 10, "speed": 140, "shield_width": 220, "interval": 0.45, "threshold": 0.65 },
{ "packets": 12, "speed": 170, "shield_width": 180, "interval": 0.35, "threshold": 0.7 },
{ "packets": 14, "speed": 200, "shield_width": 150, "interval": 0.28, "threshold": 0.75 }
]
},
"phishing": {
"name_ru": "Паспорт",
"kind": "attack",
"tiers": [
{ "clients": 3, "checks": ["name"], "threshold": 0.75 },
{ "clients": 4, "checks": ["name", "department"], "threshold": 0.75 },
{ "clients": 5, "checks": ["name", "department", "id"], "threshold": 0.8 },
{ "clients": 6, "checks": ["name", "department", "id", "photo", "expiry"], "threshold": 0.8 }
]
},
"ransomware": {
"name_ru": "Карантин",
"kind": "attack",
"tiers": [
{ "cards": 12, "exe": 1, "threshold": 0.5, "masking": "extension_visible" },
{ "cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small" },
{ "cards": 20, "exe": 2, "threshold": 0.6, "masking": "document_icon" },
{ "cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names" }
]
}
}
},
"attacks": {
"schedule": [0, 1, 1, 2, 2, 3, 3, 4, 4],
"tier_formula": "clamp(1 + floor((hour - 2) / 2.0), 1, 4)",
"spawn_window": {
"start_ratio": 0.25,
"end_ratio": 0.85,
"start_min_sec": 20,
"start_max_sec": 60,
"end_margin_sec": 20
},
"shield_reward_scale": 0.7,
"money_scale": 1.0,
"kinds": {
"junk": {
"name_ru": "Мусорный пакет",
"minigame": "junk",
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 5, "reward_sla": 1, "fail_money": -10, "fail_sla": -5, "threshold": 0.6 },
{ "reward_money": 10, "reward_sla": 2, "fail_money": -15, "fail_sla": -7, "threshold": 0.65 },
{ "reward_money": 15, "reward_sla": 3, "fail_money": -20, "fail_sla": -9, "threshold": 0.7 },
{ "reward_money": 20, "reward_sla": 4, "fail_money": -25, "fail_sla": -12, "threshold": 0.75 }
]
},
"ddos": {
"name_ru": "DDoS",
"minigame": "ddos",
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 30, "reward_sla": 2, "fail_money": -10, "fail_sla": -15, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.6 },
{ "reward_money": 40, "reward_sla": 3, "fail_money": -15, "fail_sla": -20, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.65 },
{ "reward_money": 50, "reward_sla": 4, "fail_money": -20, "fail_sla": -25, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.7 },
{ "reward_money": 60, "reward_sla": 5, "fail_money": -25, "fail_sla": -30, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.75 }
]
},
"phishing": {
"name_ru": "Фишинг",
"minigame": "phishing",
"weight_by_hour": [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 20, "reward_sla": 2, "fail_money": -40, "fail_sla": -10, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
{ "reward_money": 30, "reward_sla": 3, "fail_money": -50, "fail_sla": -12, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
{ "reward_money": 40, "reward_sla": 4, "fail_money": -60, "fail_sla": -15, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 },
{ "reward_money": 50, "reward_sla": 5, "fail_money": -70, "fail_sla": -18, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 }
]
},
"ransomware": {
"name_ru": "Шифровальщик",
"minigame": "ransomware",
"weight_by_hour": [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 25, "reward_sla": 2, "fail_money": -30, "fail_sla": -10, "folder_block_sec": 15, "threshold": 0.5 },
{ "reward_money": 35, "reward_sla": 3, "fail_money": -40, "fail_sla": -12, "folder_block_sec": 15, "threshold": 0.55 },
{ "reward_money": 45, "reward_sla": 4, "fail_money": -50, "fail_sla": -15, "folder_block_sec": 15, "threshold": 0.6 },
{ "reward_money": 55, "reward_sla": 5, "fail_money": -60, "fail_sla": -18, "folder_block_sec": 15, "threshold": 0.65 }
]
}
}
},
"difficulty": [
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 30, "request_timer": 60, "attacks": 0 },
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 2, "buffer": 4, "packet_timer": 26.325, "request_timer": 50.15, "attacks": 1 },
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 3, "buffer": 5, "packet_timer": 22.8, "request_timer": 40.6, "attacks": 1 },
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 3, "buffer": 5, "packet_timer": 18.5, "request_timer": 28.5, "attacks": 2 },
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 4, "buffer": 6, "packet_timer": 18, "request_timer": 28, "attacks": 2 },
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 4, "buffer": 6, "packet_timer": 17.5, "request_timer": 27.5, "attacks": 3 },
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 4, "buffer": 6, "packet_timer": 17, "request_timer": 27, "attacks": 3 },
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16.5, "request_timer": 26.5, "attacks": 4 },
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16, "request_timer": 26, "attacks": 4 }
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 20, "request_timer": 30, "attacks": 0 },
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 5, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 7, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 9, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 10, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 },
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 }
],
"difficulty_rules": {
"table_end_hour": 8,
"after_end_hour": "значения часа 8; +5% к скорости спавна каждые 2 часа",
"spawn_speedup_after_end": 0.05,
"table_end_hour": 9,
"after_end_hour": "значения часа 9",
"spawn_speedup_after_end": 0.0,
"spawn_speedup_period_hours": 2,
"type_availability": "час 1 — text; час 2 — text и sound; час 3 — +photo; час 4+ — все 4 (D23); атаки — с часа 2 (attack_junk)",
"timer_grace": "packet_timer: grace h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 (Economy.TIMER_GRACE). request_timer: отдельный grace h1 2.0, h2 1.7, h3 1.4, h4+ 1.0 (Economy.REQUEST_TIMER_GRACE). Формула: (base − 0.5*(h−1))*grace + 4*level. h2: packet 19.5*1.35=26.325, request 29.5*1.7=50.15."
"type_availability": "веса типов — hours[].type_weights (единственный источник правды для приёма) и совпадают с hours[].request_type_weights (запросы); H1 только text, H2 text/sound, photo с H4, video с H6",
"timer_grace": "v2: таймеры не масштабируются по часу (packet 20, request 30+7*level, cap 65)"
},
"progression": {
"unlocks": {
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4}
"shop": { "run_hour": 2, "label": "Магазин" },
"search": { "meta": "survive_hour_3", "label": "Поиск" },
"shield": { "meta": "survive_hour_5", "label": "Щит" },
"minigame_text": { "run_hour": 1 },
"minigame_sound": { "run_hour": 2 },
"minigame_photo": { "run_hour": 3 },
"minigame_video": { "run_hour": 4 },
"attack_junk": { "run_hour": 2 },
"attack_ddos": { "run_hour": 2 },
"attack_phishing": { "run_hour": 3 },
"attack_ransomware": { "run_hour": 4 }
},
"meta_rewards": {
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]}
"survive_hour_3": { "min_hours": 3, "unlocks": ["search"] },
"survive_hour_5": { "min_hours": 5, "unlocks": ["shield"] },
"complete_run": { "min_hours": 9, "unlocks": ["minigame_advanced"] }
}
},
"timers": {
@@ -127,34 +656,20 @@
"minigame_timeout": null,
"delete_long_press_sec": 0.5
},
"sla": {
"recv_bonus": 0.5,
"send_bonus": 1.0,
"reflect_bonus": 1.0,
"timeout_fine_base": 10,
"timeout_fine_per_hour": 2,
"timeout_fine_formula": "-(10 + 2*hour) — таймаут и подобные штрафы (D12)",
"wrong_attach_fine": -2,
"lost_file_fine": -5,
"buffer_overflow_fine": -5,
"sla_lose": "SLA <= 0"
},
"payout": {
"base_pay": "см. types.*.base_pay (значения в types)",
"recv_fee": "см. types.*.recv_fee (значения в types)",
"speed_formula": "speed = 0.6 + 0.4*(time_left / timer_total)",
"quality_formula": "quality = 0.5 + 0.5*mini_quality",
"multi_formula": "multi = 1 + 0.1*(n - 1)",
"protocol_mult": { "TCP": 1.15, "UDP": 1.0 },
"streak_formula": "streak = 1 + 0.02*min(10, streak)",
"income_target_by_hour": {
"hours": [1, 2, 3, 5, 9],
"approx": [500, 800, 1200, 1800, 2200],
"note": "ожидаемо ±10%; часы 4,6,7,8 — интерполяция, уточнить"
}
"spawn": {
"type_pool_by_hour": { "1": ["text", "sound"], "2": ["text", "sound", "photo"], "3+": ["text", "sound", "photo", "video"] },
"malicious_ratio": 0.0,
"required_count_by_hour": {
"1": { "1": 0.7, "2": 0.3, "3": 0.0 },
"2": { "1": 0.6, "2": 0.35, "3": 0.05 },
"3+": { "1": 0.5, "2": 0.4, "3": 0.1 }
},
"required_size_distribution": { "1": 0.5, "2": 0.4, "3": 0.1 },
"required_count_note": "legacy; v2 читает hours[].request_mix",
"attack_schedule": "attacks.schedule (0/1/1/2/2/3/3/4/4), окно 60–240 с часа"
},
"connections": {
"W_formula": "W = clamp(0.30 - 0.005*(hour-1) - 0.005*successes + 0.05*zone_upgrade_level, 0.18, 0.36)",
"W_formula": "legacy (полоска соединения удалена в v2)",
"W_base": 0.3,
"W_hour_coef": -0.005,
"W_success_coef": -0.005,
@@ -169,103 +684,14 @@
"CHANNEL_BG_PER_HOUR": 0,
"SLOT_LOAD": 0,
"BG_note": "фоновый шум и занятые слоты НЕ грузят канал; load = только активные передачи (loading/sending)",
"CHANNEL_CAPACITY": 80,
"capacity_per_upgrade": 25,
"capacity_upgrade_max_level": 4,
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / CHANNEL_CAPACITY); actual_time = base_time * factor. idle load = 0"
},
"attacks": {
"junk": {
"name_ru": "Мусорный пакет",
"spawn_probability": null,
"duration_sec": 15,
"tell": "серая иконка ?, размер 0 Б, нет таймера",
"effect": "авто-занос через duration_sec, занимает место в приёме; уничтожить можно как malicious в любой фазе (D02)",
"reward_money": 5,
"reward_sla": 1,
"fail_money": -10,
"fail_sla": -5,
"filter_disabled_sec": 10
},
"ddos": {
"name_ru": "DDoS",
"cards_min": 8,
"cards_max": 12,
"spawn_window_sec": 2.0,
"shield_hold_sec": 1.5,
"tell": "8–12 идентичных карточек без типа и таймера",
"effect": "волна карточек; отбивается удержанием щита",
"reward_money": 30,
"reward_sla": 2,
"fail_sla": -15,
"channel_debuff": 0.4,
"channel_debuff_sec": 15,
"packets_lost": 2,
"recv_disabled_sec": 5
},
"phishing": {
"name_ru": "Фишинг",
"reward_money": 20,
"reward_sla": 2,
"fail_money": -40,
"fail_sla": -10,
"file_lock_sec": 60,
"send_disabled_sec": 5,
"timeout_money": -20,
"timeout_sla": -5,
"timeout_lock_sec": 30,
"tell": "client_id формата id_XXXX с неверным символом (например id_7F3К — кириллическая К), серый аватар",
"effect": "фальшивый запрос/пакет"
},
"ransomware": {
"name_ru": "Шифровальщик",
"reward_money": 25,
"reward_sla": 2,
"folder_block_delay_sec": 3,
"auto_unlock_sec": 30,
"auto_unlock_money": -30,
"auto_unlock_sla": -10,
"tell": "расширение .exe, красный квадрат 4×4",
"effect": "файл становится locked (lock_until), кликабелен для уничтожения (D08)"
}
},
"shop": [
{ "id": "shop_slot3", "name_ru": "Третий слот", "effect": "connection_slots", "value": 1, "price": 250, "max_level": 1 },
{ "id": "shop_slot4", "name_ru": "Четвёртый слот", "effect": "connection_slots", "value": 1, "price": 450, "max_level": 1 },
{ "id": "shop_buffer", "name_ru": "Буфер +2", "effect": "buffer_slots", "value": 2, "price": 200, "max_level": 2 },
{ "id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "effect": "packet_timer", "value": 4, "unit": "сек (аддитивно)", "price": 350, "max_level": 3 },
{ "id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "unit": "доля W (аддитивно в формуле D04)", "price": 300, "max_level": 3 },
{ "id": "shop_channel", "name_ru": "Канал +25", "effect": "channel_capacity", "value": 25, "price": 250, "max_level": 4 },
{ "id": "shop_section", "name_ru": "Новая секция", "effect": "sections", "value": 1, "price": 300, "max_level": 5 },
{ "id": "shop_search", "name_ru": "Поиск", "effect": "search_highlight", "value": 1, "price": 250, "max_level": 1 },
{ "id": "shop_shield", "name_ru": "Щит", "effect": "shield", "value": 1, "price": 400, "max_level": 1 },
{ "id": "shop_sla", "name_ru": "SLA +24", "effect": "sla_add", "value": 24, "price": 300, "max_level": 3 }
],
"shop_reserve": {
"items": ["scanner", "autosort"],
"note": "резерв — в прототип НЕ входят (D13); позиций в shop НЕТ"
},
"spawn": {
"type_pool_by_hour": {
"1": ["text"],
"2": ["text", "sound"],
"3+": ["text", "sound", "photo", "video"]
},
"malicious_ratio": 0.0,
"required_count_by_hour": {
"1": { "1": 1.0 },
"2": { "1": 0.75, "2": 0.25 },
"3+": { "1": 0.6, "2": 0.3, "3": 0.1 }
},
"required_size_distribution": { "1": 0.6, "2": 0.3, "3": 0.1 },
"required_count_note": "required_count_by_hour — распределение по часу; required_size_distribution — fallback/час 3+",
"attack_schedule": "с часа 2, далее по difficulty.attacks, окно 60–240 с часа, не во время мини-игры"
"CHANNEL_CAPACITY": 10,
"capacity_per_upgrade": 6,
"capacity_upgrade_max_level": 6,
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / capacity); actual_time = base_time_sec * pow(0.90, speed_level) * factor"
},
"minigame_selection": {
"coeff": { "text": 1.0, "sound": 2.0, "photo": 3.0, "video": 4.0 },
"difficulty_formula": "sum(coeff(required)) / n",
"thresholds": { "text": "< 1.5", "sound": "< 2.5", "photo": "< 3.5", "else": "video" },
"call": "MiniGame.start(type, difficulty)",
"coeff": { "text": 1, "sound": 2, "photo": 4, "video": 8 },
"formula": "см. minigames.selection.formula (27 достижимых сумм для 1-4 файлов, 3 ступени, rank_games)",
"quality_stub": 1.0
}
}
+73 -1
View File
@@ -72,6 +72,10 @@
"retry_no_effect": "Качество прежнее — улучшить нельзя",
"delete_prep_only": "Обычные файлы удаляются только в подготовке",
"money_short": "Не хватает средств",
"folder_need_money": "НУЖНО {cost} $",
"folder_limit": "ДОСТИГНУТ ЛИМИТ ПАПОК",
"delete_file_confirm": "Удалить файл?",
"delete_file_detail": "Файл «{name}» будет уничтожен без штрафа SLA.",
"save_restored": "Прогресс смена восстановлен"
},
"shop": {
@@ -87,7 +91,75 @@
"shop_channel": "Ёмкость канала +25",
"shop_section": "Новая секция хранения",
"shop_search": "Поиск нужных файлов",
"shop_shield": "Щит (отражения атак)"
"shop_shield": "Щит (отражения атак)",
"title_offers": "МАГАЗИН · 3 ПРЕДЛОЖЕНИЯ",
"subtitle": "Новые предложения после каждого часа",
"reroll": "ОБНОВИТЬ АССОРТИМЕНТ · %d $",
"reroll_limit": "ЛИМИТ ОБНОВЛЕНИЙ",
"level": "УРОВЕНЬ %d/%d",
"now": "СЕЙЧАС",
"after": "ПОСЛЕ",
"not_enough": "НЕ ХВАТАЕТ %d $",
"maxed": "МАКСИМУМ",
"all_bought": "ВСЁ КУПЛЕНО",
"lock": "ЗАКРЕПИТЬ",
"unlock": "ОТКРЕПИТЬ",
"lot_slots": "Порты коммутатора",
"lot_speed": "Оптоволокно",
"lot_sla": "Резервный ИБП",
"lot_shield": "Заземление",
"lot_money": "Корпоративный контракт",
"lot_folder": "Складской стеллаж",
"lot_channel": "Пропускная способность",
"lot_timer": "Регламент обработки"
},
"sort": {
"title": "СТОЛ СОРТИРОВКИ",
"hint": "Перетащите файлы, чтобы разложить по папкам",
"full": "СТОЛ ЗАПОЛНЕН",
"return_blocked": "На столе остались файлы, места нет",
"empty": "Здесь можно разложить файлы после часа пика"
},
"grid": {
"cells": "%d / %d клеток",
"free": "свободно %d клеток",
"busy": "занято %d из %d · свободно %d",
"empty_folder": "ПУСТО · перетащите файл или пакет",
"pack": "УПАКОВАТЬ"
},
"packet": {
"shape": "ЗАЙМЁТ %d КЛЕТОК",
"no_space": "НЕТ МЕСТА В ПАПКЕ"
},
"request": {
"minigame": "МИНИ-ИГРА: %s · T%d",
"difficulty": "СЛОЖНОСТЬ: %d",
"waiting_slot": "ОЖИДАНИЕ СЛОТА"
},
"minigame": {
"quality": "КАЧЕСТВО %d%%",
"surrender": "СДАТЬСЯ",
"skip": "ПРОПУСТИТЬ",
"no_timer": "БЕЗ ТАЙМЕРА",
"continue": "ПРОДОЛЖИТЬ",
"clean": "ЧИСТО",
"normal": "НОРМАЛЬНО",
"weak": "СЛАБО",
"dots": "Одна линия",
"sound": "Уровни",
"text": "Чужое слово",
"video": "Раскадровка",
"photo": "Кадр",
"maze": "Лабиринт",
"junk": "Фильтр",
"ddos": "Щит",
"phishing": "Паспорт",
"ransomware": "Карантин"
},
"hour": {
"next_offers": "3 НОВЫХ ЛОТА В МАГАЗИНЕ",
"sla_regen": "SLA восстановлен: +%d",
"all_slots_busy": "ВСЕ КАНАЛЫ ЗАНЯТЫ"
},
"attack": {
"junk": {
+662 -11
View File
@@ -1,15 +1,38 @@
# DECISIONS — решения v1.2
# DECISIONS — решения v3.0
> Каждое решение: ID, что решили, что меняется в GDD. Дедлайн
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md`.
> **v1.2 (25.10.2026):** D01–D17 сохранены; добавлены D18–D25. Если D19–D24
> меняют числа старых решений, это указано в примечании к D12/D13/D16.
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md` (v3.0).
>
> **v3.0 (26.10.2026):** актуальны решения **D45–D61**. Решения **D01–D44**
> сохранены как история; конфликтующие с v3.0 помечены пометкой
> «**Статус v3.0: история**» или «**заменено**» с указанием замены. Ничего не
> удалено. D53–D56 — уточнения v3.0: TCP-соединение как сущность, idle-слот
> после отправки, UDP-риск и `lot_udp`, новые шрифты. D57–D61 — текущая волна:
> длительность часов, файлы-каталог и темп, запросы без `request_cap`, окно
> атак и визуал «ЖАРА».
>
> **Сдвиг ID:** диапазон D26–D28 занят решениями v1.2 (аудит, VHS, ambient),
> D29–D44 — решения v2.0, D45–D52 — решения v3.0, D53–D56 — уточнения v3.0
> после проверки реализации, D57–D61 — баланс темпа и UI «ЖАРА».
>
> **Где смотреть актуальные числа:** GDD v3.0 и `data/balance.json`; этот файл
> хранит историю формулировок. Прежние суммы дерева, кап SLA, число файлов,
> состав игр отправки и множитель скорости в телах D01–D52 — **история
> v1/v2/v3.0**, а не текущая спецификация. Замены: D45–D61.
>
> **Статус решений v1.x в v3.0 (кратко):** D01→D46, D02→D33/D46,
> D03→D34/D38, D04→D45, D05→D29/D46, D06→D46, D07→D38/D39, D08→D38,
> D09→D46, D10→D34/D45, D12→D29/D40/D50, D13→D50/D37, D15→D56, D16→D43,
> D17→D34/D49, D19→D29/D50, D20→D29/D50, D21→D29, D22→D33/D45, D23→D29/D38,
> D25→D43. D11, D14, D18, D24 (кроме магазина), D27, D28 остаются в силе.
## D01 — карточка запроса
**Решение:** в карточке запроса показывать имя требуемого файла
(до 12 символов) и его папку, а не только иконку типа. `required` —
конкретные id файлов, не фильтры по типу.
**GDD:** §7 (нужные файлы — по имени+папке, привязка по id).
**Статус v2.0: устарело.** В v2 `required` — это 1–3 слота контейнера, файлы
выбирает игрок; конкретные id не зашиты (D32, GDD §7).
## D02 — расход/удаление файлов и склад
**Решение:** файлы **не расходуются** при отправке, но после успешной
@@ -19,6 +42,10 @@
поражением, но пройденные 30+ часов засчитываются; в итоговом
отчёте это указывается явно.
**GDD:** §3, §5, §16.
**Статус v2.0: устарело.** Файлы **расходуются** при успешной отправке
(cooldown 0); при отмене/таймауте сохраняются (D33). «Конец забега по
складу» не используется: поражение только по SLA (D40, GDD §16). Удаление
неприкреплённого файла в подготовке — без штрафа (D44).
## D03 — заморозка таймеров модалками
**Решение:** пока открыта модалка мини-игры или панель соединения —
@@ -26,6 +53,9 @@
активного запроса продолжает идти; при полном буфере новые пакеты
не спавнятся.
**GDD:** §6, §8, §8.4.
**Статус v2.0: частично устарело.** Мини-игра отправки не ставит час на
паузу: она морозит спавн/загрузку, спавн запросов и таймер своего запроса;
другие таймеры идут. Мини-игра атаки ставит на паузу весь час (D34/D38).
## D04 — полоска соединения
**Решение:** `W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes
@@ -33,6 +63,11 @@
440/340/280 px/с; окна попадания ~130–200 мс; апгрейд зоны применяется
внутри формулы, cap 0.36.
**GDD:** §11.
**Статус v2.0: устарело.** Полоска соединения удалена. TCP/UDP — переключатель
оплаты/скорости: TCP ×1.15 к выплате, UDP ×0.8 ко времени (D33, GDD §11).
**Статус v3.0: формула снова актуальна.** TCP-соединение вернулось (D45):
та же формула зоны, скорости 440/340/280, 3 попадания; промах −4 SLA / 8 с,
Esc — 0 SLA / 4 с. UDP соединения не требует.
## D05 — один источник истины
**Решение:** буфер = `4 + 2×buffer_upgrades` (max 8); зона — только
@@ -40,18 +75,30 @@
(`balance.json → difficulty`); в окне «Отправка» максимум 4 видимых
карточки; при 5-м запросе — скролл и высота карточек 56 px.
**GDD:** §6, §7, §11.
**Статус v2.0: устарело в числах.** Буфер = 6 + уровень `lot_channel`, max 12;
`request_cap` берётся из `hours[]` (2/5/5/6/6/7/7/7/8), а не из legacy
`difficulty`. Правило «4 карточки, дальше скролл, высота 56 px» остаётся
(D29, GDD §6–7).
## D06 — файл в нескольких запросах
**Решение:** файл может входить в `required` нескольких запросов, но
`attached` — только в одном; второй запрос показывает файл серым.
При `failed` файла все запросы, где он `required`, отменяются со штрафом.
**GDD:** §7.
**Статус v2.0: частично устарело.** Файл по-прежнему прикрепляется только к
одному запросу; но `required` больше не содержит конкретных id, поэтому
правило «все запросы, где он `required`» не применяется. При `locked`/
`failed`/`malicious` файл просто открепляется (D32).
## D07 — атаки вне буфера и единая таблица штрафов
**Решение:** атаки не занимают буфер и не вытесняются ими; отдельный
слой карточек атак. Единая таблица урона: мусор −5 SLA, DDoS −15,
фишинг −10, шифровальщик −10 (вместо глобального −15).
**GDD:** §12.
**Статус v2.0: устарело.** Карточек/оверлеев атак больше нет: каждая атака —
мини-игра, которая ставит час на паузу. Урон/награда — по
`attacks.kinds.*.tiers`, плюс эффекты DDoS/фишинга/шифровальщика
(D38/D39, GDD §12.1).
## D08 — видимые теллы
**Решение:** у пакета показывать расширение файла; у шифровальщика —
@@ -59,6 +106,8 @@
символом (например `id_7F3К`, кириллическая К) и серый аватар.
`locked`/malicious файл остаётся кликабельным для уничтожения.
**GDD:** §20.
**Статус v2.0: устарело.** Зловредных пакетов/фишинговых запросов и их
теллов нет; атаки вынесены в отдельные мини-игры (D38, GDD §12.1, §20).
## D09 — состояния-флаги
**Решение:** состояния — флаги, а не один enum:
@@ -68,12 +117,23 @@
Таблица переходов при истечении таймера (загрузка/соединение/мини-игра)
— в `docs/IMPLEMENTATION.md`.
**GDD:** §17.
**Статус v2.0: обновлено.** Добавлены `table` и `sending`; `connected` —
legacy и всегда `true` (полоски нет). Резервации приёма живут в папке, а не
в запросе (D31/D32, GDD §17).
**Статус v3.0: обновлено.** `connected` снова рабочий флаг TCP; добавлены
`connecting`, `connect_attempts`, `connect_cooldown_until`, `required_by`
(D45/D46, GDD §17).
## D10 — поведение Esc
**Решение:** в час — пауза; в мини-игре — no-op; в панели соединения —
отмена (слот освобождается, запрос остаётся); закрытие модалок — только
кнопкой или таймаутом.
**GDD:** §18.
**Статус v2.0: частично устарело.** Панели соединения нет. В мини-игре
отправки `Esc` во время игры — no-op; в мини-игре атаки — двухшаговое
подтверждение `СДАТЬСЯ`; на экране результата — `ПРОДОЛЖИТЬ` (D34, GDD §18).
**Статус v3.0: обновлено.** TCP-панель вернулась: `Esc` отменяет соединение
без штрафа SLA, с кулдауном 4 с (D45, GDD §11.1, §18).
## D11 — устойчивость к лагам
**Решение:** `delta = min(delta, 0.1)`; авто-пауза при
@@ -89,6 +149,10 @@
значения, но +5 % к скорости спавна каждые 2 часа.
**GDD:** §12, §13, §23.
**v1.2:** старт SLA — 128/200; штраф за потерю пакета — по типу (D19).
**Статус v2.0: устарело.** Доход — цели 65/220/380/560/760/980/1200/1450/
1700; старт SLA 60/60; пассивных +0.5/+1 нет, есть +15 в начале часа и
награды атак. «Полный выкуп к H8–H9» не подтверждён (56,7–57,9 % дерева в v3).
Штраф `−(10+2×hour)` сохранён (D29/D40/D50, GDD §12–13).
## D13 — магазин
**Решение:** 9 позиций: 3-й слот 250, 4-й слот 450, буфер +2 — 200×2,
@@ -97,9 +161,12 @@
резерв (вырезаны из прототипа).
**GDD:** §14.
**v1.2:** добавлена 10-я позиция «SLA +24» (300, max 3); открытия — D23.
**Статус v2.0: устарело.** 8 лотов, 3 оффера, рероллы и замки; зоны/секции/
поиска в магазине нет. В v3.1 — **9 лотов** и дерево **8 825 $** (D50, D55);
прежние числа v1/v2 — история.
## D14 — фон
**Решение:** фон — статичный градиент + слабый шум 320×180,
**Решение:** шейдер фона — статичный градиент + слабый шум 320×180,
без полноэкранного fbm; CRT-эффект убрать.
**GDD:** §21.
**v1.2:** фон — тёплый бумажный дизеринг/панели, см. D18.
@@ -115,11 +182,18 @@ Press Start 2P остаётся только для крупных заголо
Допустимые кегли: **10 / 12 / 16 / 24** (Handjet узкий, поэтому 10
разрешён).
**GDD:** §21.
**Статус v3.0: заменено D51.** Основной шрифт — **Golos Text**, шкала
**11/13/17/26**; Press Start 2P — только логотип и цифры; Handjet — legacy.
**Статус v3.1:** заменено D56 — тело на Kechusilka, крупный текст на
DisplayOTF, Golos Text остаётся fallback.
## D16 — порядок P0
**Решение:** порядок P0: данные/реестр → слоты → приём/отправка → UI.
**GDD:** §23.
**v1.2:** календарь открытий — D23; в часе 1 атак нет.
**Статус v2.0: исторический.** Актуальная структура — `main.gd` +
`game_controller.gd` + `core/` + `minigames/`; порядок работ и проверки —
D43, GDD §23.
## D17 — quality/retry
**Решение:** заглушка качества возвращает 1.0; кнопка «Переделать»
@@ -130,6 +204,9 @@ Press Start 2P остаётся только для крупных заголо
`difficulty = sum(coeff)/n`, пороги `<1.5 text, <2.5 sound,
<3.5 photo, иначе video`; `MiniGame.start(type, difficulty)`.
**GDD:** §4, §8.4, §10.
**Статус v2.0: устарело.** Заглушки качества и кнопки «Переделать» нет:
`q` даёт мини-игра. Выбор игры — по плотному списку достижимых сумм и
коэффициентам 1/2/4/8 (D34/D35, GDD §8.4, §10).
## D18 — UI v2 «Kaleidos»
@@ -149,6 +226,9 @@ Press Start 2P остаётся только для крупных заголо
10-я позиция **«SLA +24»** — 300, max 3 уровня. Остальные 9 позиций D13
сохраняются; итого 10.
**GDD:** §4, §12, §14.
**Статус v2.0: устарело.** Старт SLA 60/60; лота «SLA +24» нет, кап в v3
растёт на +40 за уровень `lot_sla` до 220. Потеря пакета по типу сохранена
(D29/D40/D50, GDD §12).
## D20 — таймеры и grace
@@ -159,6 +239,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
плавно до h9 16/26. Апгрейд «Таймер пакетов +4 с» даёт чистые +4 с
поверх grace.
**GDD:** §8.
**Статус v2.0: устарело.** Grace и per-hour scaling отменены: пакет 20 с,
запрос 30 + 5×уровень (max 55) (D29, GDD §8).
## D21 — канал v1.2
@@ -167,6 +249,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
слоты не грузят; idle = 0 %. `capacity = 80 + 25×channel_level`,
`factor = max(1.0, load / capacity)`, `actual_time = base_time × factor`.
**GDD:** §9.
**Статус v2.0: устарело в числах.** `capacity = 10 + 6×level` (10…46);
формула нагрузки и idle=0 сохранены (D29, GDD §9).
## D22 — TCP-соединение
@@ -175,6 +259,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
после успешного прохождения; при промахе слот освобождается, запрос
остаётся. `Esc` — отмена с тем же результатом.
**GDD:** §11, §17.
**Статус v2.0: устарело.** Полоски/панели соединения нет; `connected` всегда
`true`. TCP/UDP — переключатель выплаты/времени (D33, GDD §11).
## D23 — открытия и мета
@@ -188,6 +274,11 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
(`difficulty.attacks = 0,1,1,2,2,3,3,4,4`). `minigame_advanced` — резерв
под будущие мини-игры, контента пока нет.
**GDD:** §3, §10, §15, §23.
**Статус v2.0: обновлено.** Магазин с H2 и мета 3/5/9 сохранены. Типы H1
text/sound, photo с H2, video с H3. Атаки: junk/ddos с H2, phishing с H4,
ransomware с H5 (по `weight_by_hour`); `minigame_advanced` по-прежнему
резерв (D29/D38, GDD §3, §14). Мета-ключ `search` остаётся в данных, но
UI-поиск в v2 не подключён — как готовая фича не документируется.
## D24 — UX и читаемость
@@ -202,6 +293,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
Маскот **БАЙТ** ведёт короткий туториал первого часа. Esc на итогах забега
ведёт в главное меню (не soft-lock).
**GDD:** §18, §19, §21.
**Статус v2.0: частично.** Поле поиска не подключено (см. D23), остальное —
имена, уничтожение папки, магазин без крестика, calm-фон — в силе.
## D25 — проверки и MR-ревью
@@ -211,6 +304,9 @@ OpenCode-ревью агентом `oracle` на модели
`opencode-go/deepseek-v4.1-flash`: dry-run 74–90 с, комментарий с
Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
**GDD:** §23, §25.
**Статус v2.0: устарело в числах.** Актуальный прогон — Windows/Linux
раннеры, **25 тестовых наборов + parse-check (26/26 PASS)**; баланс —
`python tests/sim_balance.py` (D43, GDD §23).
## D26 — фиксы аудита v1.2
@@ -227,15 +323,17 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
на слот запускает соединение, тост покупки с названием, честный статус
сейва, fallback `SLA +24`.
- `minigame_advanced` — зарезервированный unlock, контента пока нет.
**Статус v2.0: частично устарело.** Пункты про TCP-прогресс, drag на слот и
fallback `SLA +24` неактуальны: соединения и лота SLA+24 нет. Остальное
(calm-фон, Esc, уничтожение папки, мета, H1 без атак) сохранено.
## D27 — VHS главного меню
**Решение:** VHS-слой только на главном меню: процедурный
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
`MainMenu._draw` до текста. Скан-линии, слабая хроматика, зерно, виньетка и
полоса; без shader/screen texture, ввод не блокирует, текст резкий.
Галочка «VHS-эффект» — в настройках, группа «ЭКРАН»; HEADLESS не рендерит.
Карточка/кнопки меню под фильтром, модалки — нет; это не CRT на весь UI.
**Решение:** VHS-слой только на главном меню: `shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`, screen texture 1920, скан-линии 2 физ. px,
слабая хроматика, зерно, виньетка и полоса. Галочка «VHS-эффект» — в
настройках, группа «ЭКРАН»; HEADLESS не рендерит. Карточка/кнопки меню под
фильтром, модалки — нет; это не CRT на весь UI.
**GDD:** §21, §25.
## D28 — ambient machine room
@@ -245,3 +343,556 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
`scripts/autoload/ambient.gd`. Стартует после первого жеста (web autoplay),
поддерживает mute, паузу и громкость музыки. `test_ambient` — 74/0.
**GDD:** §22, §25.
---
# Решения v2.0 (D29–D44)
## D29 — стартовые статы и формулы
**Решение:** забег начинается с **50 $** и **SLA 60/60**. Кап SLA —
`60 + 40×sla_level`, жёсткий максимум **220**. Слоты — `1 + slot_level`
(1…5), канал — `10 + 6×channel_level` (10…46, он же даёт буфер
`6 + уровень`, 6…12), таймер запроса — `30 + 7×timer_level` (30…65),
таймер пакета — фиксированные **20 с**. Сетка папки —
`[5×4, 6×4, 7×5, 8×5, 8×6, 9×6]`. На старте 2 папки, максимум 6, новая —
**180 $**. В начале часа SLA +15 и заряды щита восстанавливаются.
**GDD:** §4, §6, §8, §9, §12, §14.
**Заменяет:** числа D05, D12, D19, D20, D21.
**Статус v3.0: обновлено.** Числа приведены к v3 (D50): SLA-кап 220,
таймер +7 с до 65, сетки 5×4 → 9×6, новая папка 180 $.
## D30 — сетки папок и файлы-полимино
**Решение:** каждая папка — клеточная сетка; файл — полимино: текст 1,
звук 2, фото 3, видео 4 клетки. Формы — `grid.shapes` в `data/balance.json`.
Поворот — `R` или ПКМ во время drag (0–3). Точное размещение проверяет
пересечения и границы; auto-fit идёт first-fit по строкам/столбцам. Подпись
рисуется в **верхней левой занятой** клетке, а не в пустом углу bbox
(S/Z-формы). Перемещать `attached`/`locked`/`failed`/`malicious` нельзя.
**GDD:** §5, §20.
**Заменяет:** плоские «слоты файлов» v1.
## D31 — приём, резервации и стол сортировки
**Решение:** приём резервирует клетки фигуры в папке на время загрузки;
отмена/таймаут освобождает резервацию. Полный буфер замораживает спавн, но
не сжигает бюджет часа. Стол сортировки — отдельная сетка **4×6**, доступен
в подготовке. Файл на столе нельзя прикрепить к запросу. На старте часа
стол автоматически возвращает файлы в папки; если хотя бы один не влез —
час не начинается, игрок освобождает место. Позиции на столе входят в сейв.
**GDD:** §3, §5–7, §15.
**Обновляет:** D03, D05.
## D32 — запросы и прикрепление
**Решение:** запрос требует до трёх файлов (слоты контейнера), конкретные id не
зашиты — игрок сам выбирает файлы. Файл прикреплён только к одному запросу;
со стола не прикрепляется. При `locked`/`failed`/`malicious` файл
открепляется, контейнер нужно собрать снова. Таймер запроса замирает в его
мини-игре, в очереди out-слота и во время передачи; остальные таймеры идут.
`ОТКЛОНИТЬ` — −5 $/−5 SLA.
**GDD:** §7, §8, §17.
**Заменяет:** D01, D06; обновляет D09.
**Статус v3.0: заменено D46.** Запрос снова требует конкретные `file_id`,
но их **1–4**; имена видны на карточке, есть «ПОТЕРЯН» и жёсткая резервация.
## D33 — отправка, расход файлов и TCP/UDP
**Решение:** отправка — только по явной кнопке `ОТПРАВИТЬ`; прикрепление и
закрытие мини-игры автостарт не запускают. TCP/UDP переключаются до старта:
TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, выплата **×0.85**. После
мини-игры запрос занимает out-слот или встаёт в очередь «ОЖИДАНИЕ СЛОТА»
(таймер заморожен), пока слот не освободится. При успешной отправке файлы
**расходуются**; при отмене/таймауте/конце часа сохраняются. Полоска
соединения в v2 была удалена; в v3 TCP-соединение вернулось (D45), а
`connected` снова рабочий флаг.
**GDD:** §7, §11, §17.
**Заменяет:** D02 (расход), D04, D22.
**Статус v3.0: заменено D45.** TCP снова требует полоску соединения; UDP
шлёт сразу. Выплата UDP — **×0.85** (не ×1.0), TCP — ×1.15, время UDP ×0.8.
## D34 — мини-игры: состав и контракт
**Решение:** 11 игр — семь отправки (включая удалённую в v3 игру с проводами)
и 4 атаки (`junk`, `ddos`, `phishing`, `ransomware`). Общий
контракт: без таймеров, конечная цель, локальный seeded RNG, `q ∈ 0…1`,
экран результата `q%`/вердикт и `ПРОДОЛЖИТЬ`; `finished` эмитится только
после `ПРОДОЛЖИТЬ` в модальном режиме (награда не применяется раньше).
`СДАТЬСЯ` (атаки) / `ПРОПУСТИТЬ` (отправки) с подтверждением даёт `q=0`.
Формулы `q` — GDD §10.
`Esc` во время игры отправки — no-op, в игре атаки — двухшаговое `СДАТЬСЯ`;
на результате — продолжить.
**GDD:** §10, §18.
**Заменяет:** D17 (заглушка q), D08 (теллы).
**Примечание:** строка `minigame.surrender` (`СДАТЬСЯ`) используется
мини-играми атак; у мини-игр отправки кнопка — `ПРОПУСТИТЬ`.
**Статус v3.0: заменено D47/D48.** Сейчас **10** игр: 6 отправки + 4 атаки;
игры с соединением проводов нет; у отправки 3 ступени. Общий контракт
(без таймеров, seeded RNG, отложенный `finished`) сохранён и дополнен шапкой
«название + цель», полосой прогресса и двухкликовым отказом (GDD §10).
## D35 — выбор мини-игры отправки (B4)
**Решение:** коэффициенты `text 1 / sound 2 / photo 4 / video 8`;
`S = sum(coeff)` по трём файлам максимум. `rank` — позиция S в плотном списке
`[1…14,16,17,18,20,24]` (или ближайшая меньшая), `tier = clamp(1 +
floor(rank×4/19), 1, 4)`, `id = order[rank % 7]`, где order — семь игр v2
(включая удалённую в v3 игру с проводами). Суммы 15/19/21/22/23 недостижимы,
поэтому 28-элементный массив не перечисляется. Все 7 игр и 4 ступени были
достижимы; `S=24 → video/T4` (старый пример S−1 отменён).
**GDD:** §8.4, §10.
**Заменяет:** D17 (средний коэффициент/пороги).
**Статус v3.0: история.** Актуальная формула — **27 достижимых сумм для 1–4
файлов**, 3 ступени и фиксированная таблица `rank_games` (D49, GDD §8.4).
Старые «19 сумм / 4 ступени / rank % 7» больше не действуют.
## D36 — магазин: 8 лотов и 3 оффера
**Решение:** 8 лотов (`lot_slots`, `lot_speed`, `lot_sla`, `lot_shield`,
`lot_money`, `lot_folder`, `lot_channel`, `lot_timer`), ровно **3**
предложения в подготовке. Магазин — только в подготовке с H2, в H1 и во
время часа недоступен. Максимальные лоты не предлагаются. Сумма всех
уровней v2 — историческая; в v3 дерево стоит **8 085 $** (D50). Крестика нет:
`ЗАКРЫТЬ`/`Esc`.
**GDD:** §3, §14.
**Заменяет:** D13, D19 (10 позиций).
**Статус v3.0: история в числах.** 8 лотов и 3 оффера сохранены, но цены,
эффекты и сумма дерева заменены D50 (**8 085 $**); v3.1 добавляет девятый
лот `lot_udp` — актуально **9 лотов / 8 825 $** (D55).
## D37 — рероллы, замки, веса и сохранение офферов
**Решение:** реролл стоит **40/80/120 $**, максимум **3** за подготовку;
замок отдельного предложения сохраняется при реролле. Вес =
`weight_by_hour[hour−1] × 0.5` (был в прошлой выдаче) `× 1.5` (pity, ≥2
часов без предложения). По возможности в выдаче есть хотя бы один
доступный лот. Офферы, рероллы, `last_seen` и замки сохраняются в сейве и
восстанавливаются при загрузке; счётчик/замки сбрасываются на новой
подготовке.
**GDD:** §14, §15.
**Обновляет:** D23 (открытия), D24 (магазин без крестика).
## D38 — атаки v2
**Решение:** 20 атак за забег: `[0,1,1,2,2,3,3,4,4]` для H1–H9, время —
случайное в окне **60–240 с**. Виды: `junk`, `ddos`, `phishing`,
`ransomware`; доступность по `weight_by_hour` (junk/ddos с H2, phishing с
H4, ransomware с H5). Ступень: H2–3 T1, H4–5 T2, H6–7 T3, H8–9 T4
(`clamp(1+floor((hour−2)/2),1,4)`). Каждая атака — мини-игра, которая
ставит на паузу **весь час**; карточек/пакетов/запросов атак нет.
**GDD:** §3, §10, §12.1.
**Заменяет:** D07, D08, D19 (часть).
## D39 — щит, награды и последствия провала
**Решение:** заряды щита = уровень `lot_shield` (макс. 3), восстанавливаются
в начале часа. Заряд тратится **до** открытия мини-игры: атака считается
отражённой, награда **50 %**, штрафа нет. Деньги наград/штрафов
масштабируются по часу: `max(0.75, hour/3) × attacks.money_scale`. Провал:
DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случайный файл
заблокирован на 60 с и отправка недоступна 5 с; ransomware — случайная
папка заблокирована на 15 с. SLA/деньги — из `attacks.kinds.*.tiers`.
**GDD:** §9, §12, §12.1.
**Заменяет:** D07 (таблица урона).
**Статус v3.0: история в числах.** Щит теперь даёт **70 %** награды (D50);
остальные эффекты провала сохранены.
## D40 — SLA, реген и поражение
**Решение:** старт 60/60; в начале часа +15 (не выше капа), кап рос на
+35 за `lot_sla` до исторического максимума v2 (в v3 — +40 до 220, D50).
Потеря пакета — 2/4/8/16 по типу; таймаут запроса
и провал запросов в конце часа — `−(10+2×hour)`; отклонение — −5/−5;
уничтожение папки — −5 SLA. Пассивных +0.5/+1 нет. **SLA ≤ 0 во время часа
= поражение**; «конца забега по складу» нет.
**GDD:** §12, §16.
**Заменяет:** D12 (SLA-прирост), D19 (старт), D02 (склад).
**Статус v3.0: история в числах.** Кап SLA — **220** (+40 за уровень), не 200;
см. D50.
## D41 — save v2 и миграция
**Решение:** `SaveManager.VERSION = 2`, поддерживаются 1 и 2. Позиции
хранятся **только** в `folder.files[]` и `table[]`; в `files{}` — метаданные.
Сейв включает деньги/SLA/час/фазу/streak/stats/upgrades/мету/RNG/реестр/
офферы/рероллы/замки/`last_seen`/заряды щита/час SLA-регена. Запись
атомарная (`save.tmp` → rename), в Web — `force_fs_sync()`. v1 мигрирует
без уменьшения сеток и без потери файлов; лишние файлы уходят на стол или в
`registry`.
**GDD:** §15, §17.
**Заменяет:** старую схему слотов v1.
## D42 — баланс v2
**Решение:** цели дохода H1–H9 — **150/220/380/560/760/980/1200/1450/
1700 $**. `request_cap`, параметры симулятора и расход магазина в v2 были
зафиксированы отдельно; в v3 они заменены (D50, GDD §13). В прогоне v2 все
часы укладывались в ±20 %, но расход магазина оставался ниже целевой полосы
55–65 % — это было зафиксировано честно, а не задекларировано как «полный
выкуп».
**GDD:** §13, §14.
**Статус v3.0: история.** В отчёте v3 — **7 354 $**, **56,7 %** дерева
**8 085 $**, **32/240** поражений; после H1-правки свежий прогон — **7 533 $**,
**57,9 %**, **28/240**; см. D50 и `tests/SIM_BALANCE_REPORT.md`. В v3.1 дерево
выросло до **8 825 $** (D55), отчёт требует перегенерации.
## D43 — проверки, запуск и Web
**Решение:** единый прогон:
`powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1`
(Windows) или `bash scripts/run_all_tests.sh` (Linux/macOS). Наборы —
**25 тестовых наборов + parse-check**; исторический замер — 26/26 PASS.
Баланс — `python tests/sim_balance.py` (exit 0, отчёт
`tests/SIM_BALANCE_REPORT.md`). Windows-раннер сам ищет Godot и позволяет
переопределить путь через `GODOT_BIN` или `-GodotBin`. Web-пресет
`export_presets.cfg` существует: `thread_support=false`, экспорт в
`build/web/index.html`, просмотр только по HTTP.
**Статус v3.1:** core-наборы обновлены и зелёные; UI-наборы (`test_main_*`,
`ui_smoke`) ещё прогоняются, итоговый счёт не фиксируется.
**GDD:** §23, §24.
**Заменяет:** D25 (числа/платформы).
## D44 — известные отклонения и упрощения
**Решение:** фиксируем осознанно:
- `ПРОПУСТИТЬ` → `q=0` → `quality = 0.5`, то есть половина quality-множителя,
а не нулевая выплата;
- `speed` выплаты использует `time_left/timer_total` на **старте отправки**,
пока таймер был заморожен мини-игрой; быстрый игрок видит ≈1.0;
- лимит папок **6**, новая папка **100 $** (review H5);
- любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
защита от soft-lock «нет денег/нет места» (review M16);
- удалённая в v3 игра с проводами: T4 имел `box 34`, `pitch 40`, верх `56`,
чтобы низ был ≤320 (review M11); в v3 неактуально;
- деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
**GDD:** §26.
**Не является обещанием:** это не баги «на потом», а принятые рамки
джем-прототипа; менять только вместе с балансом и тестами.
**Статус v3.0: история.** Пункт про геометрию игры с проводами неактуален:
игра удалена (D47). Лимит папок теперь 6 при цене **180 $**, магазин усилен
(D50). Остальные упрощения (q=0 у пропуска, snapshot `speed`, M16, M8)
сохранены; актуальный список — GDD §26.
---
# Решения v3.0 (D45–D52) и уточнения (D53–D56)
## D45 — TCP-соединение возвращено
**Решение:** TCP перед мини-игрой отправки снова требует мини-игру соединения
`scripts/minigames/connection_game.gd`: три палочки по очереди, скорости
**440/340/280 px/с**, три попадания в зелёную зону. UDP соединения не требует
и открывает мини-игру отправки сразу.
```
W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes + 0.05×timer_level,
0.18, 0.36)
```
- Промах/уход за край: **−4 SLA**, кулдаун повтора **8 с**, панель
закрывается (автоповтора нет).
- `Esc`: **0 SLA**, кулдаун **4 с**, запрос остаётся.
- Таймер запроса на время соединения заморожен; час идёт.
- Успех сразу открывает мини-игру отправки, увеличивает `connect_successes`
и **блокирует протокол** до конца запроса.
- Счётчик успехов сбрасывается на новом забеге (`reset_carry()`).
- Экономика: TCP — выплата **×1.15**, время ×1.0; UDP — выплата **×0.85**,
время **×0.8**.
**GDD:** §11, §11.1, §11.2, §17, §18.
**Заменяет:** D04 (снова актуальна), D33 (часть про отсутствие соединения).
**Статус v3.1:** механика уточнена D53–D54. Кнопка называется
`ПОДКЛЮЧИТЬСЯ`; успех больше **не** открывает мини-игру отправки, а занимает
out-слот отдельным `TcpConnection`. До соединения файлы прикреплять нельзя.
## D46 — запросы на конкретные файлы, 1–4
**Решение:** запрос хранит **конкретные `file_id`** в `required`; число
файлов — **1–4** по `hours[].request_mix`. При создании выбираются реальные
файлы из пула: свободные, не прикреплённые и не затребованные другим
запросом. На карточке имена обязательных файлов видны сеткой 2×2.
- Прикрепление принимается только для `required`-файла этого запроса; чужой
файл отклоняется с подсказкой.
- **Жёсткая резервация:** обязательный файл нельзя уничтожить, унести на стол
или прикрепить к другому запросу (`required_by`).
- Пропавший файл (`failed`/`malicious`/удалён) показывается как **«ПОТЕРЯН»**;
директор пытается подставить свободный файл того же типа, иначе отправка
невозможна.
- Таймер — **30 + 7×timer_level**, максимум **65 с**; замирает в мини-игре,
соединении, очереди out-слота и передаче.
- `request_cap` — **6/6/5/6/6/7/7/7/7**; `request_mix` H1–H9 — в GDD §13.
- H1-затравка: seed-файл `text` + сразу запрос на него; первый пакет в `t=0`.
Анти-голодание создаёт запрос, если активных нет и есть свободный файл
(минимальный зазор 3 с).
**GDD:** §3, §7, §13, §17.
**Заменяет:** D32; обновляет D01, D06, D09.
## D47 — удаление игры с соединением проводов
**Решение:** из набора отправки удалена send-игра, где нужно было соединять
провода с гнёздами. Осталось **6 игр отправки**: `dots`, `sound`, `text`,
`video`, `photo`, `maze`. Файла игры и записи в `balance.json` больше нет;
`MiniGameFactory` не пропускает старый id наружу. Упоминания в `dev_capture`
и тестах остались только как негативные проверки «этот id не выбирается».
**GDD:** §10, §24.
**Заменяет:** D34 (состав 7 игр отправки).
## D48 — сдвиг ступеней отправки
**Решение:** у мини-игр отправки **3 ступени**, у атак — **4**. Старая
ступень T1 отправки исчезла: прежние T2/T3/T4 стали T1/T2/T3 в
`balance.json → minigames.games.*.tiers`. Параметры игр перенесены без
смены смысла: T1 = старый T2, T2 = старый T3, T3 = старый T4.
**GDD:** §10, §24.
**Заменяет:** D34/D35 в части «4 ступени отправки».
## D49 — коэффициенты и таблица выбора мини-игры
**Решение:** коэффициенты — `text 1`, `sound 2`, `photo 4`, `video 8`.
`S = sum(coeff)` по **1–4** файлам. Достижимо ровно **27 сумм**:
`[1..22, 24, 25, 26, 28, 32]`.
```
rank = позиция S в ACHIEVABLE_SUMS (или ближайшая меньшая)
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
id = RANK_GAMES[rank]
```
`RANK_GAMES` — фиксированная таблица на 27 позиций (`economy.gd`), без
modulo. **Каждая из 6 игр достижима в каждой ступени.** `S=0` → `dots`/T1.
H1 — всегда T1; H2 — почти всегда T1 (T2 только у редкого запроса из трёх
фото, S12); H9 даёт ≈**33/22/46 %** запросов T1/T2/T3.
**GDD:** §8.4.
**Заменяет:** D35.
## D50 — усиление магазина v3
**Решение:** 8 лотов, полное дерево — **8 085 $**. Новые цены и эффекты:
| Лот | Эффект | Цены |
| --- | --- | --- |
| `lot_slots` | +1 слот | 90 / 200 / 400 / 700 |
| `lot_speed` | время передачи ×0.90 за уровень | 55 / 75 / 100 / 135 / 180 / 245 |
| `lot_sla` | кап +40 и мгновенно +40 SLA (до 220) | 80 / 160 / 270 / 430 |
| `lot_shield` | +1 заряд щита, 70 % награды | 100 / 220 / 410 |
| `lot_money` | выплата +5 % за уровень | 70 / 95 / 130 / 170 / 220 / 285 / 365 / 465 |
| `lot_folder` | сетка папки до 9×6 | 50 / 100 / 180 / 290 / 440 |
| `lot_channel` | канал +6, буфер +1 | 45 / 70 / 95 / 130 / 175 / 230 |
| `lot_timer` | таймер запроса +7 с (до 65) | 45 / 75 / 115 / 165 / 230 |
Сетки: `[5,4] → [6,4] → [7,5] → [8,5] → [8,6] → [9,6]`. Новая папка —
**180 $**; `sla_hard_cap` — **220**. Базовые выплаты типов: 18/30/44/69.
Симулятор v3: в отчёте **7 354 $**, расход **56,7 %** дерева, **32/240**
поражений; после H1-правки — **7 533 $**, **57,9 %**, **28/240**.
**GDD:** §5, §12, §13, §14.
**Заменяет:** числа D29, D36, D39, D40, D42.
**Статус v3.1:** 8 лотов и дерево 8 085 $ — история. Актуально **9 лотов**
и **8 825 $** после добавления `lot_udp` (D55); остальные цены/эффекты
D50 действуют.
## D51 — Golos Text и пиксельные иконки
**Решение:** тело интерфейса набрано **Golos Text** (`art/fonts/GolosText-VF.ttf`,
SIL OFL 1.1, полная кириллица); **Press Start 2P** — только логотип и
цифровые акценты. Шкала — **11/13/17/26 px**; подписи файлов — 12/14/16 px по
клетке. Handjet и Pixelify Sans в теме не используются и помечены как legacy.
Пиксельные иконки файлов — `art/icons/*.png` (32×32, nearest), генерируются
`art/icons/generate_icons.py` (Pillow) в палитре Kaleidos. Лицензия — MIT,
команда проекта. Исходный тайл-лист был недоступен, поэтому набор
**перегенерирован** скриптом, а не вырезан из готового файла.
**GDD:** §1–2, §20, §21.
**Заменяет:** D15.
**Статус v3.1:** роли шрифтов уточнены D56: тело — Kechusilka, крупный
текст — DisplayOTF, Golos Text остаётся fallback; Press Start 2P — цифры.
## D52 — перестройка туториала
**Решение:** вместо ленты подсказок — **5 событийных шагов**:
`packet` → `attach` → `connect` → `send` → `sla`. Каждый шаг ждёт своё
действие (аварийные таймауты 30/40/45/40/12 с), вокруг цели рисуется
пульсирующая рамка, кнопка **«ДАЛЕЕ»** пропускает шаг.
- Завершение пишется в `Game.stats["tutorial_done"]` и сохраняется в сейве.
- Автозапуск — только в **первом часу первого забега**, если обучение ещё не
пройдено.
- Кнопка **«ОБУЧЕНИЕ»** в главном меню сбрасывает флаг и запускает повтор:
в новом забеге — с подготовки, в идущем — с текущего часа.
**GDD:** §18.1.
**Заменяет:** старый «таймерный» туториал v2.
## D53 — TCP-соединение как отдельная сущность
**Решение:** TCP-соединение хранится отдельно от запроса —
`scripts/entities/tcp_connection.gd` (`TcpConnection`): `id`, `client_name`,
`client_id`, `ip`, `request_id`, `slot_index`, `hour`, `connected_at`.
`HourDirector.active_connections` — словарь живых соединений; `Request`
держит только `connection_id` и флаги.
- **До соединения файлы прикреплять нельзя:** drag в карточку отклоняется,
`attach_file` отвечает «СНАЧАЛА УСТАНОВИТЕ СОЕДИНЕНИЕ».
- Кнопка `ПОДКЛЮЧИТЬСЯ` — справа снизу на карточке, только TCP. Она запускает
`ConnectionGame` (3 попадания). Успех создаёт `TcpConnection` и занимает
**out-слот**; в слоте видны имя клиента и случайный IPv4, данных запроса
там нет.
- После успеха мини-игра отправки **не открывается**: её запускает только
`ОТПРАВИТЬ`, когда прикреплены все обязательные файлы.
- `ОТКЛОНИТЬ` отменяет запрос и закрывает его соединение; `reject`/`timeout`/
конец часа освобождают слоты.
- Слоты общие для приёма и отправки: `1 + slot_level`, максимум 5.
TCP-соединение может занять единственный слот и заблокировать приём — это
осознанное требование.
**GDD:** §3, §7, §11, §11.1, §17.
**Уточняет:** D45; заменяет часть D33 про отсутствие соединения.
## D54 — Idle-слот после успешной отправки
**Решение:** успешная TCP-отправка удаляет запрос и расходует файлы, но
**не** закрывает соединение: `TcpConnection.request_id` очищается, слот
остаётся занятым (idle) до ручного `ОТКЛЮЧИТЬ` на слоте. Отключение
мгновенное, без проверок и мини-игр.
- Отключение во время подготовки/передачи прерывает передачу: выплаты нет,
файлы не расходуются и возвращаются в папки, запрос остаётся активным;
соединение нужно устанавливать заново.
- Повторное использование idle-соединения новым запросом не предусмотрено:
каждый TCP-запрос проходит свою мини-игру соединения и занимает свой слот.
- `reject`/`timeout`/конец часа закрывают соединение и освобождают слот.
**GDD:** §3, §11, §17.
**Уточняет:** D45/D53.
## D55 — UDP-риск и лот «UDP-повторитель»
**Решение:** у UDP нет соединения; успех определяется роллом **в конце
передачи**. Шанс:
```
P = min(0.40 + 0.10 × udp_level, 0.80)
```
- Новый лот `lot_udp` «UDP-повторитель»: 4 уровня, цены **60 / 120 / 210 /
350 $**, шанс **40 → 50 → 60 → 70 → 80 %**. Всего лотов — **9**, дерево —
**8 825 $**.
- **Успех:** обычная выплата UDP (`×0.85`), файлы расходуются, запрос
удаляется.
- **Провал:** выплаты и денежного штрафа нет, **−3 SLA**, файлы не
расходуются, запрос остаётся, `udp_failures` +1, повтор через **4 с**,
out-слот освобождается, streak сбрасывается. Повтор — снова мини-игра
отправки и передача.
- Карточка показывает шанс (`UDP · ШАНС N %`) и после провала —
`ПОТЕРЯ ПАКЕТА · ПОВТОР N с`.
**GDD:** §11, §11.2, §14.
**Заменяет:** часть D50 (8 лотов / 8 085 $) и числа D33 про UDP.
## D56 — Kechusilka, DisplayOTF и Press Start 2P
**Решение:** роли шрифтов разделены:
- **Тело текста** — `Kechusilka-Regular.otf`, нормализованный под метрики
Golos (x-height **0.530 em**), шкала **11/13/17/26**.
- **Fallback** — `GolosText-VF.ttf` для глифов, которых нет в Kechusilka:
`№ – … · × ← → ↑ ↓ ≤ ≥ § ° € • © ± ≈ −`.
- **Крупный текст/заголовки** — `DisplayOTF.otf` (freeware, Denis A Serikov):
wordmark **46 px**, «ПОБЕДА/ПОРАЖЕНИЕ» **34 px**, титлбар **14 px**,
«РЕЗУЛЬТАТ» **18 px**.
- **Цифры/счётчики/брендовый акцент** — `PressStart2P-Regular.ttf`;
моноширинные цифры не дают счётчикам «дрожать».
- Лицензии и файлы — в `ASSETS.md`; Kechusilka — license confirmed by project
owner, DisplayOTF — freeware/personal.
**GDD:** §1–2, §20, §21.
**Заменяет:** D51 в части «Golos Text — тело текста».
## D57 — длительность часов 120–360 с
**Решение:** длительность часа берётся из `hours[].duration` и растёт на 30 с:
**120 / 150 / 180 / 210 / 240 / 270 / 300 / 330 / 360 с** для H1–H9
(`120 + 30×(h−1)`). Забег — **36 минут** плюс подготовка между часами.
`constants.hour_seconds = 300` остаётся только legacy-fallback, если в строке
часа нет `duration`; `HourDirector.hour_duration()` кэширует значение на час.
- Окно спавна файлов — **75 %** длительности (90–270 с), а не фиксированные
225 с.
- H1 длится 120 с, поэтому его `income_target` снижен до **100 $**; остальные
цели прежние.
- Визуальные таймеры и подсказки читают фактическую длительность, а не
константу 300.
**GDD:** §3, §6, §8, §13.
**Заменяет:** формулировки «9 часов по 300 с» / «≈45 минут».
## D58 — файлы-каталог и темп прихода
**Решение:** файлы **не расходуются**. После успешной отправки
`registry.detach_file()` только снимает привязку к запросу; файл остаётся в
папке, занимает свои клетки и сразу доступен пулу следующих запросов
(`file_cooldown_after_send_sec = 0`). То же — при отмене, таймауте, отключении
и UDP-провале.
- Общего лимита числа файлов на забег нет. Приход задаёт **скорость**:
`spawn_window = 0.75 × duration`; интервал — `spawn_window / hours[].files`
с джиттером ±20 %, поэтому последний файл успевает до конца окна.
- `hours[].files` (**6, 6, 7, 8, 9, 9, 10, 10, 10**) — скорость/ожидаемое число
файлов за окно часа, а не жёсткий cap забега и не остаток бюджета. Переноса
между часами нет (carry = 0); служебного предела на число файлов нет.
- Папки докупаются в подготовке (180 $, до 6); к финалу в симуляторе обычно
**5–6 папок**.
**GDD:** §6, §11, §11.2, §26.
**Заменяет:** D02/D33/D54/D55 в части «файлы расходуются при успешной
отправке».
## D59 — запросы без `request_cap`
**Решение:** отдельного `request_cap` нет. Темп запросов задаёт
`hours[].send_interval` (**20, 24, 18, 24, 24, 24, 24, 24, 24** с), а
ограничитель — только число **одновременно активных** запросов
`hours[].active_requests` (**2/2/2/3/3/3/4/4/4**). Новые запросы не спавнятся
в последние **10 с** часа (`REQUEST_SPAWN_CUTOFF_SEC`).
- Анти-голодание работает только для **первого** запроса часа
(`requests_spawned == 0`); H1 bootstrap создаёт первый запрос сразу.
Дальше директор не обгоняет `send_interval`.
- Запрос выбирает 1–4 файла из пула папок по `request_mix`; набор не связан
с тем, какие файлы пришли в этом часе, — подходят и старые файлы из папок.
**GDD:** §7, §13.
**Заменяет:** числа `request_cap` из D05/D46 и формулировку «анти-голодание
не даёт панели простаивать» (теперь только первый запрос).
## D60 — окно спавна атак от длительности часа
**Решение:** атаки планируются внутри часа по формуле:
```
start = clamp(0.25 × duration, 20, 60)
end = min(0.85 × duration, duration − 20)
```
Для H2 (`duration = 150`) это **37,5–127,5 с**, для H9 (`duration = 360`) —
**60–306 с**. Расписание остаётся `[0,1,1,2,2,3,3,4,4]` (20 атак за забег),
ступени — `clamp(1 + floor((hour−2)/2), 1, 4)`.
**GDD:** §3, §8, §12.1.
**Заменяет:** фиксированное окно «60–240 с» из D42 и прежних таблиц.
## D61 — визуал «ЖАРА» и `reduce_motion`
**Решение:** состояние узла показывается двумя слоями.
- **`TemperatureGauge`** (216×18) в меню-баре вместо часов **03:42**:
колба, деления на **0.34 / 0.58 / 0.80**, маркер, температура **36–95 °C**,
tooltip с разбором SLA/запросов/атак/канала/буфера/очереди. В покое и в
подготовке — пустая колба и 36 °C.
- **`StressOverlay`** — полноэкранный `ColorRect` с
`shaders/stress.gdshader`; шейдер **alpha-only**, без
`hint_screen_texture`, поэтому не тонирует модалки и тосты. Heat-модель —
чистая функция от снимка часа плюс импульсы событий, сглаживание через
`UiMotion.k()`.
- **Пульс SLA/CRIT** в `StatusBar` при SLA < 40 % или уровне 3; тряска панелей
— целые пиксели, **≤2 px**, ~140 мс, только `position`.
- **Lane B:** сглаживание drag, наклон по скорости, pop/rotate-pop, stagger
карточек, плавные meter-полосы, вход/выход тостов — всё через `UiMotion`;
layout и drop-логика не меняются.
- Настройка **«Снизить эффекты»** (`reduce_motion`) выставляет
`UiMotion.reduced` и отключает тряску, наклон/инерцию, пульсы и вспышки;
шейдер оставляет статичный тон и виньетку. В **headless** материал не
создаётся, оверлей скрыт, движение выключено.
**GDD:** §20.1, §21.
**Заменяет:** часы 03:42 и скрытую дату в меню-баре.
+751 -199
View File
File diff suppressed because it is too large. Load diff
+150 -11
View File
@@ -7,6 +7,63 @@
> Язык: GDScript. UI строится кодом; `.tscn` — только корень сцены.
> Комментарии/UI — на русском, идентификаторы — на английском.
## 0. Дельты v3.0 (читать первым)
Этот файл — исторический контракт v1. Актуальная спецификация — `docs/GDD.md`
v3.0 и `docs/DECISIONS.md` D45–D61. Ниже — что в контракте уже неверно:
- **Длительность и темп:** час длится `hours[].duration` — **120 / 150 / 180 /
210 / 240 / 270 / 300 / 330 / 360 с** (`120 + 30×(h−1)`), забег **36 минут**
плюс подготовка; `constants.hour_seconds = 300` — только legacy-fallback.
Спавн файлов идёт в окно **75 %** часа, интервал — `spawn_window / files`
(скорость `hours[].files`), без carry и общего лимита. Атаки — `clamp(0.25×duration, 20, 60)` …
`min(0.85×duration, duration−20)`. См. D57/D60, GDD §3/§6/§8.
- **Файлы — каталог, не расходник:** успешная отправка снимает только
`required_by`/attachment; файл остаётся в папке и доступен новым запросам.
Общего лимита на забег нет; `hours[].files` (6,6,7,8,9,9,10,10,10) задаёт
темп. См. D58, GDD §6/§11.
- **Запросы без `request_cap`:** только `hours[].active_requests`, cutoff
последних **10 с** и `send_interval`; анти-голодание — только для первого
запроса часа. См. D59, GDD §7/§13.
- **Запросы:** `required` снова хранит **конкретные `file_id`**, но их 1–4, а
не 1–3; запрос резервирует файлы (`required_by`), UI показывает имена
сеткой 2×2 и «ПОТЕРЯН». См. GDD §7, D46.
- **Соединение:** `ConnectionGame` **вернулось** в v3. Формула зоны и скорости
из раздела «ConnectionGame» снова актуальны (D45), но панель вызывается
только для TCP и замораживает таймер своего запроса; промах −4 SLA / 8 с,
Esc — 0 SLA / 4 с. TCP-соединение — отдельная сущность `TcpConnection`
(D53), а не только флаг запроса. До соединения файлы не прикрепляются;
успех занимает out-слот и **не** открывает мини-игру отправки. После
успешной отправки слот остаётся idle до `ОТКЛЮЧИТЬ` (D54).
- **Мини-игры:** не 11, а **10** (6 отправки + 4 атаки); игры с проводами нет.
У отправки **3 ступени**, у атак 4; выбор — `Economy.selection_for_sum()` по
27 суммам и таблице `rank_games`, без `rank % 7`. См. GDD §8.4, §10.
- **Магазин:** не 9 позиций v1, а **9 лотов** v3 (8 прежних + `lot_udp`),
дерево **8 825 $** (было 8 085 $); SLA-кап **220**; цены/эффекты — GDD §14,
D50/D55. Поля «9 позиций D13», «SLA +24», «секция/поиск/зона» устарели.
- **UDP:** соединения нет; шанс успеха — `min(0.40 + 0.10×udp_level, 0.80)`
(`lot_udp`, 4 уровня), ролл в конце передачи. Провал: −3 SLA, без выплаты,
файлы остаются, повтор через 4 с, слот освобождается, streak сбрасывается.
Успех тоже не удаляет файлы — каталог сохраняется (D58). См. D55, GDD §11.2.
- **Экономика/буфер:** SLA-кап +40 до 220 (прежняя схема v2 — история),
таймер запроса +7 с до 65 (не +5 до 55), буфер 6+уровень (не 4+2×),
папка 180 $ (не 100), сетки 5×4 → 9×6. Ускорение передачи `×0.90` за
уровень (прежний множитель v2 — история).
- **Шрифты:** Kechusilka Regular — тело текста (fallback Golos Text),
DisplayOTF — крупный текст, Press Start 2P — только цифры/счётчики; шкала
11/13/17/26; иконки — `art/icons/*.png`. Handjet — legacy. См. D56.
- **UI «ЖАРА»:** `TemperatureGauge` в меню-баре вместо часов 03:42,
`StressOverlay` с alpha-only шейдером без `hint_screen_texture`, пульс
SLA/CRIT, тряска панелей ≤2 px, `UiMotion`/`reduce_motion`; в headless
слой скрыт. См. D61, GDD §20.1.
- **Туториал:** 5 событийных шагов с рамками и «ДАЛЕЕ», а не лента подсказок;
прогресс в `Game.stats["tutorial_done"]`.
Исторические разделы ниже (9 позиций v1, 4+2 буфера, анонимные слоты,
`rank % 7`, панель соединения как «удалённая») оставлены для провенанса и не
являются текущим контрактом.
## 1. Автолоады
| Автолоад | Файл | Ответственность |
@@ -70,14 +127,15 @@ fields/настройки: music_volume: float, sfx_volume: float
| `Folder` | `scripts/entities/folder.gd` | список файлов, capacity 8, отображалка |
| `Section` | `scripts/entities/section.gd` | страницы папок, докупаемые позиции |
| `Packet` | `scripts/entities/packet.gd` | входящий пакет + свой таймер (тики слишком идут из директора) |
| `Request` | `scripts/entities/request.gd` | клиентский запрос: `required` (id), флаги `slot_reserved/connected/minigame_active`, таймер |
| `SlotManager` | `scripts/core/slot_manager.gd` | слоты соединений (start→max по D13), резерв/освобождение |
| `HourDirector` | `scripts/core/hour_director.gd` | ЕДИНЫЙ тик `tick(delta)` всех таймеров часа, спавны, канонический Clock |
| `Request` | `scripts/entities/request.gd` | клиентский запрос: `required` (id), `connection_id`, флаги `slot_reserved/connected/minigame_active`, UDP-поля, таймер |
| `TcpConnection` | `scripts/entities/tcp_connection.gd` | установленное TCP-соединение: клиент, IP, `request_id`, `slot_index`; живёт отдельно от запроса |
| `SlotManager` | `scripts/core/slot_manager.gd` | слоты соединений `1 + slot_level` (макс. 5), `kind`/`detail`/`linked_request_id`, резерв/освобождение |
| `HourDirector` | `scripts/core/hour_director.gd` | ЕДИНЫЙ тик `tick(delta)` всех таймеров часа, спавны, `active_connections`, канонический Clock |
| `AttackDirector` | `scripts/systems/attack_director.gd` | выбор атаки, её эффект, отражение щитом |
| `Shop` | `scripts/systems/shop.gd` | 9 позиций D13, уровни, цены, применение эффектов |
| `Shop` | `scripts/systems/shop.gd` | 9 лотов v3, уровни, цены, применение эффектов |
| `SaveManager` | `scripts/systems/save_system.gd` | save/load между часами, JSON, `commit()` |
| `MiniGame` | `scripts/minigames/minigame_base.gd` | базовая модалка мини-игры, `start(type, difficulty)` |
| `ConnectionGame` | `scripts/minigames/connection_game.gd` | полоска соединения по формуле D04 |
| `ConnectionGame` | `scripts/minigames/connection_game.gd` | полоска соединения по формуле D04; `finished(success, request_id)` |
### FileRegistry
```gdscript
@@ -117,26 +175,107 @@ invariants:
### ConnectionGame
```gdscript
signals:
connected(success: bool)
finished(success: bool, request_id: String)
methods:
func open(request_id: String, hour: int, successes: int, zone_level: int) -> void
func start(hour: int, successes: int, zone_level: int, request_id: String) -> void
func open(request_id: String, hour: int, successes: int, zone_level: int) -> void # legacy alias
func width() -> float # W-Zone по D04
static func compute_zone(hour: int, successes: int, zone_level: int) -> float
# W = clamp(0.30 − 0.005*(hour-1) − 0.005*successes + 0.05*zone_upgrade_level, 0.18, 0.36)
# скорости: 440/340/280 px/с; окно ~130–200 мс
# скорости: 440/340/280 px/с; 3 попадания; Esc / промах → finished(false, id)
invariants:
- zone — только эта формула (D04/D05);
- промах/уход за край: −4 SLA и кулдаун 8 с; Esc: 0 SLA и кулдаун 4 с;
- открытая панель останавливает спавн приёма и таймеры пакетов (D03);
- Esc — отмена: слот свободен, запрос остаётся (D10).
- успех не открывает мини-игру отправки: UI вызывает
`HourDirector.connection_finished(id, true)`.
```
### Шоп-Shop (D13)
### TcpConnection (D53)
```gdscript
fields:
id: String
client_name: String
client_id: String
ip: String # случайный IPv4 для UI слота
request_id: String # "" — соединение idle после отправки
slot_index: int # out-слот, который держит соединение
hour: int
connected_at: float
methods:
func has_request() -> bool
func display_name() -> String
func to_dict() -> Dictionary
static func from_dict(d: Dictionary) -> TcpConnection
invariants:
- соединение живёт в `HourDirector.active_connections`, а не в Request;
- создаётся только при успехе ConnectionGame и только если есть свободный слот;
- занимает ровно один out-слот (`kind == "tcp_connection"`), слот общий с приёмом;
- после успешной отправки `request_id` очищается, слот остаётся занятым;
- `disconnect_connection(conn_id)` освобождает слот мгновенно, без мини-игр;
- reject/timeout/конец часа вызывают `_release_connection()`.
```
### Slot (kind/detail) и SlotManager
```gdscript
fields Slot:
index: int
direction: String # "in" | "out"
entity_id: String # packet_id / request_id / conn_id
entity_name: String # имя клиента для tcp_connection
progress: float
occupied: bool
kind: String # "packet" | "request" | "tcp_connection"
detail: String # tcp_connection → IP
can_disconnect: bool
linked_request_id: String # запрос, который пользуется соединением
methods SlotManager:
func reserve(direction, entity_id, entity_name = "", kind = "", detail = "",
can_disconnect = false, linked_request_id = "") -> int
func release(index: int) -> bool
func release_by_entity(entity_id: String) -> int
func active_weights(registry) -> Array
invariants:
- всего `1 + slot_level`, максимум 5; слоты общие для приёма и отправки;
- `tcp_connection` не хранит имена файлов/required — только клиента и IP;
- вес канала для `tcp_connection` считается по файлам `linked_request_id`;
- одна сущность не занимает два слота.
```
### HourDirector: соединения и UDP
```gdscript
fields:
active_connections: Dictionary # conn_id → TcpConnection
methods:
func connect_request(req_id: String) -> bool # старт ConnectionGame
func connection_finished(req_id: String, success: bool, missed: bool = false) -> bool
func disconnect_connection(conn_id: String) -> bool # мгновенно, без проверок
func disconnect_request(req_id: String) -> bool
func get_connection(conn_id: String) -> TcpConnection
func connection_id_for_request(req_id: String) -> String
func udp_success_chance() -> float
invariants:
- `attach_file` для TCP отклоняется, пока `req.connection_id == ""`;
- успех соединения занимает out-слот; `start_send` не создаёт соединение;
- успешная отправка отвязывает соединение (`conn.request_id = ""`), но не
освобождает слот;
- отключение во время передачи обнуляет прогресс, возвращает файлы,
запрос оставляет активным;
- UDP-провал: `−3 SLA`, файлы остаются, `udp_failures += 1`,
`udp_retry_until = hour_elapsed + 4`, слот освобождается, streak = 0.
```
`Economy.udp_success_chance(upgrades, base, step, cap)` возвращает
`clamp(0.40 + 0.10×level, 0.40, 0.80)`; уровень берётся из `lot_udp`.
### Шоп-Shop (D13/D50/D55)
```gdscript
methods:
func can_buy(item_id: String) -> bool
func buy(item_id: String) -> bool
func level(item_id: String) -> int
invariants:
- 9 позиций с ценами/уровнями как в `balance.json → shop`;
- **9 лотов** с ценами/уровнями как в `balance.json → shop`, включая `lot_udp`;
- полное дерево — **8 825 $**;
- сканер/автосортировка не реализуются (D13, позиций «резерв» в данных НЕТ).
```
+92 -49
View File
@@ -2,7 +2,8 @@
**Платформа:** Web (HTML5, Godot 4.7.2)
**Жанр:** менеджмент / тайм-менеджмент, retro UI
**Длительность:** 9 игровых часов по 5 минут + подготовка между часами
**Длительность:** 9 игровых часов — от 2 до 6 минут (120 → 360 с), всего
около 36 минут + подготовка между часами
**Язык:** русский
**Управление:** мышь и клавиатура
**Окно:** 1920×1080, логический viewport 1280×720
@@ -10,38 +11,70 @@
## Короткое описание
1997 год. Ночная смена в узле файлового обмена. Принимайте пакеты,
отправляйте файлы клиентам, держите SLA — и не дайте атакам положить канал.
раскладывайте файлы по папкам, отправляйте контейнеры клиентам, держите
SLA — и не дайте атакам положить канал.
## Описание
Вы — дежурный оператор узла 7. В 03:42 на столе мигает приём: пакеты
приходят быстрее, чем вы успеваете разложить их по папкам. Клиенты присылают
запросы на конкретные файлы — имя и папку видно в карточке, так что искать
приходится по всему диску. Перетащите пакет в папку, найдите нужный файл,
прикрепите его к запросу, установите соединение и отправьте. В окне
соединения три бегунка — три попадания по очереди, и канал ваш. Промах
закрывает панель: придётся нажать «Соединиться» снова.
приходят быстрее, чем вы успеваете разложить их по папкам. Каждый файл —
фигура из клеток: текст занимает одну, видео — четыре. Фигуру можно
повернуть и плотно уложить в сетку папки; на столе сортировки всегда есть
место, чтобы разобраться с завалом.
Смена длится девять игровых часов по пять минут. На старте **SLA 128/200**,
а каждый потерянный пакет бьёт по типу файла: текст 2, звук 4, фото 8,
видео 16. Первый час проходит без атак; дальше в узел лезут мусор, DDoS,
фишинг и шифровальщик — их нужно вовремя заметить и уничтожить. Магазин
открывается после первого часа: **10 улучшений** от слотов и буфера до зоны,
канала, щита и запаса SLA. Поиск и щит открываются мета-прогрессом после
3-го и 5-го часа, продвинутые мини-игры — после 9-го (контент пока
в разработке). Дожить до конца смены — победа; уронить SLA — поражение.
Клиенты присылают запросы на 1–4 конкретных файла — их имена видны на
карточке, и прикрепить нужно именно этот файл. Соберите контейнер, выберите
протокол — TCP платит больше, UDP быстрее — и нажмите «ПОДКЛЮЧИТЬСЯ» (TCP) или
«ОТПРАВИТЬ». TCP сначала требует провести три палочки через зелёную зону и
занимает слот соединения; до этого файлы в запрос не прикрепляются. После
соединения отправку запускает отдельная кнопка, а после успешной отправки
слот остаётся занятым, пока вы не нажмёте «ОТКЛЮЧИТЬ». UDP соединяется сразу,
но в конце передачи может потерять пакет: тогда −3 SLA, файлы остаются, а
повтор — через 4 секунды. Отправка не абстрактная: перед ней открывается
мини-игра. Всего их десять: шесть для отправки и четыре для атак, у отправки
три ступени, у атак четыре. В них нет таймеров — только конечная цель и
качество выполнения, от которого зависит выплата. Результат показывает
проценты, вердикт и детали, а награда применяется после «ПРОДОЛЖИТЬ».
Смена длится девять игровых часов: первый — две минуты, каждый следующий на
тридцать секунд длиннее, финальный — шесть минут; всего около **36 минут**
плюс подготовка. Каждый час приносит больше файлов и запросов, а атаки
приходят в своё окно внутри часа. Файлы после отправки не исчезают: они
остаются в папках и могут понадобиться следующим клиентам. На старте
**SLA 60/60**, кап растёт до 220; в начале часа восстанавливается 15 SLA.
Потерянный пакет бьёт по типу файла: текст 2, звук 4, фото 8, видео 16.
Первый час проходит без атак; дальше в узел лезут мусор, DDoS, фишинг и
шифровальщик. Каждая атака — это отдельная мини-игра, которая ставит час на
паузу: отбейте её, и получите деньги и SLA; провалите — получите штраф и
временный дебафф. «Заземление» гасит атаку автоматически, но приносит 70 %
награды.
Магазин открывается после первого часа: **9 улучшений** — порты, оптика,
ИБП, заземление, контракты, стеллажи, канал, регламент и UDP-повторитель.
Каждую подготовку предлагаются три лота; ассортимент можно обновить за
40/80/120 $ или закрепить нужное предложение. Полное дерево стоит **8 825 $** —
выкупить всё за один забег не получится, придётся выбирать.
Дожить до конца смены — победа; уронить SLA — поражение. Сохранение
работает между часами, включая офферы магазина и стол сортировки.
Интерфейс вдохновлён Kaleidos: тёплая бумага, тонкие 1 px контуры, мягкие
тени и бирюзовый акцент. В первом часе рядом подсказывает маскот **БАЙТ**.
В главном меню — лёгкий процедурный VHS-эффект (скан-линии, зерно, виньетка),
который отключается в настройках; в игре UI остаётся чистым. Фон — ambient
машинного зала, включается после первого жеста и регулируется вместе с музыкой.
UI рисуется кодом, без внешних текстур; Handjet держит плотный русский
текст, Press Start 2P — логотип и цифры. Звуки синтезированы скриптом
проекта. Собрано на Godot 4.7.2 для Web без threads:
игра запускается в браузере без COOP/COEP. Это играбельный джем-прототип
полного цикла: меню, подготовка, девять часов, итоги, магазин, пауза,
настройки, атаки, сохранение и мета-открытия.
тени и бирюзовый акцент. В первом часу БАЙТ ведёт пошаговый туториал:
пакет → прикрепление → TCP-соединение → мини-игра → SLA и атаки; шаг можно
пропустить кнопкой «ДАЛЕЕ», а повторить обучение — кнопкой «ОБУЧЕНИЕ» в
меню. В главном меню — лёгкий VHS-эффект, который отключается в настройках;
в игре UI остаётся чистым. Фон — ambient машинного зала, включается после
первого жеста и регулируется вместе с музыкой.
Тело интерфейса набрано Kechusilka Regular (fallback — Golos Text), крупный
текст — DisplayOTF, Press Start 2P — цифры и счётчики; иконки файлов —
пиксельные, из `art/icons/`. В меню-баре вместо часов — термометр «ЖАРА»:
он показывает, насколько раскалён узел, а по краям экрана копится тёплый
градиент. Эффекты движения отключаются галочкой «Снизить эффекты». Звуки
синтезированы скриптом проекта. Собрано на
Godot 4.7.2 для Web без threads: игра запускается в браузере без COOP/COEP.
Это играбельный джем-прототип полного цикла: меню, подготовка, девять часов,
итоги, магазин, пауза, настройки, атаки, сохранение и мета-открытия.
## Управление
@@ -49,13 +82,21 @@ UI рисуется кодом, без внешних текстур; Handjet д
| --- | --- |
| Выбор, меню, перетаскивание | ЛКМ |
| Контекстное меню | ПКМ |
| Поворот фигуры во время drag | R или ПКМ |
| Пакет из «Приёма» → папка | drag&drop |
| Файл из папки → карточка запроса | drag&drop |
| Поиск по имени в текущей папке | после открытия «Поиска» |
| Мини-игра соединения: три попадания | ЛКМ или `Space` |
| Мини-игра подготовки: отправить | `Enter` |
| Пауза | `Esc` |
| Панель соединения: отмена | `Esc` |
| Файл из папки → карточка запроса | drag&drop (TCP — только после соединения) |
| Файл ↔ стол сортировки | drag&drop |
| TCP-соединение | кнопка «ПОДКЛЮЧИТЬСЯ», 3 попадания в зелёную зону |
| Отправка контейнера | кнопка «ОТПРАВИТЬ» |
| Отклонить запрос | кнопка «ОТКЛОНИТЬ» |
| Закрыть TCP-соединение | «ОТКЛЮЧИТЬ» на слоте |
| Открепить файл | двойной клик по чипу |
| TCP / UDP | переключатель на карточке запроса до соединения |
| Мини-игра: действие | ЛКМ / Space / стрелки |
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
| Пропустить мини-игру | «ПРОПУСТИТЬ»/«СДАТЬСЯ» + второй клик |
| Результат мини-игры | «ПРОДОЛЖИТЬ» или Esc |
| Пауза | Esc |
## Сделано на FEIS Game Jam
@@ -70,10 +111,12 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
## Ассеты и лицензии
Шрифты **Handjet** и **Press Start 2P** — SIL OFL 1.1. UI, иконки, маскот
**БАЙТ** и `icon.svg` нарисованы кодом/командой — MIT. SFX и `hum.wav`
сгенерированы скриптом проекта — MIT/CC0. Код — MIT. Полный список и
инструкции по воспроизведению: [ASSETS.md](https://git.194-146-38-78.sslip.io/admin/Game-JAM/-/blob/main/ASSETS.md).
Шрифты **Golos Text** и **Press Start 2P** — SIL OFL 1.1; **Kechusilka** —
лицензия подтверждена владельцем проекта; **DisplayOTF** — freeware/personal
(Denis A Serikov). UI, пиксельные иконки (`art/icons/`), маскот **БАЙТ** и
icon.svg сделаны командой — MIT. SFX и hum.wav сгенерированы скриптом проекта
— MIT/CC0. Код — MIT. Полный список и инструкции по воспроизведению:
[ASSETS.md](https://git.194-146-38-78.sslip.io/admin/Game-JAM/-/blob/main/ASSETS.md).
## Ссылки
@@ -89,19 +132,19 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
![Подготовка](screenshots/prep.png)
2. **Подготовка.** «Подготовка: склад и папки, SLA 128/200, магазин откроется после первого часа».
2. **Подготовка.** «Подготовка: сетка папок, стол сортировки 4×6, SLA 60/60; магазин откроется после первого часа».
![Час пик](screenshots/hour.png)
3. **Час пик.** «Час пик: 3 пакета в приёме и 2 запроса с таймерами/протоколами/слотами».
3. **Час пик.** «Час пик: пакеты-фигуры в приёме и запросы с таймерами, протоколами и слотами».
![Атаки](screenshots/attacks.png)
4. **Атаки.** «DDoS-баннер в приёме и фишинговый запрос с ???».
4. **Атаки.** «Мини-игра атаки: час на паузе, качество и вердикт после действия».
![Магазин](screenshots/shop.png)
5. **Магазин.** «Магазин после часа: доступные улучшения, SLA +24, без крестика».
5. **Магазин.** «Магазин после часа: три предложения, реролл и замок, без крестика».
![Настройки](screenshots/settings.png)
@@ -109,20 +152,20 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
## Теги
`godot` `game jam` `management` `simulation` `retro` `90s` `pixel art`
`singleplayer` `short` `web`
godot game jam management simulation retro 90s pixel art singleplayer short web
---
## Перед публикацией (служебное, в текст страницы не вставлять)
- [ ] Пересобрать `build/web` из текущего кода и загрузить в itch.io:
**HTML5**, 1920×1080, без fullscreen-lock.
- [ ] Загрузить 6 кадров из `docs/screenshots/`: 3 обязательных — `main_menu.png`,
`hour.png`, `attacks.png`; дополнительно — `prep.png`, `shop.png`,
`settings.png`; `cover.png` (630×500) — обложка страницы.
- [ ] Сверить ссылку на репозиторий и ветку (`main`); при другой ветке
заменить `main` на фактическую.
- [ ] Пересобрать build/web из текущего кода и загрузить в itch.io:
HTML5, 1920×1080, без fullscreen-lock.
- [ ] Пересобрать скриншоты под v3 (запросы с именами файлов, TCP-полоска,
иконки, сетка 9×6, 3 оффера) и загрузить 6 кадров из docs/screenshots/:
обязательные — main_menu.png, hour.png, attacks.png; дополнительно —
prep.png, shop.png, settings.png; cover.png (630×500) — обложка страницы.
- [ ] Сверить ссылку на репозиторий и ветку (main); при другой ветке
заменить main на фактическую.
- [ ] Прогнать полный забег вручную на itch.io, проверить сохранение после
перезагрузки вкладки.
- [ ] Отправить страницу и сборку в джем.
+43 -1
View File
@@ -1,5 +1,9 @@
# TEST_PLAN — матрица тестов PEAK HOUR
> **Исторический документ v1.** Актуальная матрица и запуск —
> `scripts/run_all_tests.sh` + README §Тесты; числа v3 — `data/balance.json`,
> GDD v3.
> Числовые данные — только `data/balance.json` и `docs/DECISIONS.md`.
> Юнит-Runner — headless-прогон `tests/run_tests.gd`
> (`godot --headless --path . --script tests/run_tests.gd`).
@@ -65,6 +69,44 @@
| --- | --- | --- | --- | --- |
| A01 | Дивиденд и репозиторий | git-история + отчёт об ИИ заполнены | все протоколы/джем критерии проходятся, **git push не сделать** | ревизор feis-regulator |
| A02 | RU-only | grep по `RU` в `strings_ru.json` и интерфейсе | пользовательские шапки — на русском, никаких тайных строк | аудит |
| A03 | Шрифты и лицензии | `ASSETS.md`: Handjet OFL.txt, Press Start 2P OFL | только OFL/MIT-лицензии, OFL-файлы в репо, Pixelify Sans из темы удалён | аудит |
| A03 | Шрифты и лицензии | `ASSETS.md`: Golos/Press Start 2P OFL, Kechusilka owner-confirmed, DisplayOTF freeware | лицензии зафиксированы, файлы/тексты в репо, Handjet/Pixelify Sans вне темы | аудит |
| A04 | Без секретов | grep по `password|token|key|secret` в репо | сбосов нет, все данные публичных форматов | аудит |
| A05 | itch.io | загрузка `build/web/index.html` | игра стартует в 1280×720, RU, никакого зависания | гей-смоук |
## 7. Дельты v3.1: TCP, UDP, темп и UI
Короткая матрица под текущую реализацию; числа — из `data/balance.json` и
`scripts/core/hour_director.gd`, а не из исторических таблиц выше. Полный
набор + parse-check запускается через `scripts/run_all_tests.sh` /
`.ps1`; список в runner — источник истины, счёт наборов здесь не
фиксируется. Core-наборы (`test_hour`, `test_economy`, `test_save`,
`test_slots`, `test_controller`, `test_integration`, `test_fuzz`,
`test_full_playthrough`, `test_shop`, `test_shop_modal`) обновлены и зелёные;
UI-наборы (`test_main_*`, `ui_smoke`) прогоняются отдельно.
| № | Что проверяем | Как | Ожидаемо |
| --- | --- | --- | --- |
| T01 | Файлы до TCP-соединения | drag файла в TCP-карточку; прямой `attach_file` | drag отклонён; backend: «СНАЧАЛА УСТАНОВИТЕ СОЕДИНЕНИЕ», `attached` пуст |
| T02 | Успех соединения | 3 попадания в `ConnectionGame` | создан `TcpConnection`, занят out-слот, в слоте имя + IP, мини-игра отправки не открылась |
| T03 | Отправка после соединения | прикрепить все файлы, нажать `ОТПРАВИТЬ` | открылась мини-игра отправки; при нехватке файлов — короткий тост, отправки нет |
| T04 | Успешная TCP-отправка | пройти мини-игру и передачу | запрос исчез, файлы вернулись в папку и доступны повторно, выплата TCP, слот `tcp_connection` остался занятым idle |
| T05 | Ручное отключение | нажать `ОТКЛЮЧИТЬ` на idle-слоте | слот свободен сразу, без мини-игры и проверок |
| T06 | Отключение во время передачи | `disconnect_connection` в `sending` | выплаты нет, файлы не списаны и вернулись в папки, запрос активен, соединения нет |
| T07 | Reject/timeout/конец часа | отклонить запрос с соединением; дождаться таймаута/конца часа | соединение закрыто, слот свободен |
| T08 | Общий слот | 1 слот, TCP-соединение, затем приём пакета | приём заблокирован: «ВСЕ КАНАЛЫ ЗАНЯТЫ», это ожидаемо |
| U01 | Шанс UDP | уровни `lot_udp` 0–4 | 40 / 50 / 60 / 70 / 80 %, выше 80 % не растёт |
| U02 | UDP-успех | `udp_roll_override = 0` | обычная выплата UDP (×0.85), файлы остаются в папке, запрос удалён |
| U03 | UDP-провал | `udp_roll_override = 1` | −3 SLA, без денег, файлы на месте, запрос активен, `udp_failures +1`, слот свободен, streak 0 |
| U04 | Повтор UDP | после провала подождать 4 с и нажать `ОТПРАВИТЬ` | снова мини-игра отправки + передача; до 4 с — «UDP: ПОВТОР ЧЕРЕЗ N с» |
| U05 | Индикация UDP | карточка UDP до и после провала | `UDP · ШАНС N %`, затем `ПОТЕРЯ ПАКЕТА · ПОВТОР N с` |
| G01 | Магазин | каталог, покупка `lot_udp` до 4 уровня | 9 лотов, дерево 8 825 $, 5-й уровень не продаётся |
| UI01 | Кнопки карточки | TCP/UDP-карточка | `ОТПРАВИТЬ`/`ОТКЛОНИТЬ` слева снизу, `ПОДКЛЮЧИТЬСЯ` справа снизу только у TCP |
| UI02 | Открепление | двойной клик по прикреплённому чипу | файл откреплён, отдельной кнопки `ОТКРЕПИТЬ` нет |
| UI03 | Слот соединения | TCP-слот idle/передача | имя клиента, IP, `ОТКЛЮЧИТЬ`; при передаче — прогресс, без данных запроса |
| UI04 | Шрифты/кегли | тема, логотип, счётчики | Kechusilka 11/13/17/26, DisplayOTF 46/34/14/18, цифры Press Start 2P |
| UI05 | «ЖАРА» | меню-бар, SLA ниже 40 %, атака | `TemperatureGauge` вместо часов 03:42, пульс SLA/CRIT, тряска панелей ≤2 px; `reduce_motion` и headless отключают движение |
| P01 | Длительность часов | H1/H2/H9, `hour_duration()`, таймер | 120/150/360 с; забег 36 мин; окно спавна 75 % |
| P02 | Окно атак | H2 и H9 | H2 37,5–127,5 с; H9 60–306 с; атаки не выходят за `duration−20` |
| P03 | Каталог файлов | успешная отправка, затем новый запрос | файл остался в папке, `cooldown_after_send = 0`, может быть затребован повторно |
| P04 | Приход по темпу | прогон часа с полным буфером | спавн не сгорает, без переноса (carry = 0); интервал = `spawn_window / files` ±20 % |
| P05 | Запросы без `request_cap` | H1/H5/H9, конец часа | лимит только `active_requests`; после `duration−10` новых нет; анти-голодание только для первого запроса |
+137 -49
View File
@@ -1,15 +1,23 @@
# UI_REFRESH — UI v2 «Kaleidos»
# UI_REFRESH — UI v3 «Kaleidos»
Статус: **реализовано** в v1.2. Это короткая карта текущего UI, а не план
Статус: **реализовано** в v3. Это короткая карта текущего UI, а не план
интеграции. Стиль: тёплая бумага, тонкие 1 px контуры, мягкие тени,
скругления 3–4 px, бирюзовый акцент, меньше визуального шума.
## Окно и масштаб
- Окно **1600×900**, логический viewport **1280×720**.
- Окно **1920×1080**, логический viewport **1280×720**.
- `stretch/mode = canvas_items`, `aspect = keep`, `scale_mode = fractional`.
- Шрифты: Handjet (текст), Press Start 2P (логотип/цифры).
- Всё рисуется кодом; внешних текстур UI нет.
- Шрифты: **Kechusilka Regular** — тело текста (нормализован под Golos,
x-height 0.530 em); **DisplayOTF** — крупный текст и wordmark; **Press Start
2P** — цифры/счётчики/брендовый акцент; **Golos Text** — fallback для
отсутствующих глифов. Handjet и Pixelify Sans в теме не используются
(D51, D56).
- Типографическая шкала: **11 / 13 / 17 / 26 px**. Крупнее: wordmark 46,
«ПОБЕДА/ПОРАЖЕНИЕ» 34, титлбар 14, «РЕЗУЛЬТАТ» 18. Подписи файлов —
12–16 px по размеру клетки.
- Почти всё рисуется кодом; внешняя графика — только пиксельные иконки
`art/icons/*.png` (32×32, `nearest`).
## Палитра
@@ -22,26 +30,30 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
Контур — 1 px `Theme90.BORDER`; тень только у карточек/панелей/модалок.
Радиус: 3 px — кнопки/строки, 4 px — карточки/панели, 6–8 px — модалки.
Кегли: 10 / 12 / 16 / 24.
Кегли: 11 / 13 / 17 / 26.
## Компоненты
| Файл | Поведение v2 |
| Файл | Поведение v3 |
| --- | --- |
| `theme90.gd` | палитра, `StyleBoxFlat`-хелперы, бумажный фон, `draw_meter`, иконки кодом |
| `theme90.gd` | палитра, `StyleBoxFlat`-хелперы, бумажный фон, `draw_meter`, пиксельные иконки из `art/icons` |
| `button90.gd` | высота 24, скругление 3, teal-hover, pressed inset, `active` — pale-teal |
| `panel90.gd` | светлые панели; `GROUP` — тонкая полоса заголовка |
| `window_frame.gd` | светлый титлбар, тёмный текст, волосяная линия; крестик только при `close_enabled` |
| `status_bar.gd` | одна светлая полоса, 1 px разделители, цветные шкалы |
| `file_card.gd` | белая карточка, иконка 24×24, зелёная attached-галочка, tiny locked/malicious |
| `request_card.gd` | компактные слоты 20 px, галочка attached, мигающее «ожидается» |
| `folder_row.gd`, `tree_panel.gd`, `file_grid.gd` | мягкое teal-выделение, светлые поверхности |
| `status_bar.gd` | одна светлая полоса, 1 px разделители, цветные шкалы; при SLA < 40 % или уровне «ЖАРЫ» 3 — пульс подчёркивания SLA и линия CRIT |
| `temperature_gauge.gd` | «ЖАРА» в меню-баре: колба 216×18, деления 0.34/0.58/0.80, 36–95 °C, tooltip с разбором состояния |
| `stress_overlay.gd` | полноэкранный heat-слой; alpha-only шейдер `stress.gdshader` без чтения экрана, headless-safe |
| `ui_motion.gd` | единые тайминги/кривые UI, `UiMotion.reduced` для «Снизить эффекты» |
| `file_card.gd` | белая карточка, пиксельная иконка, зелёная attached-галочка, tiny locked/malicious |
| `request_card.gd` | **400×128**, сетка обязательных файлов **2×2**, счётчик, прогноз мини-игры, `ОТПРАВИТЬ`/`ОТКЛОНИТЬ` слева снизу, `ПОДКЛЮЧИТЬСЯ` справа снизу (только TCP), `ПОТЕРЯН`, полное имя по hover, открепление двойным кликом по чипу |
| `slot_cell.gd` | слот 236×44; для `tcp_connection` — имя клиента, IP, статус/прогресс и кнопка `ОТКЛЮЧИТЬ`; файлов и required в слоте нет |
| `folder_row.gd`, `tree_panel.gd`, `file_grid.gd` | мягкое teal-выделение, светлые поверхности, иконки в сетке и drag-preview |
| `scroll_bar.gd` | тонкая дорожка, teal только при hover; скрыт без прокрутки |
| `shop_modal.gd` | 10 позиций (включая `SLA +24`), фильтр `Data.available_shop()`, без крестика; «Закрыть»/`Esc` |
| `main_menu.gd` | логотип, слоган, 4 кнопки, 3 шага смены, сводка; VHS-фон только здесь |
| `vhs_overlay.gd` | процедурный CRT/VHS-слой меню (RefCounted): скан-линии, зерно, виньетка, полоса |
| `shop_modal.gd` | 9 лотов v3 (включая `lot_udp`), 3 оффера, рероллы/замки, без крестика; «Закрыть»/`Esc` |
| `main_menu.gd` | логотип, слоган, кнопки (включая **«ОБУЧЕНИЕ»**), 3 шага смены, сводка; VHS-фон только здесь |
| `vhs_overlay.gd` | нативный CRT/VHS-слой меню: скан-линии, зерно, виньетка, полоса |
| `tutorial_mascot.gd` | маскот **БАЙТ** 64–96 px + облачко, не ловит мышь |
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма |
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма, шаги туториала и пульсирующие рамки |
## Ключевые сценарии
@@ -53,54 +65,128 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
- **Приём:** скролл для 5–8 пакетов; после приёма/перемещения текущая папка и
дерево обновляются автоматически.
- **Папки:** имена из `names_ru.json`, уникализируются в секции (`Имя`,
`Имя 2`); пустые папки можно удалять.
- **Attached:** зелёная галочка у файла и слота запроса.
- **Туториал:** БАЙТ и короткие подсказки в первом часе; без пошагового
сценария.
`Имя 2`); пустые папки можно удалять. Сетка растёт до **9×6**.
- **Запросы:** карточка 400×128 показывает имена 1–4 обязательных файлов
сеткой 2×2. Наведение на чип показывает полное имя строкой внутри карточки
и в tooltip; потерянный файл — красный чип с «ПОТЕРЯН». Прогноз
`МИНИ-ИГРА: … · Tn` и `СЛОЖНОСТЬ: N` считается по обязательным файлам ещё
до прикрепления. Кнопки: `ОТПРАВИТЬ` и `ОТКЛОНИТЬ` — слева снизу,
`ПОДКЛЮЧИТЬСЯ` — справа снизу (только TCP). Открепление — двойной клик по
чипу; отдельной `ОТКРЕПИТЬ` нет.
- **TCP:** кнопка `ПОДКЛЮЧИТЬСЯ` открывает полоску соединения 640×240
(3 попадания, 440/340/280 px/с). До соединения drag файла в карточку
отклоняется. После успеха слот показывает имя клиента и случайный IP,
мини-игра отправки сама не открывается; её запускает `ОТПРАВИТЬ`, когда
прикреплены все обязательные файлы. После успешной отправки TCP-слот
остаётся занятым (idle) с кнопкой `ОТКЛЮЧИТЬ`; отключение мгновенно
освобождает слот.
- **UDP:** соединения нет. Карточка показывает `UDP · ШАНС N %`, после
провала — `ПОТЕРЯ ПАКЕТА · ПОВТОР N с`. Повтор — снова `ОТПРАВИТЬ`
(мини-игра + передача) после 4 с.
- **Attached:** зелёная галочка у файла и чипа запроса.
- **Пустая отправка:** плашка «ЖДЁМ ЗАПРОС» и подсказка «Следующий запрос
~N с» / «До конца часа запросов не будет» / «Нет свободных файлов для
запроса».
- **Туториал:** 5 шагов (пакет → прикрепление → TCP → мини-игра → SLA/атаки),
пульсирующая рамка вокруг цели, кнопка «ДАЛЕЕ», сохранение
`tutorial_done`, повтор из меню кнопкой «ОБУЧЕНИЕ».
- **Настройки:** кнопка на главном экране открывает рабочую модалку.
- **Магазин без X:** закрытие только «Закрыть» или `Esc`.
- **Calm-фон:** при включённом «Спокойном фоне» Backdrop рисует плоскую
`PAPER` без dither; серый `#C0C0C0` не появляется.
- **Итоги забега:** строка «ОТКРЫТО: …» показывает полученные мета-награды;
`Esc` ведёт в главное меню, без soft-lock.
- **Drag пакета на слот:** перетаскивание пакета на TCP-слот запускает
настоящее соединение, а не только визуальный перенос.
- **Слоты:** полоса подписана «СЛОТЫ» — слоты общие для приёма и отправки.
TCP-соединение может занять единственный слот и заблокировать приём; это
видно по занятому слоту `tcp_connection`.
- **Подписи занятости диска** в панели файлов и подвале сетки больше нет:
остаётся только счётчик файлов/клеток.
- **Тосты:** сверху по центру (400 px, x = 440…840), стопкой вниз от `y=44`;
панель ОТПРАВКА не перекрывают.
- **Подписи файлов:** 3 / 2 / 1 строки при клетке ≥88 / 72–87 / <72 px,
кегль 12–16 px с авто-уменьшением до 11 px, полное имя — в tooltip.
- **Чипы запросов:** имя 10 px (9 px, только если это позволяет показать имя
целиком); при наведении — полное имя и папка в строке карточки и tooltip.
## Мини-игры
- Общая шапка 640×360: две строки — название и цель, полоса прогресса в
шапке, акцентная линия у цели.
- `dots` — непрерывная линия протяжкой; быстрое движение сэмплируется, а
недопустимые клетки игнорируются молча.
- `ПРОПУСТИТЬ`/`СДАТЬСЯ` требует двух кликов: первый взводит кнопку, второй
подтверждает.
- Результат: имя игры, `q%`, полоса качества, вердикт, детали, `ПРОДОЛЖИТЬ`;
`Esc` = продолжить.
- Подсказки атак — внутри окна, в нижней полосе, без обрезки.
## «ЖАРА» и движение
**Меню-бар.** Вместо часов **03:42** и скрытой даты стоит `TemperatureGauge`
(216×18, высота меню-бара 20 не меняется): подпись уровня, колба, деления на
порогах **0.34 / 0.58 / 0.80**, маркер и число **36–95 °C** справа. В покое и
в подготовке — пустая колба и 36 °C без инерции. В критическом уровне —
штриховка, пульс подчёркивания и «КРИТ». Tooltip: SLA, запросы/срочные, атаки
и тир, канал/буфер, очередь/отправка.
**Оверлей.** `StressOverlay` — полноэкранный `ColorRect` с
`shaders/stress.gdshader`. Шейдер **alpha-only**, без `hint_screen_texture`:
читает только `UV`/`FRAGCOORD`/`TIME`, поэтому модалки и тосты поверх него не
тонируются. Центр почти прозрачен (alpha ≤ 0.045), край ≤ 0.24: тон, виньетка,
скан-линии, зерно, медленная полоса, сердцебиение на уровне ≥2 и вспышка.
Heat-модель — чистая функция от снимка часа (SLA, запросы, атаки, канал,
буфер, очередь, прогресс) плюс импульсы событий; сглаживание — `UiMotion.k()`,
без твинов в `_process`. В headless материал не создаётся, слой скрыт.
**Пульс и тряска.** `StatusBar` пульсирует подчёркиванием SLA при SLA < 40 %
или уровне 3 (период 1,1 → 0,55 с). Событийная тряска панелей — только
`position`, целые пиксели, **≤2 px**, ~140 мс; layout и drop-логика не
меняются.
**Lane B.** Сглаживание drag (tau 0.035), наклон превью по скорости (до 0.045
rad), pop/rotate-pop, stagger появления карточек (0.03 с, максимум 0.09),
плавные meter-полосы, вход/выход тостов — всё через `UiMotion`. Анимации не
влияют на позиции источника/цели.
**«Снизить эффекты».** Настройка выставляет `UiMotion.reduced` и отключает
тряску, наклон/инерцию drag, пульсы, вспышки и stagger. Шейдер оставляет
только статичный тон и виньетку. В headless движение и твины выключены.
## VHS-эффект главного меню
Тонкий CRT/VHS-слой есть **только на главном меню** (`scripts/ui/vhs_overlay.gd`).
Это `RefCounted`, а не Node/Control: меню само рисует слой в `MainMenu._draw()`.
Тонкий CRT/VHS-слой есть **только на главном меню** (`shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`). Полноэкранный `ColorRect` с canvas_item-шейдером
читает готовый кадр через `hint_screen_texture`.
Порядок отрисовки:
Ключевое — **native-res**: эффект считается в физических пикселях окна
(`FRAGCOORD`/`SCREEN_PIXEL_SIZE`). При 1920×1080 скан-линии идут ровно каждые
2 px, без блоков и banding; логика UI при этом остаётся 1280×720
(`stretch/mode = canvas_items`), но VHS не растягивает 1280-текстуру.
1. бумага;
2. VHS по фону — скан-линии, зерно, виньетка, полоса;
3. карточка, правая панель и декоративные фигуры;
4. мягкий VHS-проход по карточке и панели (0.72 силы, без виньетки);
5. текст и кнопки.
- screen texture сэмплится с линейной фильтрацией 1:1. Identity-проверка
при 1920×1080: diff в области текста = 0, то есть картинка не мылится;
- хроматика ≤0.0003 UV (~0.6 px у края кадра), зелёный канал не сдвигается —
буквы не двоятся;
- полоса меняет только яркость и не двигает изображение, поэтому текст
под ней не «плывёт».
Поэтому слой не перекрывает меню, не перехватывает мышь и не может затереть
кадр фоном подготовки. Кнопки — дети `MainMenu`, они рисуются поверх.
Screen texture (`hint_screen_texture`) не используется: интерфейс не сэмплится
и остаётся резким.
Параметры (`VhsOverlay`):
Параметры (`VhsOverlay` / шейдер):
| Параметр | Значение | Смысл |
| --- | --- | --- |
| `intensity` | **0.42** (макс. 0.55) | общая сила слоя |
| `SCANLINE_STEP` | 2 лог. px | шаг скан-линий, 1 px затемнения |
| `SCANLINE_STRENGTH` | 0.07 | затемнение линий |
| проход по карточке | ×0.72 | мягче фона, текст рисуется поверх |
| зерно | тайл 128×128, 2 Гц | амплитуда ≤0.05, без мерцания |
| виньетка | 0.13 | затемнение к краям кадра |
| полоса | ~18 с на проход | мягкая тёмная, со светлой кромкой и дрожанием ±4 px |
| `intensity` | **0.40** (макс. 0.55) | общая сила слоя |
| `scanline_step` | 2 физ. px | тонкие ровные скан-линии |
| `scanline_strength` | 0.06 | затемнение ≈2.4% при intensity 0.40 |
| `aberration` | 0.0003 UV | мягкая хроматика, без двоения |
| `grain` | 0.025, 2 Гц | слабое зерно, без мерцания |
| `vignette` | 0.11 | затемнение к краям кадра |
| `band` | 0.04, ~18 с на проход | редкая полоса, только яркость |
Скан-линии рисуются в канве меню (логические 1280×720) и масштабируются
вместе с UI: на 1920×1080 шаг ≈3 физических px. Это осознанный компромисс —
эффект не читает экранную текстуру, поэтому не бывает мыла и затирания UI.
Слой — последний ребёнок `MainMenu`, `mouse_filter = IGNORE`,
`process_mode = ALWAYS`. Модалки (настройки, подтверждение) — отдельные
соседи в `modal_root`, рисуются поверх и **не** фильтруются.
В HEADLESS текстуры не создаются и слой не рисуется — тесты не тормозят.
В HEADLESS материал не создаётся, слой скрыт — тесты не тормозят.
**Как отключить:**
@@ -116,5 +202,7 @@ godot --headless --path . --script scripts/ui/ui_smoke.gd
bash scripts/run_all_tests.sh
```
`ui_smoke` — 132 проверки, 0 провалов, `SMOKE OK`. Скриншоты для страницы —
в `docs/screenshots/`; после смены UI их нужно переснять в 1600×900.
`ui_smoke` и остальные наборы — см. `scripts/run_all_tests.sh` и `tests/`
(число проверок/наборов может меняться между волнами). Скриншоты для страницы —
в `docs/screenshots/`; после смены UI их нужно переснять под текущий вид
(окно 1920×1080, логический кадр 1280×720).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cg7ddssqqlh8q"
path="res://.godot/imported/minigame.png-e653e1ec9e6eda804b745f4054348377.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/screenshots/minigame.png"
dest_files=["res://.godot/imported/minigame.png-e653e1ec9e6eda804b745f4054348377.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 142 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="PEAK HOUR 97"
config/description="Час пик. Одна смена. Ни одного свободного места."
config/version="0.1.0"
config/version="2.0.0"
run/main_scene="res://scenes/Main.tscn"
run/max_fps=60
config/features=PackedStringArray("4.7", "GL Compatibility")
+457 -42
View File
@@ -1,10 +1,19 @@
extends Node
## Audio — пул эффектов и фоновый гул (autoload).
## Audio — пул эффектов, музыка с кроссфейдом и low-SLA overlay (autoload).
##
## Пул из 8 AudioStreamPlayer для одноразовых SFX + отдельный проигрыватель
## для музыки/фона. Кастомных шин в проекте нет — работаем через Master
## и `volume_db`. AudioEffect намеренно не используются: в web-сборке
## (no threads) они недоступны.
## Пул из 8 AudioStreamPlayer для одноразовых SFX, два плеера A/B для
## музыки (menu/game/magazine/defeat) и отдельный loop-плеер для low_sla.
## Кастомных шин в проекте нет — работаем через Master и `volume_db`.
## AudioEffect намеренно не используются: в web-сборке (no threads) они
## недоступны.
##
## Приоритет музыки: defeat > shop (magazine) > scene (menu/game).
## Смена трека — кроссфейд, быстрые переключения не оставляют наложения:
## новый трек всегда стартует на «свободном» плеере, старый уходит в −80 dB
## и останавливается по завершении фейда.
##
## Web autoplay: до первого input музыка не стартует, целевой трек
## запоминается. В desktop/headless ограничение не действует.
##
## Настройки (громкости, mute) живут в полях, без UI.
@@ -22,17 +31,83 @@ const SFX_IDS: PackedStringArray = [
"hour_end", "run_end",
]
## Известный фон (без расширения). Порядок соответствует audio/music/.
const MUSIC_IDS: PackedStringArray = ["hum"]
## Все известные треки (без расширения) и их файлы.
## hum — legacy-фон, остальные — саундтреки.
const MUSIC_IDS: PackedStringArray = [
"hum", "menu", "game", "magazine", "defeat", "low_sla",
]
const MUSIC_FILES := {
"hum": "hum.wav",
"menu": "menu.mp3",
"game": "game.mp3",
"magazine": "magazine.mp3",
"defeat": "defeat.mp3",
"low_sla": "low_sla.mp3",
}
## Зацикленные треки. defeat — one-shot.
const LOOP_MUSIC_IDS: PackedStringArray = [
"hum", "menu", "game", "magazine", "low_sla",
]
const SCENE_IDS: PackedStringArray = ["menu", "game"]
const LOW_SLA_ID := "low_sla"
const SHOP_MUSIC_ID := "magazine"
const DEFEAT_MUSIC_ID := "defeat"
const SCENE_FADE := 0.9
const SHOP_FADE := 0.8
const DEFEAT_FADE := 0.6
const LOW_FADE := 0.8
const LEGACY_FADE := 0.8
## Overlay «низкий SLA»: тихо подмешивается поверх музыки, не останавливая её.
const LOW_SLA_DB := -14.0
const LOW_SLA_MAX_DB := -8.0
var music_volume: float = 0.8 ## линейно 0..1
var sfx_volume: float = 0.9 ## линейно 0..1
var muted: bool = false ## использовать set_muted()
var _sfx_streams: Dictionary = {} # id: String -> AudioStream
var _music_streams: Dictionary = {} # id: String -> AudioStream
## Legacy-словарь одиночного фона (hum). Оставлен для совместимости с
## тестами/старым кодом, которые читают/чистят `_music_streams`.
var _music_streams: Dictionary = {}
var _music_bank: Dictionary = {} # id: String -> AudioStream (все треки)
var _pool: Array[AudioStreamPlayer] = []
var _music_player: AudioStreamPlayer
var _music_player: AudioStreamPlayer # плеер A; сохранённое имя для старого API
var _music_players: Array[AudioStreamPlayer] = []
## Состояние фейдов по индексам A(0)/B(1). Гейн в dB: 0 — полная громкость,
## SILENCE_DB — тишина. Итоговая громкость = _music_db + гейн.
var _music_gain: Array[float] = [SILENCE_DB, SILENCE_DB]
var _music_fade_from: Array[float] = [SILENCE_DB, SILENCE_DB]
var _music_fade_to: Array[float] = [SILENCE_DB, SILENCE_DB]
var _music_fade_elapsed: Array[float] = [0.0, 0.0]
var _music_fade_dur: Array[float] = [0.0, 0.0]
var _music_stop_after: Array[bool] = [false, false]
var _active_music: int = -1
var _last_music: int = 1
var _low_player: AudioStreamPlayer
var _low_stream: AudioStream = null
var _low_active: bool = false
var _low_ratio: float = 0.0
var _low_gain: float = SILENCE_DB
var _low_fade_from: float = SILENCE_DB
var _low_fade_to: float = SILENCE_DB
var _low_fade_elapsed: float = 0.0
var _low_fade_dur: float = 0.0
## Логическое состояние: какой трек должен звучать.
var _scene_id: String = ""
var _shop_open: bool = false
var _defeat_active: bool = false
var _manual_id: String = "" # явный play_music(), пока не задана сцена
var _target_music: String = "" # что уже играет/запрошено ("" — тишина)
var _unlocked: bool = false
var _round_robin: int = 0
var _sfx_db: float = 0.0
var _music_db: float = 0.0
@@ -46,14 +121,34 @@ func _ready() -> void:
player.bus = &"Master"
add_child(player)
_pool.append(player)
_music_player = AudioStreamPlayer.new()
_music_player.bus = &"Master"
add_child(_music_player)
_music_player = _make_music_player("MusicA")
_music_players.append(_music_player)
_music_players.append(_make_music_player("MusicB"))
_low_player = AudioStreamPlayer.new()
_low_player.name = "LowSla"
_low_player.bus = &"Master"
_low_player.volume_db = SILENCE_DB
add_child(_low_player)
_preload_all()
# Web: старт только после первого жеста. Desktop/headless не блокируем.
_unlocked = not OS.has_feature("web")
_apply_volumes()
## Загрузка всех WAV в словари. Отсутствующий файл — предупреждение, не падение.
func _make_music_player(player_name: String) -> AudioStreamPlayer:
var player := AudioStreamPlayer.new()
player.name = player_name
player.bus = &"Master"
player.volume_db = SILENCE_DB
add_child(player)
return player
## Загрузка всех SFX и музыкальных треков. Отсутствующий файл — warning,
## не падение.
func _preload_all() -> void:
for id in SFX_IDS:
var path := SFX_DIR + String(id) + ".wav"
@@ -63,13 +158,16 @@ func _preload_all() -> void:
else:
_sfx_streams[id] = stream
for id in MUSIC_IDS:
var path := MUSIC_DIR + String(id) + ".wav"
var stream := _load_stream(path)
var key := String(id)
var stream := _load_music_stream(key)
if stream == null:
push_warning("Audio: файл музыки не найден: " + path)
push_warning("Audio: файл музыки не найден: " + _music_path(key))
else:
_music_streams[id] = stream
_enable_loop(stream, path)
_music_bank[key] = stream
# Legacy-словарь держим ровно с одним элементом (hum).
if key == "hum":
_music_streams[key] = stream
_low_stream = _music_bank.get(LOW_SLA_ID) as AudioStream
func _load_stream(path: String) -> AudioStream:
@@ -78,22 +176,68 @@ func _load_stream(path: String) -> AudioStream:
return load(path) as AudioStream
## Зацикливание для фона (гул): loop на весь файл, шов уже совпадает.
func _enable_loop(stream: AudioStream, path: String) -> void:
func _music_path(id: String) -> String:
if MUSIC_FILES.has(id):
return MUSIC_DIR + String(MUSIC_FILES[id])
# Неизвестный id: пробуем mp3, затем wav (совместимость со старым кодом).
var mp3_path := MUSIC_DIR + id + ".mp3"
if ResourceLoader.exists(mp3_path):
return mp3_path
return MUSIC_DIR + id + ".wav"
## Загрузка трека с настройкой loop. defeat — one-shot.
func _load_music_stream(id: String) -> AudioStream:
var path := _music_path(id)
var stream := _load_stream(path)
if stream == null:
return null
_configure_loop(stream, id)
return stream
func _get_music_stream(id: String) -> AudioStream:
var stream := _music_bank.get(id) as AudioStream
if stream != null:
return stream
stream = _load_music_stream(id)
if stream == null:
push_warning("Audio: музыка не найдена: " + _music_path(id))
return null
_music_bank[id] = stream
return stream
## MP3: loop=true, loop_offset=0. WAV: loop на весь файл. defeat: loop выкл.
func _configure_loop(stream: AudioStream, id: String) -> void:
var should_loop := LOOP_MUSIC_IDS.has(id)
var mp3 := stream as AudioStreamMP3
if mp3 != null:
mp3.loop = should_loop
if should_loop:
mp3.loop_offset = 0.0
return
var wav := stream as AudioStreamWAV
if wav == null or wav.loop_mode != AudioStreamWAV.LOOP_DISABLED:
if wav == null:
return
if not should_loop:
wav.loop_mode = AudioStreamWAV.LOOP_DISABLED
return
if wav.loop_mode != AudioStreamWAV.LOOP_DISABLED:
return
# Длина берётся из get_length(): одинаково корректна для PCM и для
# сжатого импорта (по умолчанию Godot импортирует WAV в QOA).
var frames := int(round(wav.get_length() * float(wav.mix_rate)))
if frames <= 0:
push_warning("Audio: не удалось вычислить длину для loop: " + path)
push_warning("Audio: не удалось вычислить длину для loop: " + _music_path(id))
return
wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
wav.loop_begin = 0
wav.loop_end = frames
# --- SFX --------------------------------------------------------------------
## Проиграть одноразовый эффект. `id` — имя файла без расширения;
## неизвестный id даёт push_warning и ни при каких условиях не падает.
func play_sfx(id: String) -> void:
@@ -113,36 +257,297 @@ func play_sfx(id: String) -> void:
player.play()
## Проиграть фон (гул) с зацикливанием. Повторный вызов во время игры — no-op.
func play_music(id: String = "hum") -> void:
var key := id.trim_suffix(".wav")
var stream := _music_streams.get(key) as AudioStream
if stream == null:
var path := MUSIC_DIR + key + ".wav"
stream = _load_stream(path)
if stream == null:
push_warning("Audio: музыка не найдена: " + path)
return
_music_streams[key] = stream
_enable_loop(stream, path)
if _music_player.playing and _music_player.stream == stream:
# --- музыка: публичный API --------------------------------------------------
## Явный запуск трека (backward-compatible: по умолчанию hum).
## Кроссфейд; повтор того же трека — no-op.
func play_music(id: String = "hum", fade: float = LEGACY_FADE) -> void:
var key := _normalize_music_id(id)
if key == LOW_SLA_ID:
set_low_sla(true)
return
_music_player.stream = stream
_music_player.volume_db = _music_db
_music_player.play()
if key == DEFEAT_MUSIC_ID:
play_defeat()
return
_manual_id = key
_request_music(key, fade)
func stop_music() -> void:
_music_player.stop()
## Смена игровой сцены: menu/game. Кроссфейд 0.9 c, повтор — no-op.
func set_scene(id: String) -> void:
var key := _normalize_music_id(id)
if not SCENE_IDS.has(key):
push_warning("Audio: неизвестная сцена для музыки: " + id)
return
# Новая сцена (в т.ч. menu после defeat) снимает defeat-состояние и
# закрывает магазинный overlay: смена сцены — более крупный переход.
_defeat_active = false
_shop_open = false
_manual_id = ""
_scene_id = key
if key == "menu":
# В меню low-SLA overlay не нужен.
_disable_low(LOW_FADE)
_request_music(_resolve_target(), SCENE_FADE)
## Открыт магазин/журнал: поверх сцены играет magazine, приоритет ниже defeat.
func set_shop_open(open: bool) -> void:
if _shop_open == open:
return
_shop_open = open
_request_music(_resolve_target(), SHOP_FADE)
## Проигрыш: кроссфейд в one-shot defeat; после конца — тишина до set_scene().
func play_defeat() -> void:
_defeat_active = true
_disable_low(LOW_FADE)
# Повторный вызов во время звучания — no-op, после конца — можно заново.
if _target_music == DEFEAT_MUSIC_ID and _active_music >= 0 \
and _music_players[_active_music].playing:
return
_request_music(DEFEAT_MUSIC_ID, DEFEAT_FADE, true)
## Остановить музыку (и overlay). fade=0 — сразу (старое поведение).
func stop_music(fade: float = 0.0) -> void:
_scene_id = ""
_shop_open = false
_defeat_active = false
_manual_id = ""
_target_music = ""
_stop_all_music(fade)
_disable_low(fade)
## Low-SLA overlay: плавно подмешивается поверх музыки, не останавливая её.
## ratio 0..1 усиливает сигнал (SLA просел сильнее). Повторное применение
## той же цели не перезапускает фейд.
func set_low_sla(active: bool, ratio: float = 0.0) -> void:
_low_ratio = clampf(ratio, 0.0, 1.0)
var target_db := _low_target_db(active)
var same := active == _low_active and absf(target_db - _low_fade_to) < 0.05
if same and (_low_fade_dur > 0.0 or _low_player.playing or not active or not _unlocked):
return
_low_active = active
_set_low_target(active, LOW_FADE)
## Выключить overlay (defeat/меню) и дать ему уйти в тишину.
func _disable_low(fade: float) -> void:
_low_active = false
_set_low_target(false, fade)
func _low_target_db(active: bool) -> float:
if not active:
return SILENCE_DB
return lerpf(LOW_SLA_DB, LOW_SLA_MAX_DB, _low_ratio)
# --- музыка: внутренняя логика ---------------------------------------------
func _normalize_music_id(id: String) -> String:
var key := id.strip_edges()
key = key.trim_suffix(".mp3")
key = key.trim_suffix(".wav")
return key
func _resolve_target() -> String:
if _defeat_active:
return DEFEAT_MUSIC_ID
if _shop_open:
return SHOP_MUSIC_ID
if _scene_id != "":
return _scene_id
return _manual_id
## Запрос трека с учётом web-autoplay и «повтор — no-op».
## force нужен только явному play_defeat() после завершения one-shot.
func _request_music(id: String, fade: float, force: bool = false) -> void:
if not _unlocked:
# Запоминаем pending: стартуем после первого input.
_target_music = id
return
if not force and id == _target_music and id != "":
return
_start_music(id, fade)
func _start_music(id: String, fade: float) -> void:
if id == "":
_stop_all_music(fade)
return
var stream := _get_music_stream(id)
if stream == null:
return
var idx := 0
if _active_music >= 0:
idx = 1 - _active_music
# Старый активный трек уходит в тишину и затем останавливается.
_begin_fade_out(_active_music, fade)
else:
idx = 1 - _last_music
# Хвост предыдущего stop_music не должен накладываться на новый трек.
for i in _music_players.size():
if i != idx and _music_players[i].playing:
_begin_fade_out(i, minf(fade, 0.2))
var player := _music_players[idx]
player.stop()
player.stream = stream
player.pitch_scale = 1.0
_music_gain[idx] = SILENCE_DB
_music_fade_from[idx] = SILENCE_DB
_music_fade_to[idx] = 0.0
_music_fade_elapsed[idx] = 0.0
_music_fade_dur[idx] = maxf(fade, 0.0)
_music_stop_after[idx] = false
if fade <= 0.0:
_music_gain[idx] = 0.0
player.play()
_active_music = idx
_last_music = idx
_target_music = id
_apply_volumes()
func _stop_all_music(fade: float) -> void:
_active_music = -1
for i in _music_players.size():
if _music_players[i].playing or _music_gain[i] > SILENCE_DB + 0.01:
_begin_fade_out(i, fade)
_apply_volumes()
func _begin_fade_out(idx: int, duration: float) -> void:
_music_fade_from[idx] = _music_gain[idx]
_music_fade_to[idx] = SILENCE_DB
_music_fade_elapsed[idx] = 0.0
_music_fade_dur[idx] = maxf(duration, 0.0)
_music_stop_after[idx] = true
if duration <= 0.0:
_music_gain[idx] = SILENCE_DB
_music_players[idx].stop()
# --- low SLA overlay --------------------------------------------------------
func _set_low_target(active: bool, fade: float) -> void:
var target_db := _low_target_db(active)
if not active:
_low_fade_from = _low_gain
_low_fade_to = SILENCE_DB
_low_fade_elapsed = 0.0
_low_fade_dur = maxf(fade, 0.0)
if fade <= 0.0:
_low_gain = SILENCE_DB
_low_player.stop()
_apply_volumes()
return
if not _unlocked:
# Запомнили намерение; запустим после первого input.
_low_fade_to = target_db
return
if _low_stream == null:
_low_stream = _get_music_stream(LOW_SLA_ID)
if _low_stream == null:
return
if not _low_player.playing:
_low_player.stop()
_low_player.stream = _low_stream
_low_player.pitch_scale = 1.0
_low_gain = SILENCE_DB
_low_player.play()
_low_fade_from = _low_gain
_low_fade_to = target_db
_low_fade_elapsed = 0.0
_low_fade_dur = maxf(fade, 0.0)
if fade <= 0.0:
_low_gain = target_db
_apply_volumes()
# --- web autoplay -----------------------------------------------------------
func _input(event: InputEvent) -> void:
if _unlocked:
return
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
_unlock()
elif event is InputEventKey and (event as InputEventKey).pressed:
_unlock()
func _unlock() -> void:
_unlocked = true
# Overlay и музыка стартуют только сейчас, с фейда.
if _low_active:
_set_low_target(true, LOW_FADE)
if _target_music != "":
var pending := _target_music
_target_music = ""
_start_music(pending, SCENE_FADE)
# --- процесс: фейды ---------------------------------------------------------
func _process(delta: float) -> void:
if not _unlocked:
return
_tick_music_fades(delta)
_tick_low_fade(delta)
func _tick_music_fades(delta: float) -> void:
var changed := false
for i in _music_players.size():
if _music_fade_dur[i] <= 0.0:
continue
changed = true
_music_fade_elapsed[i] = minf(_music_fade_elapsed[i] + delta, _music_fade_dur[i])
var t := _music_fade_elapsed[i] / _music_fade_dur[i]
_music_gain[i] = lerpf(_music_fade_from[i], _music_fade_to[i], t)
if _music_fade_elapsed[i] >= _music_fade_dur[i]:
_music_gain[i] = _music_fade_to[i]
_music_fade_dur[i] = 0.0
if _music_stop_after[i] and i != _active_music:
_music_stop_after[i] = false
_music_players[i].stop()
if changed:
_apply_volumes()
func _tick_low_fade(delta: float) -> void:
if _low_fade_dur > 0.0:
_low_fade_elapsed = minf(_low_fade_elapsed + delta, _low_fade_dur)
var t := _low_fade_elapsed / _low_fade_dur
_low_gain = lerpf(_low_fade_from, _low_fade_to, t)
if _low_fade_elapsed >= _low_fade_dur:
_low_gain = _low_fade_to
_low_fade_dur = 0.0
if not _low_active and _low_gain <= SILENCE_DB + 0.01:
_low_player.stop()
_apply_volumes()
# --- громкости --------------------------------------------------------------
## Громкость эффектов, линейно 0..1 (0.5 — вдвое тише).
func set_sfx_volume(v: float) -> void:
sfx_volume = clampf(v, 0.0, 1.0)
_apply_volumes()
## Громкость фона, линейно 0..1.
## Громкость музыки, линейно 0..1. Применяется ко всем музыкальным плеерам.
func set_music_volume(v: float) -> void:
music_volume = clampf(v, 0.0, 1.0)
_apply_volumes()
@@ -159,7 +564,17 @@ func _apply_volumes() -> void:
_music_db = SILENCE_DB if muted else _to_db(music_volume)
for player in _pool:
player.volume_db = _sfx_db
_music_player.volume_db = _music_db
for i in _music_players.size():
_music_players[i].volume_db = _combine_db(_music_db, _music_gain[i])
if _low_player != null:
_low_player.volume_db = _combine_db(_music_db, _low_gain)
## Суммарная громкость: базовая (пользователь/mute) + гейн фейда.
func _combine_db(base_db: float, gain_db: float) -> float:
if base_db <= SILENCE_DB + 0.01 or gain_db <= SILENCE_DB + 0.01:
return SILENCE_DB
return clampf(base_db + gain_db, SILENCE_DB, 0.0)
func _to_db(linear: float) -> float:
+391 -67
View File
@@ -1,88 +1,168 @@
extends Node
## Data — доступ к JSON-данным из res://data (autoload).
##
## Файлы данных сейчас заготовки, поэтому отсутствующие ключи заменяются
## безопасными дефолтами с push_warning; игра не должна падать из-за данных.
## v2: новые секции balance.json (constants/hours/grid/shop_rules/shop/
## minigames/attacks/payout/sla). Отсутствующие ключи заменяются безопасными
## дефолтами с push_warning; игра не должна падать из-за данных.
##
## Legacy-API (tr, type_def, difficulty_for, shop_item, available_shop,
## folder_name) сохранён для ещё не переписанных систем.
const DATA_DIR := "res://data"
const DEFAULT_TYPES := {
"text": {
"name_ru": "Текст", "ext": "txt", "icon": "text",
"recv_time": 2.0, "send_time": 1.5, "recv_fee": 1,
"base_pay": 80, "weight": 8, "minigame_coeff": 1.0, "size_kb": 64,
"sla_loss": 2,
"size_kb": 64,
"cells": 1, "base_time_sec": 3, "recv_fee": 1,
"base_pay": 18, "weight": 1, "minigame_coeff": 1, "sla_loss": 2,
"shapes": ["O1"],
},
"sound": {
"name_ru": "Звук", "ext": "mp3", "icon": "sound",
"recv_time": 3.5, "send_time": 2.5, "recv_fee": 2,
"base_pay": 140, "weight": 12, "minigame_coeff": 2.0, "size_kb": 3072,
"sla_loss": 4,
"size_kb": 3072,
"cells": 2, "base_time_sec": 5, "recv_fee": 2,
"base_pay": 30, "weight": 2, "minigame_coeff": 2, "sla_loss": 4,
"shapes": ["I2H", "I2V"],
},
"photo": {
"name_ru": "Фото", "ext": "jpg", "icon": "photo",
"recv_time": 5.0, "send_time": 3.5, "recv_fee": 3,
"base_pay": 220, "weight": 18, "minigame_coeff": 3.0, "size_kb": 5120,
"sla_loss": 8,
"size_kb": 5120,
"cells": 3, "base_time_sec": 8, "recv_fee": 4,
"base_pay": 44, "weight": 4, "minigame_coeff": 4, "sla_loss": 8,
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
},
"video": {
"name_ru": "Видео", "ext": "mp4", "icon": "video",
"recv_time": 8.0, "send_time": 6.0, "recv_fee": 4,
"base_pay": 380, "weight": 22, "minigame_coeff": 4.0, "size_kb": 20480,
"sla_loss": 16,
"size_kb": 20480,
"cells": 4, "base_time_sec": 12, "recv_fee": 6,
"base_pay": 69, "weight": 8, "minigame_coeff": 8, "sla_loss": 16,
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d",
"L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"],
},
}
## Таблица сложности — до часа 8 (D12); дальше используется последняя строка.
## Таблица сложности v1 — оставлена для legacy difficulty_for().
const DEFAULT_DIFFICULTY := [
{"hour": 1, "max_requests": 2, "spawn_sec": 7.0},
{"hour": 2, "max_requests": 3, "spawn_sec": 6.5},
{"hour": 3, "max_requests": 3, "spawn_sec": 6.0},
{"hour": 4, "max_requests": 4, "spawn_sec": 5.5},
{"hour": 5, "max_requests": 4, "spawn_sec": 5.0},
{"hour": 6, "max_requests": 5, "spawn_sec": 4.5},
{"hour": 7, "max_requests": 5, "spawn_sec": 4.2},
{"hour": 8, "max_requests": 6, "spawn_sec": 4.0},
{"hour": 2, "max_requests": 5, "spawn_sec": 6.5},
{"hour": 3, "max_requests": 7, "spawn_sec": 6.0},
{"hour": 4, "max_requests": 9, "spawn_sec": 5.5},
{"hour": 5, "max_requests": 10, "spawn_sec": 5.0},
{"hour": 6, "max_requests": 11, "spawn_sec": 4.5},
{"hour": 7, "max_requests": 11, "spawn_sec": 4.2},
{"hour": 8, "max_requests": 12, "spawn_sec": 4.0},
]
const DEFAULT_SHOP := [
{"id": "slot_3", "name": "3-й слот", "cost": 250, "max_level": 1},
{"id": "slot_4", "name": "4-й слот", "cost": 450, "max_level": 1},
{"id": "buffer", "name": "Буфер +2", "cost": 200, "max_level": 2},
{"id": "packet_timer", "name": "Таймер пакетов +4 с", "cost": 350, "max_level": 3},
{"id": "zone", "name": "Зона +5 %", "cost": 300, "max_level": 3},
{"id": "channel", "name": "Канал +25", "cost": 250, "max_level": 4},
{"id": "section", "name": "Секция", "cost": 300, "max_level": 5},
{"id": "search", "name": "Поиск", "cost": 250, "max_level": 1},
{"id": "shield", "name": "Щит", "cost": 400, "max_level": 1},
{"id": "shop_sla", "name": "SLA +24", "effect": "sla_add", "value": 24, "cost": 300, "max_level": 3},
const DEFAULT_SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
const DEFAULT_ACHIEVABLE_SUMS := [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
21, 22, 24, 25, 26, 28, 32,
]
## Прогрессия/открытия (дублирует data/balance.json → progression).
const DEFAULT_PROGRESSION := {
"unlocks": {
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4},
},
"meta_rewards": {
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
},
const DEFAULT_HOUR_FILES := [8, 8, 9, 10, 11, 11, 12, 12, 12]
const DEFAULT_ACTIVE_REQUESTS := [1, 2, 2, 3, 3, 3, 4, 4, 4]
const DEFAULT_ATTACKS := [0, 1, 1, 2, 2, 3, 3, 4, 4]
const DEFAULT_INCOME_TARGET := [90, 220, 380, 560, 760, 980, 1200, 1450, 1700]
const DEFAULT_RECV_INTERVAL := [12, 11, 10, 9, 8, 7.5, 7, 6.5, 6]
const DEFAULT_SEND_INTERVAL := [20, 24, 18, 24, 24, 24, 24, 24, 24]
## Длительность часа: 120 + 30 * (hour - 1) → 120..360.
const DEFAULT_HOUR_DURATION_BASE := 120
const DEFAULT_HOUR_DURATION_STEP := 30
## Порядок: text, sound, photo, video (§2.5). Совпадает с
## DEFAULT_REQUEST_TYPE_WEIGHTS: приём и запросы используют одни типы.
const DEFAULT_TYPE_WEIGHTS := [
[1.00, 0.00, 0.00, 0.00],
[0.80, 0.20, 0.00, 0.00],
[0.50, 0.50, 0.00, 0.00],
[0.40, 0.30, 0.30, 0.00],
[0.35, 0.35, 0.30, 0.00],
[0.30, 0.30, 0.30, 0.10],
[0.22, 0.23, 0.30, 0.25],
[0.15, 0.25, 0.35, 0.25],
[0.10, 0.15, 0.30, 0.45],
]
## Доли числа файлов в запросе: 1..4 (§2.6). Добавлен ключ "4" (4 файла).
const DEFAULT_REQUEST_MIX := {"1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0}
## Веса выбора ТИПА файлов для required-слотов запроса (§2.6).
## Отдельно от type_weights: те задают типы входящих пакетов.
## Порядок: text, sound, photo, video.
const DEFAULT_REQUEST_TYPE_WEIGHTS := [
[1.00, 0.00, 0.00, 0.00],
[0.80, 0.20, 0.00, 0.00],
[0.50, 0.50, 0.00, 0.00],
[0.40, 0.30, 0.30, 0.00],
[0.35, 0.35, 0.30, 0.00],
[0.30, 0.30, 0.30, 0.10],
[0.22, 0.23, 0.30, 0.25],
[0.15, 0.25, 0.35, 0.25],
[0.10, 0.15, 0.30, 0.45],
]
const DEFAULT_LOT_IDS := [
"lot_slots", "lot_speed", "lot_sla", "lot_shield",
"lot_money", "lot_folder", "lot_channel", "lot_timer", "lot_udp",
]
const DEFAULT_LOT_NAMES := [
"Порты коммутатора", "Оптоволокно", "Резервный ИБП", "Заземление",
"Корпоративный контракт", "Складской стеллаж", "Пропускная способность",
"Регламент обработки", "UDP-повторитель",
]
const DEFAULT_LOT_EFFECTS := [
"connection_slots", "transfer_speed", "sla_cap", "shield_charge",
"money_mult", "folder_cells", "channel_capacity", "request_timer",
"udp_success",
]
const DEFAULT_LOT_VALUES := [1, 0.10, 40, 1, 0.05, 1, 6, 7, 0.10]
const DEFAULT_LOT_MAX := [4, 6, 4, 3, 8, 5, 6, 5, 4]
const DEFAULT_LOT_PRICES := [
[90, 200, 400, 700],
[55, 75, 100, 135, 180, 245],
[80, 160, 270, 430],
[100, 220, 410],
[70, 95, 130, 170, 220, 285, 365, 465],
[50, 100, 180, 290, 440],
[45, 70, 95, 130, 175, 230],
[45, 75, 115, 165, 230],
[60, 120, 210, 350],
]
const DEFAULT_LOT_WEIGHTS := [
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
[0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
[0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0],
[0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2],
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9],
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7],
[0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
]
const DEFAULT_SHOP_RULES := {
"offers": 3,
"unlock_hour": 2,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
}
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
var balance: Dictionary = {}
var constants: Dictionary = {}
var economy: Dictionary = {}
var types: Dictionary = {}
var hours: Array = []
var grid: Dictionary = {}
var shop_lots: Array = []
var rules: Dictionary = {}
var minigames: Dictionary = {}
var attacks: Dictionary = {}
var payout_rules: Dictionary = {}
var sla_rules: Dictionary = {}
var difficulty: Array = []
var shop: Array = []
var names: Dictionary = {}
@@ -100,9 +180,27 @@ func _ready() -> void:
func load_all() -> void:
_cache.clear()
balance = load_json("balance.json")
constants = _as_dict(balance.get("constants", null))
economy = _as_dict(balance.get("economy", null))
types = _merge_types(balance.get("types", null))
hours = _load_hours(balance.get("hours", null))
grid = _as_dict(balance.get("grid", null))
if grid.is_empty():
push_warning("Data: balance.json → grid отсутствует, используются дефолты")
grid = {"folder_cols": 5, "folder_rows": 4, "gap": 6,
"cell_min": 56, "cell_max": 104, "table_cols": 4, "table_rows": 6,
"folder_dims": DEFAULT_GRID_DIMS.duplicate(true), "shapes": {}}
shop_lots = _load_shop_lots(balance.get("shop", null))
shop = shop_lots
rules = _load_rules(balance.get("shop_rules", null))
minigames = _as_dict(balance.get("minigames", null))
if minigames.is_empty():
push_warning("Data: balance.json → minigames отсутствует")
minigames = {"games": {}, "selection": {}}
attacks = _as_dict(balance.get("attacks", null))
payout_rules = _as_dict(balance.get("payout", null))
sla_rules = _as_dict(balance.get("sla", null))
difficulty = _load_difficulty(balance.get("difficulty", null))
shop = _load_shop(balance.get("shop", null))
names = _load_names(load_json("names_ru.json"))
strings = load_json("strings_ru.json")
var progression = balance.get("progression", null)
@@ -182,7 +280,7 @@ func type_def(type: String) -> Dictionary:
return DEFAULT_TYPES["text"].duplicate(true)
## Строка таблицы сложности; для hour > max — последняя строка.
## Строка таблицы сложности; для hour > max — последняя строка (legacy).
func difficulty_for(hour: int) -> Dictionary:
var h := maxi(1, int(hour))
if difficulty.is_empty():
@@ -204,6 +302,141 @@ func difficulty_for(hour: int) -> Dictionary:
return last.duplicate(true)
# --- v2: часы --------------------------------------------------------------
## Строка бюджета часа (duration/files/active_requests/attacks/
## income_target/type_weights/request_mix/request_type_weights).
## hour > 9 → последняя строка.
func hour_def(hour: int) -> Dictionary:
var h := maxi(1, int(hour))
if hours.is_empty():
push_warning("Data: balance.json → hours отсутствует, дефолт часа %d" % h)
return _default_hour_row(h)
var prev: Dictionary = hours[0]
for row in hours:
if not (row is Dictionary):
continue
var row_hour := int(row.get("hour", 0))
if row_hour == h:
return row.duplicate(true)
if row_hour < h:
prev = row
elif row_hour > h:
return prev.duplicate(true)
return (hours[hours.size() - 1] as Dictionary).duplicate(true)
func hour_count() -> int:
return maxi(1, hours.size())
# --- v2: магазин -----------------------------------------------------------
func lot_defs() -> Array:
return shop_lots.duplicate(true)
func lot_def(id: String) -> Dictionary:
for lot in shop_lots:
if lot is Dictionary and str(lot.get("id", "")) == str(id):
return lot.duplicate(true)
push_warning("Data: нет лота '%s'" % str(id))
return {}
func shop_rules() -> Dictionary:
return rules.duplicate(true)
# --- v2: мини-игры ---------------------------------------------------------
func minigame_def(id: String) -> Dictionary:
var games = minigames.get("games", null)
if games is Dictionary:
var def = (games as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет мини-игры '%s'" % str(id))
return {}
func minigame_selection() -> Dictionary:
var out: Dictionary = {}
var raw = minigames.get("selection", null)
if raw is Dictionary:
out = (raw as Dictionary).duplicate(true)
if not (out.get("order", null) is Array) or (out.get("order", null) as Array).is_empty():
out["order"] = DEFAULT_SEND_ORDER.duplicate()
if not (out.get("achievable_sums", null) is Array) or (out.get("achievable_sums", null) as Array).is_empty():
out["achievable_sums"] = DEFAULT_ACHIEVABLE_SUMS.duplicate()
out["max_index"] = int(out.get("max_index", 26))
var coeff: Dictionary = {}
for key in ["text", "sound", "photo", "video"]:
coeff[key] = int(type_def(key).get("minigame_coeff", DEFAULT_TYPES[key].get("minigame_coeff", 1)))
out["coeff"] = coeff
return out
## Legacy-ступень для индекса (idx / 6 + 1), три ступени отправки.
func minigame_tier(index: int) -> int:
return clampi(1 + int(floor(float(maxi(0, index)) / 6.0)), 1, 3)
# --- v2: атаки -------------------------------------------------------------
func attack_def(id: String) -> Dictionary:
var kinds = attacks.get("kinds", null)
if kinds is Dictionary:
var def = (kinds as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет атаки '%s'" % str(id))
return {}
func attack_tier(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
func attack_schedule() -> Array:
var raw = attacks.get("schedule", null)
if raw is Array:
return (raw as Array).duplicate()
return DEFAULT_ATTACKS.duplicate()
# --- v2: сетка -------------------------------------------------------------
func grid_rules() -> Dictionary:
return grid.duplicate(true)
func shape_def(id: String) -> Dictionary:
var shapes = grid.get("shapes", null)
if shapes is Dictionary:
var def = (shapes as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет фигуры '%s'" % str(id))
return {}
func shapes() -> Dictionary:
var raw = grid.get("shapes", null)
if raw is Dictionary:
return (raw as Dictionary).duplicate(true)
return {}
func folder_dims_table() -> Array:
var raw = grid.get("folder_dims", null)
if raw is Array and not (raw as Array).is_empty():
return (raw as Array).duplicate(true)
return DEFAULT_GRID_DIMS.duplicate(true)
# --- legacy shop API -------------------------------------------------------
func shop_item(id: String) -> Dictionary:
for item in shop:
if item is Dictionary and str(item.get("id", "")) == str(id):
@@ -213,7 +446,6 @@ func shop_item(id: String) -> Dictionary:
## Ключ открытия для позиции магазина. "" — позиция доступна всегда.
## shop_sla продаётся вне прогрессии; поиск/щит открываются мета-наградами.
func shop_unlock_key(id: String) -> String:
match str(id):
"shop_sla":
@@ -228,7 +460,10 @@ func shop_unlock_key(id: String) -> String:
## Открыта ли позиция магазина по текущему состоянию Game (run_hour/meta).
func shop_item_unlocked(item: Dictionary) -> bool:
var key := shop_unlock_key(str(item.get("id", "")))
var id := str(item.get("id", ""))
if id.begins_with("lot_"):
return true
var key := shop_unlock_key(id)
if key.is_empty():
return true
var game = _game_node()
@@ -237,7 +472,7 @@ func shop_item_unlocked(item: Dictionary) -> bool:
return true
## Каталог магазина без закрытых позиций (shop_sla виден всегда).
## Каталог магазина без закрытых позиций.
func available_shop() -> Array:
var out: Array = []
for item in shop:
@@ -261,6 +496,13 @@ func folder_name() -> String:
return str(values[randi_range(0, values.size() - 1)])
## Значение из constants с fallback (для других систем).
func const_value(key: String, fallback_value = null):
if constants.has(key):
return constants[key]
return fallback_value
# --- служебное -------------------------------------------------------------
func _game_node():
@@ -270,13 +512,31 @@ func _game_node():
return null
func _as_dict(raw) -> Dictionary:
if raw is Dictionary:
return (raw as Dictionary).duplicate(true)
return {}
func _load_unlocks(progression) -> Dictionary:
if progression is Dictionary:
var raw = progression.get("unlocks", null)
if raw is Dictionary and not raw.is_empty():
return raw.duplicate(true)
push_warning("Data: balance.json → progression.unlocks отсутствует, используются дефолты")
return DEFAULT_PROGRESSION["unlocks"].duplicate(true)
return {
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4},
}
func _load_meta_rewards(progression) -> Dictionary:
@@ -285,7 +545,12 @@ func _load_meta_rewards(progression) -> Dictionary:
if raw is Dictionary and not raw.is_empty():
return raw.duplicate(true)
push_warning("Data: balance.json → progression.meta_rewards отсутствует, используются дефолты")
return DEFAULT_PROGRESSION["meta_rewards"].duplicate(true)
return {
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
}
func _merge_types(raw) -> Dictionary:
var out: Dictionary = DEFAULT_TYPES.duplicate(true)
@@ -319,22 +584,81 @@ func _load_difficulty(raw) -> Array:
return DEFAULT_DIFFICULTY.duplicate(true)
func _load_shop(raw) -> Array:
if raw is Array and not raw.is_empty():
func _load_hours(raw) -> Array:
if raw is Array and not (raw as Array).is_empty():
var out: Array = []
for row in raw:
if row is Dictionary:
out.append(row.duplicate(true))
if not out.is_empty():
out.sort_custom(func(a, b): return int(a.get("hour", 0)) < int(b.get("hour", 0)))
return out
push_warning("Data: balance.json → hours отсутствует, используются дефолты")
var fallback: Array = []
for i in range(9):
fallback.append(_default_hour_row(i + 1))
return fallback
func _default_hour_row(hour: int) -> Dictionary:
var idx := clampi(hour - 1, 0, 8)
var type_keys := ["text", "sound", "photo", "video"]
var weights: Dictionary = {}
var req_weights: Dictionary = {}
for i in range(type_keys.size()):
weights[type_keys[i]] = DEFAULT_TYPE_WEIGHTS[idx][i]
req_weights[type_keys[i]] = DEFAULT_REQUEST_TYPE_WEIGHTS[idx][i]
return {
"hour": maxi(1, hour),
"duration": DEFAULT_HOUR_DURATION_BASE + DEFAULT_HOUR_DURATION_STEP * idx,
"files": DEFAULT_HOUR_FILES[idx],
"active_requests": DEFAULT_ACTIVE_REQUESTS[idx],
"attacks": DEFAULT_ATTACKS[idx],
"income_target": DEFAULT_INCOME_TARGET[idx],
"send_interval": DEFAULT_SEND_INTERVAL[idx],
"type_weights": weights,
"request_mix": DEFAULT_REQUEST_MIX.duplicate(),
"request_type_weights": req_weights,
}
func _load_shop_lots(raw) -> Array:
if raw is Array and not (raw as Array).is_empty():
var out: Array = []
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
out.append(item.duplicate(true))
if not out.is_empty():
return out
push_warning("Data: balance.json → shop отсутствует, используется дефолт")
return DEFAULT_SHOP.duplicate(true)
push_warning("Data: balance.json → shop отсутствует, используются %d дефолтных лотов" % DEFAULT_LOT_IDS.size())
var fallback: Array = []
for i in range(DEFAULT_LOT_IDS.size()):
fallback.append({
"id": DEFAULT_LOT_IDS[i],
"name_ru": DEFAULT_LOT_NAMES[i],
"desc_ru": "",
"effect": DEFAULT_LOT_EFFECTS[i],
"value": DEFAULT_LOT_VALUES[i],
"max_level": DEFAULT_LOT_MAX[i],
"prices": DEFAULT_LOT_PRICES[i].duplicate(),
"level_names": [],
"weight_by_hour": DEFAULT_LOT_WEIGHTS[i].duplicate(),
})
return fallback
func _load_rules(raw) -> Dictionary:
var out: Dictionary = DEFAULT_SHOP_RULES.duplicate(true)
if raw is Dictionary and not raw.is_empty():
for key in raw.keys():
out[str(key)] = raw[key]
return out
push_warning("Data: balance.json → shop_rules отсутствует, используются дефолты")
return out
func _load_names(raw) -> Dictionary:
if raw is Dictionary and not raw.is_empty():
# Сохраняем все ключи файла (adjectives/nouns/organizations/folders/...),
# а не только жёстко заданный набор.
return raw.duplicate(true)
push_warning("Data: names_ru.json пуст, используются пустые пулы имён")
return {
+396 -30
View File
@@ -1,6 +1,10 @@
extends Node
## Game — верхнеуровневое состояние забега (autoload).
##
## v2-экономика: старт 50 $, SLA 60/60, 1 слот, сетка 5×4, канал 10,
## буфер 6, таймер запроса 30 с. Уровни лотов хранятся в Game.upgrades
## одновременно под id лота и под агрегированным ключом эффекта.
##
## Не знает о сценах и UI: только данные, переходы фаз и сигналы.
## Фазы: menu → prep → hour → ... → results.
@@ -11,28 +15,48 @@ signal phase_changed(p)
signal run_ended(reason)
signal toast(text)
const START_MONEY := 150
const START_SLA := 128.0
const START_MONEY := 50
const START_SLA := 60.0
const HOURS_PER_RUN := 9
const MAX_SLA := 200.0
## Жёсткий кап SLA (main.gd показывает его в статус-баре). Рабочий кап —
## sla_cap(): 60 + 40 * sla_level, не выше MAX_SLA.
const MAX_SLA := 220.0
const REGISTRY_SCRIPT := preload("res://scripts/core/file_registry.gd")
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
## Резерв, если Data ещё не готов (id → товар).
const FALLBACK_SHOP := {
"shop_slot3": {"id": "shop_slot3", "name_ru": "Третий слот", "price": 250, "max_level": 1},
"shop_slot4": {"id": "shop_slot4", "name_ru": "Четвёртый слот", "price": 450, "max_level": 1},
"shop_buffer": {"id": "shop_buffer", "name_ru": "Буфер +2", "price": 200, "max_level": 2},
"shop_timer": {"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "price": 350, "max_level": 3},
"shop_zone": {"id": "shop_zone", "name_ru": "Зона +5%", "price": 300, "max_level": 3},
"shop_channel": {"id": "shop_channel", "name_ru": "Канал +25", "price": 250, "max_level": 4},
"shop_section": {"id": "shop_section", "name_ru": "Новая секция", "price": 300, "max_level": 5},
"shop_search": {"id": "shop_search", "name_ru": "Поиск", "price": 250, "max_level": 1},
"shop_shield": {"id": "shop_shield", "name_ru": "Щит", "price": 400, "max_level": 1},
"shop_sla": {"id": "shop_sla", "name_ru": "SLA +24", "price": 300, "max_level": 3},
## effect → агрегированный ключ уровня в Game.upgrades.
const LOT_EFFECT_KEYS := {
"connection_slots": "slot_level",
"transfer_speed": "speed_level",
"sla_cap": "sla_level",
"shield_charge": "shield_level",
"money_mult": "money_level",
"folder_cells": "folder_level",
"channel_capacity": "channel_level",
"request_timer": "timer_level",
"udp_success": "udp_level",
}
const FALLBACK_LOT_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
## Резерв, если Data ещё не готов (id → лот минимально необходимых полей).
const FALLBACK_SHOP := {
"lot_slots": {"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1, "max_level": 4, "prices": [90, 200, 400, 700]},
"lot_speed": {"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "value": 0.10, "max_level": 6, "prices": [55, 75, 100, 135, 180, 245]},
"lot_sla": {"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "value": 40, "max_level": 4, "prices": [80, 160, 270, 430]},
"lot_shield": {"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "value": 1, "max_level": 3, "prices": [100, 220, 410]},
"lot_money": {"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "value": 0.05, "max_level": 8, "prices": [70, 95, 130, 170, 220, 285, 365, 465]},
"lot_folder": {"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "value": 1, "max_level": 5, "prices": [50, 100, 180, 290, 440]},
"lot_channel": {"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "value": 6, "buffer_value": 1, "max_level": 6, "prices": [45, 70, 95, 130, 175, 230]},
"lot_timer": {"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "value": 7, "max_level": 5, "prices": [45, 75, 115, 165, 230]},
"lot_udp": {"id": "lot_udp", "name_ru": "UDP-повторитель", "effect": "udp_success", "value": 0.10, "max_level": 4, "prices": [60, 120, 210, 350]},
}
## Публичный алиас каталога-резерва (удобно тестам/UI).
const LOT_DEFS := FALLBACK_SHOP
var money: int = START_MONEY
var sla: float = START_SLA
var hour: int = 1
@@ -44,6 +68,19 @@ var upgrades: Dictionary = {}
var meta_unlocks: Dictionary = {}
## Награды, выданные в текущем забеге (для итогового экрана); чистится новым забегом.
var run_meta_awards: Array[String] = []
## v2-магазин: 3 id, счётчик рероллов, час генерации, pity-история и замки.
var shop_offers: Array[String] = []
var shop_rerolls: int = 0
var shop_offer_hour: int = 0
var shop_last_seen: Dictionary = {}
var shop_locked: Array[String] = []
## Заряды «Заземления»: восстанавливаются в начале часа.
var shield_charges: int = 0
## Час, за который SLA-реген уже выдан (идемпотентность вызова).
var _sla_regen_hour: int = 0
## FileRegistry; без type hint, чтобы работать без прогретого class cache.
var registry = null
var rng_seed: int = 0
@@ -78,6 +115,8 @@ func start_hour() -> bool:
return false
if registry == null:
registry = _make_registry()
regen_sla_for_hour()
regen_shield_for_hour()
phase = &"hour"
phase_changed.emit(phase)
toast.emit(_tr("msg.hour_start", "Час %d" % hour))
@@ -98,10 +137,21 @@ func end_hour() -> bool:
return true
var finished := hour
hour += 1
# Старение файлов — только на переходе часа (не во время часа и не на победе).
var expired := 0
if registry != null and registry.has_method("age_files_between_hours"):
expired = int(registry.age_files_between_hours())
if expired > 0:
stats["files_expired"] = int(stats.get("files_expired", 0)) + expired
phase = &"prep"
# Рероллы живут одну подготовку.
shop_rerolls = 0
shop_locked.clear()
hour_changed.emit(hour)
phase_changed.emit(phase)
toast.emit(_tr("msg.hour_end", "Час %d завершён" % finished))
if expired > 0:
toast.emit(_tr("msg.files_expired", "АРХИВ: %d файлов списано") % expired)
return true
@@ -128,7 +178,7 @@ func add_money(delta: int, reason: String = "") -> void:
func add_sla(delta: float, reason: String = "") -> void:
sla = clampf(sla + float(delta), 0.0, MAX_SLA)
sla = clampf(sla + float(delta), 0.0, sla_cap())
if not reason.is_empty():
stats["last_reason"] = reason
sla_changed.emit(sla)
@@ -136,10 +186,168 @@ func add_sla(delta: float, reason: String = "") -> void:
_finish_run("sla")
## +15 SLA в начале часа (не выше динамического капа). Возвращает новый SLA.
## Идемпотентно в пределах одного часа: повторный вызов не добавляет SLA.
func regen_sla_for_hour() -> float:
if _sla_regen_hour == hour:
return sla
_sla_regen_hour = hour
var regen := _const("sla_regen_per_hour", 15.0)
sla = clampf(sla + regen, 0.0, sla_cap())
sla_changed.emit(sla)
return sla
## Заряды щита восстанавливаются до максимума в начале часа.
func regen_shield_for_hour() -> int:
shield_charges = shield_max()
return shield_charges
func add_stat(key: String, delta: int = 1) -> void:
stats[key] = int(stats.get(key, 0)) + int(delta)
# --- v2: уровни лотов и производные статы ----------------------------------
## Уровень по id лота (lot_*) или по эффекту/агрегату
## (connection_slots / slot_level / ...).
func level(effect_or_lot: String) -> int:
var key := str(effect_or_lot)
if key.is_empty():
return 0
if key.begins_with("lot_"):
if upgrades.has(key):
return int(upgrades[key])
var def := _lot_def(key)
var effect := str(def.get("effect", ""))
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
if not agg.is_empty():
return int(upgrades.get(agg, 0))
return 0
if upgrades.has(key):
return int(upgrades[key])
if LOT_EFFECT_KEYS.has(key):
return int(upgrades.get(str(LOT_EFFECT_KEYS[key]), 0))
return 0
## Записать уровень лота в оба ключа и сразу применить мгновенные эффекты
## (SLA-кап/щит). Остальные статы читаются геттерами динамически.
func set_lot_level(id: String, new_level: int) -> int:
var def := _lot_def(id)
if def.is_empty():
return 0
if not (upgrades is Dictionary):
upgrades = {}
var max_level := maxi(1, int(def.get("max_level", 1)))
var old_level := level(id)
var lvl := clampi(int(new_level), 0, max_level)
upgrades[id] = lvl
var effect := str(def.get("effect", ""))
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
if not agg.is_empty():
upgrades[agg] = lvl
if effect == "sla_cap" and lvl > old_level:
# Кап уже вырос (sla_cap() читает новый уровень), добавляем +40 сразу.
var gain := _lot_value(def, 40.0) * float(lvl - old_level)
sla = clampf(sla + gain, 0.0, sla_cap())
sla_changed.emit(sla)
elif effect == "shield_charge" and lvl > old_level:
shield_charges = mini(shield_max(), shield_charges + (lvl - old_level))
if effect == "folder_cells" and lvl != old_level and registry != null \
and registry.has_method("resize_folders"):
# Лот растит уже существующие папки, а не только будущие.
registry.resize_folders(folder_dims())
return lvl
func sla_cap() -> float:
var base := _const("sla_base", START_SLA)
var step := _lot_effect_value("lot_sla", "value", _const("sla_per_level", 40.0))
var hard := _const("sla_hard_cap", MAX_SLA)
return clampf(base + step * float(level("sla_level")), base, hard)
func money_mult() -> float:
var step := _lot_effect_value("lot_money", "value", _const("money_mult_step", 0.05))
return 1.0 + step * float(level("money_level"))
## pow(0.90, speed_level) — множитель времени передачи (10% за уровень).
func speed_mult() -> float:
var cut := clampf(_lot_effect_value("lot_speed", "value", 0.10), 0.01, 0.9)
return pow(1.0 - cut, float(maxi(0, level("speed_level"))))
func folder_dims() -> Vector2i:
var table := _folder_dims_table()
if table.is_empty():
table = FALLBACK_LOT_DIMS
var idx := clampi(level("folder_level"), 0, table.size() - 1)
var pair = table[idx]
if pair is Array and (pair as Array).size() >= 2:
return Vector2i(int(pair[0]), int(pair[1]))
return Vector2i(5, 4)
## Цена следующей папки: 150 + 100 за каждую сверх стартовых 2.
## count < 0 → берём текущее число папок из реестра.
func folder_price(count: int = -1) -> int:
var base := int(_const("folder_price_start", 150.0))
var step := int(_const("folder_price_step", 100.0))
var start := int(_const("folders_start", 2.0))
var total := int(count)
if total < 0:
total = 0
if registry != null and registry.has_method("folder_count"):
total = int(registry.folder_count())
return base + step * maxi(0, total - start)
func connection_slots() -> int:
var base := int(_const("slots_start", 1.0))
var cap := int(_const("slots_max", 5.0))
return clampi(base + level("slot_level"), base, cap)
func channel_capacity() -> float:
var base := _const("channel_capacity", 10.0)
var step := _lot_effect_value("lot_channel", "value", _const("channel_per_level", 6.0))
return base + step * float(level("channel_level"))
func buffer_limit() -> int:
var base := int(_const("buffer_base", 6.0))
var step := int(_lot_effect_value("lot_channel", "buffer_value", _const("buffer_per_level", 1.0)))
var cap := int(_const("buffer_max", 12.0))
return clampi(base + step * level("channel_level"), base, cap)
func request_timer() -> float:
var base := _const("request_timer", 30.0)
var step := _lot_effect_value("lot_timer", "value", _const("request_timer_per_level", 7.0))
var cap := _const("request_timer_max", 65.0)
return clampf(base + step * float(level("timer_level")), base, cap)
func packet_timer() -> float:
return _const("packet_timer", 20.0)
func shield_max() -> int:
return maxi(0, level("shield_level"))
## Шанс успешной UDP-отправки: 40% + 10% за уровень «UDP-повторителя»,
## не выше 80%. Зеркалит Economy.udp_success_chance() для UI/контроллера.
func udp_success_chance() -> float:
var base := _const("udp_success_base", 0.40)
var cap := _const("udp_success_max", 0.80)
var step := _lot_effect_value("lot_udp", "value", 0.10)
return clampf(base + step * float(level("udp_level")), base, cap)
# --- прогрессия/открытия ---------------------------------------------------
## Открыта ли фича по data/balance.json → progression.unlocks.
@@ -190,23 +398,23 @@ func award_meta_for_run(hours: int) -> Array[String]:
return awarded
# --- магазин (заглушка эффектов) ------------------------------------------
# --- магазин (legacy-фасад) ------------------------------------------------
## Покупка проверяет деньги и max_level, но не применяет эффект (P1).
## Покупка по id лота (legacy-путь; UI v2 использует Shop.buy).
func buy_upgrade(id: String) -> bool:
var item := _shop_item(id)
if item.is_empty():
return false
var level := int(upgrades.get(id, 0))
var lvl := level(id)
var max_level := int(item.get("max_level", 1))
if max_level > 0 and level >= max_level:
if max_level > 0 and lvl >= max_level:
return false
var cost := _item_cost(item, level)
var cost := _item_cost(item, lvl)
if money < cost:
toast.emit(_tr("msg.money_short", "Недостаточно денег"))
return false
money -= cost
upgrades[id] = level + 1
set_lot_level(id, lvl + 1)
money_changed.emit(money)
toast.emit(_tr("msg.upgrade_bought", "Куплено: %s") % _item_name(item, id))
return true
@@ -234,6 +442,13 @@ func save_game() -> bool:
"rng_seed": rng_seed,
"rng_state": rng_state,
"registry": registry.to_dict(),
"shop_offers": shop_offers.duplicate(),
"shop_rerolls": shop_rerolls,
"shop_offer_hour": shop_offer_hour,
"shop_last_seen": shop_last_seen.duplicate(true),
"shop_locked": shop_locked.duplicate(),
"shield_charges": shield_charges,
"sla_regen_hour": _sla_regen_hour,
}
return SAVE_SCRIPT.save(payload)
@@ -250,14 +465,31 @@ func load_game() -> bool:
if not reg.from_dict(raw_registry):
push_warning("Game: не удалось восстановить registry")
return false
# runtime-резервы под запросы не переживают загрузку: сами запросы не
# сохраняются, поэтому file.required_by/attached_request и location
# "attached" после load указывали бы на несуществующий запрос. Такой файл
# нельзя ни переместить, ни удалить — он навсегда занимает клетку.
for file in reg.files.values():
file.required_by = ""
file.attached_request = ""
if String(file.location) == "attached":
file.location = &"folder" if not String(file.folder_id).is_empty() else &"registry"
# _repair_after_load уже разложил файл по папкам/клеткам. Если папки
# (или её bookkeeping) нет, файл должен быть видимым в реестре, а не
# числиться в несуществующем месте.
if String(file.location) == "folder":
var folder = reg.folders.get(String(file.folder_id), null)
if folder == null or not folder.file_ids.has(file.id):
file.folder_id = ""
file.location = &"registry"
registry = reg
upgrades = _dict_of(data.get("upgrades", {}))
money = maxi(0, int(data.get("money", START_MONEY)))
sla = clampf(float(data.get("sla", START_SLA)), 0.0, MAX_SLA)
hour = clampi(int(data.get("hour", 1)), 1, HOURS_PER_RUN)
phase = StringName(str(data.get("phase", "menu")))
streak = maxi(0, int(data.get("streak", 0)))
upgrades = _dict_of(data.get("upgrades", {}))
sla = clampf(float(data.get("sla", START_SLA)), 0.0, sla_cap())
meta_unlocks = _dict_of(data.get("meta", {}))
run_meta_awards = _string_array_of(data.get("run_meta_awards", []))
stats = _default_stats()
@@ -266,6 +498,10 @@ func load_game() -> bool:
for key in raw_stats.keys():
stats[key] = raw_stats[key]
rng_seed = int(data.get("rng_seed", 0))
shield_charges = clampi(int(data.get("shield_charges", 0)), 0, shield_max())
_sla_regen_hour = int(data.get("sla_regen_hour", 0))
_restore_shop_state(data)
var rng = _rng_node()
if rng != null and rng.has_method("restore_state"):
@@ -287,6 +523,13 @@ func _reset_fields(keep_meta: bool = true) -> void:
streak = 0
stats = _default_stats()
upgrades = {}
shop_offers.clear()
shop_rerolls = 0
shop_offer_hour = 0
shop_last_seen.clear()
shop_locked.clear()
shield_charges = 0
_sla_regen_hour = 0
if not keep_meta:
meta_unlocks = {}
registry = _make_registry()
@@ -319,27 +562,142 @@ func _default_stats() -> Dictionary:
"files_received": 0,
"attacks_blocked": 0,
"failed": 0,
"files_expired": 0,
}
## Восстановить офферы/рероллы/last_seen/замки; выкинуть неизвестные и
## максимальные лоты; при необходимости догенерировать до 3.
func _restore_shop_state(data: Dictionary) -> void:
shop_offers.clear()
for raw_id in _string_array_of(data.get("shop_offers", [])):
var id := str(raw_id)
if not id.begins_with("lot_"):
continue
var def := _lot_def(id)
if def.is_empty():
continue
if level(id) >= maxi(1, int(def.get("max_level", 1))):
continue
if shop_offers.has(id):
continue
shop_offers.append(id)
shop_rerolls = clampi(int(data.get("shop_rerolls", 0)), 0, _max_rerolls())
shop_offer_hour = int(data.get("shop_offer_hour", 0))
shop_last_seen = _int_dict_of(data.get("shop_last_seen", {}))
shop_locked.clear()
for raw_id in _string_array_of(data.get("shop_locked", [])):
var id := str(raw_id)
if shop_offers.has(id) and not shop_locked.has(id):
shop_locked.append(id)
if hour <= 1:
return
var target := int(_shop_rules().get("offers", 3))
if shop_offers.size() >= target and shop_offer_hour > 0:
return
var lots := _lot_defs()
if lots.is_empty():
return
var selected := SHOP_SCRIPT.select_offers(
hour, lots, upgrades, money, [], shop_last_seen,
shop_offers.duplicate(), target)
shop_offers.clear()
for id in selected:
shop_offers.append(str(id))
if shop_offer_hour <= 0:
shop_offer_hour = hour
for id in shop_offers:
shop_last_seen[str(id)] = hour
func _lot_def(id: String) -> Dictionary:
var data = _data_node()
if data != null and data.has_method("lot_def"):
var def = data.lot_def(id)
if def is Dictionary and not (def as Dictionary).is_empty():
return def
var fallback = FALLBACK_SHOP.get(str(id), null)
if fallback is Dictionary:
return (fallback as Dictionary).duplicate(true)
return {}
func _lot_defs() -> Array:
var data = _data_node()
if data != null and data.has_method("lot_defs"):
var lots = data.lot_defs()
if lots is Array and not (lots as Array).is_empty():
return lots
var out: Array = []
for id in FALLBACK_SHOP.keys():
out.append((FALLBACK_SHOP[id] as Dictionary).duplicate(true))
return out
func _shop_rules() -> Dictionary:
var data = _data_node()
if data != null and data.has_method("shop_rules"):
var rules = data.shop_rules()
if rules is Dictionary and not (rules as Dictionary).is_empty():
return rules
return {"offers": 3, "reroll_costs": [40, 80, 120], "max_rerolls": 3}
func _max_rerolls() -> int:
return maxi(0, int(_shop_rules().get("max_rerolls", 3)))
func _folder_dims_table() -> Array:
var data = _data_node()
if data != null and data.has_method("folder_dims_table"):
var table = data.folder_dims_table()
if table is Array and not (table as Array).is_empty():
return table
return FALLBACK_LOT_DIMS.duplicate(true)
func _const(key: String, fallback_value: float) -> float:
var data = _data_node()
if data != null and data.get("constants") is Dictionary:
var consts: Dictionary = data.constants
if consts.has(key):
return float(consts[key])
return float(fallback_value)
func _lot_value(def: Dictionary, fallback_value: float) -> float:
if def.has("value"):
return float(def.get("value"))
return float(fallback_value)
## Значение поля лота (source of truth для эффекта), fallback — константа.
func _lot_effect_value(id: String, field: String, fallback_value: float) -> float:
var def := _lot_def(id)
if def.has(field):
return float(def.get(field))
return float(fallback_value)
func _shop_item(id: String) -> Dictionary:
var data = _data_node()
if data != null and data.has_method("shop_item"):
var item = data.shop_item(id)
if item is Dictionary and not item.is_empty():
if item is Dictionary and not (item as Dictionary).is_empty():
return item
var fallback = FALLBACK_SHOP.get(id, null)
if fallback is Dictionary:
return fallback.duplicate(true)
return (fallback as Dictionary).duplicate(true)
return {}
func _item_cost(item: Dictionary, level: int) -> int:
func _item_cost(item: Dictionary, lvl: int) -> int:
var prices = item.get("prices", null)
if prices is Array and not prices.is_empty():
if prices is Array and not (prices as Array).is_empty():
var arr: Array = prices
if level < arr.size():
return maxi(0, int(arr[level]))
if lvl < arr.size():
return maxi(0, int(arr[lvl]))
return maxi(0, int(arr[arr.size() - 1]))
if item.has("price"):
return maxi(0, int(item.get("price", 0)))
@@ -387,6 +745,14 @@ func _dict_of(value) -> Dictionary:
return {}
func _int_dict_of(value) -> Dictionary:
var out: Dictionary = {}
if value is Dictionary:
for key in value.keys():
out[str(key)] = int(value[key])
return out
func _string_array_of(value) -> Array[String]:
var out: Array[String] = []
if value is Array:
+181 -55
View File
@@ -1,48 +1,76 @@
class_name Economy
extends RefCounted
## Чистая экономика и тайминги (D12). Все методы статические, без состояния
## Чистая экономика и тайминги v2. Все методы статические, без состояния
## и без обращений к сценам — удобно тестировать headless.
##
## ВАЖНО: только float-арифметика в формулах выплаты (никаких int/int) и
## только pow() для степеней (никакого ^).
## Фон/слоты НЕ входят в load (уточнение п.11): канал грузят только
## активные передачи. Константы оставлены нулевыми для совместимости.
## Канал: load = Σ weight(активных передач). Фон и занятые слоты не грузят.
const CHANNEL_BG_BASE := 0.0
const CHANNEL_BG_PER_HOUR := 0.0
const SLOT_LOAD := 0.0
const CHANNEL_CAPACITY_BASE := 80.0
const CHANNEL_CAPACITY_STEP := 25.0
const CHANNEL_CAPACITY_BASE := 10.0
const CHANNEL_CAPACITY_STEP := 6.0
const PACKET_TIMER_BASE := 20.0
const REQUEST_TIMER_BASE := 30.0
const TIMER_PER_HOUR := 0.5
const TIMER_UPGRADE_STEP := 4.0
const PACKET_TIMER_MIN := 12.0
const REQUEST_TIMER_MIN := 20.0
const TIMER_MAX := 999.0
const REQUEST_TIMER_STEP := 7.0
const REQUEST_TIMER_MAX := 65.0
## Grace-множитель первых часов (п.10): больше времени на старте.
## h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 — для таймера пакетов.
const TIMER_GRACE := {1: 1.5, 2: 1.35, 3: 1.2}
const BUFFER_BASE := 6
const BUFFER_PER_LEVEL := 1
const BUFFER_MAX := 12
## Отдельный grace для таймера запросов: в начале игроку нужно больше времени,
## чтобы собрать файлы. h1 2.0, h2 1.7, h3 1.4, h4+ 1.0.
## h1: 30×2.0 = 60, h2: 29.5×1.7 = 50.15, h3: 29×1.4 = 40.6.
const REQUEST_TIMER_GRACE := {1: 2.0, 2: 1.7, 3: 1.4}
## Множитель времени передачи за уровень «Оптоволокна» (10% за уровень).
const SPEED_STEP := 0.90
## Коэффициенты мини-игр: text 1, sound 2, photo 4, video 8.
const MINIGAME_COEFF := {
"text": 1.0,
"sound": 2.0,
"photo": 3.0,
"video": 4.0,
"text": 1,
"sound": 2,
"photo": 4,
"video": 8,
}
## Игры отправки (wires удалена). Порядок совпадает с data.gd/minigame_factory.
const SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
## Игра по rank (позиция суммы в ACHIEVABLE_SUMS). Прямая таблица, без modulo.
const RANK_GAMES := [
"text", "sound", "dots", "photo", "maze",
"dots", "photo", "video", "dots", "text",
"video", "maze", "photo", "sound", "video",
"text", "maze", "sound", "video", "sound",
"photo", "dots", "dots", "maze", "maze",
"photo", "video",
]
## Достижимые суммы коэффициентов для 1–4 файлов: 27 значений.
const ACHIEVABLE_SUMS := [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32,
]
## rank 0..7 → T1, 8..13 → T2, 14..26 → T3.
const TIER_MAX_RANK := [7, 13]
const PAYOUT_SPEED_BASE := 0.6
const PAYOUT_SPEED_BONUS := 0.4
const PAYOUT_QUALITY_BASE := 0.5
const PAYOUT_QUALITY_BONUS := 0.5
const PAYOUT_MULTI_STEP := 0.1
const PAYOUT_MULTI_STEP := 0.15
const PAYOUT_TCP_MULT := 1.15
const PAYOUT_UDP_MULT := 0.85
const PAYOUT_UDP_TIME_MULT := 0.8
const PAYOUT_STREAK_STEP := 0.02
const PAYOUT_STREAK_CAP := 10
const PAYOUT_MONEY_STEP := 0.05
## UDP: база 40%, +10% за уровень «UDP-повторителя», кап 80%.
const UDP_SUCCESS_BASE := 0.40
const UDP_SUCCESS_STEP := 0.10
const UDP_SUCCESS_MAX := 0.80
# --- канал -----------------------------------------------------------------
@@ -58,7 +86,7 @@ static func channel_load(_hour: int, _used_slots: int, active_weights: Array, _u
static func channel_capacity(upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["channel", "channel_level", "channel_lvl", "shop_channel", "channel_capacity"])
var level := _level(upgrades, ["channel", "channel_level", "channel_lvl", "lot_channel", "channel_capacity"])
return CHANNEL_CAPACITY_BASE + CHANNEL_CAPACITY_STEP * float(level)
@@ -75,54 +103,82 @@ static func channel_factor(load: float, capacity_or_upgrades = null) -> float:
return maxf(1.0, maxf(0.0, load) / capacity)
## Базовое время приёма/отправки умножается на фактор канала.
static func recv_time(type_def: Dictionary, factor: float, _upgrades: Dictionary = {}) -> float:
return maxf(0.0, float(type_def.get("recv_time", 0.0))) * maxf(0.0, factor)
static func buffer_limit(upgrades: Dictionary = {}) -> int:
var level := _level(upgrades, ["channel", "channel_level", "buffer", "buffer_level", "lot_channel"])
return clampi(BUFFER_BASE + BUFFER_PER_LEVEL * level, BUFFER_BASE, BUFFER_MAX)
static func send_time(type_def: Dictionary, factor: float, _upgrades: Dictionary = {}) -> float:
return maxf(0.0, float(type_def.get("send_time", 0.0))) * maxf(0.0, factor)
## pow(0.90, speed_level): 0 → 1.0, 6 → ≈0.531.
static func speed_mult(speed_level: int) -> float:
return pow(SPEED_STEP, float(maxi(0, int(speed_level))))
## Базовое время типа: v2-поле base_time_sec с fallback на legacy.
static func type_base_time(type_def: Dictionary) -> float:
if type_def.has("base_time_sec"):
return maxf(0.0, float(type_def.get("base_time_sec", 0.0)))
if type_def.has("recv_time"):
return maxf(0.0, float(type_def.get("recv_time", 0.0)))
return 0.0
## Время приёма: base_time_sec × pow(0.90, speed_level) × channel_factor.
## У приёма нет протокольного множителя.
static func recv_transfer_time(type_def: Dictionary, channel_factor_value: float = 1.0, speed_level: int = 0) -> float:
return type_base_time(type_def) * speed_mult(speed_level) * maxf(0.0, channel_factor_value)
## Время отправки: то же + UDP ×0.8 (TCP ×1.0).
static func send_transfer_time(type_def: Dictionary, channel_factor_value: float = 1.0, speed_level: int = 0, protocol = "tcp") -> float:
var mult := 1.0
if str(protocol).to_lower() == "udp":
mult = PAYOUT_UDP_TIME_MULT
return type_base_time(type_def) * speed_mult(speed_level) * maxf(0.0, channel_factor_value) * mult
## Legacy-обёртки: factor — channel_factor, upgrades даёт speed_level.
static func recv_time(type_def: Dictionary, factor: float, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["speed", "speed_level", "transfer_speed", "lot_speed"])
return recv_transfer_time(type_def, factor, level)
static func send_time(type_def: Dictionary, factor: float, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["speed", "speed_level", "transfer_speed", "lot_speed"])
return send_transfer_time(type_def, factor, level, "tcp")
# --- таймеры ---------------------------------------------------------------
## Формула (п.10): (base − 0.5*(h−1)) * grace(h) + 4*level.
## Grace умножает только базовую часть; апгрейд таймера даёт чистые +4 с.
static func packet_timer(hour: int, upgrades: Dictionary = {}) -> float:
var h := maxi(1, hour)
var level := _level(upgrades, ["timer", "timer_level", "packet_timer", "packet_timer_level", "shop_timer"])
var base := PACKET_TIMER_BASE - TIMER_PER_HOUR * float(h - 1)
var value := base * timer_grace(h) + TIMER_UPGRADE_STEP * float(level)
return clampf(value, PACKET_TIMER_MIN, TIMER_MAX)
## v2: таймер пакета фиксирован — 20 с, не покупается и не зависит от часа.
static func packet_timer(_hour: int = 1, _upgrades: Dictionary = {}) -> float:
return PACKET_TIMER_BASE
static func request_timer(hour: int, upgrades: Dictionary = {}) -> float:
var h := maxi(1, hour)
var level := _level(upgrades, ["timer", "timer_level", "request_timer", "request_timer_level", "shop_timer"])
var base := REQUEST_TIMER_BASE - TIMER_PER_HOUR * float(h - 1)
var value := base * request_timer_grace(h) + TIMER_UPGRADE_STEP * float(level)
return clampf(value, REQUEST_TIMER_MIN, TIMER_MAX)
## v2: 30 + 7 × timer_level, не выше 65 с.
static func request_timer(_hour: int = 1, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["timer", "timer_level", "request_timer", "request_timer_level", "lot_timer"])
var value := REQUEST_TIMER_BASE + REQUEST_TIMER_STEP * float(level)
return clampf(value, REQUEST_TIMER_BASE, REQUEST_TIMER_MAX)
## Grace-множитель таймеров по часу: h1 1.5, h2 1.35, h3 1.2, h4+ 1.0.
static func timer_grace(hour: int) -> float:
return float(TIMER_GRACE.get(maxi(1, hour), 1.0))
## Legacy-хуки: в v2 grace не используется.
static func timer_grace(_hour: int) -> float:
return 1.0
## Grace-множитель таймера запросов: h1 2.0, h2 1.7, h3 1.4, h4+ 1.0.
static func request_timer_grace(hour: int) -> float:
return float(REQUEST_TIMER_GRACE.get(maxi(1, hour), 1.0))
static func request_timer_grace(_hour: int) -> float:
return 1.0
# --- мини-игры -------------------------------------------------------------
## Средний коэффициент сложности. Пустой/неизвестный тип трактуем как text.
## Средний коэффициент сложности (legacy-эвристика).
static func minigame_difficulty(types: Array) -> float:
if types.is_empty():
return 1.0
var total := 0.0
for type in types:
total += float(MINIGAME_COEFF.get(str(type), 1.0))
total += float(MINIGAME_COEFF.get(str(type), 1))
return total / float(types.size())
@@ -137,8 +193,59 @@ static func minigame_type(types: Array) -> String:
return "video"
## Выбор мини-игры отправки по сумме коэффициентов (1–4 файла).
##
## rank — позиция суммы S в списке ACHIEVABLE_SUMS (для недостижимой S —
## позиция ближайшей меньшей достижимой). tier: rank 0..7 → T1, 8..13 → T2,
## 14..26 → T3. Игра берётся напрямую из RANK_GAMES[rank], без modulo.
##
## S = 0 (нет файлов) → dots/T1.
static func send_minigame_for(types: Array) -> Dictionary:
var total := 0
for type in types:
total += int(MINIGAME_COEFF.get(str(type).to_lower(), 1))
return selection_for_sum(total)
## Публичный алиас выбора по готовой сумме (для MiniGameFactory/тестов).
static func selection_for_sum(total: int) -> Dictionary:
var sum := maxi(0, int(total))
if sum <= 0:
return {"id": "dots", "tier": 1, "index": 0, "sum": 0}
var count := ACHIEVABLE_SUMS.size()
var rank := 0
for i in range(count):
if int(ACHIEVABLE_SUMS[i]) <= sum:
rank = i
else:
break
var tier := 1
if rank > int(TIER_MAX_RANK[1]):
tier = 3
elif rank > int(TIER_MAX_RANK[0]):
tier = 2
return {
"id": str(RANK_GAMES[clampi(rank, 0, RANK_GAMES.size() - 1)]),
"tier": tier,
"index": rank,
"sum": sum,
}
## Список достижимых сумм коэффициентов (копия, чтобы нельзя было испортить const).
static func achievable_sums() -> Array:
return ACHIEVABLE_SUMS.duplicate()
## Ступень атаки: H2-3 → T1, H4-5 → T2, H6-7 → T3, H8-9 → T4.
static func attack_tier(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
# --- выплата ---------------------------------------------------------------
## payout = round(base_sum × speed × quality × multi × protocol × streak ×
## money_mult). TCP ×1.15, UDP ×0.85; money_level из upgrades.
static func payout(
base_sum: float,
n: int,
@@ -147,22 +254,41 @@ static func payout(
mini_quality: float,
protocol,
streak: int,
_upgrades: Dictionary = {}
upgrades: Dictionary = {}
) -> int:
if n <= 0:
return 0
var time_ratio := 1.0
if timer_total > 0.0:
time_ratio = clampf(time_left / timer_total, 0.0, 1.0)
time_ratio = clampf(float(time_left) / float(timer_total), 0.0, 1.0)
var speed := PAYOUT_SPEED_BASE + PAYOUT_SPEED_BONUS * time_ratio
var quality := PAYOUT_QUALITY_BASE + PAYOUT_QUALITY_BONUS * clampf(mini_quality, 0.0, 1.0)
var quality := PAYOUT_QUALITY_BASE + PAYOUT_QUALITY_BONUS * clampf(float(mini_quality), 0.0, 1.0)
var multi := 1.0 + PAYOUT_MULTI_STEP * float(n - 1)
var protocol_mult := PAYOUT_TCP_MULT if str(protocol).to_lower() == "tcp" else 1.0
var protocol_mult := PAYOUT_TCP_MULT if str(protocol).to_lower() == "tcp" else PAYOUT_UDP_MULT
var streak_mult := 1.0 + PAYOUT_STREAK_STEP * float(clampi(streak, 0, PAYOUT_STREAK_CAP))
var value := maxf(0.0, base_sum) * speed * quality * multi * protocol_mult * streak_mult
var money_level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
var money_mult := 1.0 + PAYOUT_MONEY_STEP * float(money_level)
var value := maxf(0.0, float(base_sum)) * speed * quality * multi * protocol_mult * streak_mult * money_mult
return int(round(value))
static func money_mult(upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
return 1.0 + PAYOUT_MONEY_STEP * float(level)
## Шанс успешной UDP-отправки: base + step × уровень, не выше cap.
## Чистая функция для тестов и HourDirector/Game.
static func udp_success_chance(upgrades: Dictionary = {},
base: float = UDP_SUCCESS_BASE, step: float = UDP_SUCCESS_STEP,
cap: float = UDP_SUCCESS_MAX) -> float:
var level := _level(upgrades, ["udp", "udp_level", "lot_udp", "udp_success"])
var safe_base := clampf(float(base), 0.0, 1.0)
var safe_cap := clampf(float(cap), safe_base, 1.0)
var value := safe_base + maxf(0.0, float(step)) * float(level)
return clampf(value, safe_base, safe_cap)
## Итоговый счёт: часы × 1000 + заработанные деньги + SLA × 10.
static func score(hours: int, money_earned_total: int, sla: float) -> int:
var safe_hours := maxi(0, hours)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+29 -10
View File
@@ -1,6 +1,6 @@
class_name SlotManager
extends RefCounted
## Слоты соединений. Всего 2 + slot_level, но не больше 4 (D13/D16).
## Слоты соединений v2. Всего 1 + slot_level, но не больше 5.
##
## Слоты общие для приёма и отправки; направление хранится для UI и канала.
## manager не тикает сам: прогресс обновляет HourDirector.
@@ -8,8 +8,8 @@ extends RefCounted
signal changed
const SLOT_SCRIPT := preload("res://scripts/entities/slot.gd")
const BASE_SLOTS := 2
const MAX_SLOTS := 4
const BASE_SLOTS := 1
const MAX_SLOTS := 5
var slots: Array = []
var _fallback_level: int = 0
@@ -64,7 +64,12 @@ func free_count(direction: String = "") -> int:
## Занять свободный слот. Возвращает индекс или -1, если мест нет.
func reserve(direction: String, entity_id: String, entity_name: String = "") -> int:
## kind: "packet" / "request" / "tcp_connection" (пустой выводится из
## направления); detail — IP для tcp_connection; linked_request_id — запрос,
## который пользуется TCP-соединением (нужен для весов канала).
func reserve(direction: String, entity_id: String, entity_name: String = "",
kind: String = "", detail: String = "", can_disconnect: bool = false,
linked_request_id: String = "") -> int:
refresh_max_slots()
if entity_id.is_empty():
return -1
@@ -74,7 +79,8 @@ func reserve(direction: String, entity_id: String, entity_name: String = "") ->
for slot in slots:
if slot.occupied:
continue
slot.occupy(_clean_direction(direction), entity_id, entity_name)
slot.occupy(_clean_direction(direction), entity_id, entity_name, kind,
detail, can_disconnect, linked_request_id)
changed.emit()
return slot.index
return -1
@@ -129,6 +135,7 @@ func set_progress(index: int, progress: float) -> bool:
## Веса активных передач для формулы канала (D12 §9).
## entity_id может быть id файла (приём) или id запроса (отправка).
## Для kind=="tcp_connection" вес берётся по файлам связанного запроса.
func active_weights(registry) -> Array:
var weights: Array = []
if registry == null:
@@ -136,7 +143,8 @@ func active_weights(registry) -> Array:
for slot in slots:
if not slot.occupied:
continue
var weight := _entity_weight(registry, slot.entity_id)
var weight := _entity_weight(registry, slot.entity_id, str(slot.kind),
str(slot.linked_request_id))
if weight > 0.0:
weights.append(weight)
return weights
@@ -162,12 +170,23 @@ func from_dict(d: Dictionary) -> bool:
# --- служебное -------------------------------------------------------------
func _entity_weight(registry, entity_id: String) -> float:
func _entity_weight(registry, entity_id: String, kind: String = "",
linked_request_id: String = "") -> float:
if registry == null:
return 0.0
var files = registry.files if registry.get("files") != null else null
if files == null:
return 0.0
# TCP-соединение само по себе веса не имеет: считаем файлы запроса,
# который через него отправляется.
if str(kind) == "tcp_connection":
if str(linked_request_id).is_empty():
return 0.0
var conn_total := 0.0
for file in files.values():
if str(file.attached_request) == str(linked_request_id):
conn_total += _type_weight(file.type)
return conn_total
if files.has(entity_id):
return _type_weight(files[entity_id].type)
# Запрос: сумма весов прикреплённых к нему файлов.
@@ -192,11 +211,11 @@ func _slot_level() -> int:
if game == null:
return _fallback_level
var upgrades: Dictionary = game.upgrades if game.upgrades is Dictionary else {}
# Сначала агрегированный уровень, который пишет Shop.
for key in ["slot_level", "connection_slots", "slots"]:
# v2: агрегированный уровень, который пишет Shop/Game (lot_slots → slot_level).
for key in ["slot_level", "connection_slots", "lot_slots"]:
if upgrades.has(key):
return int(upgrades[key])
# Совместимость с покупками по id (Game.buy_upgrade старого образца).
# Совместимость с v1-покупками по id (если такие остались в старом сейве).
var level := 0
for key in ["shop_slot3", "shop_slot4"]:
level += int(upgrades.get(key, 0))
+69 -3
View File
@@ -6,6 +6,10 @@ extends RefCounted
## `attached_request` — к какому запросу прикреплён, `lock_until` /
## `cooldown_until` — таймеры (unix-время).
##
## v2: файл — полимино на сетке папки. `shape_id` + `rotation` задают форму,
## `origin` — верхняя левая клетка bounding box, `shape_cells` — производное
## поле (пересчитывается из shape_id/rotation, в save не пишется).
##
## ВАЖНО: проект запускается без прогретого global class cache, поэтому
## между своими скриптами используем `preload`, а не имя класса. Внутри
## этого файла допустим `new()` (не `FileItem.new()`).
@@ -13,6 +17,7 @@ extends RefCounted
const LOCATIONS: Array[String] = [
"registry", # получен, ещё не разложен по папке
"folder", # лежит в папке
"table", # лежит на столе сортировки (не в папке, нельзя attach)
"attached", # прикреплён к запросу (остаётся в папке)
"locked", # заблокирован атакой/шифровальщиком
"malicious", # вредоносный, можно уничтожить
@@ -26,11 +31,26 @@ var type: String = "text"
var folder_id: String = ""
var location: StringName = &"folder"
var attached_request: String = ""
## Runtime-резерв: id запроса, для которого файл обязателен (не сохраняется).
var required_by: String = ""
var malicious_kind: String = ""
var lock_until: float = 0.0
var cooldown_until: float = 0.0
var size_kb: int = 0
var created_at: float = 0.0
## Стабильный индекс иконки (0..FILE_ICON_COUNT-1); -1 — ещё не назначен.
var icon_id: int = -1
## Сколько часов файл провёл на складе (растёт в конце часа).
var hours_lived: int = 0
## Форма на сетке (v2).
var shape_id: String = "O1"
## Производное от shape_id/rotation; в save не входит.
var shape_cells: Array = []
## Верхняя левая клетка bounding box в сетке папки/стола.
var origin: Vector2i = Vector2i.ZERO
## 0..3, по часовой стрелке.
var rotation: int = 0
## Куда файл вернётся после снятия lock (служебное поле, в save пишется).
var _location_before_lock: StringName = &"folder"
@@ -76,8 +96,21 @@ func can_attach(now: float = -1.0) -> bool:
return is_available(now)
func to_dict() -> Dictionary:
return {
func is_on_table() -> bool:
return location == &"table"
func cell_count() -> int:
if not shape_cells.is_empty():
return shape_cells.size()
return 1
## Позиция не входит в метаданные save v2 (единый источник — folder.files /
## table), поэтому по умолчанию include_placement=true нужен только для
## прямого roundtrip FileItem.
func to_dict(include_placement: bool = true) -> Dictionary:
var out := {
"id": id,
"name": name,
"ext": ext,
@@ -90,8 +123,15 @@ func to_dict() -> Dictionary:
"cooldown_until": cooldown_until,
"size_kb": size_kb,
"created_at": created_at,
"icon_id": icon_id,
"hours_lived": hours_lived,
"location_before_lock": String(_location_before_lock),
"shape_id": shape_id,
}
if include_placement:
out["origin"] = [origin.x, origin.y]
out["rotation"] = rotation
return out
static func from_dict(d: Dictionary):
@@ -111,13 +151,39 @@ static func from_dict(d: Dictionary):
f.cooldown_until = float(d.get("cooldown_until", 0.0))
f.size_kb = int(d.get("size_kb", 0))
f.created_at = float(d.get("created_at", 0.0))
f.icon_id = int(d.get("icon_id", -1))
# Старые сейвы без hours_lived: файл считается только что полученным.
f.hours_lived = maxi(0, int(d.get("hours_lived", 0)))
var prev := StringName(str(d.get("location_before_lock", "folder")))
if prev != &"registry":
if prev != &"registry" and prev != &"folder" and prev != &"table":
prev = &"folder"
f._location_before_lock = prev
f.shape_id = str(d.get("shape_id", "O1"))
if f.shape_id.is_empty():
f.shape_id = "O1"
f.rotation = posmod(int(d.get("rotation", 0)), 4)
f.origin = _vec_from(d.get("origin", [0, 0]))
var raw_cells = d.get("shape_cells", [])
if raw_cells is Array:
for cell in raw_cells:
f.shape_cells.append(_vec_from(cell))
# Инварианты: attached без запроса и failed/malicious не бывают прикреплены.
if f.location == &"attached" and f.attached_request.is_empty():
f.location = &"folder" if not f.folder_id.is_empty() else &"registry"
if f.location == &"failed" or f.location == &"malicious":
f.attached_request = ""
if f.location == &"table":
f.attached_request = ""
return f
static func _vec_from(value) -> Vector2i:
if value is Vector2i:
return value
if value is Vector2:
return Vector2i(int(value.x), int(value.y))
if value is Array and value.size() >= 2:
return Vector2i(int(value[0]), int(value[1]))
if value is Dictionary:
return Vector2i(int(value.get("x", 0)), int(value.get("y", 0)))
return Vector2i.ZERO
+86 -8
View File
@@ -1,26 +1,91 @@
class_name Folder
extends RefCounted
## Папка секции. Хранит только id файлов; сами файлы — в FileRegistry.
## Папка секции. Хранит id файлов; сами файлы — в FileRegistry.
##
## `reserved` — счётчик слотов, зарезервированных под ещё не завершённые
## загрузки. В save не пишется: после перезапуска незавершённых загрузок нет.
## v2: папка — клеточная сетка `cols` x `rows`. `cells` — служебная карта
## Vector2i -> owner_id (id файла или id пакета для резервации). В save
## `cells` не пишется: позиции восстанавливаются из placements в FileRegistry.
##
## `reserved` — резервации пакетов: packet_id -> {shape_id, origin, rotation}.
## Клетки резервации тоже лежат в `cells`, поэтому cell_occupied() возвращает
## true и для файлов, и для резерваций.
var id: String = ""
var section: String = ""
var name: String = ""
var cols: int = 5
var rows: int = 4
var file_ids: Array[String] = []
## Служебное: зарезервированные слоты (в файл сохранения не попадает).
var reserved: int = 0
## Служебное: резервации приёма (в save пишутся отдельным списком).
var reserved: Dictionary = {}
## Служебное: занятость клеток (не сохраняется).
var cells: Dictionary = {}
## Legacy-совместимость со старым reserve_slot()/release_slot() (v1 API).
var legacy_slots: int = 0
func count() -> int:
return file_ids.size()
## Свободно ли место с учётом резерваций.
func total_cells() -> int:
return maxi(0, cols) * maxi(0, rows)
func cell_occupied(c: int, r: int) -> bool:
return cells.has(Vector2i(c, r))
func owner_at(c: int, r: int) -> String:
return str(cells.get(Vector2i(c, r), ""))
func is_reserved_cell(c: int, r: int) -> bool:
var owner := owner_at(c, r)
return not owner.is_empty() and reserved.has(owner)
func has_free_cell() -> bool:
return cells.size() < total_cells()
func occupied_cell_count() -> int:
return cells.size()
func free_cell_count() -> int:
return maxi(0, total_cells() - cells.size())
func reserved_count() -> int:
return reserved.size()
## Legacy: раньше ёмкость измерялась слотами (16 файлов).
func has_free_slot(capacity: int = 16) -> bool:
return count() + reserved < capacity
return count() + reserved.size() + legacy_slots < capacity
## Служебный API для FileRegistry: занять клетку владельцем.
func set_cell(cell: Vector2i, owner_id: String) -> void:
cells[cell] = owner_id
## Служебный API: освободить клетки, принадлежащие owner_id.
func clear_owner(owner_id: String) -> void:
# Не удаляем во время обхода keys(): иначе multi-cell фигура оставляет
# «фантомную» клетку в старой папке после переноса.
var to_erase: Array = []
for cell in cells.keys():
if str(cells[cell]) == owner_id:
to_erase.append(cell)
for cell in to_erase:
cells.erase(cell)
func clear_cells() -> void:
cells.clear()
func to_dict() -> Dictionary:
@@ -28,6 +93,8 @@ func to_dict() -> Dictionary:
"id": id,
"section": section,
"name": name,
"cols": cols,
"rows": rows,
"file_ids": file_ids.duplicate(),
}
@@ -37,9 +104,20 @@ static func from_dict(d: Dictionary):
f.id = str(d.get("id", ""))
f.section = str(d.get("section", ""))
f.name = str(d.get("name", ""))
f.cols = maxi(0, int(d.get("cols", 0)))
f.rows = maxi(0, int(d.get("rows", 0)))
var raw = d.get("file_ids", [])
if raw is Array:
for value in raw:
f.file_ids.append(str(value))
f.reserved = 0
# v2: placements могут лежать в files — id подтянет FileRegistry.
var placements = d.get("files", [])
if placements is Array:
for entry in placements:
if entry is Dictionary:
var fid := str(entry.get("id", ""))
if not fid.is_empty() and not f.file_ids.has(fid):
f.file_ids.append(fid)
f.reserved = {}
f.cells = {}
return f
+24
View File
@@ -27,6 +27,14 @@ var loading_folder_id: String = ""
var is_attack: bool = false
var attack_kind: String = ""
var size_kb: int = 0
## Стабильный индекс иконки (0..FILE_ICON_COUNT-1); -1 — ещё не назначен.
var icon_id: int = -1
## Форма, которую пакет займёт в папке. Выбирается при спавне, чтобы показать
## превью ещё до приёма. `shape_cells` — производное (не сохраняется).
var shape_id: String = "O1"
var rotation: int = 0
var shape_cells: Array = []
## Служебные поля (не входят в минимальный контракт, но нужны директору).
var lost_left: float = 0.0
@@ -88,10 +96,13 @@ func to_dict() -> Dictionary:
"is_attack": is_attack,
"attack_kind": attack_kind,
"size_kb": size_kb,
"icon_id": icon_id,
"lost_left": lost_left,
"loading_duration": loading_duration,
"order": order,
"attack_id": attack_id,
"shape_id": shape_id,
"rotation": rotation,
}
@@ -111,10 +122,23 @@ static func from_dict(d: Dictionary):
p.is_attack = bool(d.get("is_attack", false))
p.attack_kind = str(d.get("attack_kind", ""))
p.size_kb = int(d.get("size_kb", 0))
p.icon_id = int(d.get("icon_id", -1))
p.lost_left = float(d.get("lost_left", 0.0))
p.loading_duration = float(d.get("loading_duration", 0.0))
p.order = int(d.get("order", 0))
p.attack_id = str(d.get("attack_id", ""))
p.shape_id = str(d.get("shape_id", "O1"))
if p.shape_id.is_empty():
p.shape_id = "O1"
p.rotation = posmod(int(d.get("rotation", 0)), 4)
p.shape_cells = []
var raw_cells = d.get("shape_cells", [])
if raw_cells is Array:
for cell in raw_cells:
if cell is Vector2i:
p.shape_cells.append(cell)
elif cell is Array and cell.size() >= 2:
p.shape_cells.append(Vector2i(int(cell[0]), int(cell[1])))
return p
+43 -1
View File
@@ -18,9 +18,22 @@ var timer_total: float = 0.0
var slot_reserved: bool = false
var connected: bool = false
## TCP: id активного TcpConnection ("" — соединения нет). Файлы нельзя
## прикреплять, пока connection_id пуст.
var connection_id: String = ""
## TCP: идёт мини-игра установления соединения (полоска). Таймер заморожен.
var connecting: bool = false
## TCP: сколько раз игрок пытался установить соединение в этом часе.
var connect_attempts: int = 0
## TCP: до какого hour_elapsed действует пауза после неудачи.
var connect_cooldown_until: float = 0.0
var minigame_active: bool = false
var sending: bool = false
## UDP: число потерь пакета за час и момент, до которого повтор запрещён.
var udp_failures: int = 0
var udp_retry_until: float = 0.0
var quality: float = 1.0
var send_progress: float = 0.0
var is_attack: bool = false
@@ -32,16 +45,27 @@ var required_types: Array[String] = []
var send_duration: float = 0.0
var send_base_sum: float = 0.0
## v2: выбор мини-игры отправки. Сумма коэффициентов типов, выбранная игра и
## её ступень. Заполняются HourDirector/Economy, в save опциональны.
var send_sum: int = 0
var minigame_id: String = ""
var minigame_tier: int = 0
## Готов ли запрос к отправке: все required прикреплены, TCP подключён.
func is_ready() -> bool:
if missing_count() > 0:
return false
if protocol.to_lower() == "tcp" and not connected:
if protocol.to_lower() == "tcp" and not has_connection():
return false
return true
## Есть ли реальное TCP-соединение (одного флага connected мало).
func has_connection() -> bool:
return not connection_id.is_empty()
func missing_count() -> int:
var missing := 0
for file_id in required:
@@ -85,8 +109,14 @@ func to_dict() -> Dictionary:
"timer_total": timer_total,
"slot_reserved": slot_reserved,
"connected": connected,
"connection_id": connection_id,
"connecting": connecting,
"connect_attempts": connect_attempts,
"connect_cooldown_until": connect_cooldown_until,
"minigame_active": minigame_active,
"sending": sending,
"udp_failures": udp_failures,
"udp_retry_until": udp_retry_until,
"quality": quality,
"send_progress": send_progress,
"is_attack": is_attack,
@@ -95,6 +125,9 @@ func to_dict() -> Dictionary:
"required_types": required_types.duplicate(),
"send_duration": send_duration,
"send_base_sum": send_base_sum,
"send_sum": send_sum,
"minigame_id": minigame_id,
"minigame_tier": minigame_tier,
}
@@ -111,8 +144,14 @@ static func from_dict(d: Dictionary):
r.timer_total = float(d.get("timer_total", 0.0))
r.slot_reserved = bool(d.get("slot_reserved", false))
r.connected = bool(d.get("connected", false))
r.connection_id = str(d.get("connection_id", ""))
r.connecting = bool(d.get("connecting", false))
r.connect_attempts = maxi(0, int(d.get("connect_attempts", 0)))
r.connect_cooldown_until = maxf(0.0, float(d.get("connect_cooldown_until", 0.0)))
r.minigame_active = bool(d.get("minigame_active", false))
r.sending = bool(d.get("sending", false))
r.udp_failures = maxi(0, int(d.get("udp_failures", 0)))
r.udp_retry_until = maxf(0.0, float(d.get("udp_retry_until", 0.0)))
r.quality = clampf(float(d.get("quality", 1.0)), 0.0, 1.0)
r.send_progress = float(d.get("send_progress", 0.0))
r.is_attack = bool(d.get("is_attack", false))
@@ -121,6 +160,9 @@ static func from_dict(d: Dictionary):
r.required_types = _string_array(d.get("required_types", []))
r.send_duration = float(d.get("send_duration", 0.0))
r.send_base_sum = float(d.get("send_base_sum", 0.0))
r.send_sum = int(d.get("send_sum", 0))
r.minigame_id = str(d.get("minigame_id", ""))
r.minigame_tier = int(d.get("minigame_tier", 0))
return r
+42 -1
View File
@@ -1,6 +1,11 @@
class_name Slot
extends RefCounted
## Слот соединения. Направление: "in" (приём TCP) или "out" (отправка).
##
## kind: "packet" (приём файла) / "request" (UDP-отправка) /
## "tcp_connection" (установленное TCP-соединение). Для tcp_connection
## entity_id — id соединения, entity_name — имя клиента, detail — IP,
## linked_request_id — запрос, который пользуется соединением.
var index: int = 0
var direction: String = ""
@@ -9,6 +14,12 @@ var entity_name: String = ""
var progress: float = 0.0
var occupied: bool = false
var kind: String = ""
## Дополнительная строка для UI: у tcp_connection — IP.
var detail: String = ""
var can_disconnect: bool = false
var linked_request_id: String = ""
func reset() -> void:
direction = ""
@@ -16,12 +27,22 @@ func reset() -> void:
entity_name = ""
progress = 0.0
occupied = false
kind = ""
detail = ""
can_disconnect = false
linked_request_id = ""
func occupy(p_direction: String, p_entity_id: String, p_entity_name: String = "") -> void:
func occupy(p_direction: String, p_entity_id: String, p_entity_name: String = "",
p_kind: String = "", p_detail: String = "", p_can_disconnect: bool = false,
p_linked_request_id: String = "") -> void:
direction = p_direction
entity_id = p_entity_id
entity_name = p_entity_name
kind = _clean_kind(p_kind, p_direction)
detail = p_detail
can_disconnect = p_can_disconnect
linked_request_id = p_linked_request_id
progress = 0.0
occupied = true
@@ -34,6 +55,10 @@ func to_dict() -> Dictionary:
"entity_name": entity_name,
"progress": progress,
"occupied": occupied,
"kind": kind,
"detail": detail,
"can_disconnect": can_disconnect,
"linked_request_id": linked_request_id,
}
@@ -45,4 +70,20 @@ static func from_dict(d: Dictionary):
s.entity_name = str(d.get("entity_name", ""))
s.progress = float(d.get("progress", 0.0))
s.occupied = bool(d.get("occupied", false))
s.kind = _clean_kind(str(d.get("kind", "")), s.direction)
s.detail = str(d.get("detail", ""))
s.can_disconnect = bool(d.get("can_disconnect", false))
s.linked_request_id = str(d.get("linked_request_id", ""))
return s
## Пустой kind выводится из направления (совместимость со старыми сейвами).
static func _clean_kind(value: String, direction: String) -> String:
var clean := str(value).to_lower()
if clean == "packet" or clean == "request" or clean == "tcp_connection":
return clean
if direction == "in":
return "packet"
if direction == "out":
return "request"
return ""
+56
View File
@@ -0,0 +1,56 @@
class_name TcpConnection
extends RefCounted
## Установленное TCP-соединение клиента.
##
## Живёт отдельно от Request: запрос может быть удалён после успешной
## отправки, а соединение продолжает занимать out-слот до явного
## disconnect_connection() или конца часа. В слоте видны только имя клиента
## и IP (detail), данные запроса туда не попадают.
var id: String = ""
var client_name: String = ""
var client_id: String = ""
var ip: String = ""
## Запрос, который сейчас пользуется соединением. "" — соединение свободно
## после успешной отправки (слот остаётся занятым).
var request_id: String = ""
var slot_index: int = -1
var hour: int = 0
## Момент открытия (hour_elapsed часа) — для отладки/UI.
var connected_at: float = 0.0
func has_request() -> bool:
return not request_id.is_empty()
func display_name() -> String:
if client_name.is_empty():
return ip
return client_name
func to_dict() -> Dictionary:
return {
"id": id,
"client_name": client_name,
"client_id": client_id,
"ip": ip,
"request_id": request_id,
"slot_index": slot_index,
"hour": hour,
"connected_at": connected_at,
}
static func from_dict(d: Dictionary):
var c = new()
c.id = str(d.get("id", ""))
c.client_name = str(d.get("client_name", ""))
c.client_id = str(d.get("client_id", ""))
c.ip = str(d.get("ip", ""))
c.request_id = str(d.get("request_id", ""))
c.slot_index = int(d.get("slot_index", -1))
c.hour = int(d.get("hour", 0))
c.connected_at = float(d.get("connected_at", 0.0))
return c
+1
View File
@@ -0,0 +1 @@
uid://c3tyrouda4ebi
+127 -17
View File
@@ -21,6 +21,9 @@ signal shop_changed
signal toast(text: String)
signal show_connection(game)
signal show_minigame(game)
signal minigame_closed(req_id: String)
signal show_attack_minigame(game, kind: String)
signal attack_resolved(kind: String, success: bool, tier: int)
## Дополнительно: проброс изменения слотов (удобно для UI во время часа).
signal slot_changed
@@ -35,6 +38,7 @@ const HOURS_PER_RUN := 9
const FOLDER_CAPACITY := 16
const HOUR_DIRECTOR_SCRIPT := preload("res://scripts/core/hour_director.gd")
const CONNECTION_GAME_SCRIPT := preload("res://scripts/minigames/connection_game.gd")
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
const ECONOMY := preload("res://scripts/core/economy.gd")
@@ -79,7 +83,7 @@ func new_run(seed: int = 0) -> void:
func continue_run() -> bool:
_bind()
if game == null or not has_save():
if game == null or not can_continue():
return false
if not bool(game.load_game()):
return false
@@ -97,6 +101,11 @@ func has_save() -> bool:
return SAVE_SCRIPT.has_save()
## «Продолжить» доступно только для незавершённого забега.
func can_continue() -> bool:
return SAVE_SCRIPT.can_continue()
func _enter_prep() -> void:
_bind()
phase = PHASE_PREP
@@ -104,6 +113,12 @@ func _enter_prep() -> void:
modal_open = false
if game != null:
game.phase = PHASE_PREP
# Магазин: 3 лота после каждого часа, начиная с подготовки перед H2.
# Во время часа офферы не генерируются и магазин не открывается.
if int(game.hour) > 1 and int(game.shop_offer_hour) != int(game.hour):
if shop != null:
shop.refresh(int(game.hour), int(game.money))
shop_changed.emit()
# Автосейв на входе в подготовку: прогресс не теряется между часами.
game.save_game()
phase_changed.emit(phase)
@@ -119,11 +134,19 @@ func start_hour() -> bool:
# Час начинается только из подготовки (menu допустим, если Game уже в prep).
if phase != PHASE_PREP and not (phase == PHASE_MENU and game.phase == PHASE_PREP):
return false
# M4/M18: стол сортировки возвращает файлы до старта часа. Если места нет —
# час не начинается (никакого soft-lock/потери файлов).
if game.registry != null and game.registry.has_method("auto_return_table"):
if not bool(game.registry.auto_return_table()):
var message := "На столе остались файлы, места нет"
toast.emit(message)
return false
if not bool(game.start_hour()):
return false
hour_director.start_hour(int(game.hour))
hour_director.set_paused(false)
hour_director.set_modal_open(false)
hour_director.set_attack_paused(false)
phase = PHASE_HOUR
paused = false
modal_open = false
@@ -225,13 +248,14 @@ func prep_data() -> Dictionary:
for file in registry.get_folder_files(str(folder.id)):
folder_files.append(_file_entry(file))
files_map[str(folder.id)] = folder_files
capacity_total += FOLDER_CAPACITY
free_slots += maxi(0, FOLDER_CAPACITY - folder.count() - int(folder.reserved))
capacity_total += folder.total_cells()
free_slots += folder.free_cell_count()
folders_data.append({
"id": str(folder.id),
"name": str(folder.name),
"count": folder.count(),
"capacity": FOLDER_CAPACITY,
"count": folder.occupied_cell_count(),
"capacity": folder.total_cells(),
"files": folder.count(),
})
sections_data.append({
"id": str(section.id),
@@ -262,6 +286,7 @@ func hour_data() -> Dictionary:
var status := {
"tcp_used": 0,
"tcp_max": 0,
"udp_chance": 0.0,
"channel": 0.0,
"channel_factor": 1.0,
"channel_percent": 0.0,
@@ -271,6 +296,7 @@ func hour_data() -> Dictionary:
"money": int(game.money) if game != null else 0,
"hour": int(game.hour) if game != null else 1,
"time_left": 0.0,
"overtime": false,
}
if hour_director == null:
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
@@ -288,9 +314,17 @@ func hour_data() -> Dictionary:
for id in hour_director.active_requests.keys():
var request = hour_director.active_requests[id]
var req_entry: Dictionary = request.to_dict()
req_entry["ready"] = request.is_ready()
req_entry["ready"] = bool(hour_director.is_request_ready(str(id)))
req_entry["time_ratio"] = request.time_ratio()
req_entry["missing"] = request.missing_count()
req_entry["required_count"] = int(hour_director.request_required_count(str(id)))
req_entry["missing"] = maxi(0, int(req_entry["required_count"]) - request.attached.size())
req_entry["queued"] = bool(hour_director.is_request_queued(str(id)))
# UDP: шанс успеха и остаток кулдауна повтора (для карточки/статуса).
var is_udp := str(request.protocol) == "udp"
req_entry["udp_chance"] = hour_director.udp_success_chance() if is_udp else 0.0
req_entry["udp_retry_left"] = maxf(0.0,
float(request.udp_retry_until) - float(hour_director.hour_elapsed)) if is_udp else 0.0
req_entry["connection_id"] = str(request.connection_id)
requests.append(req_entry)
var used := 0
@@ -299,7 +333,20 @@ func hour_data() -> Dictionary:
used = int(hour_director.slots.used_count())
max_slots = int(hour_director.slots.max_slots())
for slot in hour_director.slots.slots:
slots_data.append(slot.to_dict())
var entry: Dictionary = slot.to_dict()
entry["kind"] = str(slot.kind)
entry["detail"] = str(slot.detail)
entry["can_disconnect"] = bool(slot.can_disconnect)
entry["entity_name"] = str(slot.entity_name)
if str(slot.kind) == "tcp_connection":
# Данные запроса в слот не попадают: только статус передачи.
var linked = hour_director.active_requests.get(str(slot.linked_request_id))
entry["sending"] = linked != null and bool(linked.sending)
var progress := 0.0
if linked != null and bool(linked.sending) and float(linked.send_duration) > 0.0:
progress = clampf(float(linked.send_progress) / float(linked.send_duration), 0.0, 1.0)
entry["progress"] = progress
slots_data.append(entry)
var channel_load := float(hour_director.channel_load())
var capacity := float(ECONOMY.channel_capacity(game.upgrades if game != null else {}))
@@ -307,9 +354,22 @@ func hour_data() -> Dictionary:
if capacity > 0.0:
percent = channel_load / capacity * 100.0
var sending_count := 0
for request in hour_director.active_requests.values():
if request != null and bool(request.sending):
sending_count += 1
var attacks_active := 0
var attacks_tier := 0
if hour_director.attack_director != null:
if hour_director.attack_director.has_method("active_count"):
attacks_active = int(hour_director.attack_director.active_count())
if hour_director.attack_director.has_method("current_tier"):
attacks_tier = int(hour_director.attack_director.current_tier())
status = {
"tcp_used": used,
"tcp_max": max_slots,
"udp_chance": float(hour_director.udp_success_chance()),
"channel": channel_load,
"channel_factor": float(hour_director.channel_factor()),
"channel_percent": percent,
@@ -319,24 +379,40 @@ func hour_data() -> Dictionary:
"money": int(game.money) if game != null else 0,
"hour": int(game.hour) if game != null else int(hour_director.hour),
"time_left": float(hour_director.time_left),
"hour_total": float(hour_director.hour_seconds()),
"overtime": hour_director.is_overtime(),
"requests_active": int(hour_director.active_requests.size()),
"request_limit": int(hour_director.active_request_limit),
"attacks_active": attacks_active,
"attacks_tier": attacks_tier,
"sending_count": sending_count,
"queued_count": int(hour_director.send_queue_count()),
}
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
## Каталог магазина с текущими уровнями/ценами — для модалки магазина.
## 3 текущих предложения магазина (v2). Пустой массив в H1/во время часа.
func shop_data() -> Array:
_bind()
var out: Array = []
if shop == null:
if shop == null or game == null:
return out
for item in shop.items():
var id := str(item.get("id", ""))
if int(game.hour) <= 1 and game.shop_offers.is_empty():
return out
for raw_id in shop.offers():
var id := str(raw_id)
var item: Dictionary = shop.get_item(id)
if item.is_empty():
continue
var item_name := str(item.get("name_ru", ""))
if item_name.is_empty():
item_name = str(item.get("name", id))
out.append({
"id": id,
"name": item_name,
"desc": str(item.get("desc_ru", "")),
"effect": str(item.get("effect", "")),
"value": float(item.get("value", 0.0)),
"price": int(shop.price(id)),
"level": int(shop.level(id)),
"max_level": int(shop.max_level(id)),
@@ -371,16 +447,41 @@ func _on_hour_toast(text: String) -> void:
toast.emit(text)
func _on_show_connection(game_node) -> void:
modal_open = true
show_connection.emit(game_node)
## HourDirector эмитит трио (request_id, hour, successes): ConnectionGame
## создаёт контроллер, а Main только монтирует готовую ноду в modal_root.
func _on_show_connection(request_id: String, hour: int, successes: int) -> void:
var zone_level := 0
if game != null and game.has_method("level"):
# Бонус зоны соединения привязан к уровню лота «Регламент обработки».
zone_level = maxi(0, int(game.level("timer_level")))
var node = CONNECTION_GAME_SCRIPT.new()
node.start(int(hour), int(successes), zone_level, str(request_id))
# Через set_modal_open(), иначе HourDirector не узнаёт о модалке и час
# продолжает спавнить пакеты/запросы под ней.
set_modal_open(true)
show_connection.emit(node)
func _on_show_minigame(game_node) -> void:
modal_open = true
set_modal_open(true)
show_minigame.emit(game_node)
func _on_minigame_closed(req_id: String) -> void:
set_modal_open(false)
minigame_closed.emit(req_id)
func _on_show_attack_minigame(game_node, kind: String) -> void:
set_modal_open(true)
show_attack_minigame.emit(game_node, kind)
func _on_attack_resolved(kind: String, success: bool, tier: int) -> void:
set_modal_open(false)
attack_resolved.emit(kind, success, tier)
func _on_slot_changed() -> void:
slot_changed.emit()
@@ -391,6 +492,9 @@ func _finish_run(reason: String) -> void:
_bind()
if game == null:
return
# Кончился забег посреди часа (SLA=0): останавливаем тик/модалки до смены фазы.
if hour_director != null and hour_director.has_method("stop_for_run_end"):
hour_director.stop_for_run_end()
phase = PHASE_RUN_RESULT
paused = false
modal_open = false
@@ -431,6 +535,9 @@ func _bind() -> void:
hour_director.toast.connect(_on_hour_toast)
hour_director.show_connection.connect(_on_show_connection)
hour_director.show_minigame.connect(_on_show_minigame)
hour_director.minigame_closed.connect(_on_minigame_closed)
hour_director.show_attack_minigame.connect(_on_show_attack_minigame)
hour_director.attack_resolved.connect(_on_attack_resolved)
hour_director.slot_changed.connect(_on_slot_changed)
if game != null and not game.run_ended.is_connected(_on_game_run_ended):
game.run_ended.connect(_on_game_run_ended)
@@ -440,7 +547,10 @@ func _bind() -> void:
func _reset_director_state() -> void:
if hour_director == null:
return
hour_director.start_hour(1)
hour_director.reset_carry()
# Подготовка перед H1: seed-файл создаём, но активный запрос/резервации
# не оставляем — иначе игрок не видит запрос, а файл уже занят.
hour_director.reset_for_prep()
hour_director.running = false
hour_director.paused = false
hour_director.set_modal_open(false)
+2896 -771
View File
File diff suppressed because it is too large. Load diff
+386
View File
@@ -0,0 +1,386 @@
class_name DdosGame
extends MiniGame
## Атака «Щит» (ddos). Конечная волна красных пакетов падает сверху.
## Щит следует за мышью по X (кнопку держать не нужно) и двигается стрелками.
##
## Движение только dt-based: p.y += speed * delta, без покадрового damping.
## Раунд заканчивается сам, когда каждый пакет волны отбит или пробил низ.
## Качество: q = blocked / total_packets (все пакеты волны входят в total).
const GAME_ID := "ddos"
const TITLE_RU := "ЩИТ"
## Поле заканчивается на y=294, чтобы подсказка (y=298..311) не легла на доску.
const FIELD := Rect2(18.0, 58.0, 604.0, 236.0)
const PACKET_W := 18.0
const PACKET_H := 20.0
const SHIELD_H := 16.0
const SHIELD_Y := 270.0
const FIELD_PAD := 10.0
const SHIELD_PAD := 8.0
const MIN_SPACING := 12.0
const TIERS := {
1: {"packets": 8, "speed": 110.0, "shield_width": 260.0, "interval": 0.55, "threshold": 0.60},
2: {"packets": 10, "speed": 140.0, "shield_width": 220.0, "interval": 0.45, "threshold": 0.65},
3: {"packets": 12, "speed": 170.0, "shield_width": 180.0, "interval": 0.35, "threshold": 0.70},
4: {"packets": 14, "speed": 200.0, "shield_width": 150.0, "interval": 0.28, "threshold": 0.75},
}
var _wave: Dictionary = {}
var _packets: Array = []
var _events: Array = []
var _spawned: int = 0
var _blocked: int = 0
var _breached: int = 0
var _elapsed: float = 0.0
var _shield_center: float = 320.0
var _flash: float = 0.0
var _round_done: bool = false
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
return ddos_quality(_blocked, int(_wave.get("total", 0)))
func _setup() -> void:
_wave = generate_wave(tier, seed_value)
_packets.clear()
_events.clear()
_spawned = 0
_blocked = 0
_breached = 0
_elapsed = 0.0
_shield_center = float(_wave.get("start_shield_x", 320.0))
_flash = 0.0
_round_done = false
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
return base
for key in ["packets"]:
if row.has(key):
base[key] = int(row[key])
for key in ["speed", "shield_width", "interval", "threshold"]:
if row.has(key):
base[key] = float(row[key])
return base
# --- генерация -------------------------------------------------------------
static func generate_wave(p_tier: int, seed: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("packets", 8)), 4, 24)
var base_speed := maxf(40.0, float(p.get("speed", 110.0)))
var shield_width := clampf(float(p.get("shield_width", 260.0)), 80.0, FIELD.size.x)
var interval := maxf(0.10, float(p.get("interval", 0.55)))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var left := FIELD.position.x + FIELD_PAD
var right := FIELD.end.x - FIELD_PAD - PACKET_W
var band := (right - left) / float(total)
var bands: Array = []
for i in range(total):
bands.append(i)
_shuffle(bands, rng)
var packets: Array = []
var spawn_at := 0.35
for i in range(total):
var band_index := int(bands[i])
var band_left := left + band * float(band_index)
var inner := maxf(1.0, band - 12.0 - PACKET_W)
var x := band_left + 6.0 + rng.randf() * inner
x = clampf(x, left, right)
var speed := base_speed * rng.randf_range(0.90, 1.10)
if i > 0:
spawn_at += interval * rng.randf_range(0.85, 1.15)
packets.append({
"id": i,
"x": x,
"speed": speed,
"spawn_at": spawn_at,
})
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
var start_shield := rng.randf_range(min_center, max_center)
return {
"tier": t,
"packets": packets,
"total": total,
"speed": base_speed,
"shield_width": shield_width,
"interval": interval,
"threshold": float(p.get("threshold", 0.6)),
"start_shield_x": start_shield,
"field": FIELD,
"shield_y": SHIELD_Y,
"packet_w": PACKET_W,
"packet_h": PACKET_H,
}
static func validate_wave(data: Dictionary) -> bool:
var packets: Array = data.get("packets", [])
var total := int(data.get("total", 0))
if total <= 0 or packets.size() != total:
return false
var shield_width := float(data.get("shield_width", 0.0))
if shield_width <= 0.0 or shield_width > FIELD.size.x:
return false
var base_speed := float(data.get("speed", 0.0))
var last_spawn := -1.0
var xs: Array = []
for packet in packets:
var p: Dictionary = packet
var x := float(p.get("x", -1000.0))
var speed := float(p.get("speed", -1.0))
var spawn_at := float(p.get("spawn_at", -1.0))
if is_nan(x) or is_inf(x) or is_nan(speed) or is_inf(speed):
return false
if is_nan(spawn_at) or is_inf(spawn_at) or spawn_at < 0.0 or spawn_at > 30.0:
return false
if x < FIELD.position.x + FIELD_PAD - 0.5 or x > FIELD.end.x - FIELD_PAD - PACKET_W + 0.5:
return false
if speed < base_speed * 0.89 or speed > base_speed * 1.11:
return false
if spawn_at <= last_spawn:
return false
last_spawn = spawn_at
xs.append(x)
# Щит такой ширины может физически перекрыть пакет.
var center := x + PACKET_W * 0.5
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
var reachable := clampf(center, min_center, max_center)
if absf(reachable - center) > shield_width * 0.5 - PACKET_W * 0.5 + 0.5:
return false
xs.sort()
for i in range(1, xs.size()):
if float(xs[i]) - float(xs[i - 1]) < MIN_SPACING - 0.01:
return false
return true
static func ddos_quality(blocked: int, total_packets: int) -> float:
if total_packets <= 0:
return 0.0
return clampf(float(blocked) / float(total_packets), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _shield_rect() -> Rect2:
var shield_width := float(_wave.get("shield_width", 260.0))
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
_shield_center = clampf(_shield_center, min_center, max_center)
return Rect2(_shield_center - shield_width * 0.5, SHIELD_Y, shield_width, SHIELD_H)
func _spawn_packet(packet: Dictionary) -> void:
_packets.append({
"id": int(packet.get("id", 0)),
"x": float(packet.get("x", 0.0)),
"y": FIELD.position.y - PACKET_H,
"speed": float(packet.get("speed", 100.0)),
"resolved": false,
})
func _add_event(pos: Vector2, text: String, color: Color) -> void:
_events.append({"pos": pos, "text": text, "color": color, "ttl": 0.7})
func _process(delta: float) -> void:
if _finished or _round_done:
return
_elapsed += delta
if _flash > 0.0:
_flash = maxf(0.0, _flash - delta)
var wave_packets: Array = _wave.get("packets", [])
while _spawned < wave_packets.size() \
and _elapsed >= float((wave_packets[_spawned] as Dictionary).get("spawn_at", 0.0)):
_spawn_packet(wave_packets[_spawned])
_spawned += 1
var shield := _shield_rect()
var active: Array = []
for entry in _packets:
var p: Dictionary = entry
var prev_bottom := float(p["y"]) + PACKET_H
p["y"] = float(p["y"]) + float(p["speed"]) * delta
var bottom := float(p["y"]) + PACKET_H
var px := float(p["x"])
if not bool(p["resolved"]) and prev_bottom < SHIELD_Y and bottom >= SHIELD_Y:
if px + PACKET_W >= shield.position.x and px <= shield.end.x:
p["resolved"] = true
_blocked += 1
_add_event(Vector2(px + PACKET_W * 0.5, SHIELD_Y - 10.0), "ОТБИТО",
Theme90.SUCCESS)
_play_sfx(&"stamp")
if not bool(p["resolved"]) and bottom >= FIELD.end.y:
p["resolved"] = true
_breached += 1
_flash = 0.22
_add_event(Vector2(px + PACKET_W * 0.5, FIELD.end.y - 12.0), "ПРОБОЙ",
Theme90.ERROR)
_play_sfx(&"bad")
if not bool(p["resolved"]):
active.append(p)
_packets = active
for event in _events:
(event as Dictionary)["ttl"] = float((event as Dictionary)["ttl"]) - delta
(event as Dictionary)["pos"] = (event as Dictionary)["pos"] + Vector2(0.0, -14.0 * delta)
_events = _events.filter(func(e): return float(e["ttl"]) > 0.0)
if _spawned >= wave_packets.size() and _packets.is_empty():
_round_done = true
var total := int(_wave.get("total", wave_packets.size()))
var q := ddos_quality(_blocked, total)
_result_detail = _with_context("ОТБИТО %d ИЗ %d · ПРОБОЙ %d" % [
_blocked, total, _breached])
_finish(q)
queue_redraw()
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _finished or _round_done:
return
_shield_center = local.x
_queue_shield_redraw()
func _on_pointer_pressed(local: Vector2, event: InputEvent) -> bool:
if _finished or _round_done:
return false
_on_pointer_moved(local, event)
return true
func _on_key_pressed(keycode: int, event: InputEvent) -> bool:
if _finished or _round_done:
return false
var step := 8.0 if event is InputEventKey and (event as InputEventKey).shift_pressed else 26.0
match keycode:
KEY_LEFT, KEY_A:
_shield_center -= step
_queue_shield_redraw()
return true
KEY_RIGHT, KEY_D:
_shield_center += step
_queue_shield_redraw()
return true
return false
func _queue_shield_redraw() -> void:
_shield_rect()
queue_redraw()
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var total := int(_wave.get("total", 0))
draw_rect(FIELD, Color(0.075, 0.110, 0.120, 1.0))
draw_rect(FIELD, Color(1, 1, 1, 0.10), false, 1.0)
var grid := Color(1, 1, 1, 0.055)
var gx := FIELD.position.x + 28.0
while gx < FIELD.end.x:
draw_line(Vector2(gx, FIELD.position.y), Vector2(gx, FIELD.end.y), grid, 1.0)
gx += 32.0
var gy := FIELD.position.y + 26.0
while gy < FIELD.end.y:
draw_line(Vector2(FIELD.position.x, gy), Vector2(FIELD.end.x, gy), grid, 1.0)
gy += 26.0
_draw_label("ОТБИТО %d / %d" % [_blocked, total], Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
_draw_label("ПРОБОЙ %d" % _breached, Vector2(420.0, 44.0), 12,
Theme90.ERROR if _breached > 0 else Theme90.SHADE, 200.0,
HORIZONTAL_ALIGNMENT_RIGHT)
if _flash > 0.0:
draw_rect(FIELD, Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b,
0.10 + 0.18 * (_flash / 0.22)))
for entry in _packets:
var p: Dictionary = entry
var rect := Rect2(float(p["x"]), float(p["y"]), PACKET_W, PACKET_H)
draw_style_box(Theme90.box(Theme90.ERROR, Theme90.ERROR.darkened(0.25), 2, 1,
Theme90.SHADOW), rect)
Theme90.text_vcenter(self, Theme90.ui(), rect, "!", 12, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
draw_line(Vector2(rect.position.x + 4.0, rect.position.y + 4.0),
Vector2(rect.end.x - 4.0, rect.end.y - 4.0), Color(1, 1, 1, 0.32), 1.0)
var shield := _shield_rect()
draw_style_box(Theme90.box(Theme90.ACCENT, Theme90.NAVY_DEEP, 3, 1, Theme90.SHADOW), shield)
Theme90.draw_hatch(self, shield.grow(-2.0), Color(1, 1, 1, 0.14), 7.0)
Theme90.text_vcenter(self, Theme90.ui(), shield, "ЩИТ", 10, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
for event in _events:
var e: Dictionary = event
var alpha := clampf(float(e["ttl"]) / 0.55, 0.0, 1.0)
var pos: Vector2 = e["pos"]
var col: Color = e["color"]
Theme90.text(self, Theme90.ui(), pos - Vector2(24.0, 0.0), str(e["text"]), 10,
Color(col.r, col.g, col.b, alpha), 48.0, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_footer_hint() -> void:
# Подсказка живёт в свободной полосе между полем (y=294) и футером (y=320).
_draw_label("ЩИТ СЛЕДУЕТ ЗА МЫШЬЮ ПО X · СТРЕЛКИ ← → ТОЖЕ", Vector2(18.0, 298.0),
10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
static func _shuffle(list: Array, rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
+1
View File
@@ -0,0 +1 @@
uid://djv0odpjkyh8p
+649
View File
@@ -0,0 +1,649 @@
class_name JunkGame
extends MiniGame
## Атака «Фильтр» (junk). Статическая сетка карточек: игрок помечает мусор
## в карантин, повторный клик снимает пометку, ПРОВЕРИТЬ фиксирует выбор.
##
## Мусор выглядит почти как обычный файл: аномалия спрятана в метаданных
## (размер, расширение/тип, дубль имени, дата, CRC, непечатаемый символ).
## Нормальные карточки полностью согласованы. Точное число мусора не
## показывается — только «ОТМЕЧЕНО: N».
##
## Здесь нет таймера и нет поражения по времени. Качество:
## q = clamp((removed_junk - false_positive) / total_junk, 0, 1).
const GAME_ID := "junk"
const TITLE_RU := "ФИЛЬТР"
const BAD_CHAR := "▯"
const COLS := 3
const CARD_W := 190.0
const CARD_GAP := 8.0
const GRID_TOP := 58.0
const GRID_BOTTOM := 294.0
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
const TIERS := {
1: {"cards": 9, "junk": 2, "hints": 1, "threshold": 0.60},
2: {"cards": 10, "junk": 3, "hints": 2, "threshold": 0.65},
3: {"cards": 11, "junk": 4, "hints": 2, "threshold": 0.70},
4: {"cards": 12, "junk": 5, "hints": 3, "threshold": 0.75},
}
const TYPE_DEFS := [
{"kind": "text", "label": "ТЕКСТ", "exts": ["TXT", "DOC", "RTF"]},
{"kind": "sound", "label": "ЗВУК", "exts": ["WAV", "MP3"]},
{"kind": "photo", "label": "ФОТО", "exts": ["JPG", "PNG", "BMP"]},
{"kind": "video", "label": "ВИДЕО", "exts": ["AVI", "MP4"]},
]
const NAMES := [
"отчёт", "смета", "письмо", "схема", "прайс", "акт", "реестр", "план",
"курс", "заявка", "договор", "опись", "факс", "сводка", "накладная",
"ведомость", "протокол", "фото", "звук", "клип",
]
const ANOMALY_DEFS := [
{"id": "zero_size", "family": "size"},
{"id": "absurd_size", "family": "size"},
{"id": "wrong_ext", "family": "ext"},
{"id": "duplicate_name", "family": "name"},
{"id": "bad_date", "family": "date"},
{"id": "missing_crc", "family": "crc"},
{"id": "bad_char", "family": "name"},
]
const ANOMALY_HINTS := {
"zero_size": "размер 0 Б — это мусор",
"absurd_size": "абсурдный размер файла",
"wrong_ext": "расширение не сходится с типом",
"duplicate_name": "два файла с одинаковым именем",
"invalid_date": "несуществующая дата",
"future_date": "дата из будущего",
"missing_crc": "нет контрольной суммы",
"bad_char": "в имени непечатаемый символ",
}
const WRONG_EXTS := {
"text": ["WAV", "JPG", "AVI", "EXE"],
"sound": ["TXT", "JPG", "AVI", "EXE"],
"photo": ["TXT", "WAV", "AVI", "EXE"],
"video": ["TXT", "WAV", "JPG", "EXE"],
}
var _data: Dictionary = {}
var _cards: Array = []
var _marked: Dictionary = {}
var _submitted: bool = false
var _hover: int = -1
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
var counts := count_marks(_cards, _marked)
return junk_quality(int(counts["removed_junk"]), int(counts["false_positive"]),
int(_data.get("total_junk", 0)))
func _setup() -> void:
_data = generate_junk(tier, seed_value)
_cards = _data.get("cards", [])
_marked.clear()
_submitted = false
_hover = -1
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
return base
for key in ["cards", "junk", "hints"]:
if row.has(key):
base[key] = int(row[key])
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
return base
# --- генерация -------------------------------------------------------------
static func generate_junk(p_tier: int, seed: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("cards", 9)), 4, 24)
var junk_count := clampi(int(p.get("junk", 2)), 1, total - 1)
var hint_count := clampi(int(p.get("hints", 1)), 0, 3)
var rng := RandomNumberGenerator.new()
rng.seed = seed
var names: Array = NAMES.duplicate()
_shuffle(names, rng)
var cards: Array = []
for i in range(total):
var td: Dictionary = TYPE_DEFS[rng.randi_range(0, TYPE_DEFS.size() - 1)]
var exts: Array = td["exts"]
var ext := str(exts[rng.randi_range(0, exts.size() - 1)])
var raw_name := str(names[i % names.size()])
if i >= names.size():
raw_name = "%s_%d" % [raw_name, i]
var size_kb := rng.randi_range(2, 480)
cards.append({
"id": i,
"name": raw_name,
"kind": str(td["kind"]),
"type_label": str(td["label"]),
"ext": ext,
"size_kb": size_kb,
"size_text": "%d КБ" % size_kb,
"date": _random_date(rng),
"crc": "%04X" % rng.randi_range(0, 65535),
"junk": false,
"anomalies": [],
})
var order: Array = []
for i in range(total):
order.append(i)
_shuffle(order, rng)
var junk_indices: Array = order.slice(0, junk_count)
var normal_indices: Array = []
for i in range(total):
if not junk_indices.has(i):
normal_indices.append(i)
for pos in range(junk_indices.size()):
var card: Dictionary = cards[int(junk_indices[pos])]
card["junk"] = true
var anomaly_ids := _pick_anomalies(t, pos, rng)
var applied: Array = []
for anomaly_id in anomaly_ids:
var result := _apply_anomaly(card, str(anomaly_id), rng, cards, normal_indices)
if not str(result).is_empty():
applied.append(str(result))
if applied.is_empty():
card["size_kb"] = 0
card["size_text"] = "0 Б"
applied.append("zero_size")
card["anomalies"] = applied
var hints := _collect_hints(cards, hint_count)
return {
"tier": t,
"cards": cards,
"total_cards": total,
"total_junk": junk_count,
"hint_count": hint_count,
"hints": hints,
"threshold": float(p.get("threshold", 0.6)),
}
static func _pick_anomalies(p_tier: int, junk_pos: int, rng: RandomNumberGenerator) -> Array:
var pool: Array = ANOMALY_DEFS.duplicate(true)
_shuffle(pool, rng)
var picked: Array = []
var families: Dictionary = {}
if p_tier == 1 and junk_pos == 0:
picked.append("zero_size")
families["size"] = true
elif p_tier <= 2 and junk_pos == 0 and rng.randf() < 0.5:
picked.append("zero_size")
families["size"] = true
for entry in pool:
if picked.size() >= 2:
break
var family := str((entry as Dictionary)["family"])
var anomaly_id := str((entry as Dictionary)["id"])
if families.has(family):
continue
picked.append(anomaly_id)
families[family] = true
if picked.is_empty():
picked.append("zero_size")
return picked
static func _apply_anomaly(card: Dictionary, anomaly_id: String, rng: RandomNumberGenerator,
cards: Array, normal_indices: Array) -> String:
match anomaly_id:
"zero_size":
card["size_kb"] = 0
card["size_text"] = "0 Б"
return "zero_size"
"absurd_size":
card["size_kb"] = 999999
card["size_text"] = "999 999 КБ"
return "absurd_size"
"wrong_ext":
var kind_name := str(card.get("kind", "text"))
var options: Array = WRONG_EXTS.get(kind_name, ["EXE"])
var alt := str(options[rng.randi_range(0, options.size() - 1)])
if _ext_matches_kind(kind_name, alt):
alt = "EXE"
card["ext"] = alt
return "wrong_ext"
"duplicate_name":
if normal_indices.is_empty() or str(card.get("name", "")).contains(BAD_CHAR):
return ""
var victim: Dictionary = cards[int(normal_indices[rng.randi_range(0,
normal_indices.size() - 1)])]
card["name"] = str(victim.get("name", ""))
return "duplicate_name"
"bad_date":
if rng.randf() < 0.5:
card["date"] = "31.02.97"
return "invalid_date"
card["date"] = "12.06.31"
return "future_date"
"missing_crc":
card["crc"] = "—"
return "missing_crc"
"bad_char":
var name := str(card.get("name", ""))
if name.length() >= 2:
card["name"] = name.substr(0, 1) + BAD_CHAR + name.substr(2)
else:
card["name"] = BAD_CHAR + name
return "bad_char"
return ""
static func _collect_hints(cards: Array, limit: int) -> Array:
if limit <= 0:
return []
var order := ["zero_size", "absurd_size", "wrong_ext", "duplicate_name",
"invalid_date", "future_date", "missing_crc", "bad_char"]
var seen: Dictionary = {}
for key in order:
for card in cards:
if not bool((card as Dictionary).get("junk", false)):
continue
var anomalies: Array = (card as Dictionary).get("anomalies", [])
if anomalies.has(key):
seen[key] = true
var hints: Array = []
for key in order:
if seen.has(key) and hints.size() < limit:
hints.append(str(ANOMALY_HINTS.get(key, "")))
return hints
# --- проверка --------------------------------------------------------------
static func _ext_matches_kind(kind: String, ext: String) -> bool:
var e := ext.strip_edges().to_upper()
for td in TYPE_DEFS:
if str((td as Dictionary)["kind"]) != kind:
continue
var exts: Array = (td as Dictionary)["exts"]
for allowed in exts:
if e == str(allowed):
return true
return false
static func _valid_date(value: String) -> bool:
var parts := value.split(".")
if parts.size() != 3:
return false
if not parts[0].is_valid_int() or not parts[1].is_valid_int() \
or not parts[2].is_valid_int():
return false
var day := int(parts[0])
var month := int(parts[1])
var year := int(parts[2])
if month < 1 or month > 12:
return false
if day < 1 or day > _days_in_month(month):
return false
return year >= 0 and year <= 99
static func _future_date(value: String) -> bool:
var parts := value.split(".")
if parts.size() != 3 or not parts[2].is_valid_int():
return false
# Игра идёт в 1996–1997; двухзначные 00–89 читаются как 2000-е.
var year := int(parts[2])
return year > 97 or year < 90
static func _days_in_month(month: int) -> int:
match month:
2:
return 28
4, 6, 9, 11:
return 30
return 31
static func _random_date(rng: RandomNumberGenerator) -> String:
var day := rng.randi_range(1, 28)
var month := rng.randi_range(1, 12)
var year := 96 + rng.randi_range(0, 1)
return "%02d.%02d.%02d" % [day, month, year]
## Все видимые аномалии карточки. Дубль имени — отношение между карточками,
## поэтому передаётся counts имён.
static func detected_anomalies(card: Dictionary, name_counts: Dictionary) -> Array:
var out: Array = []
var size_kb := int(card.get("size_kb", 0))
if size_kb == 0:
out.append("zero_size")
elif size_kb >= 999999:
out.append("absurd_size")
if not _ext_matches_kind(str(card.get("kind", "")), str(card.get("ext", ""))):
out.append("wrong_ext")
if str(card.get("crc", "")) == "—":
out.append("missing_crc")
var date := str(card.get("date", ""))
if not _valid_date(date):
out.append("invalid_date")
elif _future_date(date):
out.append("future_date")
if str(card.get("name", "")).contains(BAD_CHAR):
out.append("bad_char")
if int(name_counts.get(str(card.get("name", "")), 0)) > 1:
out.append("duplicate_name")
return out
static func validate_junk(data: Dictionary) -> bool:
var cards: Array = data.get("cards", [])
var p := tier_params(int(data.get("tier", 1)))
if cards.size() != int(data.get("total_cards", -1)) or cards.is_empty():
return false
if int(data.get("total_junk", -1)) != int(p.get("junk", -1)):
return false
var name_counts: Dictionary = {}
for card in cards:
var name := str((card as Dictionary).get("name", ""))
name_counts[name] = int(name_counts.get(name, 0)) + 1
var junk_seen := 0
for card in cards:
var c: Dictionary = card
var detected := detected_anomalies(c, name_counts)
if bool(c.get("junk", false)):
junk_seen += 1
if detected.is_empty():
return false
# Дубль имени сам по себе неоднозначен: у мусорной карточки должен
# быть ещё хотя бы один независимый признак.
var independent := 0
for anomaly in detected:
if str(anomaly) != "duplicate_name":
independent += 1
if independent == 0:
return false
else:
if int(c.get("size_kb", 0)) <= 0 or int(c.get("size_kb", 0)) > 999998:
return false
if not _ext_matches_kind(str(c.get("kind", "")), str(c.get("ext", ""))):
return false
if str(c.get("crc", "")) == "—" or str(c.get("crc", "")).length() != 4:
return false
if not _valid_date(str(c.get("date", ""))) or _future_date(str(c.get("date", ""))):
return false
if str(c.get("name", "")).contains(BAD_CHAR):
return false
if junk_seen != int(data.get("total_junk", -1)):
return false
return true
static func junk_quality(removed_junk: int, false_positive: int, total_junk: int) -> float:
if total_junk <= 0:
return 0.0
return clampf((float(removed_junk) - float(false_positive)) / float(total_junk),
0.0, 1.0)
static func count_marks(cards: Array, marked: Dictionary) -> Dictionary:
var removed := 0
var false_positive := 0
var missed := 0
for card in cards:
var c: Dictionary = card
var is_marked := bool(marked.get(int(c.get("id", -1)), false))
if bool(c.get("junk", false)):
if is_marked:
removed += 1
else:
missed += 1
elif is_marked:
false_positive += 1
return {
"removed_junk": removed,
"false_positive": false_positive,
"missed_junk": missed,
"marked": int(marked.size()),
}
# --- ввод ------------------------------------------------------------------
func _card_index_at(local: Vector2) -> int:
for i in range(_cards.size()):
if _card_rect(i).has_point(local):
return i
return -1
func _toggle(index: int) -> void:
if index < 0 or index >= _cards.size():
return
var id := int((_cards[index] as Dictionary).get("id", index))
if _marked.has(id):
_marked.erase(id)
else:
_marked[id] = true
_play_sfx(&"tick")
queue_redraw()
func _submit() -> void:
if _submitted or _finished:
return
var counts := count_marks(_cards, _marked)
var q := junk_quality(int(counts["removed_junk"]), int(counts["false_positive"]),
int(_data.get("total_junk", 0)))
_submitted = true
_result_detail = _with_context("СНЯТО %d ИЗ %d · ЛОЖНЫХ %d · ПРОПУЩЕНО %d" % [
int(counts["removed_junk"]), int(_data.get("total_junk", 0)),
int(counts["false_positive"]), int(counts["missed_junk"])])
_finish(q)
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if VERIFY_RECT.has_point(local):
_submit()
return true
var index := _card_index_at(local)
if index >= 0:
_toggle(index)
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _submitted or _finished:
return
var next := _card_index_at(local)
if next != _hover:
_hover = next
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]:
_submit()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _card_rect(index: int) -> Rect2:
var rows := maxi(1, int(ceil(float(_cards.size()) / float(COLS))))
var gap := CARD_GAP
var avail_h := GRID_BOTTOM - GRID_TOP
var card_h := minf(70.0, (avail_h - gap * float(rows - 1)) / float(rows))
var total_w := CARD_W * float(COLS) + gap * float(COLS - 1)
var x0 := 18.0 + (604.0 - total_w) * 0.5
var col := index % COLS
var row := index / COLS
return Rect2(x0 + float(col) * (CARD_W + gap), GRID_TOP + float(row) * (card_h + gap),
CARD_W, card_h)
func _draw_game() -> void:
var marked_count := _marked.size()
_draw_label("ОТМЕЧЕНО: %d" % marked_count, Vector2(24.0, 44.0), 12,
Theme90.INK, 180.0)
for i in range(_cards.size()):
_draw_card(i)
_draw_action_button(VERIFY_RECT, "ПРОВЕРИТЬ", not _submitted, true)
func _draw_card(index: int) -> void:
var card: Dictionary = _cards[index]
var rect := _card_rect(index)
var marked := _marked.has(int(card.get("id", -1)))
var junk := bool(card.get("junk", false))
var fill := Theme90.PAPER_HI
var border := Theme90.BORDER
var border_w := 1
var status := ""
if _submitted:
if junk and marked:
fill = Theme90.SUCCESS.lightened(0.78)
border = Theme90.SUCCESS
border_w = 2
status = "СНЯТ"
elif junk and not marked:
fill = Theme90.WARN.lightened(0.78)
border = Theme90.WARN
border_w = 2
status = "ПРОПУЩЕН"
elif not junk and marked:
fill = Theme90.ERROR.lightened(0.80)
border = Theme90.ERROR
border_w = 2
status = "ЛОЖНО"
elif not junk:
border = Theme90.BORDER_SOFT
elif marked:
fill = Theme90.ERROR.lightened(0.90)
border = Theme90.ERROR
border_w = 2
status = "В КАРАНТИН"
elif index == _hover:
border = Theme90.ACCENT
border_w = 2
draw_style_box(Theme90.box(fill, border, 3, border_w), rect)
var kind := str(card.get("kind", "text"))
var ext := str(card.get("ext", ""))
var cat := Theme90.ext_category(ext)
var icon_col := Theme90.ext_color(cat)
if cat.is_empty():
match kind:
"sound":
icon_col = Theme90.WARN
"photo":
icon_col = Theme90.SUCCESS
"video":
icon_col = Theme90.ERROR
_:
icon_col = Theme90.ACCENT
Theme90.draw_doc_icon(self, Rect2(rect.position + Vector2(7.0, 7.0), Vector2(28.0, 28.0)),
kind, icon_col, ext)
var tall := rect.size.y >= 64.0
var name_y := rect.position.y + (8.0 if tall else 5.0)
var meta_y := rect.position.y + (27.0 if tall else 21.0)
var sub_y := rect.position.y + (44.0 if tall else 36.0)
var text_x := rect.position.x + 42.0
var text_w := rect.size.x - 50.0
var name := Theme90.fit(Theme90.ui(), str(card.get("name", "")), 12, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, name_y), name, 12, Theme90.INK, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, meta_y),
"%s · %s" % [ext, str(card.get("size_text", ""))], 10, Theme90.DARK, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, sub_y),
"%s · CRC %s" % [str(card.get("date", "")), str(card.get("crc", ""))],
10, Theme90.SHADE, text_w)
var badge := status
if badge.is_empty():
badge = str(card.get("type_label", ""))
var badge_col := Theme90.SHADE
if _submitted and status == "СНЯТ":
badge_col = Theme90.SUCCESS
elif _submitted and status == "ЛОЖНО":
badge_col = Theme90.ERROR
elif status == "ПРОПУЩЕН":
badge_col = Theme90.WARN
elif status == "В КАРАНТИН":
badge_col = Theme90.ERROR
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x + 112.0, name_y),
Theme90.fit(Theme90.ui(), badge, 10, rect.size.x - 118.0), 10, badge_col,
rect.size.x - 118.0, HORIZONTAL_ALIGNMENT_RIGHT)
func _draw_footer_hint() -> void:
var hints: Array = _data.get("hints", [])
var text := "ПОДСКАЗКИ: " + " · ".join(hints) if not hints.is_empty() \
else "СНИМАЙТЕ ТОЛЬКО МУСОР"
# Полная подсказка — в полосе между сеткой (y=294) и футером (y=320).
_draw_label(Theme90.fit(Theme90.ui(), text, 10, 604.0),
Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
static func _shuffle(list: Array, rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
+1
View File
@@ -0,0 +1 @@
uid://dmrrnmmspoyhy
+609
View File
@@ -0,0 +1,609 @@
class_name PhishingGame
extends MiniGame
## Атака «Паспорт» (phishing). Очередь клиентов: две «бумаги» — заявка и
## пропуск. Игрок сверяет поля и жмёт ВЫДАТЬ или ОТКАТАТЬ.
##
## У каждого клиента ровно один правильный ответ. Подделка отличается ровно
## одним проверяемым полем (имя / отдел / ID / срок действия), причём ID
## использует кириллическую похожую букву. Отмена решения невозможна,
## таймера нет; после решения — короткая печать 0.12 с.
##
## Качество: q = correct / total_clients.
const GAME_ID := "phishing"
const TITLE_RU := "ПАСПОРТ"
const ISSUE_RECT := Rect2(142.0, 262.0, 166.0, 30.0)
const DENY_RECT := Rect2(332.0, 262.0, 166.0, 30.0)
const FILE_RECT := Rect2(24.0, 62.0, 286.0, 196.0)
const PASS_RECT := Rect2(330.0, 62.0, 286.0, 196.0)
const STAMP_TTL := 0.12
const TIERS := {
1: {"clients": 3, "checks": ["name"], "threshold": 0.75},
2: {"clients": 4, "checks": ["name", "department"], "threshold": 0.75},
3: {"clients": 5, "checks": ["name", "department", "id"], "threshold": 0.80},
4: {"clients": 6, "checks": ["name", "department", "id", "photo", "expiry"],
"threshold": 0.80},
}
const PEOPLE := [
{"surname": "Иванов", "first": "Пётр", "female": false},
{"surname": "Смирнова", "first": "Анна", "female": true},
{"surname": "Кузнецов", "first": "Илья", "female": false},
{"surname": "Попова", "first": "Мария", "female": true},
{"surname": "Соколов", "first": "Артём", "female": false},
{"surname": "Лебедева", "first": "Ольга", "female": true},
{"surname": "Новиков", "first": "Денис", "female": false},
{"surname": "Морозова", "first": "Вера", "female": true},
{"surname": "Волков", "first": "Сергей", "female": false},
{"surname": "Зайцева", "first": "Нина", "female": true},
{"surname": "Павлов", "first": "Максим", "female": false},
{"surname": "Орлова", "first": "Елена", "female": true},
{"surname": "Фёдоров", "first": "Олег", "female": false},
{"surname": "Егорова", "first": "Ирина", "female": true},
{"surname": "Белов", "first": "Антон", "female": false},
{"surname": "Гусева", "first": "Дарья", "female": true},
{"surname": "Тихонов", "first": "Роман", "female": false},
{"surname": "Киселёва", "first": "Света", "female": true},
{"surname": "Борисов", "first": "Кирилл", "female": false},
{"surname": "Андреева", "first": "Таня", "female": true},
]
const DEPARTMENTS := [
"БУХГАЛТЕРИЯ", "ОТДЕЛ КАДРОВ", "ЛОГИСТИКА", "ПРОДАЖИ", "ИТ-ОТДЕЛ",
"ЮРОТДЕЛ", "СКЛАД", "ДИСПЕТЧЕРСКАЯ", "АРХИВ", "СНАБЖЕНИЕ",
"КЛИЕНТСКИЙ ОТДЕЛ", "ТРАНСПОРТ",
]
## ASCII → кириллический двойник. ID всегда содержит буквы из ключей.
const LOOKALIKE := {
"A": "А", "B": "В", "C": "С", "E": "Е", "H": "Н", "K": "К",
"M": "М", "O": "О", "P": "Р", "T": "Т", "X": "Х", "Y": "У",
}
const ID_LETTERS := "ABEKMHOPCTX"
const CHECK_LABELS := {
"name": "ИМЯ",
"dept": "ОТДЕЛ",
"id": "ID",
"expiry": "СРОК",
"photo": "ФОТО",
}
var _clients: Array = []
var _index: int = 0
var _correct: int = 0
var _wrong: int = 0
var _stamp_ttl: float = 0.0
var _last_ok: bool = false
var _last_choice: String = ""
var _hover: String = ""
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
return phishing_quality(_correct, _clients.size())
func _setup() -> void:
_clients = generate_clients(tier, seed_value)
_index = 0
_correct = 0
_wrong = 0
_stamp_ttl = 0.0
_last_ok = false
_last_choice = ""
_hover = ""
mouse_default_cursor_shape = Control.CURSOR_ARROW
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
base["checks"] = _normalized_checks(base.get("checks", []))
return base
if row.has("clients"):
base["clients"] = int(row["clients"])
if row.has("checks"):
base["checks"] = _normalized_checks(row["checks"])
else:
base["checks"] = _normalized_checks(base.get("checks", []))
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
return base
static func _normalized_checks(raw) -> Array:
var out: Array = []
if raw is Array:
for entry in raw:
match str(entry).to_lower():
"name":
if not out.has("name"):
out.append("name")
"department", "dept":
if not out.has("dept"):
out.append("dept")
"id":
if not out.has("id"):
out.append("id")
"expiry", "expires", "date":
if not out.has("expiry"):
out.append("expiry")
"photo", "avatar":
if not out.has("photo"):
out.append("photo")
if out.is_empty():
out = ["name"]
return out
# --- генерация -------------------------------------------------------------
static func generate_clients(p_tier: int, seed: int) -> Array:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("clients", 3)), 2, 12)
var clients: Array = []
for i in range(total):
clients.append(generate_client(t, seed, i, -1))
var valid_count := 0
var fake_count := 0
for client in clients:
if bool((client as Dictionary).get("valid", true)):
valid_count += 1
else:
fake_count += 1
if fake_count == 0 and total >= 2:
clients[0] = generate_client(t, seed, 0, 1)
elif valid_count == 0 and total >= 2:
clients[0] = generate_client(t, seed, 0, 0)
return clients
## force_fake: -1 — случайно, 0 — честный, 1 — подделка.
static func generate_client(p_tier: int, seed: int, index: int = 0,
force_fake: int = -1) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var checks: Array = p.get("checks", ["name"])
var rng := RandomNumberGenerator.new()
rng.seed = seed + index * 104729 + 17
var person: Dictionary = PEOPLE[rng.randi_range(0, PEOPLE.size() - 1)]
var dept := str(DEPARTMENTS[rng.randi_range(0, DEPARTMENTS.size() - 1)])
var id_value := _make_id(rng)
var expiry := _random_expiry(rng)
var photo := {
"jacket": rng.randi_range(0, 3),
"hair": rng.randi_range(0, 3),
"skin": rng.randi_range(0, 2),
"female": bool(person.get("female", false)),
}
var full_name := "%s %s" % [str(person.get("surname", "")), str(person.get("first", ""))]
var file_data := {
"name": full_name,
"dept": dept,
"id": id_value,
"expiry": expiry,
"photo": photo.duplicate(true),
}
var pass_data := {
"name": full_name,
"dept": dept,
"id": id_value,
"expiry": expiry,
"photo": photo.duplicate(true),
}
var make_fake := false
if force_fake == 1:
make_fake = true
elif force_fake == 0:
make_fake = false
else:
make_fake = rng.randf() < 0.5
var mismatch := ""
if make_fake:
var kinds: Array = []
for check in checks:
if check in ["name", "dept", "id", "expiry"]:
kinds.append(str(check))
if kinds.is_empty():
kinds = ["name"]
var kind := str(kinds[rng.randi_range(0, kinds.size() - 1)])
_apply_fake(pass_data, kind, rng)
mismatch = kind
return {
"id": index,
"file": file_data,
"pass": pass_data,
"mismatch": mismatch,
"valid": mismatch.is_empty(),
"expected": "deny" if not mismatch.is_empty() else "issue",
"checks": checks.duplicate(),
}
static func _apply_fake(pass_data: Dictionary, kind: String, rng: RandomNumberGenerator) -> void:
match kind:
"name":
var full := str(pass_data.get("name", ""))
var space := full.find(" ")
if space > 0:
pass_data["name"] = _surname_variant(full.substr(0, space)) + full.substr(space)
else:
pass_data["name"] = _surname_variant(full)
"dept":
var current := str(pass_data.get("dept", ""))
var current_index := DEPARTMENTS.find(current)
if current_index < 0:
current_index = 0
var offset := rng.randi_range(1, DEPARTMENTS.size() - 1)
pass_data["dept"] = str(DEPARTMENTS[(current_index + offset) % DEPARTMENTS.size()])
"id":
pass_data["id"] = _lookalike_id(str(pass_data.get("id", "")))
"expiry":
pass_data["expiry"] = _shift_expiry(str(pass_data.get("expiry", "")))
static func _surname_variant(surname: String) -> String:
if surname.ends_with("ов") or surname.ends_with("ев") or surname.ends_with("ин"):
return surname + "а"
if surname.ends_with("а"):
return surname.substr(0, surname.length() - 1)
return surname + "а"
static func _make_id(rng: RandomNumberGenerator) -> String:
var out := "id_"
out += ID_LETTERS[rng.randi_range(0, ID_LETTERS.length() - 1)]
out += str(rng.randi_range(0, 9))
out += str(rng.randi_range(0, 9))
out += ID_LETTERS[rng.randi_range(0, ID_LETTERS.length() - 1)]
return out
static func _lookalike_id(id_value: String) -> String:
for i in range(id_value.length()):
var ch := id_value.substr(i, 1)
if LOOKALIKE.has(ch):
return id_value.substr(0, i) + str(LOOKALIKE[ch]) + id_value.substr(i + 1)
return id_value + "К"
static func _random_expiry(rng: RandomNumberGenerator) -> String:
var day := rng.randi_range(1, 28)
var month := rng.randi_range(1, 12)
var year := 97 + rng.randi_range(0, 1)
return "%02d.%02d.%02d" % [day, month, year]
static func _shift_expiry(value: String) -> String:
var parts := value.split(".")
if parts.size() != 3:
return "01.01.98"
var day := clampi(int(parts[0]), 1, 28)
var month := clampi(int(parts[1]), 1, 12)
var year := int(parts[2])
if year >= 98:
year = 97
else:
year += 1
return "%02d.%02d.%02d" % [day, month, year]
# --- проверка --------------------------------------------------------------
static func _same_photo(a: Dictionary, b: Dictionary) -> bool:
return int(a.get("jacket", -1)) == int(b.get("jacket", -2)) \
and int(a.get("hair", -1)) == int(b.get("hair", -2)) \
and int(a.get("skin", -1)) == int(b.get("skin", -2)) \
and bool(a.get("female", false)) == bool(b.get("female", true))
static func client_diff_fields(client: Dictionary) -> Array:
var out: Array = []
var file_data: Dictionary = client.get("file", {})
var pass_data: Dictionary = client.get("pass", {})
for field in ["name", "dept", "id", "expiry"]:
if str(file_data.get(field, "")) != str(pass_data.get(field, "")):
out.append(field)
if not _same_photo(file_data.get("photo", {}), pass_data.get("photo", {})):
out.append("photo")
return out
static func validate_client(client: Dictionary, p_tier: int) -> bool:
var checks: Array = tier_params(p_tier).get("checks", ["name"])
var diffs := client_diff_fields(client)
if diffs.size() > 1:
return false
var valid_now := diffs.is_empty()
if valid_now != bool(client.get("valid", false)):
return false
if str(client.get("expected", "")) != ("issue" if valid_now else "deny"):
return false
if not valid_now:
if not checks.has(str(diffs[0])):
return false
if str(client.get("mismatch", "")) != str(diffs[0]):
return false
else:
if not str(client.get("mismatch", "")).is_empty():
return false
return true
static func validate_clients(clients: Array, p_tier: int) -> bool:
if clients.is_empty():
return false
for client in clients:
if not validate_client(client, p_tier):
return false
return true
static func phishing_quality(correct: int, total_clients: int) -> float:
if total_clients <= 0:
return 0.0
return clampf(float(correct) / float(total_clients), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _current_client() -> Dictionary:
if _index < 0 or _index >= _clients.size():
return {}
return _clients[_index]
func _decide(choice: String) -> void:
if _finished or _stamp_ttl > 0.0:
return
var client := _current_client()
if client.is_empty():
return
var expected := "issue" if bool(client.get("valid", false)) else "deny"
var ok := choice == expected
if ok:
_correct += 1
else:
_wrong += 1
_last_ok = ok
_last_choice = choice
_stamp_ttl = STAMP_TTL
_hover = ""
mouse_default_cursor_shape = Control.CURSOR_ARROW
_play_sfx(&"ok" if ok else &"bad")
queue_redraw()
func _advance() -> void:
_index += 1
_last_ok = false
_last_choice = ""
if _index >= _clients.size():
var q := phishing_quality(_correct, _clients.size())
_result_detail = _with_context("ВЕРНО %d ИЗ %d · ОШИБОК %d" % [
_correct, _clients.size(), _wrong])
_finish(q)
else:
queue_redraw()
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _process(delta: float) -> void:
if _finished:
return
if _stamp_ttl <= 0.0:
return
_stamp_ttl = maxf(0.0, _stamp_ttl - delta)
if _stamp_ttl <= 0.0:
_advance()
queue_redraw()
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if _finished or _stamp_ttl > 0.0:
return false
if ISSUE_RECT.has_point(local):
_decide("issue")
return true
if DENY_RECT.has_point(local):
_decide("deny")
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _finished:
return
var next := ""
if _stamp_ttl <= 0.0:
if ISSUE_RECT.has_point(local):
next = "issue"
elif DENY_RECT.has_point(local):
next = "deny"
if next != _hover:
_hover = next
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if not next.is_empty() \
else Control.CURSOR_ARROW
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if _finished or _stamp_ttl > 0.0:
return false
match keycode:
KEY_1, KEY_A:
_decide("issue")
return true
KEY_2, KEY_D:
_decide("deny")
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var total := _clients.size()
var shown := mini(_index + 1, total)
_draw_label("КЛИЕНТ %d / %d" % [shown, total], Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
_draw_label("ВЕРНО %d · ОШИБОК %d" % [_correct, _wrong], Vector2(380.0, 44.0),
12, Theme90.ERROR if _wrong > 0 else Theme90.SHADE, 230.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var client := _current_client()
if client.is_empty():
_draw_label("ОЧЕРЕДЬ ОБРАБОТАНА", Vector2(24.0, 140.0), 24, Theme90.NAVY_DEEP, 604.0,
HORIZONTAL_ALIGNMENT_CENTER)
return
_draw_file(FILE_RECT, client)
_draw_pass(PASS_RECT, client)
_draw_action_button(ISSUE_RECT, "ВЫДАТЬ", true, true)
_draw_action_button(DENY_RECT, "ОТКАТАТЬ", true, false)
if _hover == "issue":
draw_rect(ISSUE_RECT.grow(2.0), Theme90.ACCENT, false, 2.0)
elif _hover == "deny":
draw_rect(DENY_RECT.grow(2.0), Theme90.ACCENT, false, 2.0)
if _stamp_ttl > 0.0:
_draw_stamp()
func _draw_file(rect: Rect2, client: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1, Theme90.SHADOW), rect)
_draw_paper_header(rect, "ЗАЯВКА")
var data: Dictionary = client.get("file", {})
var x := rect.position.x + 14.0
var w := rect.size.x - 90.0
_draw_avatar(Rect2(rect.end.x - 68.0, rect.position.y + 30.0, 54.0, 66.0),
data.get("photo", {}))
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 30.0), "ВЛАДЕЛЕЦ",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 42.0),
Theme90.fit(Theme90.ui(), str(data.get("name", "")), 16, w), 16, Theme90.INK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 72.0), "ОТДЕЛ",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 84.0),
Theme90.fit(Theme90.ui(), str(data.get("dept", "")), 12, w), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 110.0), "ID",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 122.0),
str(data.get("id", "")), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 148.0), "ДЕЙСТВУЕТ ДО",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 160.0),
str(data.get("expiry", "")), 12, Theme90.DARK, w)
func _draw_pass(rect: Rect2, client: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1, Theme90.SHADOW), rect)
_draw_paper_header(rect, "ПРОПУСК")
var data: Dictionary = client.get("pass", {})
_draw_avatar(Rect2(rect.position.x + 14.0, rect.position.y + 32.0, 58.0, 72.0),
data.get("photo", {}))
var x := rect.position.x + 84.0
var w := rect.size.x - 98.0
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 36.0),
Theme90.fit(Theme90.ui(), str(data.get("name", "")), 16, w), 16, Theme90.INK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 68.0),
Theme90.fit(Theme90.ui(), str(data.get("dept", "")), 12, w), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 94.0),
str(data.get("id", "")), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 120.0),
"СРОК " + str(data.get("expiry", "")), 12, Theme90.DARK, w)
var checks: Array = client.get("checks", [])
var check_text := "ПРОВЕРКА: "
var labels: Array = []
for check in checks:
labels.append(str(CHECK_LABELS.get(str(check), str(check))))
check_text += " · ".join(labels)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 150.0),
Theme90.fit(Theme90.ui(), check_text, 10, w), 10, Theme90.SHADE, w)
func _draw_paper_header(rect: Rect2, text: String) -> void:
var header := Rect2(rect.position, Vector2(rect.size.x, 20.0))
draw_style_box(Theme90.top_box(Theme90.NAVY, 4), header)
draw_rect(Rect2(header.position.x, header.end.y - 1.0, header.size.x, 1.0),
Theme90.NAVY_DEEP)
Theme90.text(self, Theme90.ui(), Vector2(header.position.x + 10.0,
header.position.y + 5.0), text, 10, Theme90.WHITE, header.size.x - 20.0)
func _draw_avatar(rect: Rect2, photo: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.BORDER, 2, 1), rect)
var jackets := [Theme90.NAVY, Theme90.ACCENT, Color(0.43, 0.48, 0.37), Color(0.48, 0.41, 0.36)]
var hairs := [Color(0.23, 0.21, 0.18), Color(0.35, 0.29, 0.23), Color(0.42, 0.38, 0.35),
Color(0.18, 0.23, 0.27)]
var skins := [Color(0.85, 0.76, 0.65), Color(0.79, 0.68, 0.56), Color(0.88, 0.80, 0.71)]
var jacket: Color = jackets[clampi(int(photo.get("jacket", 0)), 0, jackets.size() - 1)]
var hair: Color = hairs[clampi(int(photo.get("hair", 0)), 0, hairs.size() - 1)]
var skin: Color = skins[clampi(int(photo.get("skin", 0)), 0, skins.size() - 1)]
var cx := rect.get_center().x
var bottom := rect.end.y - 3.0
draw_colored_polygon(PackedVector2Array([
Vector2(cx - 22.0, bottom), Vector2(cx - 14.0, bottom - 24.0),
Vector2(cx + 14.0, bottom - 24.0), Vector2(cx + 22.0, bottom)]), jacket)
draw_rect(Rect2(cx - 4.0, bottom - 38.0, 8.0, 14.0), skin)
draw_circle(Vector2(cx, bottom - 44.0), 11.0, skin)
draw_circle(Vector2(cx, bottom - 46.0), 11.0, hair)
draw_rect(Rect2(cx - 10.0, bottom - 46.0, 20.0, 8.0), skin)
if bool(photo.get("female", false)):
draw_rect(Rect2(cx - 12.0, bottom - 42.0, 4.0, 12.0), hair)
draw_rect(Rect2(cx + 8.0, bottom - 42.0, 4.0, 12.0), hair)
func _draw_stamp() -> void:
var label := "ВЕРНО" if _last_ok else "ПРОМАХ"
var color := Theme90.SUCCESS if _last_ok else Theme90.ERROR
var rect := Rect2(250.0, 148.0, 140.0, 36.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, color, 4, 2, Theme90.SHADOW), rect)
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 16, color, -1,
HORIZONTAL_ALIGNMENT_CENTER)
func _draw_footer_hint() -> void:
if _finished:
return
var checks: Array = tier_params(tier).get("checks", ["name"])
var labels: Array = []
for check in checks:
labels.append(str(CHECK_LABELS.get(str(check), str(check))))
# Подсказка — в полосе между кнопками (y=292) и футером (y=320).
_draw_label(Theme90.fit(Theme90.ui(), "СВЕРЬТЕ: " + " · ".join(labels), 10, 604.0),
Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
+1
View File
@@ -0,0 +1 @@
uid://lj8d55h1pplr
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more