9 Commits
Author SHA1 Message Date
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
163 changed files with 40378 additions and 8551 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: 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
+629 -206
View File
@@ -1,123 +1,649 @@
{
"_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
},
"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": 6,
"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": 6,
"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": 7,
"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": 8,
"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": 9,
"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": 9,
"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": 10,
"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": 10,
"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": 10,
"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 +653,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 +681,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")
+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 := [6, 6, 7, 8, 9, 9, 10, 10, 10]
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 {
+387 -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))
@@ -99,6 +138,9 @@ func end_hour() -> bool:
var finished := hour
hour += 1
phase = &"prep"
# Рероллы живут одну подготовку.
shop_rerolls = 0
shop_locked.clear()
hour_changed.emit(hour)
phase_changed.emit(phase)
toast.emit(_tr("msg.hour_end", "Час %d завершён" % finished))
@@ -128,7 +170,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 +178,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 +390,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 +434,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 +457,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 +490,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 +515,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()
@@ -322,24 +557,138 @@ func _default_stats() -> Dictionary:
}
## Восстановить офферы/рероллы/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 +736,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))
+60 -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,12 +31,23 @@ 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
## Форма на сетке (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 +92,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,
@@ -91,7 +120,12 @@ func to_dict() -> Dictionary:
"size_kb": size_kb,
"created_at": created_at,
"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):
@@ -112,12 +146,35 @@ static func from_dict(d: Dictionary):
f.size_kb = int(d.get("size_kb", 0))
f.created_at = float(d.get("created_at", 0.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
+20
View File
@@ -28,6 +28,12 @@ var is_attack: bool = false
var attack_kind: String = ""
var size_kb: int = 0
## Форма, которую пакет займёт в папке. Выбирается при спавне, чтобы показать
## превью ещё до приёма. `shape_cells` — производное (не сохраняется).
var shape_id: String = "O1"
var rotation: int = 0
var shape_cells: Array = []
## Служебные поля (не входят в минимальный контракт, но нужны директору).
var lost_left: float = 0.0
var loading_duration: float = 0.0
@@ -92,6 +98,8 @@ func to_dict() -> Dictionary:
"loading_duration": loading_duration,
"order": order,
"attack_id": attack_id,
"shape_id": shape_id,
"rotation": rotation,
}
@@ -115,6 +123,18 @@ static func from_dict(d: Dictionary):
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
+125 -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,
@@ -288,9 +313,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 +332,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 +353,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 +378,39 @@ 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()),
"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 +445,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 +490,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 +533,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 +545,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)
+2315 -732
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
+530
View File
@@ -0,0 +1,530 @@
class_name RansomwareGame
extends MiniGame
## Атака «Карантин» (ransomware). Сетка файлов, среди которых спрятаны
## исполняемые (.exe/.scr/.bat) с документными именами.
##
## Клик — «ИЗОЛИРОВАТЬ», повторный клик снимает пометку, ПРОВЕРИТЬ фиксирует.
## На T3/T4 расширение скрыто до наведения на карточку; на T4 есть ловушки,
## в имени которых есть «exe», но расширение doc/xls.
##
## Качество: q = clamp((found_exe - 0.5 * wrong_clicks) / total_exe, 0, 1).
const GAME_ID := "ransomware"
const TITLE_RU := "КАРАНТИН"
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
const GRID_TOP := 58.0
const GRID_BOTTOM := 294.0
const GRID_GAP := 8.0
const TIERS := {
1: {"cards": 12, "exe": 1, "threshold": 0.50, "masking": "extension_visible"},
2: {"cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small"},
3: {"cards": 20, "exe": 2, "threshold": 0.60, "masking": "document_icon"},
4: {"cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names"},
}
const EXEC_EXTS := ["EXE", "SCR", "BAT"]
const CLEAN_EXTS := ["DOC", "XLS", "TXT", "JPG", "BMP", "ZIP", "WAV", "AVI", "PDF"]
const BASE_NAMES := [
"отчёт", "смета", "письмо", "фото", "схема", "архив", "звук", "клип",
"опись", "прайс", "счёт", "курс", "факс", "лог", "план", "акт", "заявка",
"реестр", "накладная", "договор", "таблица", "список", "карта", "резерв",
"сводка", "ведомость", "протокол", "этикетка",
]
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 ransomware_quality(int(counts["found_exe"]), int(counts["wrong_clicks"]),
int(_data.get("total_exe", 0)))
func _setup() -> void:
_data = generate_cards(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", "exe"]:
if row.has(key):
base[key] = int(row[key])
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
if row.has("masking"):
base["masking"] = str(row["masking"])
return base
static func masking_for(p_tier: int) -> String:
return str(tier_params(p_tier).get("masking", "extension_visible"))
# --- генерация -------------------------------------------------------------
static func generate_cards(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", 12)), 8, 30)
var exe_count := clampi(int(p.get("exe", 1)), 1, total - 2)
var trap_count := 2 if t == 4 else 0
if total - exe_count - trap_count < 2:
trap_count = 0
var rng := RandomNumberGenerator.new()
rng.seed = seed
var names: Array = BASE_NAMES.duplicate()
_shuffle(names, rng)
var cursor := 0
var execs: Array = []
for i in range(exe_count):
var ext := str(EXEC_EXTS[rng.randi_range(0, EXEC_EXTS.size() - 1)])
execs.append({
"name": str(names[cursor % names.size()]),
"ext": ext,
"size_kb": rng.randi_range(18, 46),
"date": _random_date(rng),
"executable": true,
"target": false,
"trap": false,
})
cursor += 1
var traps: Array = []
for i in range(trap_count):
var base_name := str(names[cursor % names.size()])
cursor += 1
var trap_name := ("exe_" + base_name) if i % 2 == 0 else (base_name + "_exe")
traps.append({
"name": trap_name,
"ext": "DOC" if i % 2 == 0 else "XLS",
"size_kb": rng.randi_range(2, 480),
"date": _random_date(rng),
"executable": false,
"target": false,
"trap": true,
})
var clean_count := total - execs.size() - traps.size()
var cleans: Array = []
for i in range(clean_count):
var ext := str(CLEAN_EXTS[rng.randi_range(0, CLEAN_EXTS.size() - 1)])
cleans.append({
"name": str(names[cursor % names.size()]),
"ext": ext,
"size_kb": rng.randi_range(2, 480),
"date": _random_date(rng),
"executable": false,
"target": false,
"trap": false,
})
cursor += 1
# Ровно одна «настоящая» цель; остальные исполняемые — тоже угрозы,
# но флаг target не дублируется. Ловушки целью не бывают никогда.
var target_index := rng.randi_range(0, execs.size() - 1)
(execs[target_index] as Dictionary)["target"] = true
# Хотя бы один чистый файл совпадает по размеру с исполняемым, чтобы
# размер сам по себе не выдавал цель.
if not cleans.is_empty():
(cleans[0] as Dictionary)["size_kb"] = int((execs[0] as Dictionary)["size_kb"])
var descriptors: Array = []
descriptors.append_array(execs)
descriptors.append_array(traps)
descriptors.append_array(cleans)
_shuffle(descriptors, rng)
var cards: Array = []
for i in range(descriptors.size()):
var d: Dictionary = descriptors[i]
var size_kb := int(d.get("size_kb", 2))
cards.append({
"id": i,
"name": str(d.get("name", "")),
"ext": str(d.get("ext", "")),
"size_kb": size_kb,
"size_text": "%d КБ" % size_kb,
"date": str(d.get("date", "01.01.97")),
"executable": bool(d.get("executable", false)),
"target": bool(d.get("target", false)),
"trap": bool(d.get("trap", false)),
})
return {
"tier": t,
"cards": cards,
"total_cards": total,
"total_exe": exe_count,
"trap_count": trap_count,
"threshold": float(p.get("threshold", 0.5)),
"masking": str(p.get("masking", "extension_visible")),
}
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]
# --- проверка --------------------------------------------------------------
static func validate_cards(data: Dictionary) -> bool:
var t := clampi(int(data.get("tier", 1)), 1, 4)
var p := tier_params(t)
var cards: Array = data.get("cards", [])
var total := int(data.get("total_cards", -1))
if cards.size() != total or total != int(p.get("cards", -1)):
return false
if int(data.get("total_exe", -1)) != int(p.get("exe", -1)):
return false
var exec_count := 0
var target_count := 0
var trap_count := 0
var names: Dictionary = {}
for card in cards:
var c: Dictionary = card
var name_key := str(c.get("name", ""))
if names.has(name_key):
return false
names[name_key] = true
var ext := str(c.get("ext", "")).to_upper()
if bool(c.get("trap", false)):
trap_count += 1
if bool(c.get("executable", false)) or bool(c.get("target", false)):
return false
if t != 4:
return false
if not name_key.to_lower().contains("exe"):
return false
if ext != "DOC" and ext != "XLS":
return false
if bool(c.get("executable", false)):
exec_count += 1
if not EXEC_EXTS.has(ext):
return false
if bool(c.get("trap", false)):
return false
else:
if EXEC_EXTS.has(ext):
return false
if not bool(c.get("trap", false)) and name_key.to_lower().contains("exe"):
return false
if bool(c.get("target", false)):
target_count += 1
if not bool(c.get("executable", false)):
return false
if exec_count < 1 or exec_count != int(p.get("exe", 0)):
return false
if target_count > 1:
return false
if t <= 2 and target_count != 1:
return false
if trap_count != int(data.get("trap_count", 0)):
return false
if t != 4 and trap_count != 0:
return false
return true
static func ransomware_quality(found_exe: int, wrong_clicks: int, total_exe: int) -> float:
if total_exe <= 0:
return 0.0
return clampf((float(found_exe) - 0.5 * float(wrong_clicks)) / float(total_exe),
0.0, 1.0)
static func count_marks(cards: Array, marked: Dictionary) -> Dictionary:
var found := 0
var wrong := 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("executable", false)):
if is_marked:
found += 1
else:
missed += 1
elif is_marked:
wrong += 1
return {"found_exe": found, "wrong_clicks": wrong, "missed_exe": missed,
"marked": int(marked.size())}
# --- игра ------------------------------------------------------------------
func _card_rects() -> Array:
var n := _cards.size()
if n <= 0:
return []
var cols := 4
if n > 20:
cols = 6
elif n > 16:
cols = 5
var rows := int(ceil(float(n) / float(cols)))
var card_w := (604.0 - GRID_GAP * float(cols - 1)) / float(cols)
var avail_h := GRID_BOTTOM - GRID_TOP
var card_h := minf(64.0, (avail_h - GRID_GAP * float(rows - 1)) / float(rows))
var rects: Array = []
for i in range(n):
var col := i % cols
var row := i / cols
rects.append(Rect2(18.0 + float(col) * (card_w + GRID_GAP),
GRID_TOP + float(row) * (card_h + GRID_GAP), card_w, card_h))
return rects
func _card_index_at(local: Vector2) -> int:
var rects := _card_rects()
for i in range(rects.size()):
if (rects[i] as Rect2).has_point(local):
return i
return -1
func _ext_visible(card: Dictionary, index: int) -> bool:
var masking := str(_data.get("masking", "extension_visible"))
if masking == "extension_visible" or masking == "extension_small":
return true
return index == _hover
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 := ransomware_quality(int(counts["found_exe"]), int(counts["wrong_clicks"]),
int(_data.get("total_exe", 0)))
_submitted = true
_result_detail = _with_context("НАЙДЕНО %d ИЗ %d · ЛОЖНЫХ %d · ПРОПУЩЕНО %d" % [
int(counts["found_exe"]), int(_data.get("total_exe", 0)),
int(counts["wrong_clicks"]), int(counts["missed_exe"])])
_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 _draw_game() -> void:
_draw_label("ОТМЕЧЕНО: %d" % _marked.size(), Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
var masking := str(_data.get("masking", "extension_visible"))
var mode_text := "РАСШИРЕНИЕ ВИДНО"
if masking == "document_icon":
mode_text = "НАВЕДИТЕ НА ФАЙЛ: РАСШИРЕНИЕ В ТУЛТИПЕ"
elif masking == "similar_names":
mode_text = "ОСТОРОЖНО: ИМЕНА С «EXE» МОГУТ БЫТЬ ЛОВУШКОЙ"
elif masking == "extension_small":
mode_text = "РАСШИРЕНИЕ МЕЛКИМ ШРИФТОМ"
_draw_label(mode_text, Vector2(230.0, 44.0), 10, Theme90.SHADE, 382.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var rects := _card_rects()
for i in range(_cards.size()):
_draw_card(i, rects[i] if i < rects.size() else Rect2())
_draw_action_button(VERIFY_RECT, "ПРОВЕРИТЬ", not _submitted, true)
func _draw_card(index: int, rect: Rect2) -> void:
if rect.size.x <= 0.0:
return
var card: Dictionary = _cards[index]
var marked := _marked.has(int(card.get("id", -1)))
var executable := bool(card.get("executable", false))
var trap := bool(card.get("trap", false))
var fill := Theme90.PAPER_HI
var border := Theme90.BORDER
var border_w := 1
var status := ""
if _submitted:
if executable and marked:
fill = Theme90.SUCCESS.lightened(0.78)
border = Theme90.SUCCESS
border_w = 2
status = "ИЗОЛИРОВАН"
elif executable and not marked:
fill = Theme90.WARN.lightened(0.78)
border = Theme90.WARN
border_w = 2
status = "ПРОПУЩЕН"
elif not executable and marked:
fill = Theme90.ERROR.lightened(0.80)
border = Theme90.ERROR
border_w = 2
status = "ЛОЖНО"
elif trap:
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 ext := str(card.get("ext", ""))
var show_ext := _ext_visible(card, index)
var icon_ext := ext if show_ext else ""
var icon_kind := "text"
if show_ext:
icon_kind = _kind_for_ext(ext)
var icon_col := Theme90.ext_color(Theme90.ext_category(ext))
if not show_ext:
icon_col = Theme90.NAVY
var icon_size := 24.0 if rect.size.x >= 120.0 else 20.0
Theme90.draw_doc_icon(self, Rect2(rect.position + Vector2(6.0, 7.0),
Vector2(icon_size, icon_size)), icon_kind, icon_col, icon_ext)
var name_size := 12 if rect.size.x >= 130.0 else 10
var text_x := rect.position.x + icon_size + 12.0
var text_w := rect.size.x - icon_size - 18.0
Theme90.text(self, Theme90.ui(), Vector2(text_x, rect.position.y + 6.0),
Theme90.fit(Theme90.ui(), str(card.get("name", "")), name_size, text_w),
name_size, Theme90.INK, text_w)
var meta := str(card.get("size_text", ""))
if show_ext:
meta = "%s · %s" % [ext, meta]
Theme90.text(self, Theme90.ui(), Vector2(text_x, rect.position.y + 23.0),
Theme90.fit(Theme90.ui(), meta, 10, text_w), 10, Theme90.DARK, text_w)
var bottom_text := str(card.get("date", ""))
var bottom_col := Theme90.SHADE
if not status.is_empty():
bottom_text = status
bottom_col = Theme90.ERROR
if _submitted and status == "ИЗОЛИРОВАН":
bottom_col = Theme90.SUCCESS
elif status == "ПРОПУЩЕН":
bottom_col = Theme90.WARN
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x + 6.0,
rect.position.y + rect.size.y - 14.0),
Theme90.fit(Theme90.ui(), bottom_text, 10, rect.size.x - 12.0), 10,
bottom_col, rect.size.x - 12.0)
func _kind_for_ext(ext: String) -> String:
match Theme90.ext_category(ext):
"audio":
return "sound"
"image":
return "photo"
"video":
return "video"
return "text"
func _draw_footer_hint() -> void:
var masking := str(_data.get("masking", "extension_visible"))
var text := "ИЩИТЕ ИСПОЛНЯЕМЫЕ ФАЙЛЫ · КЛИК — ИЗОЛИРОВАТЬ"
if masking == "document_icon":
text = "ИКОНКА ДОКУМЕНТА НИЧЕГО НЕ ЗНАЧИТ · НАВЕДИТЕ НА ФАЙЛ"
elif masking == "similar_names":
text = "«EXE» В ИМЕНИ — ЕЩЁ НЕ ПРИГОВОР · ПРОВЕРЬТЕ РАСШИРЕНИЕ"
# Подсказка — в полосе между сеткой (y=294) и футером (y=320).
_draw_label(text, 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
@@ -0,0 +1 @@
uid://bvm5eshivri3g
+65 -7
View File
@@ -20,6 +20,9 @@ const STICK_H := 18.0
const STICK_SPEEDS: Array[float] = [440.0, 340.0, 280.0]
const FLASH_SEC := 0.8
const CLICKS_REQUIRED := 3
## Окно гасит клик, который открыл панель: кнопка «Соединиться» находится
## вне модалки, но тот же/следующий event мог долететь уже до неё.
const INPUT_GRACE_SEC := 0.35
var hour: int = 1
var successes: int = 0
@@ -36,6 +39,8 @@ var missed: bool = false
var game_over: bool = false
var _emitted: bool = false
var _input_grace: float = 0.0
var _armed: bool = false
func _init() -> void:
@@ -49,7 +54,16 @@ func _init() -> void:
func _ready() -> void:
# start() мог быть вызван до add_child: окно монтируется уже после клика,
# поэтому отсчёт grace начинаем заново в момент появления в дереве.
if running:
_input_grace = INPUT_GRACE_SEC
_armed = false
_center_on_screen()
# Main ставит модалку через PRESET_CENTER уже после add_child, а в Godot
# 4.7.2 этот пресет обнуляет offsets (окно уезжает в правый нижний угол).
# Повторяем центрирование в конце кадра, когда родитель уже modal_root.
call_deferred("_center_on_screen")
get_viewport().size_changed.connect(_center_on_screen)
@@ -64,6 +78,8 @@ func start(p_hour: int, p_successes: int, p_zone_level: int, p_request_id: Strin
running = true
missed = false
flash_left = 0.0
_input_grace = INPUT_GRACE_SEC
_armed = false
_reset_attempt()
visible = true
set_process(true)
@@ -118,6 +134,12 @@ func _process(delta: float) -> void:
return
# Вспышка идёт по реальному delta, движение палочек — с D11-капом 0.1.
var raw_dt := maxf(delta, 0.0)
# Grace тикает по реальному времени до игровой логики: клик, открывший
# панель, не должен успеть стать попыткой соединения.
if _input_grace > 0.0:
_input_grace = maxf(0.0, _input_grace - raw_dt)
if _input_grace <= 0.0:
_armed = true
if flash_left > 0.0:
flash_left = maxf(0.0, flash_left - raw_dt)
if flash_left <= 0.0:
@@ -140,19 +162,47 @@ func _process(delta: float) -> void:
func _input(event: InputEvent) -> void:
if not running or game_over:
return
# Esc доступен всегда (в том числе в grace): отмена не должна штрафовать.
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
_mark_input_handled()
_finish(false)
return
if event.keycode == KEY_SPACE:
if event.keycode == KEY_SPACE or event.keycode == KEY_ENTER \
or event.keycode == KEY_KP_ENTER:
if _input_grace > 0.0:
# Тот же клик/нажатие, что открыло модалку, не считаем попыткой.
_mark_input_handled()
return
if not _armed:
return
_mark_input_handled()
_attempt_click()
return
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
if event.button_index != MOUSE_BUTTON_LEFT:
return
if _input_grace > 0.0:
# И press, и release в grace гасим: событие могло прийти от кнопки
# «СОЕДИНИТЬСЯ», которая находится вне окна модалки.
_mark_input_handled()
return
if event.pressed:
# Только свежее нажатие: отпускание кнопки ниже взводит _armed.
if not _armed:
return
# Клики вне окна 640×240 (карточка запроса, меню, статус-бар за
# модалкой) не должны считаться попыткой соединения: иначе промах
# закрывает панель, штрафует SLA и вешает кулдаун.
var local: Vector2 = event.position - global_position
if not Rect2(Vector2.ZERO, size).has_point(local):
return
_mark_input_handled()
_attempt_click()
else:
# Первое отпускание после grace тоже разрешает попытки (защита от
# зажатой кнопки/double-click).
_armed = true
func _attempt_click() -> void:
@@ -266,11 +316,19 @@ func _draw() -> void:
func _center_on_screen() -> void:
var viewport := get_viewport()
if viewport == null:
return
var screen := get_viewport_rect().size
position = ((screen - WINDOW_SIZE) * 0.5).floor()
# get_viewport_rect() — физический размер окна (1920×1080), а UI-канва
# логическая 1280×720. Центрируем по реальному родителю (modal_root),
# иначе модалка уезжает в правый нижний квадрант. Fallback — вьюпорт.
var area := Vector2.ZERO
if is_inside_tree() and get_parent() is Control:
area = (get_parent() as Control).size
if area.x <= 0.0 or area.y <= 0.0:
# Вне дерева (нода создана до add_child) вьюпорта ещё нет —
# центрируем позже в _ready()/повторном start().
if not is_inside_tree():
return
area = get_viewport_rect().size
position = ((area - size) * 0.5).floor()
position.x = maxf(0.0, position.x)
position.y = maxf(0.0, position.y)
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
class_name MiniGameFactory
extends RefCounted
## Фабрика мини-игр v2 и выбор игры для отправки.
##
## create_send() загружает res://scripts/minigames/send/<id>.gd.
## create_attack() — заготовка: возвращает null, если скрипта атаки ещё нет.
## selection_for_sum()/send_for_types() делегируют Economy (единственный
## источник правды: 6 игр, 3 ступени, 27 достижимых сумм).
const SEND_IDS: Array[String] = [
"dots", "sound", "text", "video", "photo", "maze",
]
const SEND_TIERS := 3
const ATTACK_IDS: Array[String] = [
"junk", "ddos", "phishing", "ransomware",
]
const COEFF := {
"text": 1,
"sound": 2,
"photo": 4,
"video": 8,
}
const SEND_DIR := "res://scripts/minigames/send"
const ATTACK_DIR := "res://scripts/minigames/attack"
const ECONOMY_PATH := "res://scripts/core/economy.gd"
const ECONOMY_SCRIPT := preload("res://scripts/core/economy.gd")
static func create_send(game_id: String, tier: int, seed: int,
context: Dictionary = {}) -> MiniGame:
var id := str(game_id).strip_edges().to_lower()
if not SEND_IDS.has(id):
id = MiniGame.resolve_game_id(id)
if not SEND_IDS.has(id):
return null
var path := "%s/%s.gd" % [SEND_DIR, id]
if not ResourceLoader.exists(path):
return null
var script = load(path)
if script == null:
return null
var node = script.new()
if node == null or not (node is MiniGame):
if node != null:
node.free()
return null
node.start(id, clampi(tier, 1, SEND_TIERS), seed, context)
return node
## Атаки добавляются отдельным агентом. Пока скрипта нет — null.
static func create_attack(kind: String, tier: int, seed: int,
context: Dictionary = {}):
var id := str(kind).strip_edges().to_lower()
if id.is_empty():
return null
var path := "%s/%s.gd" % [ATTACK_DIR, id]
if not ResourceLoader.exists(path):
return null
var script = load(path)
if script == null:
return null
var node = script.new()
if node == null or not (node is MiniGame):
if node != null:
node.free()
return null
node.start(id, clampi(tier, 1, 4), seed, context)
return node
static func is_send_id(game_id: String) -> bool:
return SEND_IDS.has(str(game_id).strip_edges().to_lower())
static func send_ids() -> Array[String]:
return SEND_IDS.duplicate()
static func tier_for_hour(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, hour) - 2) / 2.0)), 1, SEND_TIERS)
## Канонический выбор: полностью делегируется Economy.selection_for_sum()
## (rank в achievable_sums, таблица rank_games, 3 ступени). Здесь только
## страховка: не пропускаем наружу неизвестную игру и ступень вне 1..3.
static func selection_for_sum(total: int) -> Dictionary:
var sum := maxi(total, 0)
var result: Dictionary = ECONOMY_SCRIPT.selection_for_sum(sum)
var id := str(result.get("id", "")).strip_edges().to_lower()
if not SEND_IDS.has(id):
id = SEND_IDS[0]
result["id"] = id
result["tier"] = clampi(int(result.get("tier", 1)), 1, SEND_TIERS)
result["sum"] = sum
return result
static func send_for_types(types: Array) -> Dictionary:
var delegated := _economy_selection(types)
if not delegated.is_empty():
if not delegated.has("id") and delegated.has("game"):
delegated["id"] = str(delegated["game"])
var id := str(delegated.get("id", "")).strip_edges().to_lower()
# Economy может работать по старым таблицам: «wires» уже нет, а
# ступень могла остаться четвёртой. Не пропускаем такое наружу.
if SEND_IDS.has(id):
delegated["id"] = id
delegated["tier"] = clampi(int(delegated.get("tier", 1)), 1, SEND_TIERS)
return delegated
var sum := 0
for type in types:
sum += int(COEFF.get(str(type).to_lower(), 1))
return selection_for_sum(sum)
static func _economy_selection(types: Array) -> Dictionary:
if not ResourceLoader.exists(ECONOMY_PATH):
return {}
var script = load(ECONOMY_PATH)
if script == null:
return {}
var has_method := false
for method in script.get_script_method_list():
if str(method.get("name", "")) == "send_minigame_for":
has_method = true
break
if not has_method:
return {}
var instance = script.new()
if instance == null:
return {}
var result = instance.call("send_minigame_for", types)
if result is Dictionary:
return (result as Dictionary).duplicate(true)
return {}
@@ -0,0 +1 @@
uid://brr3sl85uqcxa
+540
View File
@@ -0,0 +1,540 @@
extends MiniGame
## «Одна линия» (dots). Гамильтонов путь по свободным клеткам.
##
## FIX B3: сначала выбираются свободные клетки и препятствия, затем ищется
## путь только по свободным клеткам. Если случайная раскладка нерешаема —
## новый локальный RNG и ограниченное число попыток; последний рубеж — змейка.
const TIERS := {
1: {"cols": 4, "rows": 3, "obstacles": 2, "undo_penalty": 0.04},
2: {"cols": 4, "rows": 4, "obstacles": 4, "undo_penalty": 0.04},
3: {"cols": 5, "rows": 4, "obstacles": 6, "undo_penalty": 0.03},
}
const MAX_BOARD_ATTEMPTS := 16
const MAX_START_TRIES := 8
const DFS_GUARD := 120000
const RESET_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
var board: Dictionary = {}
var _path: Array = []
var _undos: int = 0
var _invalid: int = 0
var _dragging: bool = false
var _cell_rects: Array[Rect2] = []
var _cell_ids: Array = []
var _stride: float = 60.0
var _port_rect: Rect2 = Rect2()
func _own_game_id() -> String:
return "dots"
func _setup() -> void:
board = generate_tier(tier, seed_value)
_path.clear()
_undos = 0
_invalid = 0
_dragging = false
_build_geometry()
func _goal_text() -> String:
return "ВЕДИТЕ ЛИНИЮ ОТ «С» ПО ВСЕМ КЛЕТКАМ · R — СБРОС"
func _progress_ratio() -> float:
var free_count := int(board.get("free_count", 0))
if free_count <= 0:
return 0.0
return clampf(float(_path.size()) / float(free_count), 0.0, 1.0)
func _result_stats() -> String:
return "ЛИНИЯ %d/%d · ОТКАТОВ %d · ОШИБОК %d" % [_path.size(),
int(board.get("free_count", 0)), _undos, _invalid]
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("dots", p_tier)
if row.is_empty():
return base
var grid = row.get("grid", null)
if grid is Array and (grid as Array).size() >= 2:
base["cols"] = int((grid as Array)[0])
base["rows"] = int((grid as Array)[1])
for key in ["cols", "rows", "obstacles"]:
if row.has(key):
base[key] = int(row[key])
if row.has("undo_penalty"):
base["undo_penalty"] = float(row["undo_penalty"])
return base
# --- генерация/валидация ---------------------------------------------------
static func generate_tier(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
return generate(int(p["cols"]), int(p["rows"]), int(p["obstacles"]), seed)
## Чистая генерация. Возвращает разложение по int-id (row * cols + col).
static func generate(cols: int, rows: int, obstacles: int, seed: int) -> Dictionary:
var c := maxi(2, cols)
var r := maxi(2, rows)
var cells := c * r
var obs := clampi(obstacles, 0, cells - 4)
var free_count := cells - obs
var ids: Array = []
for i in range(cells):
ids.append(i)
var fallback_used := false
for attempt in range(MAX_BOARD_ATTEMPTS):
var rng := RandomNumberGenerator.new()
rng.seed = seed + 7919 * (attempt + 1)
var shuffled := ids.duplicate()
_shuffle(shuffled, rng)
var blocked: Array = shuffled.slice(0, obs)
var free: Array = shuffled.slice(obs)
if free.size() != free_count:
continue
if not _free_connected(free, c, r):
continue
if not _parity_ok(free, c):
continue
var starts := free.duplicate()
_shuffle(starts, rng)
var tries := mini(MAX_START_TRIES, starts.size())
for si in range(tries):
var start := int(starts[si])
var visited := {start: true}
var path: Array = [start]
var guard: Array = [0]
if _ham_dfs(start, free, c, r, visited, path, free_count, guard, rng):
if _path_valid(path, free, c, r):
return _make_board(c, r, free, blocked, path, attempt + 1, false)
# Случайный, но гарантированно валидный откат: полный гамильтонов путь
# по всей сетке, затем первые free_count клеток — свободные, хвост — стены.
for fb_attempt in range(4):
var rng3 := RandomNumberGenerator.new()
rng3.seed = seed + 15485863 + fb_attempt * 101
var all_ids: Array = []
for i in range(cells):
all_ids.append(i)
var starts3 := all_ids.duplicate()
_shuffle(starts3, rng3)
var start3 := int(starts3[0])
var visited3 := {start3: true}
var full_path: Array = [start3]
var guard3: Array = [0]
if _ham_dfs(start3, all_ids, c, r, visited3, full_path, cells, guard3, rng3):
var free3: Array = full_path.slice(0, free_count)
var blocked3: Array = full_path.slice(free_count, cells)
return _make_board(c, r, free3, blocked3, free3.duplicate(),
MAX_BOARD_ATTEMPTS + fb_attempt + 1, true)
# Последний рубеж: змейка по всей сетке; свободные — её префикс.
fallback_used = true
var rng2 := RandomNumberGenerator.new()
rng2.seed = seed + 104729
var snake := _snake_ids(c, r)
var free2: Array = snake.slice(0, free_count)
var blocked2: Array = snake.slice(free_count, snake.size())
_shuffle(blocked2, rng2)
return _make_board(c, r, free2, blocked2, free2.duplicate(), MAX_BOARD_ATTEMPTS,
fallback_used)
static func _make_board(c: int, r: int, free: Array, blocked: Array, path: Array,
attempts: int, fallback_used: bool) -> Dictionary:
var path_cells: Array = []
for id in path:
path_cells.append(Vector2i(int(id) % c, int(id) / c))
var sorted_blocked := blocked.duplicate()
sorted_blocked.sort()
var sorted_free := free.duplicate()
sorted_free.sort()
return {
"cols": c,
"rows": r,
"obstacles": sorted_blocked.size(),
"free_count": sorted_free.size(),
"blocked": sorted_blocked,
"free": sorted_free,
"path": path.duplicate(),
"path_cells": path_cells,
"start": int(path[0]),
"finish": int(path[path.size() - 1]),
"attempts": attempts,
"fallback": fallback_used,
}
static func _free_connected(free: Array, c: int, r: int) -> bool:
if free.is_empty():
return false
var free_map := {}
for id in free:
free_map[int(id)] = true
var seen := {int(free[0]): true}
var queue: Array = [int(free[0])]
while not queue.is_empty():
var id := int(queue.pop_front())
for n in _neighbors(id, c, r):
if free_map.has(n) and not seen.has(n):
seen[n] = true
queue.append(n)
return seen.size() == free.size()
static func _parity_ok(free: Array, c: int) -> bool:
var even := 0
var odd := 0
for id in free:
var x := int(id) % c
var y := int(id) / c
if (x + y) % 2 == 0:
even += 1
else:
odd += 1
return absi(even - odd) <= 1
static func _ham_dfs(current: int, free: Array, c: int, r: int, visited: Dictionary,
path: Array, total: int, guard: Array, rng: RandomNumberGenerator) -> bool:
guard[0] = int(guard[0]) + 1
if int(guard[0]) > DFS_GUARD:
return false
if path.size() == total:
return true
var free_map := {}
for id in free:
free_map[int(id)] = true
var near: Array = []
for n in _neighbors(current, c, r):
if free_map.has(n) and not visited.has(n):
near.append(n)
_shuffle(near, rng)
for n in near:
visited[n] = true
path.append(n)
if _ham_dfs(int(n), free, c, r, visited, path, total, guard, rng):
return true
path.pop_back()
visited.erase(n)
return false
static func _path_valid(path: Array, free: Array, c: int, r: int) -> bool:
if path.size() != free.size() or path.is_empty():
return false
var free_map := {}
for id in free:
free_map[int(id)] = true
var seen := {}
for i in range(path.size()):
var id := int(path[i])
if not free_map.has(id) or seen.has(id):
return false
seen[id] = true
if i > 0 and not _adjacent(int(path[i - 1]), id, c):
return false
return true
static func validate_board(board_data: Dictionary) -> bool:
if not board_data.has("cols") or not board_data.has("rows"):
return false
var c := int(board_data["cols"])
var r := int(board_data["rows"])
var free: Array = board_data.get("free", [])
var blocked: Array = board_data.get("blocked", [])
var path: Array = board_data.get("path", [])
if free.size() + blocked.size() != c * r:
return false
if int(board_data.get("obstacles", blocked.size())) != blocked.size():
return false
if int(board_data.get("free_count", free.size())) != free.size():
return false
if not _path_valid(path, free, c, r):
return false
if int(board_data.get("start", -1)) != int(path[0]):
return false
if int(board_data.get("finish", -1)) != int(path[path.size() - 1]):
return false
return true
static func _neighbors(id: int, c: int, r: int) -> Array:
var x := id % c
var y := id / c
var out: Array = []
if y > 0:
out.append(id - c)
if x < c - 1:
out.append(id + 1)
if y < r - 1:
out.append(id + c)
if x > 0:
out.append(id - 1)
return out
static func _adjacent(a: int, b: int, c: int) -> bool:
var ax := a % c
var ay := a / c
var bx := b % c
var by := b / c
return absi(ax - bx) + absi(ay - by) == 1
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
static func _snake_ids(c: int, r: int) -> Array:
var out: Array = []
for y in range(r):
for i in range(c):
var x := i if y % 2 == 0 else c - 1 - i
out.append(y * c + x)
return out
static func quality_for(p_undos: int, p_invalid: int, p_tier: int) -> float:
var p := tier_params(p_tier)
var penalty := float(p.get("undo_penalty", 0.04))
return clampf(1.0 - penalty * float(maxi(0, p_undos))
- 0.1 * float(maxi(0, p_invalid)), 0.0, 1.0)
# --- геометрия/игра --------------------------------------------------------
func _build_geometry() -> void:
var cols := int(board.get("cols", 3))
var rows := int(board.get("rows", 3))
var area := Rect2(20.0, 54.0, 600.0, 256.0)
_stride = minf(area.size.x / float(cols), area.size.y / float(rows))
_stride = clampf(floorf(_stride), 46.0, 78.0)
var total_w := _stride * float(cols)
var total_h := _stride * float(rows)
_port_rect = Rect2(area.position.x + (area.size.x - total_w) * 0.5,
area.position.y + (area.size.y - total_h) * 0.5, total_w, total_h)
_cell_rects.clear()
_cell_ids.clear()
for y in range(rows):
for x in range(cols):
var pad := 5.0
_cell_rects.append(Rect2(
Vector2(_port_rect.position.x + x * _stride,
_port_rect.position.y + y * _stride) + Vector2(pad, pad),
Vector2(_stride - pad * 2.0, _stride - pad * 2.0)))
_cell_ids.append(y * cols + x)
func _cell_at(local: Vector2) -> int:
for i in range(_cell_rects.size()):
if _cell_rects[i].grow(4.0).has_point(local):
return int(_cell_ids[i])
return -1
func _blocked_map() -> Dictionary:
var out := {}
for id in board.get("blocked", []):
out[int(id)] = true
return out
func _free_map() -> Dictionary:
var out := {}
for id in board.get("free", []):
out[int(id)] = true
return out
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if RESET_RECT.has_point(local):
_reset_line()
return true
var id := _cell_at(local)
if id < 0:
return false
_dragging = false
if _path.is_empty():
if id == int(board.get("start", -1)):
_path = [id]
_dragging = true
_play_sfx(&"tick")
else:
_invalid += 1
queue_redraw()
return true
var last := int(_path[_path.size() - 1])
if _path.size() >= 2 and id == int(_path[_path.size() - 2]):
_pop_path()
_dragging = true
return true
if id == last:
_dragging = true
return true
if _try_append_cell(id):
_dragging = true
else:
# Ошибкой считается только осознанный клик, не движение курсора.
_invalid += 1
queue_redraw()
return true
## Общая точка роста пути для клика и drag: молча игнорирует недопустимые
## клетки, чтобы быстрый проход мышью не портил качество.
func _try_append_cell(id: int) -> bool:
if id < 0 or _path.is_empty() or _finished:
return false
var last := int(_path[_path.size() - 1])
if id == last:
return false
var free := _free_map()
if not free.has(id) or _path.has(id):
return false
var cols := int(board.get("cols", 3))
if not _adjacent(last, id, cols):
return false
if id == int(board.get("finish", -1)) \
and _path.size() < int(board.get("free_count", 0)) - 1:
# Финиш можно только последним: иначе линия не покроет все порты.
return false
_path.append(id)
_play_sfx(&"tick")
if _path.size() == int(board.get("free_count", 0)) \
and id == int(board.get("finish", -1)):
_finish(quality_for(_undos, _invalid, tier))
queue_redraw()
return true
func _pop_path() -> void:
if _path.size() < 2:
return
_path.pop_back()
_undos += 1
_play_sfx(&"tick")
queue_redraw()
func _handle_motion_cell(id: int) -> void:
if id < 0 or _path.is_empty() or _finished:
return
var last := int(_path[_path.size() - 1])
if id == last:
return
if _path.size() >= 2 and id == int(_path[_path.size() - 2]):
_pop_path()
return
_try_append_cell(id)
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if not _dragging or _finished or _path.is_empty():
return
# Быстрый drag может проскочить целую клетку между двумя motion-событиями.
# Поэтому идём от центра последней клетки к курсору шагами ~полклетки и
# скармливаем каждую точку той же логике добавления.
var start := _cell_center(int(_path[_path.size() - 1]))
var dist := start.distance_to(local)
var step := maxf(6.0, _stride * 0.5)
var steps := maxi(1, int(ceil(dist / step)))
for i in range(1, steps + 1):
var point := start.lerp(local, float(i) / float(steps))
_handle_motion_cell(_cell_at(point))
if _finished:
return
_handle_motion_cell(_cell_at(local))
func _on_pointer_released(_local: Vector2, _event: InputEvent) -> void:
_dragging = false
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_R:
_reset_line()
return true
return false
func _reset_line() -> void:
if not _path.is_empty():
_undos += 1
_path.clear()
_dragging = false
_play_sfx(&"tick")
queue_redraw()
func _draw_game() -> void:
var font := Theme90.ui()
var cols := int(board.get("cols", 3))
var blocked := _blocked_map()
var free := _free_map()
var path_set := {}
for id in _path:
path_set[int(id)] = true
_draw_label("ЛИНИЯ %d / %d" % [_path.size(), int(board.get("free_count", 0))],
Vector2(24.0, 46.0), 10, Theme90.NAVY, 220.0)
_draw_label("ПРЕПЯТСТВИЙ %d · ОТКАТОВ %d · ОШИБОК %d" % [
int(board.get("obstacles", 0)), _undos, _invalid],
Vector2(300.0, 46.0), 10, Theme90.DARK, 300.0, HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
Rect2(_port_rect.position - Vector2(8, 8), _port_rect.size + Vector2(16, 16)))
for i in range(_cell_ids.size()):
var id := int(_cell_ids[i])
var rect := _cell_rects[i]
if blocked.has(id):
draw_style_box(Theme90.fill_box(Theme90.DARK.darkened(0.08), 3), rect)
Theme90.draw_hatch(self, rect.grow(-2.0), Color(1, 1, 1, 0.10), 5.0)
continue
var fill := Theme90.PAPER_HI
if path_set.has(id):
fill = Theme90.ACCENT_PALE
draw_style_box(Theme90.box(fill, Theme90.BORDER, 3, 1), rect)
var center := rect.get_center()
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.22, Theme90.ACCENT_LIGHT)
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.13, Theme90.ACCENT)
if id == int(board.get("start", -1)):
Theme90.text_vcenter(self, font, rect, "С", 16, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
elif id == int(board.get("finish", -1)):
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.30, Theme90.SUCCESS)
Theme90.text_vcenter(self, font, rect, "Ф", 16, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
for i in range(1, _path.size()):
var a := _cell_center(int(_path[i - 1]))
var b := _cell_center(int(_path[i]))
draw_line(a, b, Theme90.ACCENT, 4.0, true)
if i == _path.size() - 1:
draw_circle(b, 4.0, Theme90.NAVY_DEEP)
_draw_secondary_button(RESET_RECT, "СБРОС (R)")
func _cell_center(id: int) -> Vector2:
for i in range(_cell_ids.size()):
if int(_cell_ids[i]) == id:
return _cell_rects[i].get_center()
return _port_rect.get_center()
+1
View File
@@ -0,0 +1 @@
uid://co766vx8eagfo
+457
View File
@@ -0,0 +1,457 @@
extends MiniGame
## «Лабиринт» (maze). Recursive backtracker + BFS-валидация.
## Пакет следует за мышью с максимальной скоростью, скользит по осям.
## Качество: 1 - 0.04*collisions - 0.02*dead_ends (без нижнего порога 0.4).
const TIERS := {
1: {"cols": 11, "rows": 4, "field_w": 440.0, "field_h": 160.0,
"corridor": 24.0, "speed": 470.0},
2: {"cols": 13, "rows": 4, "field_w": 494.0, "field_h": 152.0,
"corridor": 20.0, "speed": 520.0},
3: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0,
"corridor": 18.0, "speed": 560.0},
}
const HITBOX := 9.0
const PACKET := 12.0
const LOOP_CHANCE := 0.12
const COLLISION_COOLDOWN := 0.25
var maze: Dictionary = {}
var params: Dictionary = {}
var _walls: Array[Rect2] = []
var _field: Rect2 = Rect2()
var _pitch: float = 20.0
var _corridor: float = 20.0
var _solution: Array = []
var _sol_index: Dictionary = {}
var _dead_ends: Dictionary = {}
var _packet: Vector2 = Vector2.ZERO
var _target: Vector2 = Vector2.ZERO
var _entrance: Vector2 = Vector2.ZERO
var _exit: Vector2 = Vector2.ZERO
var _collisions: int = 0
var _dead_end_hits: int = 0
var _visited_dead: Dictionary = {}
var _collision_cd: float = 0.0
var _max_sol: int = 0
var _exited: bool = false
func _own_game_id() -> String:
return "maze"
func _setup() -> void:
params = tier_params(tier)
maze = generate_tier(tier, seed_value)
_pitch = float(params["field_w"]) / float(maze["cols"])
_corridor = float(params["corridor"])
_field = Rect2((640.0 - float(params["field_w"])) * 0.5,
70.0 + (180.0 - float(params["field_h"])) * 0.5,
float(params["field_w"]), float(params["field_h"]))
_walls = build_walls(maze["cells"], int(maze["cols"]), int(maze["rows"]),
int(maze["ent_row"]), int(maze["exit_row"]), _field, _corridor)
_solution = maze.get("solution", [])
_sol_index.clear()
for i in range(_solution.size()):
_sol_index[int(_solution[i])] = i
_entrance = Vector2(_field.position.x + _pitch * 0.5,
_field.position.y + (float(maze["ent_row"]) + 0.5) * _pitch)
_exit = Vector2(_field.end.x - _pitch * 0.5,
_field.position.y + (float(maze["exit_row"]) + 0.5) * _pitch)
_packet = _entrance
_target = _entrance
_collisions = 0
_dead_end_hits = 0
_visited_dead.clear()
_collision_cd = 0.0
_max_sol = 0
_exited = false
_dead_ends = find_dead_ends(maze["cells"], int(maze["cols"]), int(maze["rows"]),
int(maze["ent_row"]), int(maze["exit_row"]))
func _goal_text() -> String:
return "ПРОВЕДИТЕ ПАКЕТ ОТ ВХОДА ДО ЗЕЛЁНОГО ВЫХОДА"
func _progress_ratio() -> float:
if _solution.is_empty():
return 0.0
return clampf((float(_max_sol) + 1.0) / float(_solution.size()), 0.0, 1.0)
func _result_stats() -> String:
return "СТОЛКНОВЕНИЙ %d · ТУПИКОВ %d" % [_collisions, _dead_end_hits]
# --- параметры/генерация ---------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("maze", p_tier)
if row.is_empty():
return base
for key in ["cols", "rows"]:
if row.has(key):
base[key] = int(row[key])
if row.has("width"):
base["field_w"] = float(row["width"])
if row.has("height"):
base["field_h"] = float(row["height"])
for key in ["field_w", "field_h", "corridor", "speed"]:
if row.has(key):
base[key] = float(row[key])
if row.has("speed"):
base["speed"] = float(row["speed"])
return base
static func generate_tier(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var temp := RandomNumberGenerator.new()
temp.seed = seed
var ent_row := temp.randi_range(0, int(p["rows"]) - 1)
var exit_row := temp.randi_range(0, int(p["rows"]) - 1)
return generate(int(p["cols"]), int(p["rows"]), seed, ent_row, exit_row)
static func generate(cols: int, rows: int, seed: int, ent_row: int = -1,
exit_row: int = -1) -> Dictionary:
var c := maxi(3, cols)
var r := maxi(2, rows)
var rng := RandomNumberGenerator.new()
rng.seed = seed
var ent := clampi(ent_row if ent_row >= 0 else rng.randi_range(0, r - 1), 0, r - 1)
var ex := clampi(exit_row if exit_row >= 0 else rng.randi_range(0, r - 1), 0, r - 1)
var cells: Array = []
for y in range(r):
var row: Array = []
for x in range(c):
row.append({"n": true, "e": true, "s": true, "w": true, "v": false})
cells.append(row)
var stack: Array = [Vector2i(0, ent)]
cells[ent][0]["v"] = true
var visited := 1
while visited < c * r:
var top: Vector2i = stack[stack.size() - 1]
var near: Array = []
if top.y > 0 and not bool(cells[top.y - 1][top.x]["v"]):
near.append([top.x, top.y - 1, "n", "s"])
if top.x < c - 1 and not bool(cells[top.y][top.x + 1]["v"]):
near.append([top.x + 1, top.y, "e", "w"])
if top.y < r - 1 and not bool(cells[top.y + 1][top.x]["v"]):
near.append([top.x, top.y + 1, "s", "n"])
if top.x > 0 and not bool(cells[top.y][top.x - 1]["v"]):
near.append([top.x - 1, top.y, "w", "e"])
if near.is_empty():
stack.pop_back()
continue
var pick: Array = near[rng.randi_range(0, near.size() - 1)]
cells[top.y][top.x][str(pick[2])] = false
cells[int(pick[1])][int(pick[0])][str(pick[3])] = false
cells[int(pick[1])][int(pick[0])]["v"] = true
visited += 1
stack.append(Vector2i(int(pick[0]), int(pick[1])))
# Петли: аккуратно убираем часть внутренних стен (10–20%).
for y in range(r):
for x in range(c):
if x < c - 1 and bool(cells[y][x]["e"]) and rng.randf() < LOOP_CHANCE:
cells[y][x]["e"] = false
cells[y][x + 1]["w"] = false
if y < r - 1 and bool(cells[y][x]["s"]) and rng.randf() < LOOP_CHANCE:
cells[y][x]["s"] = false
cells[y + 1][x]["n"] = false
# Вход/выход.
cells[ent][0]["w"] = false
cells[ex][c - 1]["e"] = false
var solution := bfs(cells, c, r, ent * c + 0, ex * c + (c - 1))
return {
"cols": c,
"rows": r,
"seed": seed,
"ent_row": ent,
"exit_row": ex,
"cells": cells,
"solution": solution,
"solvable": not solution.is_empty(),
}
static func neighbors_open(cells: Array, x: int, y: int, cols: int, rows: int) -> Array:
var out: Array = []
if x < 0 or y < 0 or x >= cols or y >= rows:
return out
var cell: Dictionary = cells[y][x]
if y > 0 and not bool(cell["n"]):
out.append(Vector2i(x, y - 1))
if x < cols - 1 and not bool(cell["e"]):
out.append(Vector2i(x + 1, y))
if y < rows - 1 and not bool(cell["s"]):
out.append(Vector2i(x, y + 1))
if x > 0 and not bool(cell["w"]):
out.append(Vector2i(x - 1, y))
return out
static func bfs(cells: Array, cols: int, rows: int, start_id: int, goal_id: int) -> Array:
var sx := start_id % cols
var sy := start_id / cols
var gx := goal_id % cols
var gy := goal_id / cols
if sx < 0 or sy < 0 or sx >= cols or sy >= rows:
return []
if gx < 0 or gy < 0 or gx >= cols or gy >= rows:
return []
var prev := {start_id: -1}
var queue: Array = [start_id]
while not queue.is_empty():
var current := int(queue.pop_front())
if current == goal_id:
var path: Array = []
var node := current
while node >= 0:
path.append(node)
node = int(prev.get(node, -1))
path.reverse()
return path
var cx := current % cols
var cy := current / cols
for n in neighbors_open(cells, cx, cy, cols, rows):
var nid: int = int(n.y) * cols + int(n.x)
if prev.has(nid):
continue
prev[nid] = current
queue.append(nid)
return []
static func find_dead_ends(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int) -> Dictionary:
var out := {}
for y in range(rows):
for x in range(cols):
var degree := neighbors_open(cells, x, y, cols, rows).size()
if degree == 1 and not (x == 0 and y == ent_row) \
and not (x == cols - 1 and y == exit_row):
out[y * cols + x] = true
return out
## Все рёбра лабиринта: closed=true/false. Ключи:
## v:bx:by — вертикальная стена слева от клетки (bx,by), 0<=bx<=cols;
## h:bx:by — горизонтальная стена над клеткой (bx,by), 0<=by<=rows.
static func wall_edges(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int) -> Dictionary:
var out := {}
for by in range(rows):
for bx in range(cols + 1):
var closed := true
if bx == 0:
closed = by != ent_row
elif bx == cols:
closed = by != exit_row
else:
closed = bool(cells[by][bx - 1]["e"])
out["v:%d:%d" % [bx, by]] = closed
for by in range(rows + 1):
for bx in range(cols):
var closed := true
if by == 0:
closed = true
elif by == rows:
closed = true
else:
closed = bool(cells[by - 1][bx]["s"])
out["h:%d:%d" % [bx, by]] = closed
return out
static func build_walls(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int, field: Rect2, corridor: float) -> Array[Rect2]:
var pitch := field.size.x / float(cols)
var wall := pitch - corridor
var out: Array[Rect2] = []
var edges := wall_edges(cells, cols, rows, ent_row, exit_row)
for by in range(rows):
for bx in range(cols + 1):
if not bool(edges["v:%d:%d" % [bx, by]]):
continue
out.append(Rect2(field.position.x + float(bx) * pitch - wall * 0.5,
field.position.y + float(by) * pitch - wall * 0.5,
wall, pitch + wall))
for by in range(rows + 1):
for bx in range(cols):
if not bool(edges["h:%d:%d" % [bx, by]]):
continue
out.append(Rect2(field.position.x + float(bx) * pitch - wall * 0.5,
field.position.y + float(by) * pitch - wall * 0.5,
pitch + wall, wall))
return out
static func hit_wall(center: Vector2, walls: Array, half: float) -> bool:
var box := Rect2(center - Vector2(half, half), Vector2(half, half) * 2.0)
for wall in walls:
if box.intersects(wall as Rect2):
return true
return false
## Движение с раздельным скольжением по осям. Возвращает новую позицию и
## флаг столкновения. Чистая функция — удобна для headless-теста.
static func move_packet(pos: Vector2, target: Vector2, speed: float, dt: float,
walls: Array, field: Rect2, half: float) -> Dictionary:
var delta := target - pos
var dist := delta.length()
var max_move := maxf(0.0, speed) * maxf(0.0, dt)
if dist > max_move and dist > 0.001:
delta = delta / dist * max_move
var blocked := false
if delta.length_squared() <= 0.000001:
return {"pos": pos, "blocked": false}
var steps := maxi(1, int(ceil(maxf(absf(delta.x), absf(delta.y)) / 3.0)))
var sx := delta.x / float(steps)
var sy := delta.y / float(steps)
for i in range(steps):
var nx := pos.x + sx
if absf(sx) > 0.0001:
if not hit_wall(Vector2(nx, pos.y), walls, half):
pos.x = nx
else:
blocked = true
var ny := pos.y + sy
if absf(sy) > 0.0001:
if not hit_wall(Vector2(pos.x, ny), walls, half):
pos.y = ny
else:
blocked = true
pos.x = clampf(pos.x, field.position.x + half, field.end.x - half)
pos.y = clampf(pos.y, field.position.y + half, field.end.y - half)
return {"pos": pos, "blocked": blocked}
static func quality_for(collisions: int, dead_ends: int) -> float:
return clampf(1.0 - 0.04 * float(maxi(0, collisions))
- 0.02 * float(maxi(0, dead_ends)), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _process(delta: float) -> void:
if not running or _exited:
return
var half := HITBOX * 0.5
var result := move_packet(_packet, _target, float(params.get("speed", 420.0)),
minf(maxf(delta, 0.0), 0.1), _walls, _field, half)
_packet = result["pos"]
_collision_cd = maxf(0.0, _collision_cd - delta)
if bool(result["blocked"]) and _collision_cd <= 0.0:
_collisions += 1
_collision_cd = COLLISION_COOLDOWN
var id := _cell_under(_packet)
if id >= 0:
if _sol_index.has(id) and int(_sol_index[id]) > _max_sol:
_max_sol = int(_sol_index[id])
if _dead_ends.has(id) and not _visited_dead.has(id):
_visited_dead[id] = true
_dead_end_hits += 1
# Выход справа, в строке выхода.
if _packet.x >= _field.end.x - half - 1.5 \
and absf(_packet.y - _exit.y) <= _corridor * 0.5:
_exited = true
_play_sfx(&"minigame_win")
_finish(quality_for(_collisions, _dead_end_hits))
return
queue_redraw()
func _cell_under(pos: Vector2) -> int:
var gx := int(floor((pos.x - _field.position.x) / _pitch))
var gy := int(floor((pos.y - _field.position.y) / _pitch))
var cols := int(maze.get("cols", 1))
var rows := int(maze.get("rows", 1))
if gx < 0 or gy < 0 or gx >= cols or gy >= rows:
return -1
return gy * cols + gx
func _clamp_target(local: Vector2) -> Vector2:
var half := HITBOX * 0.5
return Vector2(
clampf(local.x, _field.position.x + half, _field.end.x - half),
clampf(local.y, _field.position.y + half, _field.end.y - half))
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if local.y < FOOTER_Y and _field.grow(20.0).has_point(local):
_target = _clamp_target(local)
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if local.y >= FOOTER_Y:
return
if _field.grow(20.0).has_point(local):
_target = _clamp_target(local)
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
var step := 12.0
match keycode:
KEY_LEFT:
_target.x -= step
KEY_RIGHT:
_target.x += step
KEY_UP:
_target.y -= step
KEY_DOWN:
_target.y += step
KEY_HOME:
_target = _entrance
_:
return false
_target = _clamp_target(_target)
return true
func _draw_game() -> void:
var font := Theme90.ui()
_draw_label("МАРШРУТ · ВЫХОД СПРАВА", Vector2(24.0, 46.0), 10,
Theme90.DARK, 280.0)
_draw_label("СТОЛКНОВЕНИЙ %d · ТУПИКОВ %d" % [_collisions, _dead_end_hits],
Vector2(320.0, 46.0), 10, Theme90.DARK, 280.0,
HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1),
_field.grow(10.0))
# Лёгкая шахматная подложка.
var pitch := _pitch
for y in range(int(maze.get("rows", 1))):
for x in range(int(maze.get("cols", 1))):
if (x + y) % 2 == 0:
draw_rect(Rect2(_field.position + Vector2(float(x) * pitch,
float(y) * pitch), Vector2(pitch, pitch)),
Color(0.18, 0.30, 0.32, 0.035))
for wall in _walls:
draw_rect(wall, Theme90.NAVY_DEEP)
# Вход/выход.
draw_rect(Rect2(_entrance - Vector2(3, 4), Vector2(6, 8)), Theme90.ACCENT)
draw_circle(_exit, maxf(5.0, _corridor * 0.32), Theme90.SUCCESS)
# Пакет и цель.
draw_circle(_target, 2.0, Theme90.ACCENT.lightened(0.25))
draw_style_box(Theme90.box(Theme90.ACCENT, Theme90.NAVY_DEEP, 3, 1,
Theme90.SHADOW), Rect2(_packet - Vector2(PACKET, PACKET) * 0.5,
Vector2(PACKET, PACKET)))
draw_circle(_packet + Vector2(0, -2), 2.0, Theme90.WHITE)
var progress := 0.0
if not _solution.is_empty():
progress = clampf((float(_max_sol) + 1.0) / float(_solution.size()), 0.0, 1.0)
_draw_label("ПРОГРЕСС %d%%" % roundi(progress * 100.0),
Vector2(_field.position.x, _field.end.y + 12.0), 10, Theme90.DARK,
_field.size.x)
+1
View File
@@ -0,0 +1 @@
uid://b21ebusrqnwn
+368
View File
@@ -0,0 +1,368 @@
extends MiniGame
## «Кадр» (photo). Бюст клиента всегда движется случайно; игрок ведёт рамку
## и делает N снимков. FIX H4: движение считается по фиксированному
## логическому шагу (SIM_DT), внутри которого используется dt-вероятность
## смены цели 1-exp(-rate*dt), retarget_timer -= dt и current_dir = dir.
## Поэтому сид даёт одинаковую траекторию на 30 и 144 fps.
const SCAN := Rect2(30.0, 44.0, 580.0, 268.0)
const BUST := Vector2(36.0, 48.0)
const INSET := 4.0
const SIM_DT := 1.0 / 120.0
const TWITCH_RATE := 1.25
const TIERS := {
1: {"speed": 140.0, "frame_w": 180.0, "frame_h": 120.0, "shots": 4,
"switch_min": 0.6, "switch_max": 1.0, "turn_rate": 260.0},
2: {"speed": 200.0, "frame_w": 150.0, "frame_h": 100.0, "shots": 5,
"switch_min": 0.5, "switch_max": 0.8, "turn_rate": 300.0},
3: {"speed": 260.0, "frame_w": 120.0, "frame_h": 80.0, "shots": 6,
"switch_min": 0.35, "switch_max": 0.65, "turn_rate": 340.0},
}
var state: Dictionary = {}
var params: Dictionary = {}
var _frame_pos: Vector2 = Vector2.ZERO
var _shots_taken: int = 0
var _captures: int = 0
var _acc: float = 0.0
var _flash: float = 0.0
var _last_capture_ok: bool = false
func _own_game_id() -> String:
return "photo"
func _setup() -> void:
params = tier_params(tier)
state = _make_state(rng, params)
_frame_pos = _clamp_frame(Vector2(SCAN.get_center().x, SCAN.position.y),
params)
_shots_taken = 0
_captures = 0
_acc = 0.0
_flash = 0.0
_last_capture_ok = false
func _goal_text() -> String:
return "НАВЕДИТЕ РАМКУ НА КЛИЕНТА · ЛКМ / ПРОБЕЛ — СНИМОК"
func _progress_ratio() -> float:
var shots := int(params.get("shots", 1))
if shots <= 0:
return 0.0
return clampf(float(_shots_taken) / float(shots), 0.0, 1.0)
func _result_stats() -> String:
return "УДАЧНЫХ %d ИЗ %d" % [_captures, int(params.get("shots", 0))]
# --- параметры/чистая математика ------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("photo", p_tier)
if row.is_empty():
return base
for key in ["speed", "frame_w", "frame_h", "switch_min", "switch_max",
"turn_rate"]:
if row.has(key):
base[key] = float(row[key])
for alias in [["retarget_min", "switch_min"], ["retarget_max", "switch_max"]]:
if row.has(alias[0]):
base[alias[1]] = float(row[alias[0]])
if row.has("shots"):
base["shots"] = int(row["shots"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
return _make_state(rng, tier_params(p_tier))
static func _make_state(p_rng: RandomNumberGenerator, p: Dictionary) -> Dictionary:
var speed := float(p.get("speed", 90.0)) * p_rng.randf_range(0.9, 1.1)
var pos := _random_point(p_rng)
var target := _random_point(p_rng)
var dir := Vector2.RIGHT.rotated(p_rng.randf_range(0.0, TAU))
var timer := p_rng.randf_range(float(p.get("switch_min", 0.8)),
float(p.get("switch_max", 1.2)))
return {
"pos": pos,
"target": target,
"current_dir": dir,
"retarget_timer": timer,
"speed": speed,
"params": p,
}
static func _random_point(p_rng: RandomNumberGenerator) -> Vector2:
var half := BUST * 0.5
return Vector2(
p_rng.randf_range(SCAN.position.x + half.x + 2.0,
SCAN.end.x - half.x - 2.0),
p_rng.randf_range(SCAN.position.y + half.y + 2.0,
SCAN.end.y - half.y - 2.0))
static func _clamp_bust(pos: Vector2) -> Vector2:
var half := BUST * 0.5
return Vector2(
clampf(pos.x, SCAN.position.x + half.x, SCAN.end.x - half.x),
clampf(pos.y, SCAN.position.y + half.y, SCAN.end.y - half.y))
## H4: один логический шаг. dt здесь всегда SIM_DT, поэтому вероятность
## смены цели масштабируется временем, а не кадром.
static func step_motion(p_state: Dictionary, p_rng: RandomNumberGenerator,
dt: float) -> void:
if dt <= 0.0:
return
var p: Dictionary = p_state.get("params", {})
var pos: Vector2 = p_state.get("pos", SCAN.get_center())
var target: Vector2 = p_state.get("target", SCAN.get_center())
var current_dir: Vector2 = p_state.get("current_dir", Vector2.RIGHT)
var speed := float(p_state.get("speed", float(p.get("speed", 90.0))))
var turn_rate := deg_to_rad(float(p.get("turn_rate", 240.0)))
var to_target := target - pos
var desired := current_dir
if to_target.length_squared() > 0.001:
desired = to_target.normalized()
var max_turn := turn_rate * dt
var angle := current_dir.angle_to(desired)
current_dir = current_dir.rotated(clampf(angle, -max_turn, max_turn))
# H4: явно фиксируем текущее направление и двигаемся по нему.
var dir := current_dir
pos += dir * speed * dt
pos = _clamp_bust(pos)
# Редкая резкая смена цели, вероятность зависит от dt.
if p_rng.randf() < 1.0 - exp(-TWITCH_RATE * dt):
target = _random_point(p_rng)
p_state["retarget_timer"] = p_rng.randf_range(
float(p.get("switch_min", 0.8)), float(p.get("switch_max", 1.2)))
var timer := float(p_state.get("retarget_timer", 1.0)) - dt
if timer <= 0.0 or pos.distance_to(target) < 24.0:
target = _random_point(p_rng)
timer = p_rng.randf_range(float(p.get("switch_min", 0.8)),
float(p.get("switch_max", 1.2)))
p_state["pos"] = pos
p_state["target"] = target
p_state["current_dir"] = current_dir
p_state["retarget_timer"] = timer
## Детерминированная симуляция на фиксированном шаге. fps влияет только на
## группировку вызовов, не на последовательность шагов/позиций.
static func simulate(seed: int, p_tier: int, duration: float, fps: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var p := tier_params(p_tier)
var s := _make_state(rng, p)
var total_steps := int(floor(maxf(0.0, duration) / SIM_DT))
var positions: Array[Vector2] = []
var frame_dt := 1.0 / float(maxi(1, fps))
var acc := 0.0
var steps := 0
var guard := 0
while steps < total_steps and guard < total_steps * 4 + 16:
guard += 1
acc += frame_dt
while acc >= SIM_DT and steps < total_steps:
step_motion(s, rng, SIM_DT)
positions.append(s["pos"])
steps += 1
acc -= SIM_DT
return {
"positions": positions,
"final": s["pos"],
"steps": steps,
}
static func capture_success(bust_pos: Vector2, frame_pos: Vector2,
frame_size: Vector2) -> bool:
var b := Rect2(bust_pos - BUST * 0.5, BUST)
var f := Rect2(frame_pos, frame_size)
return b.position.x >= f.position.x and b.position.y >= f.position.y \
and b.end.x <= f.end.x and b.end.y <= f.end.y
static func quality_for(captures: int, shots: int) -> float:
if shots <= 0:
return 0.0
return clampf(float(maxi(0, captures)) / float(shots), 0.0, 1.0)
static func _clamp_frame(mouse: Vector2, p: Dictionary) -> Vector2:
var fw := float(p.get("frame_w", 220.0))
var fh := float(p.get("frame_h", 150.0))
var x := clampf(mouse.x - fw * 0.5,
SCAN.position.x + INSET, SCAN.end.x - INSET - fw)
var y := clampf(mouse.y - fh * 0.5,
SCAN.position.y + INSET, SCAN.end.y - INSET - fh)
return Vector2(x, y)
func _frame_size() -> Vector2:
return Vector2(float(params.get("frame_w", 220.0)),
float(params.get("frame_h", 150.0)))
func _frame_rect() -> Rect2:
return Rect2(_frame_pos, _frame_size())
# --- игра ------------------------------------------------------------------
func _process(delta: float) -> void:
if not running or _finished:
return
var mouse := _frame_pos + _frame_size() * 0.5
if is_inside_tree():
mouse = get_local_mouse_position()
if SCAN.grow(2.0).has_point(mouse):
_frame_pos = _clamp_frame(mouse, params)
_acc += minf(maxf(delta, 0.0), 0.1)
var guard := 0
while _acc >= SIM_DT and guard < 32:
guard += 1
step_motion(state, rng, SIM_DT)
_acc -= SIM_DT
if _flash > 0.0:
_flash = maxf(0.0, _flash - delta)
queue_redraw()
func _capture() -> void:
if _finished:
return
var shots := int(params.get("shots", 3))
if _shots_taken >= shots:
return
_shots_taken += 1
var ok := capture_success(state["pos"], _frame_pos, _frame_size())
_last_capture_ok = ok
if ok:
_captures += 1
_flash = 0.22
_play_sfx(&"minigame_win" if ok else &"minigame_fail")
if _shots_taken >= shots:
_finish(quality_for(_captures, shots))
queue_redraw()
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if local.y >= FOOTER_Y or not SCAN.grow(6.0).has_point(local):
return false
_frame_pos = _clamp_frame(local, params)
_capture()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if local.y >= FOOTER_Y:
return
_frame_pos = _clamp_frame(local, params)
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_SPACE or keycode == KEY_ENTER:
_capture()
return true
var step := 6.0
var center := _frame_pos + _frame_size() * 0.5
if keycode == KEY_LEFT:
center.x -= step
elif keycode == KEY_RIGHT:
center.x += step
elif keycode == KEY_UP:
center.y -= step
elif keycode == KEY_DOWN:
center.y += step
else:
return false
_frame_pos = _clamp_frame(center, params)
queue_redraw()
return true
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var font := Theme90.ui()
var shots := int(params.get("shots", 3))
var inside := capture_success(state["pos"], _frame_pos, _frame_size())
draw_style_box(Theme90.box(Theme90.DARK, Color(0, 0, 0, 0), 4, 1), SCAN)
# Мягкий фон скана: окно/стол, на игру не влияет.
draw_rect(Rect2(SCAN.position + Vector2(18, 16), Vector2(178, 118)),
Color(0.86, 0.84, 0.76, 0.12))
draw_line(SCAN.position + Vector2(18, 134), SCAN.position + Vector2(196, 134),
Color(1, 1, 1, 0.16), 2.0)
draw_rect(Rect2(SCAN.end - Vector2(170, 102), Vector2(120, 72)),
Color(0.65, 0.58, 0.45, 0.12))
# Статус рисуется поверх тёмного скана, иначе его закрывает фон.
_draw_label("СКАН КЛИЕНТА · КАДР %d / %d" % [_shots_taken, shots],
Vector2(40.0, 47.0), 10, Theme90.PAPER_HI, 280.0)
_draw_label("УДАЧНЫХ %d · ОСТАЛОСЬ %d" % [_captures, shots - _shots_taken],
Vector2(320.0, 47.0), 10, Theme90.ACCENT_LIGHT, 270.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var bust_pos: Vector2 = state["pos"]
_draw_bust(bust_pos)
var frame := _frame_rect()
var fcol := Theme90.SUCCESS if inside else Theme90.ACCENT_LIGHT
Theme90.draw_dashed_box(self, frame, fcol, 5.0, 2.0)
# Уголки рамки.
var corner := 12.0
for p in [frame.position, Vector2(frame.end.x, frame.position.y),
Vector2(frame.position.x, frame.end.y), frame.end]:
draw_circle(p, 2.2, fcol)
draw_line(frame.position + Vector2(0, corner),
frame.position + Vector2(0, 0), fcol, 2.0)
draw_line(frame.position + Vector2(0, 0),
frame.position + Vector2(corner, 0), fcol, 2.0)
draw_line(Vector2(frame.end.x, frame.position.y),
Vector2(frame.end.x, frame.position.y + corner), fcol, 2.0)
draw_line(Vector2(frame.end.x - corner, frame.position.y),
Vector2(frame.end.x, frame.position.y), fcol, 2.0)
if _flash > 0.0:
var a := _flash / 0.22
draw_rect(SCAN, Color(1, 1, 1, 0.22 * a))
var text := "В РАМКЕ" if _last_capture_ok else "МИМО"
_draw_label(text, Vector2(SCAN.get_center().x - 60.0, SCAN.position.y + 12.0),
16, Theme90.SUCCESS if _last_capture_ok else Theme90.ERROR, 120.0,
HORIZONTAL_ALIGNMENT_CENTER)
_draw_label("рамка %d×%d" % [roundi(_frame_size().x), roundi(_frame_size().y)],
Vector2(466.0, 344.0), 10, Theme90.DARK, 156.0,
HORIZONTAL_ALIGNMENT_RIGHT)
func _draw_bust(pos: Vector2) -> void:
var b := Rect2(pos - BUST * 0.5, BUST)
draw_circle(Vector2(pos.x, b.end.y - 4.0), 12.0, Color(0, 0, 0, 0.16))
# Плечи.
draw_colored_polygon(PackedVector2Array([
Vector2(pos.x - 15.0, b.end.y - 2.0),
Vector2(pos.x - 9.0, b.position.y + 26.0),
Vector2(pos.x + 9.0, b.position.y + 26.0),
Vector2(pos.x + 15.0, b.end.y - 2.0)]), Theme90.NAVY)
# Голова.
draw_circle(Vector2(pos.x, b.position.y + 14.0), 9.0, Theme90.NAVY)
# Шея.
draw_rect(Rect2(Vector2(pos.x - 3.0, b.position.y + 22.0), Vector2(6, 8)),
Theme90.NAVY_DEEP)
# Лицо/блик — чтобы бюст читался.
draw_circle(Vector2(pos.x + 3.0, b.position.y + 12.0), 1.5, Theme90.PAPER_HI)
+1
View File
@@ -0,0 +1 @@
uid://cld1besfvk2ow
+330
View File
@@ -0,0 +1,330 @@
extends MiniGame
## «Уровни» (sound). Вертикальные фейдеры, зелёные целевые зоны.
## T1–T3: 4/5/6 каналов, допуск 11/8/6%, на T2/T3 — синусоидальный дрейф.
## Таймера нет; качество фиксируется кнопкой «ЗАПИСАТЬ».
const TIERS := {
1: {"count": 4, "tolerance": 0.11, "drift_amp": 0.0, "drift_hz": 0.0},
2: {"count": 5, "tolerance": 0.08, "drift_amp": 0.02, "drift_hz": 0.15},
3: {"count": 6, "tolerance": 0.06, "drift_amp": 0.03, "drift_hz": 0.25},
}
const LABELS := [
["НЧ", "60"], ["НСЧ", "250"], ["СЧ", "1К"], ["ВСЧ", "4К"], ["ВЧ", "12К"],
["УВЧ", "20К"],
]
const TRACK_TOP := 92.0
const TRACK_H := 154.0
const TRACK_W := 30.0
var state: Dictionary = {}
var _values: Array[float] = []
var _targets: Array[float] = []
var _phases: Array[float] = []
var _time: float = 0.0
var _dragging: int = -1
var _active: int = 0
func _own_game_id() -> String:
return "sound"
func _setup() -> void:
state = generate(tier, seed_value)
_values.clear()
_targets.clear()
_phases.clear()
for v in state.get("starts", []):
_values.append(float(v))
for v in state.get("targets", []):
_targets.append(float(v))
for p in state.get("phases", []):
_phases.append(float(p))
_time = 0.0
_dragging = -1
_active = 0
func _goal_text() -> String:
return "СОВМЕСТИТЕ БЕГУНКИ С ЗЕЛЁНЫМИ ПОЛОСАМИ · ENTER — ЗАПИСАТЬ"
func _result_stats() -> String:
var in_zone := 0
for i in range(_values.size()):
var tol := float(state.get("tolerance", 0.11))
if absf(_values[i] - _current_target(i)) <= tol:
in_zone += 1
return "В ЗОНЕ %d/%d · ДОПУСК ±%d%%" % [in_zone, _values.size(),
roundi(float(state.get("tolerance", 0.11)) * 100.0)]
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("sound", p_tier)
if row.is_empty():
return base
if row.has("channels"):
base["count"] = int(row["channels"])
if row.has("count"):
base["count"] = int(row["count"])
if row.has("tolerance"):
base["tolerance"] = float(row["tolerance"])
if row.has("drift"):
base["drift_amp"] = float(row["drift"])
if row.has("drift_amp"):
base["drift_amp"] = float(row["drift_amp"])
if row.has("drift_hz"):
base["drift_hz"] = float(row["drift_hz"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var count := clampi(int(p.get("count", 3)), 2, 6)
var tol := clampf(float(p.get("tolerance", 0.16)), 0.02, 0.30)
var amp := maxf(0.0, float(p.get("drift_amp", 0.0)))
var hz := maxf(0.0, float(p.get("drift_hz", 0.0)))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var targets: Array[float] = []
var min_gap := tol * 2.0
var lo := tol
var hi := 1.0 - tol
var attempts := 0
while targets.size() < count and attempts < 600:
attempts += 1
var candidate := rng.randf_range(lo, hi)
var ok := true
for t in targets:
if absf(candidate - float(t)) < min_gap - 0.0001:
ok = false
break
if ok:
targets.append(candidate)
# Резерв: равномерная сетка, если rejection не сошёлся.
if targets.size() < count:
targets.clear()
var span := maxf(0.0, hi - lo)
var step := span / float(maxi(1, count - 1))
for i in range(count):
targets.append(clampf(lo + step * float(i), 0.02, 0.98))
targets.sort()
var starts: Array[float] = []
var phases: Array[float] = []
for i in range(count):
var target := float(targets[i])
var sign := -1.0 if rng.randf() < 0.5 else 1.0
var offset := rng.randf_range(tol * 1.10, tol * 2.8)
var start := clampf(target + sign * offset, 0.02, 0.98)
if absf(start - target) <= tol * 1.02:
start = clampf(target - sign * offset, 0.02, 0.98)
if absf(start - target) <= tol:
start = clampf(target + tol * 2.0, 0.02, 0.98)
if absf(start - target) <= tol:
start = clampf(target - tol * 2.0, 0.02, 0.98)
if absf(start - target) <= tol:
start = 0.02 if target >= 0.5 else 0.98
starts.append(start)
phases.append(rng.randf_range(0.0, TAU))
return {
"tier": clampi(p_tier, 1, 3),
"count": count,
"tolerance": tol,
"drift_amp": amp,
"drift_hz": hz,
"targets": targets,
"starts": starts,
"phases": phases,
}
static func target_at(target: float, amp: float, hz: float, phase: float,
time: float) -> float:
if amp <= 0.0 or hz <= 0.0:
return target
return clampf(target + amp * sin(TAU * hz * time + phase), 0.0, 1.0)
static func channel_quality(value: float, target: float, tol: float) -> float:
if tol <= 0.0:
return 1.0 if is_equal_approx(value, target) else 0.0
return clampf(1.0 - absf(value - target) / (2.0 * tol), 0.0, 1.0)
static func quality_from(values: Array, targets: Array, tol: float,
amp: float = 0.0, hz: float = 0.0, phases: Array = [], time: float = 0.0) -> float:
var n := mini(values.size(), targets.size())
if n <= 0:
return 0.0
var total := 0.0
for i in range(n):
var target := float(targets[i])
if amp > 0.0 and hz > 0.0:
var phase := float(phases[i]) if i < phases.size() else 0.0
target = target_at(target, amp, hz, phase, time)
total += channel_quality(float(values[i]), target, tol)
return clampf(total / float(n), 0.0, 1.0)
func _current_target(i: int) -> float:
if i < 0 or i >= _targets.size():
return 0.5
return target_at(_targets[i], float(state.get("drift_amp", 0.0)),
float(state.get("drift_hz", 0.0)), _phases[i] if i < _phases.size() else 0.0,
_time)
func _quality_now() -> float:
var targets: Array = []
for i in range(_targets.size()):
targets.append(_current_target(i))
return quality_from(_values, targets, float(state.get("tolerance", 0.16)))
func _process(delta: float) -> void:
if not running:
return
if float(state.get("drift_amp", 0.0)) > 0.0:
_time += maxf(0.0, delta)
queue_redraw()
# --- геометрия -------------------------------------------------------------
func _track_rect(i: int) -> Rect2:
var count := _values.size()
var spacing := minf(104.0, 480.0 / float(maxi(1, count)))
var center_x := 320.0
var x := center_x + (float(i) - float(count - 1) * 0.5) * spacing - TRACK_W * 0.5
return Rect2(x, TRACK_TOP, TRACK_W, TRACK_H)
func _value_to_y(rect: Rect2, value: float) -> float:
var pad := 7.0
return rect.position.y + pad + (1.0 - clampf(value, 0.0, 1.0)) * (rect.size.y - pad * 2.0)
func _y_to_value(rect: Rect2, y: float) -> float:
var pad := 7.0
var inner := rect.size.y - pad * 2.0
return clampf(1.0 - (y - rect.position.y - pad) / inner, 0.0, 1.0)
func _fader_at(local: Vector2) -> int:
for i in range(_values.size()):
var rect := _track_rect(i)
if local.x >= rect.position.x - 24.0 and local.x <= rect.end.x + 24.0 \
and local.y >= rect.position.y - 20.0 and local.y <= rect.end.y + 18.0:
return i
return -1
# --- ввод ------------------------------------------------------------------
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
_finish(_quality_now())
return true
var idx := _fader_at(local)
if idx < 0:
return false
_dragging = idx
_active = idx
_values[idx] = _y_to_value(_track_rect(idx), local.y)
_play_sfx(&"tick")
queue_redraw()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _dragging < 0:
return
_values[_dragging] = _y_to_value(_track_rect(_dragging), local.y)
queue_redraw()
func _on_pointer_released(_local: Vector2, _event: InputEvent) -> void:
_dragging = -1
func _on_key_pressed(keycode: int, event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
_finish(_quality_now())
return true
if keycode == KEY_LEFT or keycode == KEY_A:
_active = posmod(_active - 1, _values.size())
queue_redraw()
return true
if keycode == KEY_RIGHT or keycode == KEY_D:
_active = (_active + 1) % _values.size()
queue_redraw()
return true
if keycode in [KEY_UP, KEY_DOWN, KEY_W, KEY_S]:
var key_event := event as InputEventKey
var step := 0.005 if key_event != null and key_event.shift_pressed else 0.02
var sign := 1.0 if keycode in [KEY_UP, KEY_W] else -1.0
_values[_active] = clampf(_values[_active] + sign * step, 0.0, 1.0)
queue_redraw()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var tol := float(state.get("tolerance", 0.16))
var in_zone := 0
for i in range(_values.size()):
if absf(_values[i] - _current_target(i)) <= tol:
in_zone += 1
_draw_label("МИКШЕР · КАНАЛ 97", Vector2(24.0, 46.0), 10, Theme90.ACCENT, 240.0)
_draw_label("В ЗОНЕ %d / %d · ДОПУСК ±%d%%" % [in_zone, _values.size(),
roundi(tol * 100.0)], Vector2(280.0, 46.0), 10,
Theme90.SUCCESS if in_zone == _values.size() else Theme90.DARK, 320.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var panel := Rect2(18.0, 58.0, 604.0, 250.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1), panel)
# Общая шкала 100/50/0.
var first := _track_rect(0)
for v in [1.0, 0.5, 0.0]:
var y := _value_to_y(first, v)
Theme90.text(self, Theme90.ui(), Vector2(first.position.x - 44.0, y - 6.0),
"%d" % roundi(v * 100.0), 10, Theme90.DARK, 34.0,
HORIZONTAL_ALIGNMENT_RIGHT)
for i in range(_values.size()):
var rect := _track_rect(i)
var target := _current_target(i)
var active := i == _active
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 3, 1), rect)
var y_top := _value_to_y(rect, minf(1.0, target + tol))
var y_bottom := _value_to_y(rect, maxf(0.0, target - tol))
draw_rect(Rect2(rect.position.x + 2.0, y_top, rect.size.x - 4.0,
maxf(2.0, y_bottom - y_top)), Color(Theme90.SUCCESS.r, Theme90.SUCCESS.g,
Theme90.SUCCESS.b, 0.30))
draw_line(Vector2(rect.position.x - 4.0, _value_to_y(rect, target)),
Vector2(rect.end.x + 4.0, _value_to_y(rect, target)), Theme90.SUCCESS, 2.0)
var y := _value_to_y(rect, _values[i])
draw_rect(Rect2(rect.position.x + 4.0, y, rect.size.x - 8.0,
rect.end.y - y - 7.0), Color(Theme90.ACCENT.r, Theme90.ACCENT.g,
Theme90.ACCENT.b, 0.24))
draw_style_box(Theme90.box(Theme90.WHITE, Theme90.ACCENT, 3, 1,
Theme90.SHADOW), Rect2(rect.position.x - 7.0, y - 6.0, rect.size.x + 14.0, 12.0))
if active:
Theme90.draw_dashed_box(self, rect.grow(3.0), Theme90.ACCENT, 3.0, 1.0)
var label: Array = LABELS[i % LABELS.size()]
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x - 10.0,
rect.end.y + 8.0), str(label[0]), 12, Theme90.DARK, rect.size.x + 20.0,
HORIZONTAL_ALIGNMENT_CENTER)
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x - 10.0,
rect.end.y + 23.0), "%s · %d%%" % [str(label[1]), roundi(_values[i] * 100.0)],
10, Theme90.DARK, rect.size.x + 20.0, HORIZONTAL_ALIGNMENT_CENTER)
_draw_action_button(ACTION_RECT, "ЗАПИСАТЬ", true, true)
+1
View File
@@ -0,0 +1 @@
uid://besue7m8ig8k6
+447
View File
@@ -0,0 +1,447 @@
extends MiniGame
## «Чужое слово» (text). Письмо с нормальными и чужими словами.
## Клик помечает/снимает слово; «ОТПРАВИТЬ» фиксирует.
## q = clamp((found - false_positive) / aliens, 0, 1), ложное слово
## учитывается не более одного раза.
const TIERS := {
1: {"words": 15, "aliens": 2, "lines": 2, "types": ["foreign", "deformed"]},
2: {"words": 19, "aliens": 3, "lines": 3, "types": ["foreign", "deformed", "semantic"]},
3: {"words": 24, "aliens": 4, "lines": 3, "types": ["foreign", "deformed", "semantic", "archaic"]},
}
const NORMAL := [
"смета", "отчёт", "курсор", "канал", "папка", "сервер", "файл", "запрос",
"договор", "смена", "пакет", "клиент", "письмо", "проверка", "срочно",
"подтвердить", "согласовано", "принято", "оплата", "счёт", "архив",
"копия", "печать", "модем", "линия", "обмен", "срок", "заявка",
"подпись", "курьер", "терминал", "диспетчер", "доставка", "опись",
"накладная", "пломба", "сверка", "реестр", "график", "смена", "склад",
"погрузка", "маршрут", "квитанция", "сектор", "порт", "кабель", "узел",
"сбой", "ремонт", "дежурный", "инструкция", "согласование", "скан",
]
const ALIEN_POOLS := {
"foreign": [
"deadline", "please", "update", "attachment", "urgent", "email",
"file", "message", "report", "invoice", "delivery", "storage",
],
"deformed": [
"подтверDить", "согласоваNо", "клиеNт", "проверXа", "докумеNт",
"срочNо", "пакеD", "адреZ", "сектоR", "накладNая",
],
"semantic": [
"СУХОЙ-МОДЕМ", "КВАДРАТНЫЙ-ЧАЙ", "ЖИДКИЙ-ФАКС", "ГЛУХОЙ-ЗВОНОК",
"СОЛЁНЫЙ-КАБЕЛЬ", "ЦИФРОВОЙ-СНЕГ", "МОКРАЯ-ПОЧТА", "ХРУСТЯЩИЙ-ДИСК",
],
"archaic": [
"ТЕЛЕГРАФ", "ПЕРФОЛЕНТА", "ТЕЛЕКС", "ГРАММОФОН", "АРИФМОМЕТР",
"ПАТЕФОН", "КЕРОСИНКА", "БОБИНА", "ТАБУЛЯТОР", "ДИСКЕТА",
],
}
const ACTION_LABEL := "ОТПРАВИТЬ"
var generated: Dictionary = {}
var _words: Array = []
var _marked: Dictionary = {}
var _layout: Array = []
var _hover: int = -1
var _found: int = 0
func _own_game_id() -> String:
return "text"
func _setup() -> void:
generated = generate(tier, seed_value)
_words = []
for w in generated.get("words", []):
_words.append((w as Dictionary).duplicate(true))
_marked.clear()
_found = 0
_hover = -1
_build_layout()
func _goal_text() -> String:
return "НАЙДИТЕ ЧУЖИЕ СЛОВА И НАЖМИТЕ «ОТПРАВИТЬ»"
func _progress_ratio() -> float:
var aliens := int(generated.get("aliens", 0))
if aliens <= 0:
return 0.0
return clampf(float(_found) / float(aliens), 0.0, 1.0)
func _result_stats() -> String:
return "НАЙДЕНО %d/%d · ЛОЖНЫХ %d" % [_found,
int(generated.get("aliens", 0)), _false_positive_count()]
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("text", p_tier)
if row.is_empty():
return base
for key in ["words", "aliens", "lines"]:
if row.has(key):
base[key] = int(row[key])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var total := clampi(int(p.get("words", 11)), 6, 30)
var aliens := clampi(int(p.get("aliens", 1)), 1, mini(6, total - 2))
var lines := clampi(int(p.get("lines", 2)), 1, 4)
var allowed: Array = p.get("types", ["foreign"])
if allowed.is_empty():
allowed = ["foreign"]
var rng := RandomNumberGenerator.new()
rng.seed = seed
var normal_pool := NORMAL.duplicate()
_shuffle(normal_pool, rng)
var chosen: Array = []
var used := {}
for word in normal_pool:
var key := str(word).to_lower()
if used.has(key):
continue
used[key] = true
chosen.append(str(word))
if chosen.size() >= total - aliens:
break
while chosen.size() < total - aliens:
var filler := "пакет%d" % (chosen.size() + 1)
if not used.has(filler):
used[filler] = true
chosen.append(filler)
var alien_words: Array = []
var types_used: Array = []
var type_cycle: Array = allowed.duplicate()
_shuffle(type_cycle, rng)
for i in range(aliens):
var type := str(type_cycle[i % type_cycle.size()])
var pool: Array = ALIEN_POOLS.get(type, ALIEN_POOLS["foreign"])
var candidates := pool.duplicate()
_shuffle(candidates, rng)
var picked := ""
for candidate in candidates:
var candidate_key := str(candidate).to_lower()
if not used.has(candidate_key):
picked = str(candidate)
break
if picked.is_empty():
# Пул исчерпан — берём любую другую разрешённую категорию.
for alt in allowed:
var alt_pool: Array = ALIEN_POOLS.get(str(alt), [])
for candidate in alt_pool:
var candidate_key := str(candidate).to_lower()
if not used.has(candidate_key):
picked = str(candidate)
type = str(alt)
break
if not picked.is_empty():
break
if picked.is_empty():
picked = "чужое%d" % (i + 1)
used[picked.to_lower()] = true
alien_words.append({"text": picked, "type": type})
if not types_used.has(type):
types_used.append(type)
# Строки: сумма ровно total, все строки непустые.
var rows: Array = _split_counts(total, lines, rng)
var words: Array = []
for word in chosen:
words.append({"text": word, "alien": false, "type": "", "row": 0})
for alien in alien_words:
words.append({"text": str(alien["text"]), "alien": true,
"type": str(alien["type"]), "row": 0})
_shuffle(words, rng)
var row_index := 0
var in_row := 0
for i in range(words.size()):
if row_index >= rows.size():
row_index = rows.size() - 1
words[i]["row"] = row_index
in_row += 1
if in_row >= int(rows[row_index]) and row_index < rows.size() - 1:
row_index += 1
in_row = 0
var alien_count := 0
for w in words:
if bool(w["alien"]):
alien_count += 1
return {
"tier": clampi(p_tier, 1, 3),
"words": words,
"count": words.size(),
"aliens": alien_count,
"rows": rows,
"types": allowed,
}
static func _split_counts(total: int, lines: int, rng: RandomNumberGenerator) -> Array:
var out: Array = []
var base := total / lines
var rest := total % lines
for i in range(lines):
out.append(base + (1 if i < rest else 0))
if lines > 1 and rng.randf() < 0.5 and int(out[0]) > 1 and int(out[lines - 1]) > 1:
out[0] = int(out[0]) + 1
out[lines - 1] = int(out[lines - 1]) - 1
return out
static func validate_generation(data: Dictionary, p_tier: int) -> bool:
var p := tier_params(p_tier)
var words: Array = data.get("words", [])
var rows: Array = data.get("rows", [])
if words.size() != int(p.get("words", words.size())):
return false
var expected_aliens := int(p.get("aliens", 1))
var aliens := 0
var seen := {}
var row_sum := 0
for r in rows:
row_sum += int(r)
if row_sum != words.size() or rows.size() != int(p.get("lines", rows.size())):
return false
for w in words:
var text := str(w.get("text", "")).strip_edges()
if text.is_empty():
return false
var key := text.to_lower()
if seen.has(key):
return false
seen[key] = true
if bool(w.get("alien", false)):
aliens += 1
if not _is_known_alien(text):
return false
else:
if _is_known_alien(text):
return false
return aliens == expected_aliens
static func _is_known_alien(word: String) -> bool:
var key := word.strip_edges().to_lower()
for pool in ALIEN_POOLS.values():
for candidate in (pool as Array):
if str(candidate).strip_edges().to_lower() == key:
return true
return false
static func quality_for(found: int, false_positive: int, aliens: int) -> float:
if aliens <= 0:
return 1.0
return clampf((float(found) - float(false_positive)) / float(aliens), 0.0, 1.0)
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
# --- раскладка/игра --------------------------------------------------------
func _build_layout() -> void:
_layout.clear()
var font := Theme90.ui()
if _words.is_empty():
return
var panel := Rect2(28.0, 62.0, 584.0, 232.0)
var inner_w := panel.size.x - 40.0
var rows_count := int(generated.get("rows", []).size())
if rows_count <= 0:
rows_count = 2
var counts: Array = generated.get("rows", [])
var size_px := 10
for candidate in [16, 12, 10]:
var fits := true
for r in range(counts.size()):
var width := 0.0
for w in _row_words(r, counts):
width += font.get_string_size(str(w["text"]), HORIZONTAL_ALIGNMENT_LEFT,
-1, int(candidate)).x + 10.0
width += 7.0 * float(maxi(0, _row_words(r, counts).size() - 1))
if width > inner_w:
fits = false
break
if fits:
size_px = int(candidate)
break
var line_h := float(size_px) + 13.0
# Первая строка ниже заголовка записки, а не поверх него.
var header_h := 24.0
var top := panel.position.y + header_h + 2.0
var avail := panel.size.y - header_h - 10.0
var gap := 10.0
if rows_count > 1:
gap = clampf((avail - line_h * float(rows_count)) / float(rows_count - 1),
10.0, 46.0)
var block_h := line_h * float(rows_count) + gap * float(maxi(0, rows_count - 1))
var y := top + maxf(0.0, (avail - block_h) * 0.5)
for r in range(counts.size()):
var row_list := _row_words(r, counts)
var widths: Array = []
var total_w := 0.0
for w in row_list:
var ww := font.get_string_size(str(w["text"]), HORIZONTAL_ALIGNMENT_LEFT,
-1, size_px).x + 10.0
widths.append(ww)
total_w += ww
total_w += 7.0 * float(maxi(0, row_list.size() - 1))
var x := panel.position.x + 20.0 + maxf(0.0, (inner_w - total_w) * 0.5)
var row_layout: Array = []
for i in range(row_list.size()):
var ww := float(widths[i])
row_layout.append({
"idx": int(row_list[i]["idx"]),
"rect": Rect2(x, y, ww, line_h),
})
x += ww + 7.0
_layout.append(row_layout)
y += line_h + gap
_layout.append(size_px)
func _row_words(r: int, counts: Array) -> Array:
var out: Array = []
for i in range(_words.size()):
if int(_words[i].get("row", 0)) == r:
var copy: Dictionary = (_words[i] as Dictionary).duplicate(true)
copy["idx"] = i
out.append(copy)
return out
func _word_index_at(local: Vector2) -> int:
for row in _layout:
if not (row is Array):
continue
for item in (row as Array):
var hit: Rect2 = (item as Dictionary)["rect"]
if hit.grow(3.0).has_point(local):
return int((item as Dictionary)["idx"])
return -1
func _false_positive_count() -> int:
var n := 0
for idx in _marked.keys():
var i := int(idx)
if i >= 0 and i < _words.size() and not bool(_words[i].get("alien", false)):
n += 1
return n
func _recount() -> void:
_found = 0
for idx in _marked.keys():
var i := int(idx)
if i >= 0 and i < _words.size() and bool(_words[i].get("alien", false)):
_found += 1
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
var fp := _false_positive_count()
_finish(quality_for(_found, fp, int(generated.get("aliens", 1))))
return true
var idx := _word_index_at(local)
if idx < 0:
return false
if _marked.has(idx):
_marked.erase(idx)
else:
_marked[idx] = true
_recount()
_play_sfx(&"tick")
queue_redraw()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
var idx := _word_index_at(local)
if idx != _hover:
_hover = idx
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
var fp := _false_positive_count()
_finish(quality_for(_found, fp, int(generated.get("aliens", 1))))
return true
return false
func _draw_game() -> void:
var font := Theme90.ui()
var fp := _false_positive_count()
_draw_label("ПИСЬМО КЛИЕНТА · ЗАДАНИЕ 97", Vector2(24.0, 46.0), 10,
Theme90.NAVY, 260.0)
_draw_label("НАЙДЕНО %d / %d · ЛОЖНЫХ %d" % [_found,
int(generated.get("aliens", 1)), fp], Vector2(300.0, 46.0), 10,
Theme90.SUCCESS if _found >= int(generated.get("aliens", 1)) else Theme90.DARK,
300.0, HORIZONTAL_ALIGNMENT_RIGHT)
var panel := Rect2(28.0, 62.0, 584.0, 232.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1,
Theme90.SHADOW), panel)
draw_rect(Rect2(panel.position.x + 18.0, panel.position.y + 23.0,
panel.size.x - 36.0, 1.0), Theme90.HAIRLINE)
_draw_label("СЛУЖЕБНАЯ ЗАПИСКА · НАЙДИТЕ ЧУЖИЕ СЛОВА", Vector2(panel.position.x + 20.0,
panel.position.y + 7.0), 10, Theme90.DARK, panel.size.x - 40.0)
var size_px := 12
if not _layout.is_empty() and typeof(_layout[_layout.size() - 1]) == TYPE_INT:
size_px = int(_layout[_layout.size() - 1])
for row in _layout:
if not (row is Array):
continue
for item in (row as Array):
var d: Dictionary = item
var idx := int(d["idx"])
var rect: Rect2 = d["rect"]
var word: String = str(_words[idx].get("text", ""))
var marked := _marked.has(idx)
if marked and _hover == idx:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT, 2, 1),
rect)
elif marked:
var fcol := Theme90.ERROR if not bool(_words[idx].get("alien", false)) \
else Theme90.SUCCESS
draw_style_box(Theme90.box(Color(fcol.r, fcol.g, fcol.b, 0.13),
fcol, 2, 1), rect)
elif _hover == idx:
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 2, 1),
rect)
var color := Theme90.INK
if marked:
color = Theme90.ERROR if not bool(_words[idx].get("alien", false)) else Theme90.SUCCESS
Theme90.text_vcenter(self, font, rect, word, size_px, color,
rect.size.x, HORIZONTAL_ALIGNMENT_CENTER)
if marked and bool(_words[idx].get("alien", false)):
draw_line(Vector2(rect.position.x + 2.0, rect.get_center().y + 5.0),
Vector2(rect.end.x - 2.0, rect.get_center().y + 5.0),
Theme90.SUCCESS, 1.5)
_draw_label("КЛИК ПО СЛОВУ — ПОМЕТИТЬ · ПОВТОРНЫЙ КЛИК — СНЯТЬ",
Vector2(24.0, 300.0), 10, Theme90.DARK, 400.0)
_draw_action_button(ACTION_RECT, ACTION_LABEL, true, true)
+1
View File
@@ -0,0 +1 @@
uid://cn5qitwtcdc72
+440
View File
@@ -0,0 +1,440 @@
extends MiniGame
## «Раскадровка» (video). Разложить кадры по слотам.
##
## T1 4/4 (таймкод), T2 5 карточек/4 слота (1 скрытый брак),
## T3 6 карточек/5 слотов (1 брак + 1 зеркальный кадр).
## Таймкоды случайны для каждой раздачи, брак дублирует ровно один реальный
## таймкод и отличается силуэтом.
const TIERS := {
1: {"cards": 4, "slots": 4, "decoy": false, "mirror": false, "hint": "timecode"},
2: {"cards": 5, "slots": 4, "decoy": true, "mirror": false, "hint": "ghost"},
3: {"cards": 6, "slots": 5, "decoy": true, "mirror": true, "hint": "timecode"},
}
const SHAPES := ["circle", "triangle", "square", "diamond", "cross"]
const COLORS := ["accent", "warn", "success", "error", "navy"]
const COLOR_HEX := {
"accent": Color(0.184314, 0.498039, 0.560784),
"warn": Color(0.772549, 0.541176, 0.180392),
"success": Color(0.247059, 0.560784, 0.368627),
"error": Color(0.760784, 0.356863, 0.290196),
"navy": Color(0.172549, 0.360784, 0.407843),
}
var generated: Dictionary = {}
var _cards: Array = []
var _selected: int = -1
var _card_slot: Array[int] = []
var _slot_rects: Array[Rect2] = []
var _tray_rects: Array = []
func _own_game_id() -> String:
return "video"
func _setup() -> void:
generated = generate(tier, seed_value)
_cards = []
for card in generated.get("cards", []):
_cards.append((card as Dictionary).duplicate(true))
_card_slot.clear()
for i in range(_cards.size()):
_card_slot.append(-1)
_selected = -1
_build_slot_rects()
func _goal_text() -> String:
return "РАЗЛОЖИТЕ КАДРЫ ПО СЛОТАМ · КАДР → СЛОТ · ОТПРАВИТЬ"
func _progress_ratio() -> float:
var slots := int(generated.get("slots", 0))
if slots <= 0:
return 0.0
return clampf(float(_occupied_slots()) / float(slots), 0.0, 1.0)
func _result_stats() -> String:
var c := _counts()
return "ВЕРНО %d ИЗ %d · ОШИБОК %d · БРАК %d" % [int(c["correct"]),
int(generated.get("slots", 0)), int(c["wrong"]), int(c["decoys"])]
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("video", p_tier)
if row.is_empty():
return base
if row.has("frames"):
base["cards"] = int(row["frames"])
if row.has("cards"):
base["cards"] = int(row["cards"])
if row.has("slots"):
base["slots"] = int(row["slots"])
if row.has("decoy"):
base["decoy"] = bool(row["decoy"])
if row.has("decoys"):
base["decoy"] = int(row["decoys"]) > 0
if row.has("mirror"):
base["mirror"] = bool(row["mirror"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var slots := clampi(int(p.get("slots", 3)), 2, 5)
var cards_total := clampi(int(p.get("cards", slots)), slots, 7)
var want_decoy := bool(p.get("decoy", false)) and cards_total > slots
var want_mirror := bool(p.get("mirror", false))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var shape_pool := SHAPES.duplicate()
var color_pool := COLORS.duplicate()
_shuffle(shape_pool, rng)
_shuffle(color_pool, rng)
# Таймкоды: случайная стартовая секунда и случайные интервалы; слот i
# получает i-й по возрастанию таймкод, поэтому порядок не фиксирован.
var seconds := rng.randi_range(0, 18)
var timecodes: Array[String] = []
for i in range(slots):
timecodes.append("00:%02d" % seconds)
seconds += rng.randi_range(2, 7)
var slot_shapes: Array = []
var cards: Array = []
var mirror_index := -1
if want_mirror:
mirror_index = rng.randi_range(0, slots - 1)
for i in range(slots):
var shape := str(shape_pool[i % shape_pool.size()])
var color := str(color_pool[i % color_pool.size()])
var mirrored := i == mirror_index
if mirrored and shape == "circle":
# Зеркало заметно только на несимметричной фигуре.
shape = "triangle"
slot_shapes.append({"shape": shape, "color": color, "timecode": timecodes[i]})
cards.append({
"id": cards.size(),
"timecode": timecodes[i],
"shape": shape,
"color": color,
"correct_slot": i,
"decoy": false,
"mirrored": mirrored,
})
if want_decoy:
var source_slot := rng.randi_range(0, slots - 1)
var source: Dictionary = cards[source_slot]
var decoy_shape := str(source["shape"])
var candidates: Array = []
for s in SHAPES:
if str(s) != decoy_shape:
candidates.append(str(s))
decoy_shape = str(candidates[rng.randi_range(0, candidates.size() - 1)])
cards.append({
"id": cards.size(),
"timecode": str(source["timecode"]),
"shape": decoy_shape,
"color": str(source["color"]),
"correct_slot": -1,
"decoy": true,
"mirrored": false,
})
# В лотке карточки перемешаны; брак не первый и не последний.
var tray := cards.duplicate(true)
_shuffle(tray, rng)
if want_decoy and tray.size() > 2:
var decoy_idx := -1
for i in range(tray.size()):
if bool((tray[i] as Dictionary).get("decoy", false)):
decoy_idx = i
break
if decoy_idx == 0 or decoy_idx == tray.size() - 1:
var safe_idx := rng.randi_range(1, tray.size() - 2)
var tmp = tray[decoy_idx]
tray[decoy_idx] = tray[safe_idx]
tray[safe_idx] = tmp
return {
"tier": clampi(p_tier, 1, 3),
"slots": slots,
"cards": tray,
"card_count": tray.size(),
"slot_timecodes": timecodes,
"slot_shapes": slot_shapes,
"hint": str(p.get("hint", "timecode")),
"has_decoy": want_decoy,
"has_mirror": want_mirror,
}
static func quality_from(slots: int, correct: int, wrong: int, decoys_in_slot: int) -> float:
if slots <= 0:
return 0.0
return clampf((float(correct) - 0.5 * float(maxi(0, wrong))
- float(maxi(0, decoys_in_slot))) / float(slots), 0.0, 1.0)
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
static func decoy_info(data: Dictionary) -> Dictionary:
var cards: Array = data.get("cards", [])
var counts := {}
for card in cards:
var tc := str(card.get("timecode", ""))
counts[tc] = int(counts.get(tc, 0)) + 1
for card in cards:
if not bool(card.get("decoy", false)):
continue
var tc := str(card.get("timecode", ""))
var duplicate_real := -1
for other in cards:
if other == card or bool(other.get("decoy", false)):
continue
if str(other.get("timecode", "")) == tc:
duplicate_real = int(other.get("id", -1))
break
return {
"decoy_timecode": tc,
"duplicate_real": duplicate_real,
"decoy_shape": str(card.get("shape", "")),
"duplicate_count": int(counts.get(tc, 0)),
}
return {"decoy_timecode": "", "duplicate_real": -1, "decoy_shape": "",
"duplicate_count": 0}
# --- раскладка/игра --------------------------------------------------------
func _build_slot_rects() -> void:
_slot_rects.clear()
var slots := int(generated.get("slots", 3))
var area := Rect2(40.0, 62.0, 560.0, 128.0)
var gap := 10.0
var w := (area.size.x - gap * float(maxi(0, slots - 1))) / float(maxi(1, slots))
for i in range(slots):
_slot_rects.append(Rect2(area.position.x + float(i) * (w + gap),
area.position.y, w, area.size.y))
func _recompute_tray() -> void:
_tray_rects.clear()
var visible: Array = []
for i in range(_cards.size()):
if _card_slot[i] < 0:
visible.append(i)
var card_w := 84.0
var card_h := 96.0
var gap := 8.0
var total := float(visible.size()) * card_w + float(maxi(0, visible.size() - 1)) * gap
var x := 320.0 - total * 0.5
for idx in visible:
_tray_rects.append({"card": int(idx), "rect": Rect2(x, 224.0, card_w, card_h)})
x += card_w + gap
func _slot_at(local: Vector2) -> int:
for i in range(_slot_rects.size()):
if _slot_rects[i].has_point(local):
return i
return -1
func _card_at(local: Vector2) -> int:
_recompute_tray()
for entry in _tray_rects:
if (entry["rect"] as Rect2).has_point(local):
return int(entry["card"])
return -1
func _counts() -> Dictionary:
var correct := 0
var wrong := 0
var decoys := 0
for i in range(_cards.size()):
var slot := _card_slot[i]
if slot < 0:
continue
var card: Dictionary = _cards[i]
if bool(card.get("decoy", false)):
decoys += 1
elif slot == int(card.get("correct_slot", -2)):
correct += 1
else:
wrong += 1
return {"correct": correct, "wrong": wrong, "decoys": decoys}
func _quality_now() -> float:
var c := _counts()
return quality_from(int(generated.get("slots", 3)), int(c["correct"]),
int(c["wrong"]), int(c["decoys"]))
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
_finish(_quality_now())
return true
var si := _slot_at(local)
if si >= 0:
# Клик по занятому слоту возвращает кадр в лоток.
for i in range(_cards.size()):
if _card_slot[i] == si:
_card_slot[i] = -1
_selected = -1
_play_sfx(&"tick")
queue_redraw()
return true
if _selected >= 0:
_card_slot[_selected] = si
_selected = -1
_play_sfx(&"tick")
queue_redraw()
return true
var ci := _card_at(local)
if ci >= 0:
_selected = -1 if _selected == ci else ci
_play_sfx(&"tick")
queue_redraw()
return true
return false
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
_finish(_quality_now())
return true
return false
func _draw_game() -> void:
var font := Theme90.ui()
var slots := int(generated.get("slots", 3))
var hint := str(generated.get("hint", "timecode"))
var c := _counts()
_draw_label("РАСКАДРОВКА · СЛОЙ 97", Vector2(24.0, 46.0), 10,
Theme90.ACCENT, 260.0)
_draw_label("РАЗМЕЩЕНО %d / %d · ОШИБОК %d · БРАК %d" % [
_occupied_slots(), slots, int(c["wrong"]), int(c["decoys"])],
Vector2(300.0, 46.0), 10, Theme90.DARK, 300.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var slot_shapes: Array = generated.get("slot_shapes", [])
for i in range(slots):
var rect := _slot_rects[i]
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 3, 1), rect)
draw_rect(Rect2(rect.position.x + 1.0, rect.position.y + 20.0,
rect.size.x - 2.0, 1.0), Theme90.HAIRLINE)
_draw_label("СЛОТ %02d" % (i + 1), Vector2(rect.position.x + 8.0,
rect.position.y + 5.0), 10, Theme90.ACCENT, rect.size.x - 16.0)
var occupant := -1
for ci in range(_cards.size()):
if _card_slot[ci] == i:
occupant = ci
break
if occupant >= 0:
_draw_card(_cards[occupant], Rect2(rect.position + Vector2(8, 28),
rect.size - Vector2(16, 36)), true, hint)
_draw_label("КЛИК — ВЕРНУТЬ", Vector2(rect.position.x, rect.end.y - 14.0),
10, Theme90.DARK, rect.size.x, HORIZONTAL_ALIGNMENT_CENTER)
continue
Theme90.draw_dashed_box(self, Rect2(rect.position + Vector2(8, 28),
rect.size - Vector2(16, 36)), Theme90.BORDER_SOFT, 3.0, 1.0)
if i < slot_shapes.size():
var ghost: Dictionary = slot_shapes[i]
var gshape := str(ghost.get("shape", "circle"))
var gcol: Color = COLOR_HEX.get(str(ghost.get("color", "accent")),
Theme90.NAVY)
if hint == "shape" or hint == "ghost":
_draw_frame_shape(Vector2(rect.get_center().x, rect.position.y + 72.0),
34.0, gshape, Color(gcol.r, gcol.g, gcol.b, 0.50), false)
if hint != "shape":
_draw_centered(str(ghost.get("timecode", "")),
Rect2(rect.position.x, rect.end.y - 34.0, rect.size.x, 24.0),
16 if hint == "ghost" else 24, Theme90.NAVY)
else:
_draw_centered(str(ghost.get("timecode", "")),
Rect2(rect.position.x, rect.end.y - 30.0, rect.size.x, 20.0),
10, Theme90.DARK)
_recompute_tray()
for entry in _tray_rects:
var card: Dictionary = _cards[int(entry["card"])]
var rect: Rect2 = entry["rect"]
if int(entry["card"]) == _selected:
rect.position.y -= 4.0
_draw_card(card, rect, false, hint)
if int(entry["card"]) == _selected:
draw_rect(rect.grow(3.0), Theme90.NAVY_DEEP, false, 2.0)
_draw_action_button(ACTION_RECT, "ОТПРАВИТЬ", true, true)
func _empty_slots() -> int:
var n := 0
for i in range(_cards.size()):
if _card_slot[i] < 0:
n += 1
return n
func _occupied_slots() -> int:
var n := 0
for i in range(_cards.size()):
if _card_slot[i] >= 0:
n += 1
return mini(n, int(generated.get("slots", 3)))
func _draw_card(card: Dictionary, rect: Rect2, in_slot: bool, hint: String) -> void:
var col: Color = COLOR_HEX.get(str(card.get("color", "accent")), Theme90.ACCENT)
draw_style_box(Theme90.box(col, Theme90.BORDER, 3, 1), rect)
var center := Vector2(rect.get_center().x, rect.position.y + rect.size.y * 0.46)
_draw_frame_shape(center, minf(rect.size.y * 0.34, 38.0),
str(card.get("shape", "circle")), Color(1, 1, 1, 0.90),
bool(card.get("mirrored", false)))
var label := str(card.get("timecode", ""))
if hint == "shape" and not in_slot:
label = "КАДР"
_draw_centered(label, Rect2(rect.position.x, rect.end.y - 25.0, rect.size.x, 20.0),
12 if rect.size.x >= 90.0 else 10, Theme90.WHITE)
func _draw_frame_shape(center: Vector2, s: float, shape: String, color: Color,
mirrored: bool) -> void:
var flip := -1.0 if mirrored else 1.0
match shape:
"circle":
draw_circle(center, s * 0.52, color)
"triangle":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -s * 0.62) * Vector2(flip, 1),
center + Vector2(s * 0.58, s * 0.40) * Vector2(flip, 1),
center + Vector2(-s * 0.58, s * 0.40) * Vector2(flip, 1)]), color)
"square":
draw_rect(Rect2(center - Vector2(s, s) * 0.46, Vector2(s, s) * 0.92), color)
"diamond":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -s * 0.64) * Vector2(flip, 1),
center + Vector2(s * 0.64, 0) * Vector2(flip, 1),
center + Vector2(0, s * 0.64) * Vector2(flip, 1),
center + Vector2(-s * 0.64, 0) * Vector2(flip, 1)]), color)
_:
draw_rect(Rect2(center - Vector2(s * 0.15, s * 0.60),
Vector2(s * 0.30, s * 1.20)), color)
draw_rect(Rect2(center - Vector2(s * 0.60, s * 0.15),
Vector2(s * 1.20, s * 0.30)), color)
+1
View File
@@ -0,0 +1 @@
uid://cdhe6b2d04ehx
+237
View File
@@ -0,0 +1,237 @@
# run_all_tests.ps1 - headless test runner for PEAK HOUR 97 v2 (Windows).
#
# Usage:
# powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
# $env:GODOT_BIN = "D:\path\Godot_v4.7.2-stable_win64_console.exe"
# powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
#
# Each test is a separate Godot process with a per-test timeout. Missing test
# files are skipped (SKIP), not failed. APPDATA is redirected to a temp folder
# for the run so tests never touch the real user save. The final parse check
# runs `--headless --path <root> --quit`.
[CmdletBinding()]
param(
[string]$GodotBin = $env:GODOT_BIN,
[int]$TimeoutSec = 180
)
$ErrorActionPreference = "Stop"
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location $Root
if ([string]::IsNullOrWhiteSpace($GodotBin)) {
$GodotBin = "D:\Apps\godot\Godot_v4.7.2-stable_win64_console.exe"
}
if (-not (Test-Path -LiteralPath $GodotBin -PathType Leaf)) {
$fallback = Get-Command godot4 -ErrorAction SilentlyContinue
if (-not $fallback) { $fallback = Get-Command godot -ErrorAction SilentlyContinue }
if ($fallback) { $GodotBin = $fallback.Source }
}
if (-not (Test-Path -LiteralPath $GodotBin -PathType Leaf)) {
Write-Host "FAIL: Godot binary not found: $GodotBin" -ForegroundColor Red
Write-Host "Set GODOT_BIN or pass -GodotBin <path>."
exit 1
}
$Tests = @(
"res://tests/run_tests.gd",
"res://tests/test_hour.gd",
"res://tests/test_economy.gd",
"res://tests/test_save.gd",
"res://tests/test_slots.gd",
"res://tests/test_shop.gd",
"res://tests/test_minigame.gd",
"res://tests/test_send_games.gd",
"res://tests/test_attack_games.gd",
"res://tests/test_file_grid.gd",
"res://tests/test_shop_modal.gd",
"res://tests/test_audio.gd",
"res://tests/test_ambient.gd",
"res://tests/test_fuzz.gd",
"res://tests/test_integration.gd",
"res://tests/test_attacks.gd",
"res://tests/test_controller.gd",
"res://tests/test_main_prep.gd",
"res://tests/test_main_hour.gd",
"res://tests/test_main_interactions.gd",
"res://tests/test_main_attacks.gd",
"res://tests/test_main_pause.gd",
"res://tests/test_main_fullscreen.gd",
"res://tests/test_main_polish.gd",
"res://tests/test_toast.gd",
"res://tests/test_full_playthrough.gd",
"res://scripts/ui/ui_smoke.gd"
)
$ErrorRe = 'SCRIPT ERROR|Parse Error|USER ERROR|Failed to load|Cannot open file|FAIL|SMOKE FAILED'
$LeakRe = 'ObjectDB instances leaked|leaked at exit'
function Quote-Arg([string]$Value) {
return '"' + $Value.Replace('"', '\"') + '"'
}
function Invoke-Godot([string]$Arguments, [int]$Timeout) {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $GodotBin
$psi.Arguments = $Arguments
$psi.WorkingDirectory = $Root
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $psi
$null = $process.Start()
$outTask = $process.StandardOutput.ReadToEndAsync()
$errTask = $process.StandardError.ReadToEndAsync()
$exited = $process.WaitForExit($Timeout * 1000)
if (-not $exited) {
try { $process.Kill() } catch { }
try { $process.WaitForExit(5000) | Out-Null } catch { }
}
$stdout = ""
$stderr = ""
try { $stdout = $outTask.Result } catch { }
try { $stderr = $errTask.Result } catch { }
$code = -1
if ($exited) { $code = $process.ExitCode }
return @{
ExitCode = $code
TimedOut = (-not $exited)
Output = ($stdout + "`n" + $stderr)
}
}
function Write-Tail([string]$Text, [int]$Count = 40) {
$lines = $Text -split "`r?`n"
if ($lines.Count -gt $Count) { $lines = $lines[($lines.Count - $Count)..($lines.Count - 1)] }
Write-Host "----- output (last $Count lines) -----"
foreach ($line in $lines) { Write-Host $line }
Write-Host "-------------------------------------"
}
function Test-Log([string]$Text, [string]$Pattern) {
# Case-sensitive on purpose: test summaries print "fails=0", which must not
# be mistaken for the uppercase FAIL marker.
return [bool]([regex]::IsMatch($Text, $Pattern))
}
$Tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("peak-hour-tests-" + [guid]::NewGuid().ToString("N"))
$LogDir = Join-Path $Tmp "logs"
$OldAppData = $env:APPDATA
New-Item -ItemType Directory -Path $Tmp -Force | Out-Null
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
$env:APPDATA = Join-Path $Tmp "appdata"
New-Item -ItemType Directory -Path $env:APPDATA -Force | Out-Null
$Passed = 0
$Failed = 0
$Skipped = @()
$FailedNames = @()
Write-Host "PEAK HOUR 97 - headless tests (v2, Windows)"
Write-Host "Godot: $GodotBin"
Write-Host "APPDATA (temp): $env:APPDATA"
Write-Host ""
try {
foreach ($script in $Tests) {
$localPath = Join-Path $Root ($script -replace '^res://', '' -replace '/', '\')
$name = [System.IO.Path]::GetFileNameWithoutExtension($script)
if (-not (Test-Path -LiteralPath $localPath -PathType Leaf)) {
$Skipped += $name
Write-Host "SKIP $name (no file)"
continue
}
Write-Host ""
Write-Host ">>> $script"
$godotArgs = "--headless --path $(Quote-Arg $Root) --script $(Quote-Arg $script)"
$result = Invoke-Godot $godotArgs $TimeoutSec
$logPath = Join-Path $LogDir ($name + ".log")
Set-Content -LiteralPath $logPath -Value $result.Output -Encoding UTF8
if ($result.TimedOut) {
Write-Host "FAIL $name (timeout ${TimeoutSec}s)"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
continue
}
if ($result.ExitCode -eq 0) {
if (Test-Log $result.Output $ErrorRe) {
Write-Host "FAIL $name (exit 0, but errors in log)"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
} else {
Write-Host "PASS $name"
$Passed++
}
continue
}
if (Test-Log $result.Output $ErrorRe) {
Write-Host "FAIL $name (exit $($result.ExitCode))"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
continue
}
if ((Test-Log $result.Output $LeakRe) -and -not (Test-Log $result.Output 'ERROR:')) {
Write-Host "PASS $name (exit $($result.ExitCode), harmless ObjectDB leak warning)"
$Passed++
continue
}
Write-Host "FAIL $name (exit $($result.ExitCode))"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
}
Write-Host ""
Write-Host ">>> parse check: --headless --path . --quit"
$parseArgs = "--headless --path $(Quote-Arg $Root) --quit"
$parse = Invoke-Godot $parseArgs $TimeoutSec
Set-Content -LiteralPath (Join-Path $LogDir "parse_check.log") -Value $parse.Output -Encoding UTF8
$parseOk = $false
if (-not $parse.TimedOut) {
if ($parse.ExitCode -eq 0) {
$parseOk = $true
} elseif ((Test-Log $parse.Output $LeakRe) -and -not (Test-Log $parse.Output 'ERROR:')) {
$parseOk = $true
}
}
if ($parseOk) {
Write-Host "PASS parse-check"
$Passed++
} else {
Write-Host "FAIL parse-check (exit $($parse.ExitCode))"
Write-Tail $parse.Output
$Failed++
$FailedNames += "parse-check"
}
}
finally {
$env:APPDATA = $OldAppData
try {
Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
} catch { }
}
Write-Host ""
Write-Host "================================"
Write-Host "Total: PASS=$Passed FAIL=$Failed SKIP=$($Skipped.Count)"
if ($Skipped.Count -gt 0) {
Write-Host "Skipped: $($Skipped -join ', ')"
}
if ($Failed -ne 0) {
Write-Host "Failed: $($FailedNames -join ', ')" -ForegroundColor Red
exit 1
}
Write-Host "All tests passed." -ForegroundColor Green
exit 0
+41 -16
View File
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
# run_all_tests.sh — прогон всех headless-тестов PEAK HOUR 97.
# run_all_tests.sh — прогон всех headless-тестов PEAK HOUR 97 (v2).
#
# Запуск:
# bash scripts/run_all_tests.sh
# GODOT_BIN=/path/to/godot bash scripts/run_all_tests.sh
# GODOT_BIN="/d/Apps/godot/Godot_v4.7.2-stable_win64_console.exe" bash scripts/run_all_tests.sh
#
# GODOT_BIN имеет приоритет. Если он не задан и Linux-путь по умолчанию
# отсутствует, скрипт пробует `godot4`/`godot` из PATH.
#
# Каждый тест — отдельный процесс Godot с таймаутом 180 с. XDG_DATA_HOME
# выносится во временный каталог, чтобы тесты не трогали пользовательские
# данные и не мешали параллельным прогонам. В конце — проверка парсинга
# проекта (`--quit`).
#
# Известный безвредный шум Godot при выходе (ObjectDB instances leaked /
# leaked at exit) не считается провалом: тест, завершившийся с таким
# предупреждением и без реальных ошибок, помечается PASS.
# выносится во временный каталог. В конце — проверка парсинга проекта.
# Отсутствующие файлы тестов пропускаются (SKIP), а не считаются FAIL:
# волны разработки иногда удаляют модули.
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -21,8 +21,17 @@ cd "$ROOT_DIR"
GODOT_BIN="${GODOT_BIN:-/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64}"
TEST_TIMEOUT="${TEST_TIMEOUT:-180}"
if [[ ! -x "$GODOT_BIN" ]]; then
for candidate in godot4 godot; do
if command -v "$candidate" >/dev/null 2>&1; then
GODOT_BIN="$(command -v "$candidate")"
break
fi
done
fi
if [[ ! -x "$GODOT_BIN" ]]; then
echo "FAIL: Godot binary is not executable: $GODOT_BIN" >&2
echo "Set GODOT_BIN=... explicitly." >&2
exit 1
fi
@@ -33,18 +42,24 @@ mkdir -p "$XDG_DATA_HOME" "$LOG_DIR"
export XDG_DATA_HOME
cleanup() {
# Осторожная уборка без rm -rf: только внутри созданного mktemp-каталога.
find "$TMP_BASE" -mindepth 1 -delete 2>/dev/null || true
rmdir "$TMP_BASE" 2>/dev/null || true
}
trap cleanup EXIT
# Aggregator + all standalone v2 suites. New wave-D1 suites are included.
TESTS=(
"res://tests/run_tests.gd"
"res://tests/test_hour.gd"
"res://tests/test_economy.gd"
"res://tests/test_save.gd"
"res://tests/test_slots.gd"
"res://tests/test_shop.gd"
"res://tests/test_minigame.gd"
"res://tests/test_send_games.gd"
"res://tests/test_attack_games.gd"
"res://tests/test_file_grid.gd"
"res://tests/test_shop_modal.gd"
"res://tests/test_audio.gd"
"res://tests/test_ambient.gd"
"res://tests/test_fuzz.gd"
@@ -63,7 +78,17 @@ TESTS=(
"res://scripts/ui/ui_smoke.gd"
)
# Какие строки означают настоящую ошибку (а не безвредный leak-шум).
EXISTING_TESTS=()
SKIPPED_NAMES=()
for t in "${TESTS[@]}"; do
local_path="${ROOT_DIR}/${t#res://}"
if [[ -f "$local_path" ]]; then
EXISTING_TESTS+=("$t")
else
SKIPPED_NAMES+=("$(basename "$t" .gd)")
fi
done
ERROR_RE='SCRIPT ERROR|Parse Error|USER ERROR|Failed to load|Cannot open file|FAIL|SMOKE FAILED'
LEAK_RE='ObjectDB instances leaked|leaked at exit'
@@ -102,7 +127,6 @@ run_one() {
fi
if [[ "$rc" -eq 0 ]]; then
# Godot может вернуть 0, но напечатать SCRIPT ERROR/Parse Error в лог.
if grep -qE "$ERROR_RE" "$log"; then
echo "FAIL $name (exit 0, но в логе есть ошибки)"
print_log_tail "$log"
@@ -115,7 +139,6 @@ run_one() {
return 0
fi
# Ненулевой код: отличаем реальную ошибку от известного leak-шума.
if grep -qE "$ERROR_RE" "$log"; then
echo "FAIL $name (exit $rc, ${elapsed}s)"
print_log_tail "$log"
@@ -136,11 +159,14 @@ run_one() {
FAILED_NAMES+=("$name")
}
echo "PEAK HOUR 97 — headless tests"
echo "PEAK HOUR 97 — headless tests (v2)"
echo "Godot: $GODOT_BIN"
echo "XDG_DATA_HOME: $XDG_DATA_HOME"
if [[ ${#SKIPPED_NAMES[@]} -gt 0 ]]; then
echo "SKIP (нет файлов): ${SKIPPED_NAMES[*]}"
fi
for t in "${TESTS[@]}"; do
for t in "${EXISTING_TESTS[@]}"; do
run_one "$t"
done
@@ -165,11 +191,10 @@ fi
echo ""
echo "================================"
echo "Итог: PASS=$PASSED FAIL=$FAILED"
echo "Итог: PASS=$PASSED FAIL=$FAILED SKIP=${#SKIPPED_NAMES[@]}"
if [[ "$FAILED" -ne 0 ]]; then
echo "Провалены: ${FAILED_NAMES[*]}"
echo "Логи: $LOG_DIR"
# Сохраняем логи провалов рядом с прогоном для разбора в CI.
if [[ -n "${CI:-}" ]]; then
mkdir -p "$ROOT_DIR/build/test-logs"
for n in "${FAILED_NAMES[@]}"; do
File diff suppressed because it is too large. Load diff
+25 -2
View File
@@ -8,7 +8,9 @@ extends RefCounted
const SAVE_PATH := "user://save.json"
const TMP_PATH := "user://save.tmp"
const VERSION := 1
## v2: клеточные сетки/фигуры. v1 читается для миграции (Game/FileRegistry).
const VERSION := 2
const SUPPORTED_VERSIONS: Array[int] = [1, 2]
static func save(data: Dictionary) -> bool:
@@ -70,7 +72,7 @@ static func _read_save() -> Dictionary:
return {}
var data: Dictionary = parsed
var version := int(data.get("version", -1))
if version != VERSION:
if not SUPPORTED_VERSIONS.has(version):
push_warning("SaveManager: версия save %d не поддерживается (ожидается %d)" % [version, VERSION])
return {}
return data
@@ -80,6 +82,27 @@ static func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)
## Можно ли продолжить забег: файл читается и забег ещё не завершён.
## Не мутирует Game и не меняет сейв — только решает, показывать ли «Продолжить».
static func can_continue() -> bool:
if not FileAccess.file_exists(SAVE_PATH):
return false
var data := _read_save()
if data.is_empty():
return false
var registry = data.get("registry", null)
if not (registry is Dictionary):
return false
if str(data.get("phase", "")) == "results":
return false
if float(data.get("sla", 0.0)) <= 0.0:
return false
# Должно совпадать с GameController.HOURS_PER_RUN (9): 9-й час — финал.
if int(data.get("hour", 0)) > 9:
return false
return true
static func delete_save() -> bool:
return _remove_if_exists(SAVE_PATH)
+474 -134
View File
@@ -1,119 +1,107 @@
class_name Shop
extends RefCounted
## Магазин: 10 позиций из data/balance.json → shop (D13 + SLA +24, п.13).
## Магазин v2: 8 семейств лотов, ровно 3 предложения, реролл 40/80/120
## (максимум 3 за подготовку) и замок отдельного предложения.
##
## Уровни покупок хранятся в Game.upgrades. Для совместимости с ядром
## дублируются агрегированные ключи эффектов: slot_level, buffer_level,
## timer_level, zone_level, channel_level, sections_unlocked,
## search_unlocked, shield_unlocked.
## Уровни покупок хранятся в Game.upgrades под id лота и под агрегированным
## ключом эффекта (slot_level, speed_level, ...). Эффекты применяет Game
## (set_lot_level + динамические геттеры), магазин только списывает деньги и
## повышает уровень.
signal offers_changed
signal purchased(id: String)
const EFFECT_TARGETS := {
"connection_slots": "slot_level",
"buffer_slots": "buffer_level",
"packet_timer": "timer_level",
"zone_width": "zone_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",
"sections": "sections_unlocked",
"search_highlight": "search_unlocked",
"shield": "shield_unlocked",
"sla_add": "sla_level",
"request_timer": "timer_level",
"udp_success": "udp_level",
}
const DEFAULT_RULES := {
"offers": 3,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
}
## Резерв, если data/balance.json недоступен. Совпадает с §3.2/3.3.
const FALLBACK_ITEMS := [
{"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, "price": 350, "max_level": 3},
{"id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "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},
{"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1,
"max_level": 4, "prices": [90, 200, 400, 700],
"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": "Оптоволокно", "effect": "transfer_speed", "value": 0.10,
"max_level": 6, "prices": [55, 75, 100, 135, 180, 245],
"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": "Резервный ИБП", "effect": "sla_cap", "value": 40,
"max_level": 4, "prices": [80, 160, 270, 430],
"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": "Заземление", "effect": "shield_charge", "value": 1,
"max_level": 3, "prices": [100, 220, 410],
"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": "Корпоративный контракт", "effect": "money_mult", "value": 0.05,
"max_level": 8, "prices": [70, 95, 130, 170, 220, 285, 365, 465],
"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": "Складской стеллаж", "effect": "folder_cells", "value": 1,
"max_level": 5, "prices": [50, 100, 180, 290, 440],
"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": "Пропускная способность", "effect": "channel_capacity", "value": 6,
"buffer_value": 1, "max_level": 6, "prices": [45, 70, 95, 130, 175, 230],
"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": "Регламент обработки", "effect": "request_timer", "value": 7,
"max_level": 5, "prices": [45, 75, 115, 165, 230],
"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-повторитель", "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]},
]
var _items: Array = []
func _init() -> void:
refresh()
_load_items()
func refresh() -> void:
_items = []
var data = _data_node()
var raw = null
if data != null and data.get("balance") is Dictionary:
raw = data.balance.get("shop", null)
if raw is Array and not raw.is_empty():
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
_items.append(item.duplicate(true))
if _items.is_empty():
for item in FALLBACK_ITEMS:
_items.append(item.duplicate(true))
# --- каталог ---------------------------------------------------------------
func items() -> Array:
return _items.duplicate(true)
## Каталог без закрытых прогрессией позиций. shop_sla доступен всегда.
func available_items() -> Array:
var out: Array = []
for item in _items:
if item is Dictionary and item_unlocked(item):
out.append(item.duplicate(true))
return out
## Открыта ли позиция: shop_sla всегда; shop_search/shield — по мета-награде;
## остальные — по открытию магазина (progression.unlocks.shop).
func item_unlocked(item: Dictionary) -> bool:
var key := _unlock_key(str(item.get("id", "")))
if key.is_empty():
return true
var game = _game()
if game != null and game.has_method("feature_unlocked"):
return bool(game.feature_unlocked(key))
return true
func item_count() -> int:
return _items.size()
func get_item(id: String) -> Dictionary:
for item in _items:
if str(item.get("id", "")) == str(id):
if item is Dictionary and str(item.get("id", "")) == str(id):
return item.duplicate(true)
return {}
## Позиции каталога без гейта прогрессии (v2: все лоты доступны с H2,
## видимость магазина решает фаза/час).
func available_items() -> Array:
return _items.duplicate(true)
func item_unlocked(_item: Dictionary) -> bool:
return true
func level(id: String) -> int:
var game = _game()
if game == null:
return 0
var upgrades: Dictionary = game.upgrades if game.upgrades is Dictionary else {}
if upgrades.has(str(id)):
return int(upgrades[str(id)])
var item := get_item(id)
if item.is_empty():
return 0
var effect := str(item.get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
var aggregate := 0
if not target.is_empty():
aggregate = int(upgrades.get(target, 0))
# Для двух слотовых товаров агрегат slot_level раскладывается по порядку
# с учётом уже купленного второго товара.
if effect == "connection_slots":
var other_id := "shop_slot4" if id == "shop_slot3" else "shop_slot3"
var other_level := int(upgrades.get(other_id, 0))
return clampi(aggregate - other_level, 0, 1)
return clampi(aggregate, 0, max_level(id))
if game != null and game.has_method("level"):
return int(game.level(id))
return level_for(get_item(id), _levels(game))
func max_level(id: String) -> int:
@@ -123,64 +111,54 @@ func max_level(id: String) -> int:
return maxi(1, int(item.get("max_level", 1)))
## Цена следующего уровня. -1, если лот максимален (никогда не индексируем
## prices[max_level]).
func price(id: String) -> int:
var item := get_item(id)
if item.is_empty():
return 0
var current := level(id)
var prices = item.get("prices", null)
if prices is Array and not (prices as Array).is_empty():
var arr: Array = prices
return maxi(0, int(arr[mini(current, arr.size() - 1)]))
if item.has("price"):
return maxi(0, int(item.get("price", 0)))
return maxi(0, int(item.get("cost", 0)))
return price_for(item, level(id))
func can_buy(id: String) -> bool:
var item := get_item(id)
if item.is_empty():
return false
if not item_unlocked(item):
return false
if level(id) >= max_level(id):
return false
var game = _game()
if game == null:
return false
return int(game.money) >= price(id)
if not offers().has(str(id)):
return false
var item := get_item(id)
if item.is_empty():
return false
if level(id) >= max_level(id):
return false
var cost := price(id)
if cost < 0:
return false
return int(game.money) >= cost
func buy(id: String) -> bool:
if not can_buy(id):
return false
var item := get_item(id)
var game = _game()
if game == null:
return false
var cost := price(id)
if cost < 0:
return false
game.money = maxi(0, int(game.money) - cost)
if game.has_signal("money_changed"):
game.money_changed.emit(game.money)
if not (game.upgrades is Dictionary):
game.upgrades = {}
var current := level(id)
game.upgrades[id] = current + 1
var effect := str(item.get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
if not target.is_empty():
game.upgrades[target] = int(game.upgrades.get(target, 0)) + 1
if effect == "sections":
_unlock_section(game)
elif effect == "sla_add":
# SLA +24 за уровень; Game.add_sla сам клампит до MAX_SLA.
var value := float(item.get("value", 24))
if game.has_method("add_sla"):
game.add_sla(value, "shop")
var new_level := level(id) + 1
if game.has_method("set_lot_level"):
game.set_lot_level(id, new_level)
else:
_apply_level_fallback(game, id, new_level)
_play_sfx(&"money_down")
purchased.emit(id)
offers_changed.emit()
if game.has_signal("toast"):
game.toast.emit("Куплено: %s" % str(item.get("name_ru", id)))
game.toast.emit("Куплено: %s" % str(get_item(id).get("name_ru", id)))
return true
@@ -189,37 +167,327 @@ func effect_level(effect: String) -> int:
var game = _game()
if game == null:
return 0
var target := str(EFFECT_TARGETS.get(effect, ""))
var target := str(EFFECT_TARGETS.get(str(effect), ""))
if target.is_empty():
return 0
return int(game.upgrades.get(target, 0))
return int(_levels(game).get(target, 0))
# --- v2: предложения/реролл -----------------------------------------------
func offers() -> Array[String]:
var out: Array[String] = []
var game = _game()
if game == null:
return out
for id in game.shop_offers:
out.append(str(id))
return out
## Сгенерировать 3 предложения на подготовку. Идемпотентно: повторный вызов
## для того же часа ничего не меняет, пока force = true.
func refresh(hour: int = 0, money: int = -1, force: bool = false) -> void:
_load_items()
if hour <= 0:
return
var game = _game()
if game == null:
return
var h := maxi(1, int(hour))
if not force and int(game.shop_offer_hour) == h and not game.shop_offers.is_empty():
offers_changed.emit()
return
var m := int(money) if money >= 0 else int(game.money)
var previous: Array = []
for id in game.shop_offers:
previous.append(str(id))
game.shop_offer_hour = h
game.shop_rerolls = 0
game.shop_locked.clear()
var selected := select_offers(h, _items, _levels(game), m, previous,
_seen(game), [], _offer_count())
_write_offers(game, selected, h)
offers_changed.emit()
func reroll_cost() -> int:
var game = _game()
if game == null:
return -1
var max_rerolls := int(_rules().get("max_rerolls", 3))
if int(game.shop_rerolls) >= max_rerolls:
return -1
if _non_maxed_count(_levels(game)) <= 0:
return -1
return _cost_for(int(game.shop_rerolls))
func can_reroll() -> bool:
var game = _game()
if game == null:
return false
if game.shop_offers.is_empty():
return false
var cost := reroll_cost()
if cost < 0:
return false
return int(game.money) >= cost
## Реролл сохраняет закреплённые предложения. Стоимость 40/80/120.
func reroll() -> bool:
if not can_reroll():
return false
var game = _game()
if game == null:
return false
var cost := reroll_cost()
if cost < 0:
return false
game.money = maxi(0, int(game.money) - cost)
if game.has_signal("money_changed"):
game.money_changed.emit(game.money)
game.shop_rerolls = int(game.shop_rerolls) + 1
var h := maxi(1, int(game.hour))
var previous: Array = []
for id in game.shop_offers:
previous.append(str(id))
var locked: Array = []
for id in game.shop_locked:
locked.append(str(id))
var selected := select_offers(h, _items, _levels(game), int(game.money), previous,
_seen(game), locked, _offer_count())
game.shop_offer_hour = h
_write_offers(game, selected, h)
offers_changed.emit()
return true
## Переключить замок предложения. Возвращает новое состояние (true = locked).
func toggle_lock(index: int) -> bool:
var game = _game()
if game == null:
return false
var ofs := offers()
if index < 0 or index >= ofs.size():
return false
var id := ofs[index]
if game.shop_locked.has(id):
game.shop_locked.erase(id)
else:
game.shop_locked.append(id)
offers_changed.emit()
return game.shop_locked.has(id)
func is_locked(index: int) -> bool:
var game = _game()
if game == null:
return false
var ofs := offers()
if index < 0 or index >= ofs.size():
return false
return game.shop_locked.has(ofs[index])
func locked_ids() -> Array[String]:
var out: Array[String] = []
var game = _game()
if game == null:
return out
for id in game.shop_locked:
out.append(str(id))
return out
# --- v2: генерация (статические, тестируемые) ------------------------------
## Вес лота: база по часу × 0.5 за прошлое предложение × 1.5 за pity.
static func lot_weight(lot: Dictionary, hour: int, last_offers: Array, last_seen: Dictionary) -> float:
var id := str(lot.get("id", ""))
var w := 1.0
var arr = lot.get("weight_by_hour", null)
if arr is Array and not (arr as Array).is_empty():
var table: Array = arr
w = float(table[clampi(int(hour) - 1, 0, table.size() - 1)])
var rules: Dictionary = DEFAULT_RULES
if last_offers.has(id):
w *= float(rules.get("last_offer_weight", 0.5))
if last_seen is Dictionary and last_seen.has(id):
var since := int(hour) - int(last_seen[id])
if since >= int(rules.get("pity_hours", 2)):
w *= float(rules.get("pity_weight", 1.5))
return maxf(0.0, w)
## Цена следующего уровня лота. -1 при максимальном уровне.
static func price_for(lot: Dictionary, lvl: int) -> int:
if lot.is_empty():
return 0
var max_level := maxi(1, int(lot.get("max_level", 1)))
if lvl >= max_level:
return -1
var prices = lot.get("prices", null)
if prices is Array and not (prices as Array).is_empty():
var arr: Array = prices
return maxi(0, int(arr[clampi(lvl, 0, arr.size() - 1)]))
if lot.has("price"):
return maxi(0, int(lot.get("price", 0)))
return maxi(0, int(lot.get("cost", 0)))
## Уровень лота из словаря upgrades: id лота → агрегат эффекта → 0.
static func level_for(lot: Dictionary, levels: Dictionary) -> int:
if lot.is_empty():
return 0
var id := str(lot.get("id", ""))
var max_level := maxi(1, int(lot.get("max_level", 1)))
if levels.has(id):
return clampi(int(levels[id]), 0, max_level)
var effect := str(lot.get("effect", ""))
var agg := str(EFFECT_TARGETS.get(effect, ""))
if not agg.is_empty() and levels.has(agg):
return clampi(int(levels[agg]), 0, max_level)
return 0
## Выбрать до count различных не-максимальных лотов.
## must_keep — закреплённые id (сохраняются в выдаче).
static func select_offers(
hour: int,
lots: Array,
levels: Dictionary,
money: int,
last_offers: Array,
last_seen: Dictionary,
must_keep: Array = [],
count: int = 3,
rand_source = null
) -> Array[String]:
var h := maxi(1, int(hour))
var lot_map: Dictionary = {}
for lot in lots:
if lot is Dictionary:
var id := str(lot.get("id", ""))
if not id.is_empty():
lot_map[id] = lot
var result: Array[String] = []
for raw_id in must_keep:
var kept := str(raw_id)
if result.has(kept) or not lot_map.has(kept):
continue
var kept_lot: Dictionary = lot_map[kept]
if level_for(kept_lot, levels) >= maxi(1, int(kept_lot.get("max_level", 1))):
continue
result.append(kept)
var pool: Array = []
for lot in lots:
if not (lot is Dictionary):
continue
var id := str(lot.get("id", ""))
if id.is_empty() or result.has(id):
continue
if level_for(lot, levels) >= maxi(1, int(lot.get("max_level", 1))):
continue
pool.append(lot)
var target := mini(maxi(0, int(count)), result.size() + pool.size())
while result.size() < target and not pool.is_empty():
var picked_index := _pick_weighted(pool, h, last_offers, last_seen, rand_source)
var picked: Dictionary = pool[picked_index]
result.append(str(picked.get("id", "")))
pool.remove_at(picked_index)
# Хотя бы одно доступное по деньгам предложение (если такое существует).
if money >= 0 and not _any_affordable(result, lot_map, levels, money):
_try_swap_affordable(result, lots, lot_map, levels, money, must_keep)
return result
# --- служебное -------------------------------------------------------------
## Ключ открытия позиции. Данные — из Data, иначе локальная копия правила.
func _unlock_key(id: String) -> String:
func _load_items() -> void:
_items = []
var data = _data_node()
if data != null and data.has_method("shop_unlock_key"):
return str(data.shop_unlock_key(id))
match id:
"shop_sla":
return ""
"shop_search":
return "search"
"shop_shield":
return "shield"
_:
return "shop"
if data != null and data.has_method("lot_defs"):
var raw = data.lot_defs()
if raw is Array and not (raw as Array).is_empty():
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
_items.append((item as Dictionary).duplicate(true))
if _items.is_empty():
for item in FALLBACK_ITEMS:
_items.append((item as Dictionary).duplicate(true))
func _unlock_section(game) -> void:
if game.registry == null or not game.registry.has_method("create_section"):
return
for letter in ["D", "E", "F", "G", "H"]:
if not game.registry.sections.has(letter):
game.registry.create_section(letter)
return
func _write_offers(game, ids: Array, hour: int) -> void:
game.shop_offers.clear()
for id in ids:
game.shop_offers.append(str(id))
if not (game.shop_last_seen is Dictionary):
game.shop_last_seen = {}
for id in game.shop_offers:
game.shop_last_seen[str(id)] = int(hour)
func _apply_level_fallback(game, id: String, new_level: int) -> void:
if not (game.upgrades is Dictionary):
game.upgrades = {}
game.upgrades[id] = new_level
var effect := str(get_item(id).get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
if not target.is_empty():
game.upgrades[target] = new_level
func _offer_count() -> int:
return maxi(1, int(_rules().get("offers", 3)))
func _cost_for(reroll_index: int) -> int:
var costs = _rules().get("reroll_costs", [40, 80, 120])
if costs is Array and not (costs as Array).is_empty():
var arr: Array = costs
return maxi(0, int(arr[clampi(int(reroll_index), 0, arr.size() - 1)]))
return 40
func _non_maxed_count(levels: Dictionary) -> int:
var count := 0
for lot in _items:
if not (lot is Dictionary):
continue
if level_for(lot, levels) < maxi(1, int(lot.get("max_level", 1))):
count += 1
return count
func _rules() -> Dictionary:
var data = _data_node()
var out: Dictionary = DEFAULT_RULES.duplicate(true)
if data != null and data.has_method("shop_rules"):
var raw = data.shop_rules()
if raw is Dictionary and not (raw as Dictionary).is_empty():
for key in raw.keys():
out[str(key)] = raw[key]
return out
func _seen(game) -> Dictionary:
if game == null or not (game.shop_last_seen is Dictionary):
return {}
return game.shop_last_seen
func _levels(game) -> Dictionary:
if game == null:
return {}
if game.upgrades is Dictionary:
return game.upgrades
return {}
func _game():
@@ -243,3 +511,75 @@ func _play_sfx(id: StringName) -> void:
var audio = (loop as SceneTree).root.get_node_or_null("Audio")
if audio != null and audio.has_method("play_sfx"):
audio.play_sfx(str(id))
# --- статические хелперы выбора -------------------------------------------
static func _pick_weighted(pool: Array, hour: int, last_offers: Array, last_seen: Dictionary, rand_source) -> int:
var weights: Array = []
var total := 0.0
for lot in pool:
var w := lot_weight(lot, hour, last_offers, last_seen)
weights.append(w)
total += w
if total <= 0.0:
return int(float(_randf(rand_source)) * float(pool.size())) % pool.size()
var roll := _randf(rand_source) * total
var acc := 0.0
for i in range(weights.size()):
acc += float(weights[i])
if roll < acc:
return i
return pool.size() - 1
static func _any_affordable(ids: Array, lot_map: Dictionary, levels: Dictionary, money: int) -> bool:
for raw_id in ids:
var id := str(raw_id)
if not lot_map.has(id):
continue
var p := price_for(lot_map[id], level_for(lot_map[id], levels))
if p >= 0 and p <= money:
return true
return false
static func _try_swap_affordable(ids: Array, lots: Array, lot_map: Dictionary, levels: Dictionary, money: int, must_keep: Array) -> void:
var candidate := ""
var best := 2147483647
for lot in lots:
if not (lot is Dictionary):
continue
var id := str(lot.get("id", ""))
if id.is_empty() or ids.has(id):
continue
var lvl := level_for(lot, levels)
if lvl >= maxi(1, int(lot.get("max_level", 1))):
continue
var p := price_for(lot, lvl)
if p >= 0 and p <= money and p < best:
best = p
candidate = id
if candidate.is_empty():
return
# Заменяем самое дорогое незакреплённое предложение.
var swap_index := -1
var worst := -1
for i in range(ids.size()):
var id := str(ids[i])
if must_keep.has(id):
continue
if not lot_map.has(id):
continue
var p := price_for(lot_map[id], level_for(lot_map[id], levels))
if p > worst:
worst = p
swap_index = i
if swap_index >= 0:
ids[swap_index] = candidate
static func _randf(rand_source) -> float:
if rand_source != null and rand_source.has_method("randf"):
return clampf(float(rand_source.randf()), 0.0, 0.999999)
return clampf(randf(), 0.0, 0.999999)
+104
View File
@@ -0,0 +1,104 @@
class_name AttackAlert
extends Control
## Короткое полноэкранное предупреждение перед мини-игрой атаки:
## затемнение, панель «ИДЁТ АТАКА» + название, пульс и лёгкая тряска.
## Живёт 1.1 с и эмитит finished; в reduced/headless — без движения.
signal finished
const DURATION := 1.1
const PANEL_SIZE := Vector2(560, 168)
var _title := "ИДЁТ АТАКА"
var _subtitle := ""
var _duration := DURATION
var _t := 0.0
var _done := false
var _reduced := false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_NONE
process_mode = Node.PROCESS_MODE_ALWAYS
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_reduced = UiMotion.reduced or DisplayServer.get_name() == "headless"
set_process(false)
func play(title: String, subtitle: String, duration: float = DURATION) -> void:
_title = title
_subtitle = subtitle
_duration = maxf(0.15, duration)
_t = 0.0
_done = false
_reduced = UiMotion.reduced or DisplayServer.get_name() == "headless"
visible = true
set_process(true)
queue_redraw()
## Досрочно завершить (смена фазы/отмена атаки) без ожидания таймера.
func finish_now() -> void:
if _done:
return
_done = true
set_process(false)
visible = false
finished.emit()
queue_free()
func _process(delta: float) -> void:
if _done:
return
_t += delta
queue_redraw()
if _t >= _duration:
finish_now()
func _draw() -> void:
var full := Rect2(Vector2.ZERO, size)
draw_rect(full, Color(0.20, 0.05, 0.04, 0.48))
draw_rect(full, Color(0.70, 0.16, 0.12, 0.10))
var pulse := 0.0
var shake := Vector2.ZERO
if not _reduced:
pulse = 0.5 + 0.5 * sin(TAU * _t / 0.42)
shake = Vector2(sin(_t * 46.0) * 2.0, cos(_t * 37.0) * 1.0).round()
var panel := Rect2(((size - PANEL_SIZE) * 0.5).round() + shake, PANEL_SIZE)
# Красное свечение-пульс вокруг панели.
if not _reduced:
draw_rect(panel.grow(4.0 + 5.0 * pulse),
Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b,
0.08 + 0.16 * pulse), false, 2.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.ERROR, 4, 2,
Theme90.SHADOW), panel)
draw_rect(Rect2(panel.position.x + 1, panel.position.y + 1,
panel.size.x - 2, 4), Theme90.ERROR)
# Тонкая внутренняя линия, чтобы панель не сливалась с затемнением.
draw_rect(Rect2(panel.position.x + 14, panel.position.y + 54,
panel.size.x - 28, 1), Theme90.HAIRLINE)
var title_col := Theme90.ERROR.darkened(0.18)
Theme90.text_vcenter(self, Theme90.display(),
Rect2(panel.position.x, panel.position.y + 20, panel.size.x, 34),
_title, 30, title_col, -1, HORIZONTAL_ALIGNMENT_CENTER)
Theme90.text_vcenter(self, Theme90.ui(),
Rect2(panel.position.x, panel.position.y + 62, panel.size.x, 26),
_subtitle, 18, Theme90.NAVY_DEEP, -1, HORIZONTAL_ALIGNMENT_CENTER)
var hint_col := Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b,
0.68 + (0.32 * pulse if not _reduced else 0.0))
Theme90.text_vcenter(self, Theme90.ui(),
Rect2(panel.position.x, panel.position.y + 100, panel.size.x, 18),
"ПРИГОТОВЬТЕСЬ", 10, hint_col, -1, HORIZONTAL_ALIGNMENT_CENTER)
# Маленькие красные штрихи-«маячки» по краям панели.
var blink := 1.0 if _reduced else (0.45 + 0.55 * pulse)
for i in 2:
var x := panel.position.x + 18.0 + i * (panel.size.x - 40.0)
draw_rect(Rect2(x, panel.end.y - 18, 8, 2),
Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, blink))
+1
View File
@@ -0,0 +1 @@
uid://dl2emfhwp53wf
+192 -36
View File
@@ -39,19 +39,138 @@ enum Icon { NONE, X, MIN, MAX, PLUS, MINUS, ARROW_UP, ARROW_DOWN, CHECK, LEFT, R
queue_redraw()
@export var pad := 9.0
## Тайминги состояний (UiMotion). Только draw-only: position/size не меняются.
const HOVER_IN := 0.10
const HOVER_OUT := 0.12
const PRESS_IN := 0.06
const PRESS_OUT := 0.08
const FOCUS_DUR := 0.12
## Насколько сдвигается содержимое в нажатом состоянии, px.
const PRESS_SHIFT := 1.0
## Клик-рябь: draw-only кольцо из точки нажатия.
const RIPPLE_DUR := 0.22
var _hover_amt := 0.0
var _press_amt := 0.0
var _focus_amt := 0.0
## Локальный выключатель движения (Lane A / настройки). UiMotion.reduced тоже.
var _motion_on := true
var _ripple_t := 1.0
var _ripple_pos := Vector2.ZERO
func _ready() -> void:
focus_mode = Control.FOCUS_ALL
mouse_default_cursor_shape = Control.CURSOR_ARROW
mouse_entered.connect(queue_redraw)
mouse_exited.connect(queue_redraw)
button_down.connect(queue_redraw)
button_up.connect(queue_redraw)
mouse_entered.connect(_touch)
mouse_exited.connect(_touch)
button_down.connect(_touch)
button_down.connect(_start_ripple)
button_up.connect(_touch)
focus_entered.connect(_touch)
focus_exited.connect(_touch)
resized.connect(queue_redraw)
if not pressed.is_connected(_play_click):
pressed.connect(_play_click)
## Клик-рябь из точки нажатия (0.22 c, только отрисовка).
func _start_ripple() -> void:
if _reduced():
return
_ripple_pos = get_local_mouse_position()
_ripple_t = 0.0
queue_redraw()
set_process(true)
## Разбудить плавные состояния. Дальше _process сам остановится на цели.
func _touch() -> void:
set_process(true)
queue_redraw()
## Внешний переключатель (Lane A вызывает через has_method).
func set_motion_enabled(on: bool) -> void:
_motion_on = on
if _reduced():
_snap()
_ripple_t = 1.0
queue_redraw()
func _reduced() -> bool:
return not _motion_on or UiMotion.reduced
func _targets() -> Vector3:
var mode := get_draw_mode()
var dis := mode == BaseButton.DRAW_DISABLED or disabled
var hover := (mode == BaseButton.DRAW_HOVER or mode == BaseButton.DRAW_HOVER_PRESSED) and not dis
var press := (mode == BaseButton.DRAW_PRESSED or mode == BaseButton.DRAW_HOVER_PRESSED) and not dis
if toggle_mode and button_pressed:
press = true
return Vector3(
1.0 if hover else 0.0,
1.0 if press else 0.0,
1.0 if has_focus() and not dis else 0.0)
func _snap() -> void:
var t := _targets()
_hover_amt = t.x
_press_amt = t.y
_focus_amt = t.z
func _process(delta: float) -> void:
var t := _targets()
if _reduced():
if not _at(t) or _ripple_t < 1.0:
_snap()
_ripple_t = 1.0
queue_redraw()
set_process(false)
return
var changed := false
if not is_equal_approx(_hover_amt, t.x):
_hover_amt = move_toward(_hover_amt, t.x, delta / (HOVER_IN if t.x > _hover_amt else HOVER_OUT))
changed = true
if not is_equal_approx(_press_amt, t.y):
_press_amt = move_toward(_press_amt, t.y, delta / (PRESS_IN if t.y > _press_amt else PRESS_OUT))
changed = true
if not is_equal_approx(_focus_amt, t.z):
_focus_amt = move_toward(_focus_amt, t.z, delta / FOCUS_DUR)
changed = true
if _ripple_t < 1.0:
_ripple_t = minf(1.0, _ripple_t + delta / RIPPLE_DUR)
changed = true
if changed:
queue_redraw()
# Цель достигнута — не крутим _process зря.
if _at(t) and _ripple_t >= 1.0:
_hover_amt = t.x
_press_amt = t.y
_focus_amt = t.z
set_process(false)
func _at(t: Vector3) -> bool:
return is_equal_approx(_hover_amt, t.x) \
and is_equal_approx(_press_amt, t.y) \
and is_equal_approx(_focus_amt, t.z)
## Значения для _draw: в reduced — мгновенные, иначе текущие плавные.
func _amts() -> Vector3:
if _reduced():
return _targets()
var t := _targets()
if not is_processing() and (not _at(t) or _ripple_t < 1.0):
set_process(true)
return Vector3(_hover_amt, _press_amt, _focus_amt)
## Клик-звук UI. Audio — autoload; в отрыве от дерева (превью) молча пропускаем.
func _play_click() -> void:
var audio := get_node_or_null("/root/Audio")
@@ -77,13 +196,13 @@ func _accent_border() -> Color:
func _draw() -> void:
var amts := _amts()
var hov := amts.x
var prs := amts.y
var foc := amts.z
var r := Rect2(Vector2.ZERO, size)
var mode := get_draw_mode()
var dis := mode == BaseButton.DRAW_DISABLED or disabled
var pressed := mode == BaseButton.DRAW_PRESSED or mode == BaseButton.DRAW_HOVER_PRESSED
var hover := mode == BaseButton.DRAW_HOVER or mode == BaseButton.DRAW_HOVER_PRESSED
if toggle_mode and button_pressed:
pressed = true
var is_active := active or (toggle_mode and button_pressed)
var f := Theme90.ui()
@@ -91,80 +210,117 @@ func _draw() -> void:
var bg := Color(0, 0, 0, 0)
if is_active:
bg = Theme90.ACCENT_PALE
elif hover and not dis:
bg = Color(1, 1, 1, 0.5)
elif hov > 0.001 and not dis:
bg = Color(1, 1, 1, 0.5 * hov)
if bg.a > 0.0:
draw_style_box(Theme90.box(bg, Color(0, 0, 0, 0), 3, 0), r)
var fg := Theme90.INK
if dis:
fg = Theme90.SHADE
elif is_active or hover:
elif is_active:
fg = Theme90.NAVY
else:
fg = Theme90.INK.lerp(Theme90.NAVY, hov)
if is_active:
draw_rect(Rect2(r.position.x + 5, r.end.y - 2, maxf(0.0, r.size.x - 10), 2),
Theme90.ACCENT)
_draw_content(r, f, fg, pressed, dis)
_draw_glow(r, hov, true)
_draw_ripple(r, true)
_draw_content(r, f, fg, PRESS_SHIFT * roundf(prs), dis)
return
var face := Theme90.PAPER_HI
var border := Theme90.BORDER
var fg := Theme90.INK
var shadow := Theme90.SHADOW
var hover_face := Theme90.WHITE
var hover_border := _accent_border()
var hover_fg := Theme90.NAVY
if dis:
face = Theme90.PAPER
border = Theme90.BORDER_SOFT
fg = Theme90.SHADE
shadow = Color(0, 0, 0, 0)
hover_face = face
hover_border = border
hover_fg = fg
elif accent:
face = Theme90.ACCENT.darkened(0.06) if pressed else Theme90.ACCENT
face = Theme90.ACCENT
border = Theme90.ACCENT.darkened(0.18)
fg = Theme90.WHITE
if hover:
face = Theme90.ACCENT.lightened(0.04)
hover_face = Theme90.ACCENT.lightened(0.04)
hover_border = border
hover_fg = fg
elif danger:
face = Theme90.PAPER_HI
border = Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.42)
fg = Theme90.ERROR
if hover:
face = Color(1, 1, 1, 1)
border = Theme90.ERROR
hover_face = Color(1, 1, 1, 1)
hover_border = Theme90.ERROR
hover_fg = fg
elif is_active:
face = Theme90.ACCENT_PALE
border = Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.65)
fg = Theme90.NAVY
if hover:
face = Theme90.ACCENT_PALE.lerp(Theme90.WHITE, 0.35)
else:
if hover:
face = Theme90.WHITE
border = _accent_border()
fg = Theme90.NAVY
hover_face = Theme90.ACCENT_PALE.lerp(Theme90.WHITE, 0.35)
hover_border = border
hover_fg = fg
if pressed and not dis:
face = face.darkened(0.05) if face.get_luminance() > 0.4 else face.darkened(0.1)
shadow = Color(0, 0, 0, 0)
face = face.lerp(hover_face, hov)
border = border.lerp(hover_border, hov)
fg = fg.lerp(hover_fg, hov)
if not (pressed and not dis):
if prs > 0.0 and not dis:
var dv := 0.05 if face.get_luminance() > 0.4 else 0.1
face = face.darkened(dv * prs)
shadow = shadow.lerp(Color(0, 0, 0, 0), prs)
if prs < 0.5 or dis:
draw_style_box(Theme90.box(face, border, 3, 1, shadow), r)
else:
draw_style_box(Theme90.box(face, border, 3, 1), r)
draw_line(Vector2(3, 1), Vector2(r.size.x - 3, 1), Color(0, 0, 0, 0.10), 1.0)
# мягкий верхний блик
if not pressed and not dis and face.get_luminance() > 0.55 and r.size.y >= 16.0:
if prs < 0.5 and not dis and face.get_luminance() > 0.55 and r.size.y >= 16.0:
draw_line(Vector2(4, 1), Vector2(r.size.x - 4, 1), Color(1, 1, 1, 0.55), 1.0)
# аккуратный фокус — тонкое кольцо внутри
if has_focus() and not dis:
# аккуратный фокус — тонкое кольцо внутри, α плавно проявляется
if foc > 0.01 and not dis:
draw_style_box(Theme90.box(Color(0, 0, 0, 0),
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.75), 3, 1),
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.75 * foc), 3, 1),
r.grow(-2.0))
_draw_content(r, f, fg, pressed, dis)
_draw_glow(r, hov, face.get_luminance() > 0.5)
_draw_ripple(r, face.get_luminance() > 0.5)
_draw_content(r, f, fg, PRESS_SHIFT * roundf(prs), dis)
func _draw_content(r: Rect2, f: Font, fg: Color, pressed: bool, dis: bool) -> void:
var shift := Vector2(0, 1) if pressed and not dis else Vector2.ZERO
## Мягкая подсветка при наведении — внутри рамки, без изменения layout.
func _draw_glow(r: Rect2, hov: float, light: bool) -> void:
if hov <= 0.01:
return
var c := Theme90.ACCENT_LIGHT if light else Theme90.WHITE
draw_style_box(Theme90.box(Color(c.r, c.g, c.b, (0.10 if light else 0.09) * hov),
Color(0, 0, 0, 0), 3, 0), r.grow(-2.0))
## Клик-рябь: кольцо из точки нажатия, гаснет за 0.22 c (draw-only).
func _draw_ripple(r: Rect2, light: bool) -> void:
if _ripple_t >= 1.0 or _reduced():
return
var p := clampf(_ripple_t, 0.0, 1.0)
var a := (1.0 - p) * 0.32
if a <= 0.01:
return
var max_r := maxf(2.0, minf(r.size.x, r.size.y) * 0.62)
var rad := lerpf(2.0, max_r, p)
var c := Theme90.ACCENT if light else Theme90.WHITE
draw_arc(_ripple_pos, rad, 0.0, TAU, 40, Color(c.r, c.g, c.b, a), 1.5, true)
func _draw_content(r: Rect2, f: Font, fg: Color, shift_y: float, _dis: bool) -> void:
var shift := Vector2(0, shift_y)
var icon_w := 0.0
if icon != Icon.NONE:
icon_w = 12.0
+24 -24
View File
@@ -21,7 +21,6 @@ var _yes_text := "Да"
var _no_text := "Нет"
var _btn_yes: Button90
var _btn_no: Button90
var _btn_x: Button90
func _ready() -> void:
@@ -65,12 +64,6 @@ func _build() -> void:
add_child(_btn_no)
_btn_no.pressed.connect(_answer.bind(false))
_btn_x = Button90.new()
_btn_x.icon = Button90.Icon.X
_btn_x.tooltip_text = "Нет"
_btn_x.pressed.connect(_answer.bind(false))
add_child(_btn_x)
func _answer(value: bool) -> void:
if not visible:
@@ -88,15 +81,14 @@ func _window_rect() -> Rect2:
func _layout() -> void:
var r := _window_rect()
# Нижний ряд: 100 + 12 + 100 = 212, по центру панели.
var row_x := r.position.x + roundf((W - 212.0) * 0.5)
if _btn_yes != null:
_btn_yes.position = r.position + Vector2(236, 102)
_btn_yes.position = Vector2(row_x, r.position.y + 102)
_btn_yes.size = Vector2(100, 24)
if _btn_no != null:
_btn_no.position = r.position + Vector2(348, 102)
_btn_no.position = Vector2(row_x + 112.0, r.position.y + 102)
_btn_no.size = Vector2(100, 24)
if _btn_x != null:
_btn_x.position = r.position + Vector2(W - 22, 4)
_btn_x.size = Vector2(16, 16)
func _unhandled_input(event: InputEvent) -> void:
@@ -121,23 +113,31 @@ func _draw_frame(r: Rect2) -> void:
Theme90.draw_bevel(self, r, false, Theme90.BEVEL, Theme90.WHITE)
Theme90.modal_header(self, r, "ПОДТВЕРЖДЕНИЕ", "ТРЕБУЕТСЯ ОТВЕТ")
# знак вопроса в утопленной плашке
var well := Rect2(r.position + Vector2(18, 28), Vector2(34, 34))
# вопрос и пояснение — центрируем иконку и текст как единый блок
var f := Theme90.ui()
var lines := Theme90.wrap(f, _question, Theme90.FS_BODY, 370, 2)
var text_w := 0.0
for line in lines:
text_w = maxf(text_w, f.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1,
Theme90.FS_BODY).x)
var icon_size := 24.0
var icon_gap := 10.0
var group_w := icon_size + icon_gap + text_w
var gx := r.position.x + roundf((W - group_w) * 0.5)
var well := Rect2(gx, r.position.y + 27, icon_size, icon_size)
Theme90.draw_sunken(self, well)
Theme90.text_vcenter(self, Theme90.logo(), well, "?",
18, Theme90.ERROR if _danger else Theme90.NAVY)
# вопрос и пояснение
var f := Theme90.ui()
var lines := Theme90.wrap(f, _question, 14, 378, 2)
var y := r.position.y + 30
14, Theme90.ERROR if _danger else Theme90.NAVY)
var tx := gx + icon_size + icon_gap
var y := r.position.y + 31
for line in lines:
Theme90.text(self, f, Vector2(r.position.x + 62, y), line, 14, Theme90.INK)
Theme90.text(self, f, Vector2(tx, y), line, Theme90.FS_BODY, Theme90.INK,
text_w, HORIZONTAL_ALIGNMENT_CENTER)
y += 18.0
if not _detail.is_empty():
Theme90.text(self, f, Vector2(r.position.x + 62, r.position.y + 58),
Theme90.fit(f, _detail, Theme90.FS_MICRO, 378), Theme90.FS_MICRO,
Theme90.ERROR if _danger else Theme90.DARK)
Theme90.text_vcenter(self, f, Rect2(r.position.x + 12, y + 4.0, W - 24, 14),
Theme90.fit(f, _detail, Theme90.FS_MICRO, W - 24), Theme90.FS_MICRO,
Theme90.ERROR if _danger else Theme90.DARK, -1, HORIZONTAL_ALIGNMENT_CENTER)
# разделитель над кнопками
var ly := r.position.y + 88
+648 -39
View File
@@ -1,8 +1,63 @@
extends SceneTree
## Dev-инструмент (не часть игры): снимает кадр сцены в PNG.
##
## godot --path . --script scripts/ui/dev_capture.gd -- --scene=res://scenes/Main.tscn --out=/tmp/opencode/peak.png
## godot --path . --script scripts/ui/dev_capture.gd -- --probe --out=/tmp/opencode/font_probe.png
## Базовые примеры:
## godot --path . --script scripts/ui/dev_capture.gd -- --scene=res://scenes/Main.tscn --out=/tmp/opencode/peak.png
## godot --path . --script scripts/ui/dev_capture.gd -- --probe --out=/tmp/opencode/font_probe.png
##
## Экраны игры (флаги сами запускают новый забег, если это нужно):
## ... -- --run --out=/tmp/opencode/prep.png
## ... -- --hour --seconds=8 --spawn=packet,request --out=/tmp/opencode/hour.png
## ... -- --minigame=dots --tier=2 --out=/tmp/opencode/dots.png
## ... -- --shop --out=/tmp/opencode/shop.png
## ... -- --tutorial --out=/tmp/opencode/tutorial.png
##
## Флаги:
## --scene=PATH сцена для загрузки (по умолчанию res://scenes/Main.tscn)
## --out=PATH куда сохранить PNG (по умолчанию /tmp/opencode/peak.png)
## --wait=N сколько кадров ждать перед снимком (по умолчанию 10)
## --probe отрисовать шрифтовой probe вместо Main
## --run нажать «НОВЫЙ ЗАБЕГ» (Main._start_new_run)
## --seed-demo в подготовку положить sound-файл (2 клетки) для проверки drag
## --hour после --run начать час 1 (Main._on_start_hour)
## --seconds=N сколько реальных секунд крутить час (по умолчанию 6) — нужен --hour
## --spawn=A,B через HourDirector спавнить packet и/или request (нужен --hour)
## --folder=ID открыть папку по id (как раньше); с --run без --folder
## автоматически открывается первая папка
## --minigame=ID dots|sound|text|video|photo|maze|junk|ddos|phishing|ransomware
## --tier=N сложность мини-игры, 1..4 (по умолчанию 2)
## --seed=N сид мини-игры (по умолчанию 12345)
## --shop свернуть офферы для часа 2 и открыть ShopModal
## --tutorial запустить забег и показать маскота-туториал
## --sla=N выставить SLA (dev, для скриншотов «ЖАРЫ»)
## --stress=STATE calm|warm|hot|critical|attack — force_heat оверлея
## --reduce включить «Снизить эффекты» (UiMotion.reduced)
## --menu контекстное меню 90-х (как раньше)
## --filemenu выпадающее меню «ФАЙЛ» (как раньше)
## --toast два демо-тоста (как раньше)
## --hover=x,y навести курсор
## --press=x,y нажать ЛКМ в точке (без отпускания)
## --drag=x1,y1,x2,y2 настоящий drag: press → motion ×8 → release
## --hold с --drag: остановиться до release (кадр «в процессе»)
## --raw-coords координаты --hover/--press/--drag заданы в пикселях окна,
## а не в логических 1280×720 (аварийный режим совместимости)
##
## ПРОСТРАНСТВО КООРДИНАТ:
## Все координаты указателя (--hover/--press/--drag) — ЛОГИЧЕСКИЕ 1280×720,
## как в макете. Внутри они умножаются на реальный размер окна / 1280×720
## (обычно 1920×1080 → ×1.5). Скриншот всегда сохраняется в пикселях окна
## (обычно 1920×1080). --raw-coords отключает пересчёт.
##
## При ошибке (нет метода Main, пустая папка, мини-игра не создалась) скрипт
## печатает причину и завершается с кодом 1, а не снимает меню молча.
const LOGICAL_SIZE := Vector2(1280.0, 720.0)
const SEND_GAMES: Array[String] = [
"dots", "sound", "text", "video", "photo", "maze",
]
const ATTACK_GAMES: Array[String] = [
"junk", "ddos", "phishing", "ransomware",
]
var _scene_path := "res://scenes/Main.tscn"
var _out := "/tmp/opencode/peak.png"
@@ -16,6 +71,28 @@ var _folder := ""
var _filemenu := false
var _main: Node = null
var _run := false
var _seed_demo := false
var _hour := false
var _seconds := 6.0
var _spawn: Array[String] = []
var _minigame := ""
var _tier := 2
var _seed := 12345
var _shop := false
var _tutorial := false
## [start, end] в логических координатах; пустой массив — drag не нужен.
var _drag: Array[Vector2] = []
var _hold := false
var _raw_coords := false
## «ЖАРА»: dev-флаги для скриншотов нагрева.
var _sla := -1.0
var _stress := ""
var _reduce := false
## Снимок делается только после того, как сцена и запрошенные экраны готовы.
var _setup_done := false
func _initialize() -> void:
for a in OS.get_cmdline_user_args():
@@ -33,51 +110,178 @@ func _initialize() -> void:
_filemenu = true
elif a == "--toast":
_toast = true
elif a.begins_with("--hover="):
var hp := a.substr(8).split(",")
_hover = Vector2(float(hp[0]), float(hp[1]))
elif a == "--run":
_run = true
elif a == "--seed-demo":
_seed_demo = true
elif a == "--hour":
_hour = true
elif a.begins_with("--seconds="):
_seconds = maxf(0.0, float(a.substr(10)))
elif a.begins_with("--spawn="):
_spawn.clear()
for raw in a.substr(8).split(",", false):
var token := raw.strip_edges().to_lower()
if token.is_empty():
continue
if token != "packet" and token != "request":
_fail("--spawn: неизвестный тип '%s' (ожидается packet или request)" % token)
return
_spawn.append(token)
elif a.begins_with("--folder="):
_folder = a.substr(9)
elif a.begins_with("--minigame="):
_minigame = a.substr(11).strip_edges().to_lower()
elif a.begins_with("--tier="):
_tier = clampi(int(a.substr(7)), 1, 4)
elif a.begins_with("--seed="):
_seed = int(a.substr(7))
elif a == "--shop":
_shop = true
elif a == "--tutorial":
_tutorial = true
elif a.begins_with("--hover="):
_hover = _parse_pair(a.substr(8))
if _hover.x < 0.0:
_fail("--hover: нужен формат x,y")
return
elif a.begins_with("--press="):
var pp := a.substr(8).split(",")
_press = Vector2(float(pp[0]), float(pp[1]))
_press = _parse_pair(a.substr(8))
if _press.x < 0.0:
_fail("--press: нужен формат x,y")
return
elif a.begins_with("--drag="):
var parts := a.substr(7).split(",", false)
if parts.size() != 4:
_fail("--drag: нужен формат x1,y1,x2,y2")
return
var p1 := _parse_pair("%s,%s" % [parts[0], parts[1]])
var p2 := _parse_pair("%s,%s" % [parts[2], parts[3]])
if p1.x < 0.0 or p2.x < 0.0:
_fail("--drag: координаты должны быть числами x1,y1,x2,y2")
return
_drag = [p1, p2]
elif a == "--hold":
_hold = true
elif a == "--raw-coords":
_raw_coords = true
elif a.begins_with("--sla="):
_sla = float(a.substr(6))
elif a.begins_with("--stress="):
_stress = a.substr(9).strip_edges().to_lower()
elif a == "--reduce":
_reduce = true
elif a == "--help" or a == "-h":
_print_help()
quit(0)
return
if not _minigame.is_empty() and not SEND_GAMES.has(_minigame) \
and not ATTACK_GAMES.has(_minigame):
_fail("--minigame: '%s' не поддерживается (см. --help)" % _minigame)
return
if _hold and _drag.is_empty():
_fail("--hold имеет смысл только вместе с --drag")
return
if not _stress.is_empty() and not ["calm", "warm", "hot", "critical", "attack"].has(_stress):
_fail("--stress: неизвестное состояние '%s'" % _stress)
return
if _probe:
root.add_child(Probe.new())
else:
var ps: PackedScene = load(_scene_path)
if ps == null:
push_error("cannot load " + _scene_path)
quit(1)
return
_main = ps.instantiate()
root.add_child(_main)
call_deferred("_after_ready")
_setup_done = true
return
var ps: PackedScene = load(_scene_path)
if ps == null:
_fail("cannot load " + _scene_path)
return
_main = ps.instantiate()
root.add_child(_main)
call_deferred("_after_ready")
func _after_ready() -> void:
if _toast and _main != null and _main.has_method("_toast"):
# Дадим Main один-два кадра на _ready/_process и ленивые модалки.
await _frames(2)
if _main == null:
_fail("Main не загрузился")
return
if _toast and _main.has_method("_toast"):
_main.call("_toast", "СОЕДИНЕНИЕ УСТАНОВЛЕНО · договор_11.doc", "success")
_main.call("_toast", "КАПАЛ 45% · БУФЕР 3/4", "warn")
if not _folder.is_empty() and _main != null:
if _main.has_method("_on_folder_selected"):
_main.call("_on_folder_selected", _folder)
if _hover.x >= 0.0:
await create_timer(0.15).timeout
Input.warp_mouse(_hover)
if _press.x >= 0.0:
await create_timer(0.15).timeout
var ev := InputEventMouseButton.new()
ev.button_index = MOUSE_BUTTON_LEFT
ev.pressed = true
ev.position = _press
ev.global_position = _press
Input.parse_input_event(ev)
# Игровые экраны невозможны без забега: --hour/--shop/--tutorial/--minigame
# и --spawn сами включают его, чтобы команды из документации работали.
var need_run: bool = _run or _hour or _shop or _tutorial \
or not _minigame.is_empty() or not _spawn.is_empty()
if need_run:
if not _invoke_main("_start_new_run", [], "start_new_run"):
return
await _frames(4)
# --folder имеет приоритет; иначе после --run открываем первую папку,
# чтобы в кадре была сетка файлов, а не пустая панель.
if not _folder.is_empty():
if not _invoke_main("_on_folder_selected", [_folder]):
return
await _frames(2)
elif need_run:
if not _select_first_folder():
return
await _frames(2)
# Dev-подготовка: 2-клеточный sound-файл рядом с bootstrap text-файлом,
# чтобы проверять перенос/фантомы в сетке без запуска часа.
if _seed_demo and not _hour:
if not _seed_prep_demo():
return
await _frames(2)
if _hour or not _spawn.is_empty():
# Новый забег начинается с пустого склада: без файлов HourDirector
# не сможет создать запросы. Кладём демо-файлы в первую папку.
if not _seed_demo_files():
return
if not _invoke_main("_on_start_hour"):
return
await _frames(3)
if not _spawn.is_empty() and not _spawn_cards():
return
if _seconds > 0.0:
# Настоящее время: HourDirector тикает из Main._process, пакеты и
# запросы успевают появиться и отрисоваться.
await create_timer(_seconds).timeout
await _frames(3)
if _shop:
if not _open_shop():
return
await _frames(3)
if _tutorial:
if not _show_tutorial():
return
await _frames(2)
if not _minigame.is_empty():
if not _show_minigame():
return
await _frames(6)
# «ЖАРА»: dev-флаги нагрева/SLA/reduce — последними, чтобы кадр был стабилен.
if _sla >= 0.0 or _reduce or not _stress.is_empty():
if not _apply_stress_flags():
return
# Даём термометру довести display-ease (tau 0.25) до заданного heat.
await create_timer(1.5).timeout
# Прежние сценарии меню/тостов/файлового меню.
if _filemenu and _main != null:
await create_timer(0.1).timeout
var menus: Dictionary = _main.get("_menus")
if menus.has("ФАЙЛ"):
var pm: PopupMenu = menus["ФАЙЛ"]
pm.popup(Rect2i(Vector2i(6, 44), Vector2i.ZERO))
if _menu:
await create_timer(0.1).timeout
ContextMenu90.show_items(_main, [
@@ -88,18 +292,424 @@ func _after_ready() -> void:
{"label": "Уничтожить содержимое", "id": "destroy", "danger": true},
], Vector2(120, 220), func(_id: String) -> void: pass)
if _hover.x >= 0.0:
await create_timer(0.15).timeout
Input.warp_mouse(_window_point(_hover))
if _press.x >= 0.0:
await create_timer(0.15).timeout
_send_button(_window_point(_press), true)
if not _drag.is_empty():
await _do_drag()
_setup_done = true
func _process(_delta: float) -> bool:
_wait -= 1
if _wait > 0:
return false
var img := root.get_texture().get_image()
img.save_png(_out)
print("saved ", _out, " ", img.get_size())
quit()
if not _setup_done:
return false
_wait -= 1
if _wait > 0:
return false
if DisplayServer.get_name() == "headless":
_fail("headless-режим: скриншот невозможен, нужен реальный дисплей")
return true
var tex := root.get_texture()
if tex == null:
_fail("нет текстуры окна (headless? нужен реальный дисплей)")
return true
var img := tex.get_image()
if img == null:
_fail("не удалось получить изображение кадра")
return true
var err := img.save_png(_out)
if err != OK:
_fail("не удалось сохранить %s (ошибка %d)" % [_out, err])
return true
print("saved ", _out, " ", img.get_size())
quit()
return true
# --------------------------------------------------------------------------
# Шаги сценария
# --------------------------------------------------------------------------
## Вызвать метод Main; при отсутствии — внятная ошибка и код 1.
func _invoke_main(method: String, args: Array = [], fallback: String = "") -> bool:
if _main == null:
_fail("Main не загружен")
return false
var target := method
if not _main.has_method(target):
if fallback.is_empty() or not _main.has_method(fallback):
_fail("Main не имеет метода %s()" % method)
return false
target = fallback
_main.callv(target, args)
return true
func _select_first_folder() -> bool:
var folder_id := _first_folder_id()
if folder_id.is_empty():
_fail("в подготовке нет ни одной папки для --run")
return false
return _invoke_main("_on_folder_selected", [folder_id])
func _first_folder_id() -> String:
var ctrl = _main.get("controller")
if ctrl == null or not ctrl.has_method("prep_data"):
return ""
var d: Dictionary = ctrl.call("prep_data")
for sec in d.get("sections", []):
var folders: Array = sec.get("folders", [])
if folders.is_empty():
continue
var folder_id := str(folders[0].get("id", ""))
if not folder_id.is_empty():
return folder_id
return ""
func _folder_exists(folder_id: String) -> bool:
var ctrl = _main.get("controller")
if ctrl == null or not ctrl.has_method("prep_data"):
return false
var d: Dictionary = ctrl.call("prep_data")
for sec in d.get("sections", []):
for fol in sec.get("folders", []):
if str(fol.get("id", "")) == folder_id:
return true
return false
## Dev: sound-файл на 2 клетки в первую папку подготовки (без запуска часа).
func _seed_prep_demo() -> bool:
var game_node := root.get_node_or_null("Game")
if game_node == null:
_fail("--seed-demo: автолоад Game недоступен")
return false
var registry = game_node.get("registry")
var folder_id := _first_folder_id()
if registry == null or folder_id.is_empty():
_fail("--seed-demo: нет реестра или папки")
return false
if registry.has_method("find_by_name") \
and registry.call("find_by_name", "звонок_демо") != null:
return true
var file = registry.call("create_file", "звонок_демо", "sound", folder_id)
if file == null:
_fail("--seed-demo: не удалось создать sound-файл")
return false
# Детерминированно: 2 клетки горизонтально в (1,0).
file.shape_id = "I2H"
file.shape_cells = registry.call("shape_cells", "I2H", 0)
if not bool(registry.call("move_file", str(file.id), folder_id,
Vector2i(1, 0), 0)):
_fail("--seed-demo: не удалось разместить sound-файл в (1,0)")
return false
if _main.has_method("_refresh_prep"):
_main.call("_refresh_prep")
return true
## Демо-файлы для часа: без них запросы не спавнятся (реестр пуст).
func _seed_demo_files() -> bool:
var game_node := root.get_node_or_null("Game")
if game_node == null:
_fail("автолоад Game недоступен")
return false
var registry = game_node.get("registry")
if registry == null or not registry.has_method("create_file"):
_fail("Game.registry.create_file() недоступен")
return false
var folder_id := _folder
if folder_id.is_empty() or not _folder_exists(folder_id):
folder_id = _first_folder_id()
if folder_id.is_empty():
_fail("некуда положить демо-файлы: нет папок")
return false
var demo := [["договор_97", "text"], ["смета_демо", "photo"],
["отчёт_демо", "video"]]
for pair in demo:
var file_name := str(pair[0])
if registry.has_method("find_by_name") \
and registry.call("find_by_name", file_name) != null:
continue
registry.call("create_file", file_name, str(pair[1]), folder_id)
if _main.has_method("_refresh_prep"):
_main.call("_refresh_prep")
return true
## «ЖАРА»: --sla=N, --stress=..., --reduce — только для dev-скриншотов.
func _apply_stress_flags() -> bool:
var game_node := root.get_node_or_null("Game")
if _sla >= 0.0:
if game_node == null:
_fail("--sla: автолоад Game недоступен")
return false
var cap := 60.0
if game_node.has_method("sla_cap"):
cap = float(game_node.call("sla_cap"))
game_node.set("sla", clampf(_sla, 0.0, maxf(1.0, cap)))
if _main != null and _main.has_method("_refresh_hour"):
_main.call("_refresh_hour")
if _reduce:
if _main == null or not _main.has_method("_apply_settings"):
_fail("--reduce: Main._apply_settings() недоступен")
return false
_main.call("_apply_settings", {"reduce_motion": true}, false)
if _stress.is_empty():
return true
var overlay = _main.get("stress_overlay") if _main != null else null
if overlay == null or not overlay.has_method("force_heat"):
_fail("Main.stress_overlay.force_heat() недоступен")
return false
var heat := 0.0
match _stress:
"calm":
heat = 0.08
"warm":
heat = 0.46
"hot":
heat = 0.70
"critical":
heat = 0.92
"attack":
heat = 0.88
_:
_fail("--stress: неизвестное состояние '%s'" % _stress)
return false
overlay.call("force_heat", heat)
if _stress == "attack" and overlay.has_method("notify"):
overlay.call("notify", "attack_start")
if _main != null and _main.has_method("_sync_stress_ui"):
_main.call("_sync_stress_ui")
return true
func _spawn_cards() -> bool:
var ctrl = _main.get("controller")
if ctrl == null:
_fail("Main.controller недоступен")
return false
var director = ctrl.get("hour_director")
if director == null:
_fail("HourDirector недоступен")
return false
for token in _spawn:
if token == "packet":
if not director.has_method("spawn_packet_now"):
_fail("HourDirector.spawn_packet_now() отсутствует")
return false
var spawned := 0
for i in 3:
if not str(director.call("spawn_packet_now")).is_empty():
spawned += 1
if spawned == 0:
_fail("HourDirector не смог создать ни одного пакета")
return false
elif token == "request":
if not director.has_method("spawn_request_now"):
_fail("HourDirector.spawn_request_now() отсутствует")
return false
var spawned_req := 0
for i in 2:
var req_id := str(director.call("spawn_request_now"))
if req_id.is_empty():
# Страховка: один файл на запрос, если обычный ролл не прошёл.
req_id = str(director.call("spawn_request_now", 1))
if not req_id.is_empty():
spawned_req += 1
if spawned_req == 0:
_fail("HourDirector не смог создать ни одного запроса")
return false
if _main.has_method("_refresh_hour"):
_main.call("_refresh_hour")
return true
func _open_shop() -> bool:
var ctrl = _main.get("controller")
if ctrl == null:
_fail("Main.controller недоступен")
return false
var shop = ctrl.get("shop")
if shop == null:
_fail("GameController.shop недоступен")
return false
var modal = _main.get("shop_modal")
if modal == null:
_fail("Main.shop_modal недоступен")
return false
if not modal.has_method("open_prep"):
_fail("ShopModal.open_prep() отсутствует")
return false
# Магазин в игре открывается в подготовке ПОСЛЕ часа 1. Main._on_shop()
# на часе 1 отказывает, поэтому крутим офферы для часа 2 и открываем
# модалку напрямую тем же API, что и игра.
if shop.has_method("refresh"):
shop.call("refresh", 2, _game_money())
var offers: Array = []
if shop.has_method("offers"):
offers = shop.call("offers")
if offers.is_empty():
_fail("магазин не выдал офферы для часа 2")
return false
modal.call("open_prep", shop, _game_money(), 2)
if not bool(modal.get("visible")):
_fail("ShopModal не открылся")
return false
return true
func _show_tutorial() -> bool:
var mascot = _main.get("mascot")
if mascot == null or not mascot.has_method("show_message"):
_fail("Main.mascot/TutorialMascot.show_message() недоступен")
return false
var phase := ""
var ctrl = _main.get("controller")
if ctrl != null:
phase = str(ctrl.get("phase"))
if phase == "hour" and _main.has_method("_show_tutorial_step"):
_main.call("_show_tutorial_step", 0)
elif _main.has_method("_maybe_show_prep_tutorial"):
_main.call("_maybe_show_prep_tutorial")
if not bool(mascot.get("visible")):
# В headless авто-туториал выключен; для инструмента показываем явно.
mascot.call("show_message",
"Привет, я БАЙТ. Час пик начинается с приёма пакетов.", -1.0)
if not bool(mascot.get("visible")):
_fail("маскот-туториал не стал видимым")
return false
return true
func _show_minigame() -> bool:
var game = null
if SEND_GAMES.has(_minigame):
game = MiniGameFactory.create_send(_minigame, _tier, _seed)
elif ATTACK_GAMES.has(_minigame):
game = MiniGameFactory.create_attack(_minigame, _tier, _seed)
if game == null:
_fail("MiniGameFactory не создал '%s' (tier %d)" % [_minigame, _tier])
return false
var host: Node = _main
var mr = _main.get("modal_root")
if mr != null:
host = mr
host.add_child(game)
if game is Control:
# См. Main._show_modal_game: фиксируем min-size, затем центрируем KEEP_SIZE.
var ctl := game as Control
ctl.size = ctl.get_combined_minimum_size()
ctl.set_anchors_and_offsets_preset(Control.PRESET_CENTER, Control.PRESET_MODE_KEEP_SIZE)
if game.has_method("start"):
game.call("start", _minigame, _tier, _seed)
if game is CanvasItem and not (game as CanvasItem).visible:
_fail("мини-игра '%s' создана, но не видима" % _minigame)
return false
return true
# --------------------------------------------------------------------------
# Ввод: логические 1280×720 → пиксели окна
# --------------------------------------------------------------------------
func _window_point(logical: Vector2) -> Vector2:
if _raw_coords:
return logical
var win := Vector2(DisplayServer.window_get_size())
if win.x <= 0.0 or win.y <= 0.0:
return logical
return Vector2(logical.x * win.x / LOGICAL_SIZE.x,
logical.y * win.y / LOGICAL_SIZE.y)
func _send_button(pos: Vector2, pressed: bool) -> void:
var ev := InputEventMouseButton.new()
ev.button_index = MOUSE_BUTTON_LEFT
ev.pressed = pressed
ev.position = pos
ev.global_position = pos
ev.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0
Input.parse_input_event(ev)
func _do_drag() -> void:
var start := _window_point(_drag[0])
var finish := _window_point(_drag[1])
_send_button(start, true)
await _frames(2)
var steps := 8
var prev := start
for i in range(1, steps + 1):
var p := start.lerp(finish, float(i) / float(steps))
if DisplayServer.get_name() != "headless":
# Держим и системный курсор рядом: hover-open папок смотрит на него.
Input.warp_mouse(p)
var motion := InputEventMouseMotion.new()
motion.position = p
motion.global_position = p
motion.relative = p - prev
motion.button_mask = MOUSE_BUTTON_MASK_LEFT
Input.parse_input_event(motion)
prev = p
await process_frame
if not _hold:
# --hold оставляет кнопку зажатой: снимок ловит середину drag'а.
_send_button(finish, false)
await _frames(2)
func _frames(count: int) -> void:
for i in count:
await process_frame
func _parse_pair(text: String) -> Vector2:
var parts := text.split(",", false)
if parts.size() != 2:
return Vector2(-1, -1)
if not parts[0].strip_edges().is_valid_float() \
or not parts[1].strip_edges().is_valid_float():
return Vector2(-1, -1)
return Vector2(float(parts[0]), float(parts[1]))
func _game_money() -> int:
var node := root.get_node_or_null("Game")
if node == null:
return 0
return int(node.get("money"))
func _fail(message: String) -> void:
printerr("dev_capture: ERROR: ", message)
push_error("dev_capture: " + message)
quit(1)
func _print_help() -> void:
print("dev_capture.gd — снимок экрана PEAK HOUR 97")
print(" --run --hour --seconds=N --spawn=packet,request")
print(" --minigame=ID --tier=N --seed=N --shop --tutorial")
print(" --folder=ID --hover=x,y --press=x,y --drag=x1,y1,x2,y2 --hold")
print(" --sla=N --stress=calm|warm|hot|critical|attack --reduce")
print(" --menu --filemenu --toast --probe --out=PATH --wait=N")
print(" Координаты указателя — логические 1280x720; --raw-coords для пикселей окна.")
print(" Мини-игры: ", ", ".join(SEND_GAMES + ATTACK_GAMES))
# --------------------------------------------------------------------------
# Font probe
# --------------------------------------------------------------------------
class Probe:
extends Control
@@ -161,4 +771,3 @@ class Probe:
var i := 0 if eg == 1.0 else 1
var j := [0, 2, 8].find(esh)
return 360.0 + (i * 3 + j) * 26.0
+321
View File
@@ -0,0 +1,321 @@
class_name DragController
extends Node
## Центральный контроллер перетаскивания: держит одно превью (FileShape) и
## его режим (shape | square), двигает превью за мышью и отсчитывает 250 мс
## наведения на папку БЕЗ Timer-узлов.
##
## Владелец — Main. Компоненты находят его через группу "drag_controller"
## или через свойство drag_controller.
##
## ВАЖНО: при отсутствии превью контроллер не должен падать — все вызовы
## проверяют is_instance_valid.
signal hover_open(folder_id: String)
signal drag_ended
## Пользователь повернул фигуру (R / ПКМ). Слушают все сетки, чтобы ghost
## обновлялся даже у той, которая не получила событие ввода.
signal rotation_changed(rotation: int)
const HOVER_DELAY := 0.25
const PREVIEW_OFFSET := Vector2(9, 9)
## Сглаживание превью за мышью и «жизнь» drag (Lane B). Всё — только
## transform превью: позиции источника/цели и drop-логику не трогаем.
const SMOOTH_TAU := 0.035
const TILT_TAU := 0.12
## Скорость мыши (px/s), при которой наклон максимальный.
const TILT_MAX_SPEED := 1400.0
const POP_DUR := 0.10
const POP_SCALE := 1.04
const DRAG_ALPHA := 0.92
const FLINCH_DUR := 0.16
const FLINCH_PX := 3.0
var _preview: Control = null
var _mode: String = "shape"
var _rotation: int = 0
var _rotate_frame: int = -1
var _hover_id: String = ""
var _hover_left: float = 0.0
var _hover_fired: bool = false
var _pos := Vector2.ZERO
var _mouse_prev := Vector2.ZERO
var _tilt := 0.0
var _pop_t := 1.0
var _rot_pop_t := 1.0
var _flinch_left := 0.0
var _placing := false
var _motion_on := true
func _ready() -> void:
if not is_in_group("drag_controller"):
add_to_group("drag_controller")
set_process(false)
func begin(preview_control: Control, p_mode: String = "shape",
p_initial_rotation: int = 0) -> void:
end()
_preview = preview_control
_mode = _normalize(p_mode)
# Пакет создаёт превью со своим поворотом, но зовёт begin() без третьего
# аргумента. Берём поворот из превью, чтобы drop не сбрасывал его в 0.
var seeded := posmod(int(p_initial_rotation), 4)
if seeded == 0 and is_instance_valid(preview_control):
var preview_rotation = preview_control.get("shape_rotation")
if preview_rotation != null:
seeded = posmod(int(preview_rotation), 4)
_rotation = seeded
_rotate_frame = -1
_pos = Vector2.ZERO
_tilt = 0.0
_pop_t = 1.0
_rot_pop_t = 1.0
_flinch_left = 0.0
if is_instance_valid(_preview):
_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
_preview.top_level = true
_preview.z_index = 200
if _preview.get_parent() == null:
add_child(_preview)
if _preview.has_method("set_mode"):
_preview.set_mode(_mode)
else:
_preview.set("mode", _mode)
_preview.visible = true
_preview.modulate.a = DRAG_ALPHA
_preview.scale = Vector2.ONE
_preview.rotation = 0.0
_placing = true
_sync_preview_rotation()
# Первый кадр — сразу под курсором, чтобы не было рывка из (0,0).
_place()
_pop_t = 0.0 if not _reduced() else 1.0
_mouse_prev = _mouse_pos()
_apply_transform()
_preview.queue_redraw()
set_process(true)
func set_mode(p_mode: String) -> void:
var next := _normalize(p_mode)
_mode = next
if is_instance_valid(_preview):
if _preview.has_method("set_mode"):
_preview.set_mode(next)
else:
_preview.set("mode", next)
_preview.queue_redraw()
func end(_absorb_to: Variant = null) -> void:
var had := is_instance_valid(_preview) or not _hover_id.is_empty()
if is_instance_valid(_preview):
_preview.visible = false
var parent := _preview.get_parent()
if parent != null:
parent.remove_child(_preview)
_preview.queue_free()
# preview() обязан вернуть null сразу после end() — без ожидания кадра.
_preview = null
_rotation = 0
_rotate_frame = -1
_pos = Vector2.ZERO
_tilt = 0.0
_pop_t = 1.0
_rot_pop_t = 1.0
_flinch_left = 0.0
_placing = false
reset_hover()
if had:
drag_ended.emit()
set_process(false)
func preview() -> Control:
return _preview if is_instance_valid(_preview) else null
func mode() -> String:
return _mode
# --- поворот ---------------------------------------------------------------
## Текущий поворот фигуры (0..3). Авторитетное состояние на время drag.
func rotation() -> int:
return _rotation
func set_rotation(value: int) -> void:
var next := posmod(int(value), 4)
if next == _rotation:
return
_rotation = next
_sync_preview_rotation()
_rot_pop_t = 0.0 if not _reduced() else 1.0
_apply_transform()
rotation_changed.emit(_rotation)
## +90° по часовой. Повторный вызов в том же кадре игнорируется: R/ПКМ
## приходят и источнику, и цели drop, а повернуть нужно один раз.
func rotate_cw() -> int:
var frame := Engine.get_process_frames()
if _rotate_frame == frame:
return _rotation
_rotate_frame = frame
set_rotation(_rotation + 1)
return _rotation
## Есть ли активное превью — то есть drag действительно идёт.
func is_active() -> bool:
return is_instance_valid(_preview)
func _sync_preview_rotation() -> void:
if not is_instance_valid(_preview):
return
if _preview.has_method("setup"):
_preview.setup(str(_preview.get("file_type")), str(_preview.get("shape_id")),
_rotation, str(_preview.get("file_name")), str(_preview.get("ext")),
float(_preview.get("cell_size")))
if _preview.has_method("set_mode"):
_preview.set_mode(_mode)
else:
_preview.set("shape_rotation", _rotation)
_preview.queue_redraw()
# setup() меняет custom_minimum_size — превью должно остаться под курсором.
_place()
func hover_folder(folder_id: String) -> void:
if folder_id.is_empty():
reset_hover()
return
if folder_id == _hover_id:
# повторный вход в ту же папку не сбрасывает и не дублирует таймер
return
_hover_id = folder_id
_hover_left = HOVER_DELAY
_hover_fired = false
set_process(true)
func reset_hover() -> void:
_hover_id = ""
_hover_left = 0.0
_hover_fired = false
if not is_instance_valid(_preview):
set_process(false)
func hover_id() -> String:
return _hover_id
func _process(delta: float) -> void:
if not _hover_id.is_empty() and not _hover_fired:
_hover_left -= delta
if _hover_left <= 0.0:
_hover_fired = true
hover_open.emit(_hover_id)
if is_instance_valid(_preview) and _preview.visible:
_animate(delta)
if not is_instance_valid(_preview) and (_hover_id.is_empty() or _hover_fired):
set_process(false)
## Локальный выключатель движения (Lane A зовёт через has_method).
func set_motion_enabled(on: bool) -> void:
_motion_on = on
if _reduced():
_tilt = 0.0
_flinch_left = 0.0
_pop_t = 1.0
_rot_pop_t = 1.0
if is_instance_valid(_preview):
_pos = _target_pos()
_apply_transform()
## Короткий отказ: превью дёргается по X, не меняя реальную позицию drag.
func flinch() -> void:
if _reduced():
return
_flinch_left = FLINCH_DUR
if is_instance_valid(_preview):
set_process(true)
func _reduced() -> bool:
return not _motion_on or UiMotion.reduced
func _mouse_pos() -> Vector2:
var vp := get_viewport()
return vp.get_mouse_position() if vp != null else Vector2.ZERO
## Куда превью стремится: под курсором, с прежним смещением.
func _target_pos() -> Vector2:
if not is_instance_valid(_preview):
return _pos
return _mouse_pos() - _preview.custom_minimum_size * 0.5 + PREVIEW_OFFSET
func _animate(delta: float) -> void:
if not is_instance_valid(_preview):
return
var target := _target_pos()
if _reduced():
_pos = target
_tilt = 0.0
_pop_t = 1.0
_rot_pop_t = 1.0
_flinch_left = 0.0
else:
_pos = _pos.lerp(target, UiMotion.k(delta, SMOOTH_TAU))
var mouse := _mouse_pos()
if delta > 0.0001:
var vel := (mouse - _mouse_prev) / delta
var want := clampf(vel.x / TILT_MAX_SPEED, -1.0, 1.0) * UiMotion.DRAG_TILT
_tilt = lerpf(_tilt, want, UiMotion.k(delta, TILT_TAU))
_mouse_prev = mouse
_pop_t = minf(1.0, _pop_t + delta / POP_DUR)
_rot_pop_t = minf(1.0, _rot_pop_t + delta / POP_DUR)
_flinch_left = maxf(0.0, _flinch_left - delta)
_apply_transform()
func _apply_transform() -> void:
if not is_instance_valid(_preview):
return
_preview.pivot_offset = _preview.custom_minimum_size * 0.5
# Лёгкая «пружина»: основная волна + малый обратный горб, мягко гаснет.
var pop := sin(PI * _pop_t) + 0.22 * sin(TAU * _pop_t)
var rpop := sin(PI * _rot_pop_t) + 0.35 * sin(TAU * _rot_pop_t)
var s := 1.0 + (POP_SCALE - 1.0) * pop + (POP_SCALE - 1.0) * rpop
_preview.scale = Vector2(s, s)
_preview.rotation = _tilt if not _reduced() else 0.0
var off := Vector2.ZERO
if _flinch_left > 0.0 and not _reduced():
var p := _flinch_left / FLINCH_DUR
off.x = sin(p * PI * 3.0) * FLINCH_PX * p
_preview.position = _pos + off
func _place() -> void:
if not is_instance_valid(_preview):
return
if _placing:
_pos = _target_pos()
_placing = false
_apply_transform()
func _normalize(value: String) -> String:
return "square" if value == "square" else "shape"
+1
View File
@@ -0,0 +1 @@
uid://cybnog1pa4ni4
+160 -17
View File
@@ -12,6 +12,17 @@ signal drag_started(id: String)
const CARD := Vector2(132, 84)
# --- Motion (Lane B) -------------------------------------------------------
const ENTER_DUR := 0.14
## Draw-only сдвиг содержимого при входе, px.
const ENTER_OFFSET := 2.0
const HOVER_DUR := 0.10
const SELECT_DUR := 0.12
const BADGE_POP_DUR := 0.14
## Hover-подъём и отскок выделения — только отрисовка.
const HOVER_LIFT := 1.0
const SEL_BOUNCE_DUR := 0.18
var id := ""
var file_name := ""
var ext := ""
@@ -20,16 +31,27 @@ var type := "text"
var state := "stored"
var selected := false:
set(v):
if v and not selected:
_sel_clock = 0.0
selected = v
set_process(true)
queue_redraw()
var hovered := false:
set(v):
hovered = v
set_process(true)
queue_redraw()
var _press_pos := Vector2.ZERO
var _drag_ready := false
var _hover_amt := 0.0
var _sel_amt := 0.0
var _enter_elapsed := ENTER_DUR
var _badge_clock := -1.0
var _sel_clock := -1.0
var _motion_on := true
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
@@ -40,6 +62,108 @@ func _ready() -> void:
mouse_entered.connect(func() -> void: hovered = true)
mouse_exited.connect(func() -> void: hovered = false)
_update_tooltip()
set_process(false)
# --- motion (Lane B) -------------------------------------------------------
## Появление: alpha 0.14 c + draw-only подъём на 2 px.
func enter(_index: int = 0) -> void:
if _reduced():
_enter_elapsed = ENTER_DUR
modulate.a = 1.0
return
_enter_elapsed = 0.0
modulate.a = 0.0
set_process(true)
## Внешний переключатель движения (Lane A / настройки).
func set_motion_enabled(on: bool) -> void:
_motion_on = on
if _reduced():
_hover_amt = 1.0 if hovered else 0.0
_sel_amt = 1.0 if selected else 0.0
_enter_elapsed = ENTER_DUR
modulate.a = 1.0
_badge_clock = -1.0
queue_redraw()
func _reduced() -> bool:
return not _motion_on or UiMotion.reduced
func _process(delta: float) -> void:
var hov := 1.0 if hovered else 0.0
var sel := 1.0 if selected else 0.0
if _reduced():
_hover_amt = hov
_sel_amt = sel
elif not is_equal_approx(_hover_amt, hov):
_hover_amt = move_toward(_hover_amt, hov, delta / HOVER_DUR)
if not is_equal_approx(_sel_amt, sel):
if _reduced():
_sel_amt = sel
else:
_sel_amt = move_toward(_sel_amt, sel, delta / SELECT_DUR)
if _enter_elapsed < ENTER_DUR:
_enter_elapsed = minf(ENTER_DUR, _enter_elapsed + delta)
modulate.a = clampf(_enter_elapsed / ENTER_DUR, 0.0, 1.0)
if _badge_clock >= 0.0:
_badge_clock += delta
if _badge_clock >= BADGE_POP_DUR:
_badge_clock = -1.0
if _sel_clock >= 0.0:
_sel_clock += delta
if _sel_clock >= SEL_BOUNCE_DUR:
_sel_clock = -1.0
queue_redraw()
if _enter_elapsed >= ENTER_DUR and _badge_clock < 0.0 and _sel_clock < 0.0 \
and is_equal_approx(_hover_amt, hov) and is_equal_approx(_sel_amt, sel):
set_process(false)
## 0..1 → 0 подскок бейджа attached.
func _badge_pop() -> float:
if _badge_clock < 0.0:
return 0.0
return sin(PI * clampf(_badge_clock / BADGE_POP_DUR, 0.0, 1.0))
## 0..1 → 0 отскок выделения (полоска слева и подъём).
func _sel_bounce() -> float:
if _sel_clock < 0.0:
return 0.0
return sin(PI * clampf(_sel_clock / SEL_BOUNCE_DUR, 0.0, 1.0))
## Draw-only подъём: hover 1 px, выделение — короткий отскок.
func _lift_offset() -> float:
if _reduced():
return 0.0
var off := HOVER_LIFT * _draw_hover()
off += 1.5 * _sel_bounce()
return off
func _draw_hover() -> float:
if _reduced():
return 1.0 if hovered else 0.0
return _hover_amt
func _draw_sel() -> float:
if _reduced():
return 1.0 if selected else 0.0
return _sel_amt
## Draw-only подъём при входе: position карточки не меняется.
func _enter_offset() -> float:
if _enter_elapsed >= ENTER_DUR:
return 0.0
return ENTER_OFFSET * (1.0 - clampf(_enter_elapsed / ENTER_DUR, 0.0, 1.0))
func setup(p_id: String, p_name: String, p_ext: String, p_size: int, p_type: String,
@@ -49,8 +173,12 @@ func setup(p_id: String, p_name: String, p_ext: String, p_size: int, p_type: Str
ext = p_ext
size_kb = p_size
type = p_type
var was := state
state = p_state
if state == "attached" and was != "attached":
_badge_clock = 0.0
_update_tooltip()
set_process(true)
queue_redraw()
@@ -109,23 +237,33 @@ func _draw() -> void:
var f := Theme90.ui()
var tc := icon_color(type, ext)
var locked := state == "locked"
var bad := state == "malicious" or state == "failed"
var hov := _draw_hover()
var sel := _draw_sel()
var bg := Theme90.PAPER_HI
var border := Theme90.BORDER
var shadow := Theme90.SHADOW
if selected:
bg = Theme90.ACCENT_PALE
border = Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.8)
shadow = Color(0, 0, 0, 0.12)
elif hovered:
bg = Theme90.WHITE
border = Theme90.ACCENT_LIGHT
shadow = Color(0, 0, 0, 0.11)
var bg := Theme90.PAPER_HI.lerp(Theme90.WHITE, hov)
var border := Theme90.BORDER.lerp(Theme90.ACCENT_LIGHT, hov)
var shadow := Theme90.SHADOW.lerp(Color(0, 0, 0, 0.11), hov)
if sel > 0.001:
bg = bg.lerp(Theme90.ACCENT_PALE, sel)
border = border.lerp(
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.8), sel)
shadow = shadow.lerp(Color(0, 0, 0, 0.12), sel)
var off := _enter_offset() - _lift_offset()
if off != 0.0:
draw_set_transform(Vector2(0, off), 0.0, Vector2.ONE)
draw_style_box(Theme90.box(bg, border, 4, 1, shadow), r)
if selected:
draw_rect(Rect2(1.5, 12, 2.5, size.y - 24), Theme90.ACCENT)
# Мягкое свечение внутри рамки при наведении/выделении (draw-only).
var glow := maxf(hov, sel)
if glow > 0.02:
var gc := Theme90.ACCENT_LIGHT if sel < 0.5 else Theme90.ACCENT
draw_style_box(Theme90.box(Color(gc.r, gc.g, gc.b, 0.10 * glow),
Color(0, 0, 0, 0), 4, 0), r.grow(-2.0))
if sel > 0.01:
var bar_h := size.y - 24.0 + 4.0 * _sel_bounce()
draw_rect(Rect2(1.5, (size.y - bar_h) * 0.5, 2.5, bar_h),
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, sel))
if locked:
tc = Theme90.SHADE
@@ -133,7 +271,8 @@ func _draw() -> void:
# состояние — только маленькими значками, без подписей
if state == "attached":
Theme90.draw_check_badge(self, Vector2(size.x - 14, 14), 7.0)
Theme90.draw_check_badge(self, Vector2(size.x - 14, 14),
7.0 + 1.2 * _badge_pop())
elif locked:
draw_circle(Vector2(size.x - 14, 14), 7.0, Color(Theme90.SHADE.r, Theme90.SHADE.g,
Theme90.SHADE.b, 0.16))
@@ -165,8 +304,12 @@ func _draw() -> void:
Theme90.text(self, f, Vector2(10 + ew + 5, 68), _spaced(size_kb) + " КБ", 10,
meta_col)
if hovered and not selected:
draw_rect(Rect2(10, 34, size.x - 20, 1), Theme90.HAIRLINE)
if hov > 0.02 and sel < 0.5:
draw_rect(Rect2(10, 34, size.x - 20, 1),
Color(Theme90.HAIRLINE.r, Theme90.HAIRLINE.g, Theme90.HAIRLINE.b,
Theme90.HAIRLINE.a * hov))
if off != 0.0:
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
static func _spaced(v: int) -> String:
+1117 -264
View File
File diff suppressed because it is too large. Load diff
+557
View File
@@ -0,0 +1,557 @@
class_name FileShape
extends Control
## Одна фигура-полимино: превью drag, карточка пакета, стол сортировки.
##
## Рисуется без дочерних узлов: заливка занятых клеток, ОДИН внешний контур
## 2 px (внутренние границы не рисуются), подпись в верхней левой ЗАНЯТОЙ
## клетке. Тот же painter (`paint`) использует FileGrid — так превью и сетка
## выглядят одинаково.
##
## Режимы:
## shape — полимино по shape_id/rotation (для сетки и стола);
## square — одна клетка 1×1 (для карточки запроса: файл = квадрат).
##
## ВАЖНО: global class cache при `--script` может быть пустым, поэтому
## FileRegistry подключаем через preload.
const FR = preload("res://scripts/core/file_registry.gd")
const GAP := 0.0
var file_type: String = "text"
var shape_id: String = "O1"
var shape_rotation: int = 0
var file_name: String = ""
var ext: String = ""
var cell_size: float = 48.0
var mode: String = "shape"
var state: String = "stored"
var selected: bool = false
var hovered: bool = false
var show_label: bool = true
var alpha: float = 1.0
## Непрозрачная замена контура/заливки (ghost, ошибки). Alpha 0 — обычные.
var outline_override: Color = Color(0, 0, 0, 0)
var fill_override: Color = Color(0, 0, 0, 0)
func _init() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_sync_size()
func setup(p_type: String, p_shape_id: String, p_rotation: int, p_name: String,
p_ext: String, p_cell: float) -> void:
file_type = p_type
shape_id = p_shape_id if not p_shape_id.is_empty() else "O1"
shape_rotation = posmod(int(p_rotation), 4)
file_name = p_name
ext = p_ext
cell_size = maxf(8.0, p_cell)
_sync_size()
func set_mode(value: String) -> void:
var next := "square" if value == "square" else "shape"
if mode == next:
return
mode = next
_sync_size()
queue_redraw()
func get_mode() -> String:
return mode
func set_state(value: String) -> void:
state = value
queue_redraw()
func set_selected(value: bool) -> void:
selected = value
queue_redraw()
func resolved_cells() -> Array:
if mode == "square":
return [Vector2i.ZERO]
return FR.shape_cells(shape_id, shape_rotation)
func _sync_size() -> void:
var cells := resolved_cells()
var b := FR.shape_bounds(cells)
var w := maxf(1.0, float(b.x) * cell_size)
var h := maxf(1.0, float(b.y) * cell_size)
custom_minimum_size = Vector2(w, h)
size = custom_minimum_size
func _draw() -> void:
var cells := resolved_cells()
paint(self, Vector2.ZERO, cells, cell_size, {
"type": file_type,
"name": file_name,
"ext": ext,
"state": state,
"selected": selected,
"hovered": hovered,
"alpha": alpha,
"show_label": show_label,
"gap": GAP,
"square": mode == "square",
"outline": outline_override,
"fill": fill_override,
})
# --- палитра ---------------------------------------------------------------
static func type_color(file_type: String) -> Color:
match file_type:
"sound":
return Theme90.WARN
"photo":
return Theme90.SUCCESS
"video":
return Theme90.ERROR
_:
return Theme90.ACCENT
static func ext_color(file_type: String, ext_value: String) -> Color:
var cat := Theme90.ext_category(ext_value)
if not cat.is_empty():
return Theme90.ext_color(cat)
return type_color(file_type)
static func _with_alpha(c: Color, a: float) -> Color:
return Color(c.r, c.g, c.b, c.a * a)
# --- геометрия -------------------------------------------------------------
## Рёбра внешнего контура в координатах клеток. Каждое ребро — словарь
## {"from": Vector2i-клетка, "dir": Vector2i-направление наружу}.
## Внутренние границы (сосед занят) не попадают в список.
static func outer_edges(cells: Array) -> Array:
var out: Array = []
var occupied := {}
for c in cells:
occupied[Vector2i(c)] = true
var dirs: Array[Vector2i] = [
Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(0, -1),
]
for c in cells:
var p := Vector2i(c)
for d in dirs:
if not occupied.has(p + d):
out.append({"from": p, "dir": d})
return out
static func _cell_vec(value) -> Vector2:
if value is Vector2:
return value
var s := float(value)
return Vector2(s, s)
## Прямоугольник клетки в пикселях. gap расширяет заливку на половину зазора,
## чтобы соседние клетки одной фигуры сливались в цельное полимино.
## cell_px — float (квадрат) или Vector2 (прямоугольная клетка стола).
static func cell_pixel_rect(pos: Vector2, cell: Vector2i, cell_px, gap: float) -> Rect2:
var cs := _cell_vec(cell_px)
var step := cs + Vector2(gap, gap)
var p := pos + Vector2(float(cell.x) * step.x, float(cell.y) * step.y)
if gap > 0.0:
var e := gap * 0.5
return Rect2(p - Vector2(e, e), cs + Vector2(gap, gap))
return Rect2(p, cs)
## Отрезок внешнего ребра в пикселях.
static func edge_segment(pos: Vector2, from_cell: Vector2i, dir: Vector2i,
cell_px, gap: float) -> PackedVector2Array:
var cs := _cell_vec(cell_px)
var step := cs + Vector2(gap, gap)
var p := pos + Vector2(float(from_cell.x) * step.x, float(from_cell.y) * step.y)
var e := gap * 0.5 if gap > 0.0 else 0.0
var l := p.x - e
var t := p.y - e
var r := p.x + cs.x + e
var b := p.y + cs.y + e
if dir == Vector2i(1, 0):
return PackedVector2Array([Vector2(r, t), Vector2(r, b)])
if dir == Vector2i(-1, 0):
return PackedVector2Array([Vector2(l, t), Vector2(l, b)])
if dir == Vector2i(0, 1):
return PackedVector2Array([Vector2(l, b), Vector2(r, b)])
return PackedVector2Array([Vector2(l, t), Vector2(r, t)])
# --- painter ---------------------------------------------------------------
## Рисует фигуру. opts: type, name, ext, state, selected, hovered, alpha,
## show_label, gap, square, outline, fill.
static func paint(ci: CanvasItem, pos: Vector2, cells: Array, cell_px,
opts: Dictionary = {}) -> void:
var cs := _cell_vec(cell_px)
if cells.is_empty() or cs.x <= 0.0 or cs.y <= 0.0:
return
var file_type := str(opts.get("type", "text"))
var name := str(opts.get("name", ""))
var ext_value := str(opts.get("ext", ""))
var state := str(opts.get("state", "stored"))
var selected := bool(opts.get("selected", false))
var hovered := bool(opts.get("hovered", false))
var alpha := clampf(float(opts.get("alpha", 1.0)), 0.0, 1.0)
if alpha <= 0.01:
return
var show_label := bool(opts.get("show_label", true))
var gap := float(opts.get("gap", 0.0))
var square := bool(opts.get("square", false))
var outline_override: Color = opts.get("outline", Color(0, 0, 0, 0))
var fill_override: Color = opts.get("fill", Color(0, 0, 0, 0))
var base := type_color(file_type)
var locked := state == "locked"
var bad := state == "malicious" or state == "failed"
if locked:
base = base.lerp(Theme90.SHADE, 0.55)
var fill := fill_override if fill_override.a > 0.0 else _with_alpha(base, 0.22)
if hovered and fill_override.a <= 0.0:
fill = _with_alpha(base, 0.30)
fill.a *= alpha
var outline := outline_override
if outline.a <= 0.0:
if selected:
outline = Theme90.ACCENT
elif bad:
outline = Theme90.ERROR
elif locked:
outline = Theme90.SHADE
else:
outline = base
outline.a *= alpha
var outline_w := 2.6 if selected else 2.0
# 1) заливка занятых клеток (без внутренних швов)
for c in cells:
var cell := Vector2i(c)
var r := cell_pixel_rect(pos, cell, cell_px, gap)
ci.draw_rect(r, fill)
if hovered and fill_override.a <= 0.0:
ci.draw_rect(r, Color(1, 1, 1, 0.09 * alpha))
# 2) один внешний контур
for edge in outer_edges(cells):
var seg := edge_segment(pos, Vector2i(edge["from"]), Vector2i(edge["dir"]),
cell_px, gap)
ci.draw_line(seg[0], seg[1], outline, outline_w, false)
# 3) тонкая красная рамка вокруг bbox для malicious/failed
if bad:
var b := FR.shape_bounds(cells)
var step := cs + Vector2(gap, gap)
var frame := Rect2(pos - Vector2(2, 2),
Vector2(float(b.x) * step.x - gap + 4.0, float(b.y) * step.y - gap + 4.0))
ci.draw_rect(frame, _with_alpha(Theme90.ERROR, 0.55 * alpha), false, 1.0)
if not show_label:
return
var lbl := FR.label_cell(cells)
if square:
_paint_square_label(ci, pos, lbl, cs, gap, base, file_type, name, alpha, locked)
else:
_paint_cell_label(ci, pos, lbl, cs, gap, base, file_type, name, ext_value,
alpha, locked, state)
## Кегль подписи файла по размеру клетки: 12 / 14 / 16 px.
## Используется и в сетке, и в карточке запроса (square), чтобы имена
## не были микроскопическими на больших клетках.
## Это БАЗОВЫЙ кегль: если имя не влезает в отведённые строки, painter
## уменьшает его на 1 px (не ниже 11) прежде чем обрезать многоточием.
static func label_font_size(cell_px: float) -> int:
if cell_px > 96.0:
return 16
if cell_px >= 72.0:
return 14
return 12
## Сколько строк имени даёт клетка: 3 при >= 88, 2 при 72–87, 1 ниже 72.
## Больше строк — длинные имена читаются целиком, а не «запас_е…».
static func label_max_lines(cell_px: float) -> int:
if cell_px >= 88.0:
return 3
if cell_px >= 72.0:
return 2
return 1
## Перенос по словам с РАЗНОЙ шириной строк: первая уже (в ней иконка),
## последующие занимают всю клетку за вычетом полей.
static func _wrap_name(f: Font, text: String, size: int, first_w: float,
rest_w: float) -> PackedStringArray:
var out := PackedStringArray()
if text.is_empty():
return out
var cur := ""
for raw in text.split(" ", false):
var word := str(raw)
var width := first_w if out.is_empty() else rest_w
var cand := word if cur.is_empty() else cur + " " + word
if f.get_string_size(cand, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x <= width:
cur = cand
else:
if not cur.is_empty():
out.append(cur)
cur = word
if not cur.is_empty():
out.append(cur)
return out
## Имя целиком помещается в max_lines при данном кегле (без многоточия)?
static func _name_fits(f: Font, text: String, size: int, first_w: float,
rest_w: float, max_lines: int) -> bool:
var lines := _wrap_name(f, text, size, first_w, rest_w)
if lines.size() > max_lines:
return false
for i in lines.size():
var width := first_w if i == 0 else rest_w
if f.get_string_size(lines[i], HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > width:
return false
return true
## Самый крупный кегль от базового, при котором имя ещё влезает целиком.
## Ниже 11 px не опускаемся: лучше многоточие, чем нечитаемая строка.
static func name_font_size(cell_px: float, text: String, first_w: float,
rest_w: float, max_lines: int) -> int:
return _max_fit_size(Theme90.ui(), text, cell_px, first_w, rest_w, max_lines)
static func _max_fit_size(f: Font, text: String, cell_px: float, first_w: float,
rest_w: float, max_lines: int) -> int:
var base := label_font_size(cell_px)
var lo := mini(11, base)
for size in range(base, lo - 1, -1):
if _name_fits(f, text, size, first_w, rest_w, max_lines):
return size
return lo
## Готовые строки для отрисовки. Если имя не влезает — последняя строка
## получает хвост и многоточие; отдельные слишком длинные слова режутся
## по ширине своей строки.
static func _build_lines(f: Font, text: String, size: int, first_w: float,
rest_w: float, max_lines: int) -> PackedStringArray:
var lines := _wrap_name(f, text, size, first_w, rest_w)
if lines.size() > max_lines:
var kept := PackedStringArray()
for i in max_lines:
kept.append(lines[i])
kept[max_lines - 1] = Theme90.fit(f,
kept[max_lines - 1] + " " + lines[max_lines], size,
first_w if max_lines == 1 else rest_w)
lines = kept
for i in lines.size():
var lw := first_w if i == 0 else rest_w
if f.get_string_size(lines[i], HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > lw:
lines[i] = Theme90.fit(f, lines[i], size, lw)
return lines
## Сколько символов имени реально видно (без многоточий и пробелов).
static func _visible_chars(lines: PackedStringArray) -> int:
var total := 0
for line in lines:
total += str(line).replace("…", "").replace(" ", "").length()
return total
## Раскладка подписи. Обычно иконка стоит в верхнем левом углу, а первая
## строка начинается справа от неё (icon_inline = true).
## Если первое слово в такую строку не помещается даже после сжатия кегля,
## текст переносится ПОД иконку и все строки получают полную ширину:
## иначе длинное слово теряло бы начало («запас_енер…» вместо имени целиком).
## Возвращает {"size": int, "lines": PackedStringArray, "icon_inline": bool}.
static func _label_layout(f: Font, name: String, cell_px: float, first_w: float,
rest_w: float, max_lines: int) -> Dictionary:
var inline_size := name_font_size(cell_px, name, first_w, rest_w, max_lines)
if _name_fits(f, name, inline_size, first_w, rest_w, max_lines):
return {"size": inline_size, "icon_inline": true,
"lines": _build_lines(f, name, inline_size, first_w, rest_w, max_lines)}
var below_size := _max_fit_size(f, name, cell_px, rest_w, rest_w, max_lines)
if _name_fits(f, name, below_size, rest_w, rest_w, max_lines):
return {"size": below_size, "icon_inline": false,
"lines": _build_lines(f, name, below_size, rest_w, rest_w, max_lines)}
# Не влезает ни так, ни так — оставляем раскладку, где видно больше имени.
var inline_lines := _build_lines(f, name, inline_size, first_w, rest_w, max_lines)
var below_lines := _build_lines(f, name, below_size, rest_w, rest_w, max_lines)
var below_better := below_size > inline_size \
or (below_size == inline_size
and _visible_chars(below_lines) > _visible_chars(inline_lines))
if below_better:
return {"size": below_size, "icon_inline": false, "lines": below_lines}
return {"size": inline_size, "icon_inline": true, "lines": inline_lines}
static func _paint_cell_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
cs: Vector2, gap: float, base: Color, file_type: String, name: String,
ext_value: String, alpha: float, locked: bool, state: String) -> void:
var step := cs + Vector2(gap, gap)
var lr := Rect2(pos + Vector2(float(lbl.x) * step.x, float(lbl.y) * step.y), cs)
if lr.size.x < 16.0 or lr.size.y < 16.0:
return
var cell_min := minf(cs.x, cs.y)
var f := Theme90.ui()
var ink := Theme90.SHADE if locked else Theme90.INK
var shade := Theme90.SHADE
# Поля: строки после первой идут во всю ширину клетки (cell − 10 px).
const PAD := 5.0
var base_size := label_font_size(cell_min)
var ext_size := maxi(9, base_size - 3)
var max_lines := label_max_lines(cell_min)
var icon_size := clampf(cs.x * 0.16, 10.0, 16.0)
var ty := lr.position.y + 3.0
# Иконка — в верхнем левом углу. Первая строка идёт справа от неё,
# остальные — от левого поля, во всю ширину; если имя не влезает рядом
# с иконкой, весь текст уходит под неё (см. _label_layout).
var text_x := lr.position.x + PAD
var first_x := text_x + icon_size + 2.0
var badge_room := 10.0 if state == "attached" or state == "malicious" \
or state == "failed" else 0.0
var first_w := maxf(8.0, lr.end.x - first_x - PAD - badge_room)
var rest_w := maxf(8.0, lr.size.x - PAD * 2.0)
var layout := _label_layout(f, name, cell_min, first_w, rest_w, max_lines)
var name_size: int = int(layout["size"])
var lines: PackedStringArray = layout["lines"]
var icon_inline: bool = bool(layout["icon_inline"])
var line_h := float(name_size) + 1.0
# Строка с иконкой занимает не меньше самой иконки — иначе вторая строка
# наехала бы на неё.
var first_h := maxf(line_h, icon_size + 2.0)
var text_top := ty if icon_inline else ty + first_h
var icon := Rect2(Vector2(text_x, ty + maxf(0.0, (line_h - icon_size) * 0.5))
.round(), Vector2(icon_size, icon_size))
if not Theme90.draw_file_icon(ci, icon, file_type, _with_alpha(Color.WHITE, alpha)):
draw_type_icon(ci, icon, file_type, base, alpha)
var last_bottom := ty
for i in lines.size():
var lx := text_x
var lw := rest_w
var ly := text_top + float(i) * line_h
if icon_inline:
if i == 0:
lx = first_x
lw = first_w
ly = ty
else:
ly = ty + first_h + float(i - 1) * line_h
Theme90.text(ci, f, Vector2(lx, ly), lines[i], name_size,
_with_alpha(ink, alpha), lw)
last_bottom = ly + line_h
if not ext_value.is_empty():
var ey := (last_bottom if not lines.is_empty() else ty) + 1.0
# Расширение — отдельной мелкой строкой, если в клетке осталось место.
if ey + float(ext_size) + 2.0 <= lr.end.y:
Theme90.text(ci, f, Vector2(text_x, ey),
Theme90.fit(f, ext_value, ext_size, rest_w),
ext_size, _with_alpha(shade, alpha), rest_w)
# состояния в верхнем правом углу label-клетки
var badge := Vector2(lr.end.x - 8.0, lr.position.y + 8.0)
if state == "attached":
Theme90.draw_check_badge(ci, badge, 5.5, _with_alpha(Theme90.SUCCESS, alpha),
_with_alpha(Theme90.WHITE, alpha))
elif state == "malicious" or state == "failed":
ci.draw_circle(badge, 5.5, _with_alpha(Theme90.ERROR, alpha))
Theme90.text_vcenter(ci, f, Rect2(badge.x - 5.0, badge.y - 6.0, 10.0, 12.0),
"!", 10, _with_alpha(Theme90.WHITE, alpha), 10.0, HORIZONTAL_ALIGNMENT_CENTER)
elif locked:
Theme90.draw_lock_icon(ci, Vector2(lr.end.x - 12.0, lr.end.y - 11.0),
_with_alpha(Theme90.SHADE, alpha))
static func _paint_square_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
cs: Vector2, gap: float, base: Color, file_type: String, name: String,
alpha: float, locked: bool) -> void:
var step := cs + Vector2(gap, gap)
var lr := Rect2(pos + Vector2(float(lbl.x) * step.x, float(lbl.y) * step.y), cs)
var f := Theme90.ui()
var cell_min := minf(cs.x, cs.y)
var name_size := label_font_size(cell_min)
var has_name := cell_min >= 40.0 and not name.is_empty()
var icon_size := clampf(cell_min * 0.30, 14.0, 18.0)
var name_y := lr.end.y - float(name_size) - 4.0
var icon := Rect2(lr.get_center().x - icon_size * 0.5,
lr.get_center().y - icon_size * 0.5, icon_size, icon_size)
if has_name:
# Иконка слева от имени, выровнена по строке подписи.
icon = Rect2(lr.position.x + 3.0,
name_y + (float(name_size) - icon_size) * 0.5,
icon_size, icon_size)
if not Theme90.draw_file_icon(ci, icon, file_type, _with_alpha(Color.WHITE, alpha)):
draw_type_icon(ci, icon, file_type, base, alpha)
if has_name:
var col := Theme90.SHADE if locked else Theme90.INK
var nx := icon.end.x + 2.0
Theme90.text(ci, f, Vector2(nx, name_y),
Theme90.fit(f, name, name_size, lr.end.x - nx - 3.0), name_size,
_with_alpha(col, alpha), lr.end.x - nx - 3.0)
## 8×8 иконка типа: строки (text), волна (sound), горы (photo), play (video).
static func draw_type_icon(ci: CanvasItem, rect: Rect2, file_type: String,
color: Color, alpha: float = 1.0) -> void:
var s := minf(rect.size.x, rect.size.y)
if s <= 0.0:
return
var r := Rect2(rect.position.round(), Vector2(s, s))
var tint := _with_alpha(color, 0.18 * alpha)
ci.draw_style_box(Theme90.fill_box(tint, 2), r)
var c := _with_alpha(color, alpha)
var x := r.position.x
var y := r.position.y
match file_type:
"sound":
var pts := PackedVector2Array([
Vector2(x + 1.0, y + s * 0.55),
Vector2(x + s * 0.30, y + s * 0.25),
Vector2(x + s * 0.52, y + s * 0.75),
Vector2(x + s * 0.74, y + s * 0.38),
Vector2(x + s - 1.0, y + s * 0.55),
])
ci.draw_polyline(pts, c, 1.0, false)
"photo":
var pts := PackedVector2Array([
Vector2(x + 1.0, y + s - 2.0),
Vector2(x + s * 0.34, y + s * 0.30),
Vector2(x + s * 0.56, y + s * 0.62),
Vector2(x + s * 0.72, y + s * 0.44),
Vector2(x + s - 1.0, y + s - 2.0),
])
ci.draw_polyline(pts, c, 1.0, false)
ci.draw_circle(Vector2(x + s * 0.72, y + s * 0.26), 1.2, c)
"video":
ci.draw_colored_polygon(PackedVector2Array([
Vector2(x + s * 0.30, y + s * 0.18),
Vector2(x + s * 0.30, y + s * 0.82),
Vector2(x + s * 0.82, y + s * 0.50),
]), c)
_:
for i in 3:
var ly := y + s * (0.30 + float(i) * 0.20)
ci.draw_line(Vector2(x + s * 0.22, ly),
Vector2(x + s * (0.78 - float(i) * 0.10), ly), c, 1.0, false)
+1
View File
@@ -0,0 +1 @@
uid://cywkgtu02b8pk
+188 -17
View File
@@ -7,38 +7,73 @@ extends Control
signal folder_selected(folder_id: String)
signal expansion_toggled(folder_id: String)
signal file_dropped(folder_id: String, payload: Dictionary)
signal packet_dropped(folder_id: String, payload: Dictionary)
signal context_menu_requested(folder_id: String, global_pos: Vector2)
## Папка раскрыта/открыта наведением во время drag (250 мс).
signal hover_open_requested(folder_id: String)
const ROW_H := 24.0
const SECTION_H := 22.0
# --- Motion (Lane B) -------------------------------------------------------
const HOVER_DUR := 0.10
const SELECT_DUR := 0.12
const WAVE_DUR := 0.8
const ACCEPT_DUR := 0.20
## Отскок полоски выделения.
const SEL_BOUNCE_DUR := 0.18
var folder_id := ""
var folder_name := ""
## v2: count — занятые КЛЕТКИ, capacity — всего клеток.
var count := 0
var capacity := 16
## Число файлов (для тултипа «N файлов · X из Y клеток»).
var files_count := 0
var free_text := ""
var depth := 0
var is_section := false
var expandable := true
var hover_open := false:
set(v):
if hover_open == v:
return
hover_open = v
queue_redraw()
if v and not is_section:
hover_open_requested.emit(folder_id)
var expanded := true:
set(v):
expanded = v
queue_redraw()
var selected := false:
set(v):
if v and not selected:
_sel_clock = 0.0
selected = v
set_process(true)
queue_redraw()
var hovered := false:
set(v):
hovered = v
set_process(true)
queue_redraw()
var drop_active := false:
set(v):
if drop_active != v:
drop_active = v
_wave = 0.0
set_process(true)
queue_redraw()
var locked := false
var _hover_amt := 0.0
var _sel_amt := 0.0
var _wave := 0.0
var _accept_clock := -1.0
var _sel_clock := -1.0
var _motion_on := true
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_PASS
@@ -51,6 +86,91 @@ func _ready() -> void:
set_process(false)
# --- motion (Lane B) -------------------------------------------------------
## Внешний переключатель движения (Lane A / настройки).
func set_motion_enabled(on: bool) -> void:
_motion_on = on
if _reduced():
_hover_amt = 1.0 if hovered else 0.0
_sel_amt = 1.0 if selected else 0.0
_accept_clock = -1.0
queue_redraw()
func _reduced() -> bool:
return not _motion_on or UiMotion.reduced
func _process(delta: float) -> void:
var hov := 1.0 if hovered else 0.0
var sel := 1.0 if selected else 0.0
if _reduced():
_hover_amt = hov
_sel_amt = sel
_wave = 0.0
else:
if not is_equal_approx(_hover_amt, hov):
_hover_amt = move_toward(_hover_amt, hov, delta / HOVER_DUR)
if not is_equal_approx(_sel_amt, sel):
_sel_amt = move_toward(_sel_amt, sel, delta / SELECT_DUR)
if drop_active:
_wave = fmod(_wave + delta, WAVE_DUR)
if _accept_clock >= 0.0:
_accept_clock += delta
if _accept_clock >= ACCEPT_DUR:
_accept_clock = -1.0
if _sel_clock >= 0.0:
_sel_clock += delta
if _sel_clock >= SEL_BOUNCE_DUR:
_sel_clock = -1.0
queue_redraw()
if not drop_active and _accept_clock < 0.0 and _sel_clock < 0.0 \
and is_equal_approx(_hover_amt, hov) and is_equal_approx(_sel_amt, sel):
set_process(false)
## 0..1 → 0 отскок полоски выделения.
func _sel_bounce() -> float:
if _sel_clock < 0.0:
return 0.0
return sin(PI * clampf(_sel_clock / SEL_BOUNCE_DUR, 0.0, 1.0))
func _draw_hover() -> float:
if _reduced():
return 1.0 if hovered else 0.0
return _hover_amt
func _draw_sel() -> float:
if _reduced():
return 1.0 if selected else 0.0
return _sel_amt
## Флэш «файл принят» после drop — 0.20 c.
func _start_accept() -> void:
_accept_clock = 0.0
set_process(true)
## Волна drop_active и вспышка приёма — только отрисовка, layout не меняется.
func _draw_effects(r: Rect2) -> void:
if drop_active and not _reduced() and not is_section:
var p := clampf(_wave / WAVE_DUR, 0.0, 1.0)
var w := 22.0
var x := lerpf(-w, size.x, p)
var a := 0.06 + 0.20 * (1.0 - p)
draw_rect(Rect2(x, 2, w, maxf(0.0, size.y - 4)),
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, a))
if _accept_clock >= 0.0:
var ae := clampf(1.0 - _accept_clock / ACCEPT_DUR, 0.0, 1.0)
draw_style_box(Theme90.box(Color(0, 0, 0, 0),
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.9 * ae), 3, 1),
Rect2(2, 1, maxf(0.0, size.x - 4), maxf(0.0, size.y - 2)))
func setup(p_id: String, p_name: String, p_depth: int, p_section: bool = false) -> void:
folder_id = p_id
folder_name = p_name
@@ -65,22 +185,37 @@ func setup(p_id: String, p_name: String, p_depth: int, p_section: bool = false)
func _draw() -> void:
var r := Rect2(Vector2.ZERO, size)
var f := Theme90.ui()
var hov := _draw_hover()
if is_section:
_draw_section(r, f)
_draw_section(r, f, hov)
_draw_effects(r)
return
if selected:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Color(0, 0, 0, 0), 3, 0),
Rect2(2, 1, size.x - 4, size.y - 2))
draw_rect(Rect2(2, 5, 2, size.y - 10), Theme90.ACCENT)
elif drop_active:
var sel := _draw_sel()
if sel > 0.001:
draw_style_box(Theme90.box(
Color(Theme90.ACCENT_PALE.r, Theme90.ACCENT_PALE.g, Theme90.ACCENT_PALE.b, sel),
Color(0, 0, 0, 0), 3, 0), Rect2(2, 1, size.x - 4, size.y - 2))
var bar_h := size.y - 10.0 + 4.0 * _sel_bounce()
draw_rect(Rect2(2, (size.y - bar_h) * 0.5, 2, bar_h),
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, sel))
if hov > 0.02:
# Мягкое свечение строки при наведении (draw-only).
draw_rect(Rect2(2, 1, size.x - 4, size.y - 2),
Color(Theme90.ACCENT_LIGHT.r, Theme90.ACCENT_LIGHT.g,
Theme90.ACCENT_LIGHT.b, 0.16 * hov))
if drop_active:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT, 3, 1),
Rect2(2, 1, size.x - 4, size.y - 2))
elif hovered:
draw_rect(Rect2(2, 1, size.x - 4, size.y - 2), Color(1, 1, 1, 0.45))
elif hover_open:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT_LIGHT, 3, 1),
Rect2(2, 1, size.x - 4, size.y - 2))
draw_rect(Rect2(2, 5, 2, size.y - 10), Theme90.ACCENT_LIGHT)
elif hov > 0.02 and sel < 0.5:
draw_rect(Rect2(2, 1, size.x - 4, size.y - 2), Color(1, 1, 1, 0.30 * hov))
var fg := Theme90.NAVY if selected else Theme90.INK
var sub := Theme90.ACCENT if selected else Theme90.SHADE
var fg := Theme90.INK.lerp(Theme90.NAVY, sel)
var sub := Theme90.SHADE.lerp(Theme90.ACCENT, sel)
var sx := 5.0 + depth * 12.0
# тонкий шеврон вместо объёмного квадрата
@@ -110,20 +245,21 @@ func _draw() -> void:
if locked:
Theme90.draw_lock_icon(self, Vector2(size.x - 58, 8), Theme90.SHADE)
_draw_effects(r)
func _draw_section(r: Rect2, f: Font) -> void:
if hovered:
draw_rect(r, Color(1, 1, 1, 0.35))
func _draw_section(r: Rect2, f: Font, hov: float) -> void:
if hov > 0.02:
draw_rect(r, Color(1, 1, 1, 0.35 * hov))
draw_rect(Rect2(2, size.y - 1, maxf(0.0, size.x - 4), 1), Theme90.HAIRLINE)
var sx := 4.0
if expandable:
Theme90.draw_chevron(self, Vector2(sx + 6, size.y * 0.5), 1 if expanded else 2,
Theme90.NAVY, 3.0, 1.3)
Theme90.draw_drive_icon(self, Vector2(sx + 16, 6), Theme90.NAVY)
var fg := Theme90.NAVY if selected else Theme90.INK
var fg := Theme90.INK.lerp(Theme90.NAVY, _draw_sel())
Theme90.text_vcenter(self, f, Rect2(sx + 32, 0, size.x - 80, size.y),
Theme90.fit(f, folder_name, 11, size.x - 110), 11, fg)
Theme90.fit(f, folder_name, 12, size.x - 110), 12, fg)
if not free_text.is_empty():
var tw := f.get_string_size(free_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(size.x - tw - 8, 6), free_text, 10, Theme90.SHADE)
@@ -155,16 +291,51 @@ func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
if k != "file" and k != "packet":
return false
drop_active = true
if not is_section:
_request_hover_open()
return true
func _drop_data(_at_position: Vector2, data: Variant) -> void:
drop_active = false
if is_section:
hover_open = false
_reset_hover_timer()
if is_section or not (data is Dictionary):
return
file_dropped.emit(folder_id, data)
_start_accept()
if str(data.get("kind", "")) == "packet":
packet_dropped.emit(folder_id, data)
else:
file_dropped.emit(folder_id, data)
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
drop_active = false
hover_open = false
_reset_hover_timer()
elif what == NOTIFICATION_MOUSE_EXIT:
if drop_active or hover_open:
drop_active = false
hover_open = false
_reset_hover_timer()
func set_hover(on: bool) -> void:
hover_open = on
func _request_hover_open() -> void:
if not is_inside_tree():
return
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc != null and dc.has_method("hover_folder"):
dc.call("hover_folder", folder_id)
func _reset_hover_timer() -> void:
if not is_inside_tree():
return
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc != null and dc.has_method("reset_hover"):
dc.call("reset_hover")
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more