Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2191545629 | ||
|
|
9cb983a9b5 | ||
|
|
40c08c16ae | ||
|
|
d8e3c1bb49 |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
# AI_REPORT — PEAK HOUR 97 (v2.0)
|
||||
# AI_REPORT — PEAK HOUR 97 (v3.0)
|
||||
|
||||
## 1. Инструменты и модели
|
||||
|
||||
@@ -16,52 +16,59 @@ test-writer, explorer, feis-regulator, oracle):
|
||||
MR-ревью — агент **oracle** (opencode-go/deepseek-v4.1-flash): dry-run
|
||||
74–90 с, комментарий в MR, без мержа.
|
||||
|
||||
## 2. Что изменилось в v2.0
|
||||
## 2. Что изменилось в v3.0
|
||||
|
||||
- **Экономика:** старт 50 $, SLA 60/60, 1 слот, канал 10, буфер 6, таймер
|
||||
запроса 30 с; кап SLA +35 за уровень до 200; сетка 5×4 → 8×6.
|
||||
- **Магазин:** 8 лотов, ровно 3 оффера, рероллы 40/80/120, замки, веса/pity;
|
||||
полное дерево — 13 100 $.
|
||||
- **Файлы:** полимино 1/2/3/4 клетки, поворот, подпись в занятой клетке,
|
||||
стол 4×6, резервации приёма, авто-возврат стола.
|
||||
- **Мини-игры:** 11 игр (7 отправки + 4 атаки), без таймеров, seeded RNG,
|
||||
экран результата с отложенным finished.
|
||||
- **Атаки:** 20 за забег, 4 вида, 4 ступени, пауза часа, щит и эффекты
|
||||
провала.
|
||||
- **Save:** VERSION 2, единый источник позиций, миграция v1.
|
||||
- **Документация:** GDD/DECISIONS/README/ITCH_PAGE переписаны под фактические
|
||||
числа и поведение; старые решения помечены, а не удалены.
|
||||
- **Запросы:** 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 $.
|
||||
- **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. Где применялся ИИ
|
||||
|
||||
- **Код:** HourDirector, FileRegistry, Economy, Shop, AttackDirector,
|
||||
SaveManager, UI и мини-игры.
|
||||
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал, VHS главного меню.
|
||||
SaveManager, UI, соединение TCP и мини-игры.
|
||||
- **UI v3:** Golos Text, пиксельные иконки, карточка запроса, туториал с
|
||||
рамками, VHS главного меню.
|
||||
- **Ambient:** 4 WAV (audio/amb), генератор generate_ambient.py, autoload
|
||||
после первого жеста, mute/пауза/громкость.
|
||||
- **Баланс:** цели дохода, дерево магазина, симулятор на 240 сидах.
|
||||
- **Аудит:** B4 (дыры в выборе мини-игры), H5 (лимит папок/цена),
|
||||
M8 (масштаб денег атак), M11 (геометрия wires T4), M16 (soft-lock
|
||||
«нет денег/нет места»).
|
||||
- **Баланс:** цели дохода, дерево магазина 8 085 $, симулятор на 240 сидах.
|
||||
- **Аудит:** B4 (дыры в выборе мини-игры) закрыт ещё в v2; в v3 выбор
|
||||
перестроен на 27 сумм и 3 ступени. M8 (масштаб денег атак), M16
|
||||
(soft-lock «нет денег/нет места») сохранены.
|
||||
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; docs/;
|
||||
18 SFX + hum.wav на Python.
|
||||
|
||||
## 4. Приём: «враждебный критик»
|
||||
|
||||
Промпт просил быть враждебным критиком: «найди блокеры, из-за которых
|
||||
прототип нельзя собрать; не хвали». Для v2 главный найденный дефект —
|
||||
**B4**: выбор мини-игры по S−1 давал дыры и недостижимые строки, потому что
|
||||
при максимум 3 файлах существуют только 19 сумм из 28. Исправление:
|
||||
прототип нельзя собрать; не хвали». Главный дефект v2 — **B4**: выбор
|
||||
мини-игры по `S−1` давал дыры и недостижимые строки. В v3 выбор перестроен:
|
||||
|
||||
ACHIEVABLE_SUMS = [1..14, 16, 17, 18, 20, 24]
|
||||
ACHIEVABLE_SUMS = [1..22, 24, 25, 26, 28, 32] # 27 сумм для 1–4 файлов
|
||||
rank = index of S in ACHIEVABLE_SUMS
|
||||
tier = clamp(1 + floor(rank * 4 / 19), 1, 4)
|
||||
id = order[rank % 7]
|
||||
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
|
||||
id = RANK_GAMES[rank] # фиксированная таблица
|
||||
|
||||
Все 7 игр и все 4 ступени достижимы; S=24 → video/T4. Старый пример S−1 из
|
||||
спецификации отменён. Дополнительно закрыты M11 (низ wires T4 ≤ 320),
|
||||
M16 (бесплатное уничтожение неприкреплённого файла) и H5 (лимит 6 папок /
|
||||
100 $).
|
||||
Все 6 игр достижимы в каждой из 3 ступеней; S=32 → video/T3. Дополнительно
|
||||
закрыты: H1-голодание запросов (анти-голодание + seed-файл), потерянные
|
||||
required-файлы (подстановка того же типа), неоднозначность TCP-протокола
|
||||
(блокировка после соединения). M16 (бесплатное уничтожение неприкреплённого
|
||||
файла) сохранён, но файл, закреплённый за запросом, удалить нельзя.
|
||||
|
||||
## 5. Что сделано без ИИ
|
||||
|
||||
@@ -72,52 +79,80 @@ M16 (бесплатное уничтожение неприкреплённог
|
||||
|
||||
## 6. Ассеты и лицензии
|
||||
|
||||
- **Handjet** и **Press Start 2P** — SIL OFL 1.1, тексты в
|
||||
art/fonts/OFL-Handjet.txt и OFL-PressStart2P.txt.
|
||||
- **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/.
|
||||
- UI, иконки, маскот **БАЙТ** и icon.svg — команда проекта, MIT.
|
||||
- Пиксельные иконки `art/icons/*.png` сгенерированы `generate_icons.py`
|
||||
(Pillow), команда проекта, **MIT**; исходный тайл-лист был недоступен,
|
||||
поэтому набор перегенерирован с нуля.
|
||||
- UI, маскот **БАЙТ** и icon.svg — команда проекта, MIT.
|
||||
- SFX, ambient и hum.wav — скрипты проекта, MIT/CC0.
|
||||
- Код — MIT (LICENSE); OFL-шрифты совместимы с MIT при сохранении лицензий.
|
||||
Подробности — в ASSETS.md.
|
||||
|
||||
## 7. Проверки v2.0
|
||||
## 7. Проверки v3.0
|
||||
|
||||
По прогону v2 (Windows, Godot 4.7.2):
|
||||
- **Симулятор баланса:** `python tests/sim_balance.py`, 240 сидов, 9 часов,
|
||||
отчёт `tests/SIM_BALANCE_REPORT.md`. Вердикт **PASS**: все 9 часов в ±20 %,
|
||||
аналитические капы не ниже целей.
|
||||
- В отчёте: средний итог — **7 533 $** (payout 6 495 + recv_fee 410 + атаки
|
||||
628), расход магазина — **4 681 $** (**57,9 %** дерева **8 085 $**),
|
||||
**28 поражений из 240**, SLA в конце H9 — **62,2**; 54,4 запроса,
|
||||
18,7 отражённых атак, 4,4 атаки щитом. Все 9 часов в ±20 % (H1 — 149 $ при
|
||||
цели 150), отчёт соответствует текущему `data/balance.json`.
|
||||
- **Тесты:** актуальный список — **25 наборов + parse-check** в
|
||||
`scripts/run_all_tests.sh` (см. также `tests/`; счётчик может меняться между
|
||||
волнами). Полный прогон перепроверен после финальных правок кода:
|
||||
`PASS=26 FAIL=0 SKIP=0`.
|
||||
- **Web-пресет** `export_presets.cfg` существует: `thread_support=false`,
|
||||
экспорт в `build/web/index.html`, просмотр только по HTTP.
|
||||
|
||||
- **25 тестовых наборов + parse-check — итого 26/26 PASS**.
|
||||
Полный прогон:
|
||||
powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
|
||||
(Linux/macOS — bash scripts/run_all_tests.sh).
|
||||
- Windows-раннер сам ищет Godot; путь переопределяется GODOT_BIN или
|
||||
-GodotBin.
|
||||
- **Симулятор баланса:** python tests/sim_balance.py, 240 сидов, 9 часов,
|
||||
exit 0, вердикт **PASS**: все часы в ±20 %, аналитические капы не ниже
|
||||
целей. Средний итог — **8 184 $**; средний расход магазина — **5 771 $**
|
||||
(**44,1 %** дерева 13 100 $), остаток после H9 — **2 463 $**.
|
||||
- **Web-пресет** export_presets.cfg существует: thread_support=false,
|
||||
экспорт в build/web/index.html, просмотр только по HTTP.
|
||||
- Повторный запуск тестов в сессии правки документации не выполнялся:
|
||||
shell в этом окружении недоступен. Числа выше — из прогона v2 и
|
||||
tests/SIM_BALANCE_REPORT.md.
|
||||
### Финальная проверка v3
|
||||
|
||||
- **Headless:** полный прогон после всех правок — `PASS=26 FAIL=0 SKIP=0`
|
||||
(25 наборов, включая `ui_smoke`, + parse-check).
|
||||
- **Баланс:** симулятор — `PASS`, 240 сидов, все 9 часов в ±20 %, средний
|
||||
итог **7 533 $**, магазин — **4 681 $ (57,9 %)** дерева **8 085 $**,
|
||||
поражений **28/240** (`tests/SIM_BALANCE_REPORT.md`, шапка v3).
|
||||
- **E2E на реальной сцене Main (1920×1080):** **190/190** проверок PASS —
|
||||
меню → подготовка → час, приём пакета, прикрепление конкретного файла,
|
||||
модалка TCP и промах/кулдаун, мини-игра отправки → завершение (файлы
|
||||
списаны, деньги +18, сетка обновлена), 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 выплаты фиксируется на старте отправки, пока таймер был заморожен
|
||||
- `ПРОПУСТИТЬ` даёт `q=0`; `quality = 0.5`, то есть половина
|
||||
quality-множителя, а не нулевая выплата. Осознанно.
|
||||
- `speed` выплаты фиксируется на старте отправки, пока таймер был заморожен
|
||||
мини-игрой; быстрый игрок видит ≈1.0.
|
||||
- Лимит папок 6, новая папка 100 $ (review H5).
|
||||
- Лимит папок 6, новая папка 180 $.
|
||||
- Бесплатное уничтожение любого неприкреплённого файла в подготовке
|
||||
(review M16).
|
||||
- wires T4: box 34, pitch 40, верх 56 (review M11).
|
||||
- Деньги атак масштабируются по часу max(0.75, hour/3) (review M8).
|
||||
- Расход магазина 44,1 % **ниже** старой цели 55–65 %: доход H9 частично
|
||||
остаётся неистраченным, забег заканчивается. Это не «полный выкуп к H8–H9».
|
||||
(review M16); файл, закреплённый за запросом, не удаляется.
|
||||
- Деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
|
||||
- H2 теоретически может выдать T2-запрос из трёх фото (S12); на практике
|
||||
кривая H2 остаётся почти полностью T1.
|
||||
- Расход магазина 57,9 % — внутри целевой полосы; это не «полный выкуп».
|
||||
|
||||
## 9. Ограничения
|
||||
|
||||
Туториал — маскот и подсказки; web-save — IndexedDB; плейтест, громкость и
|
||||
Туториал автоматически запускается только в первом часу первого забега;
|
||||
повтор — из главного меню. Web-save — IndexedDB; плейтест, громкость и
|
||||
масштаб — за человеком; remote push ещё не выполнен. Мета-ключ `search`
|
||||
остаётся в данных, но UI-поиск не подключён. Скриншоты в docs/screenshots/
|
||||
стоит пересобрать под v2 перед публикацией страницы.
|
||||
остаётся в данных, но UI-поиск не подключён. Скриншоты в `docs/screenshots/`
|
||||
стоит пересобрать под v3 перед публикацией страницы.
|
||||
@@ -5,23 +5,40 @@
|
||||
|
||||
## Шрифты
|
||||
|
||||
### Handjet — основной UI-шрифт
|
||||
### Golos Text — основной UI-шрифт (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 |
|
||||
| Использование | `default_font` темы `art/fonts/ui_theme.tres`; весь текст UI |
|
||||
|
||||
Типографическая шкала v3: **11 / 13 / 17 / 26 px** (`FS_MICRO`, `FS_BODY`,
|
||||
`FS_HEAD`, `FS_LOGO`). Подписи файлов в сетке — 12/14/16 px в зависимости от
|
||||
размера клетки.
|
||||
|
||||
### 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 +49,10 @@ 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()`) |
|
||||
|
||||
Шрифт слишком широкий для карточек (17 символов не влезают в 132 px), поэтому
|
||||
основным текстом он не используется.
|
||||
Шрифт слишком широкий для тела текста и карточек, поэтому основным текстом
|
||||
он не используется (D51).
|
||||
|
||||
### Pixelify Sans — в теме не используется
|
||||
|
||||
@@ -54,22 +71,46 @@ 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-тексты лежат рядом с файлами.
|
||||
|
||||
## Графика
|
||||
|
||||
| Файл | Автор | Лицензия | Примечание |
|
||||
| --- | --- | --- | --- |
|
||||
| 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 +149,22 @@ 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
|
||||
```
|
||||
|
||||
Ожидаемые хеши — в таблицах выше. Тексты OFL уже лежат в `art/fonts/`; при
|
||||
обновлении шрифта обновите и их. Pixelify Sans скачивается по ссылке из его
|
||||
раздела, но для сборки не требуется.
|
||||
обновлении шрифта обновите и их. Handjet и Pixelify Sans для сборки не
|
||||
требуются.
|
||||
|
||||
### Графика
|
||||
|
||||
Генерировать нечего: UI и иконки рисуются кодом, `icon.svg` — обычный SVG.
|
||||
После клонирования достаточно импортировать проект в Godot 4.7.2.
|
||||
Иконки пересобираются `python3 art/icons/generate_icons.py` (нужен Pillow).
|
||||
`icon.svg` — обычный SVG. После клонирования достаточно импортировать проект
|
||||
в Godot 4.7.2.
|
||||
|
||||
## Чего в проекте нет
|
||||
|
||||
|
||||
@@ -12,47 +12,60 @@
|
||||
**Окно:** 1920×1080, логический viewport 1280×720, fractional scale.
|
||||
**Забег:** 9 игровых часов по 5 минут (≈45 минут) + подготовка между часами.
|
||||
|
||||
## Статус v2.0
|
||||
## Статус v3.0
|
||||
|
||||
Играбельный веб-прототип полного цикла: **меню → подготовка → час → итоги
|
||||
часа → … → итоги забега**. Актуальная спецификация — [GDD v2.0](docs/GDD.md),
|
||||
решения — [DECISIONS](docs/DECISIONS.md) (D29–D44).
|
||||
часа → … → итоги забега**. Актуальная спецификация — [GDD v3.0](docs/GDD.md),
|
||||
решения — [DECISIONS](docs/DECISIONS.md) (актуальны D45–D52, D01–D44 — история).
|
||||
|
||||
Реализовано:
|
||||
|
||||
- **9 часов**, старт **50 $**, **SLA 60/60** (кап растёт +35 за уровень
|
||||
`lot_sla` до 200), 1 слот (макс. 5), канал 10 (макс. 46), буфер 6 (макс. 12),
|
||||
таймер запроса 30 с (макс. 55), таймер пакета 20 с.
|
||||
- **9 часов**, старт **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 → 8×6.
|
||||
поворот `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-файла и сразу с запроса на него; анти-голодание не даёт
|
||||
панели запросов простаивать.
|
||||
- **8 лотов магазина**; ровно 3 предложения, рероллы 40/80/120 (макс. 3 за
|
||||
подготовку), замок отдельного предложения. Магазин — с H2 и только в
|
||||
подготовке. Полное дерево — 13 100 $.
|
||||
- **7 мини-игр отправки** (`dots`, `sound`, `wires`, `text`, `video`, `photo`,
|
||||
`maze`) и **4 мини-игры атак** (`junk`, `ddos`, `phishing`, `ransomware`);
|
||||
без таймеров, с экраном `q%`/вердикт и `ПРОДОЛЖИТЬ`.
|
||||
- **Явная отправка** `ОТПРАВИТЬ` (автостарта нет) и переключатель **TCP/UDP**:
|
||||
TCP +15 % к выплате, UDP ×0.8 ко времени. Файлы расходуются при успешной
|
||||
отправке и сохраняются при отмене/таймауте.
|
||||
подготовке. Полное дерево — **8 085 $**.
|
||||
- **6 мини-игр отправки** (`dots`, `sound`, `text`, `video`, `photo`, `maze`;
|
||||
**3 ступени**) и **4 мини-игры атак** (`junk`, `ddos`, `phishing`,
|
||||
`ransomware`; 4 ступени); без таймеров, с шапкой «название + цель», полосой
|
||||
прогресса и экраном результата: имя игры, `q%`, полоса качества, вердикт,
|
||||
детали и `ПРОДОЛЖИТЬ`/`Esc`.
|
||||
- **Явная отправка** `СОЕДИНИТЬСЯ`/`ОТПРАВИТЬ` (автостарта нет) и
|
||||
переключатель **TCP/UDP**: TCP требует три попадания в полосе соединения,
|
||||
платит **×1.15**; UDP соединяется сразу, платит **×0.85** и передаёт
|
||||
**×0.8** ко времени. Файлы расходуются при успешной отправке и сохраняются
|
||||
при отмене/таймауте.
|
||||
- **20 атак за забег** (`0,1,1,2,2,3,3,4,4`), 4 вида, 4 ступени; атака — это
|
||||
мини-игра, которая ставит час на паузу. Щит «Заземление» гасит атаку
|
||||
автоматически (50 % награды, без штрафа).
|
||||
автоматически (**70 %** награды, без штрафа).
|
||||
- **Пошаговый туториал v3:** 5 шагов (пакет → прикрепление → TCP-соединение →
|
||||
мини-игра → SLA/атаки), пульсирующие рамки у цели и кнопка «ДАЛЕЕ».
|
||||
Прохождение хранится в `Game.stats["tutorial_done"]`; кнопка «ОБУЧЕНИЕ» в
|
||||
главном меню запускает повтор.
|
||||
- **Save VERSION 2**: позиции только в `folder.files`/`table`, метаданные в
|
||||
`files`; v1-сейвы мигрируют без потери файлов и без уменьшения сеток;
|
||||
офферы/рероллы/замки и стол сохраняются.
|
||||
- Мета-награды после 3/5/9 часов, маскот **БАЙТ**, UI v2 «Kaleidos»,
|
||||
ambient машинного зала, VHS только в главном меню.
|
||||
офферы/рероллы/замки, стол и `tutorial_done` сохраняются.
|
||||
- Мета-награды после 3/5/9 часов, маскот **БАЙТ**, UI v3 «Kaleidos», ambient
|
||||
машинного зала, VHS только в главном меню.
|
||||
|
||||
Ограничения:
|
||||
|
||||
- Туториал — маскот и несколько подсказок, без пошагового сценария.
|
||||
- Сохранение в Web идёт через IndexedDB (`user://save.json`); проверяйте
|
||||
продолжение после перезагрузки вкладки.
|
||||
- Средний расход магазина в симуляторе — **44,1 %** дерева (ниже старой
|
||||
цели 55–65 %); это известное ограничение баланса, а не «полный выкуп».
|
||||
- Средний расход магазина в симуляторе — **57,9 %** дерева 8 085 $; это
|
||||
внутри целевой полосы 55–65 %, то есть не «полный выкуп».
|
||||
- Remote push ещё не выполнен; сборка для itch.io готовится локально.
|
||||
|
||||
## Управление
|
||||
@@ -65,10 +78,12 @@
|
||||
| Пакет из «Приёма» → папка | drag&drop |
|
||||
| Файл из папки → карточка запроса | drag&drop |
|
||||
| Файл ↔ стол сортировки | drag&drop |
|
||||
| Соединение TCP | кнопка `СОЕДИНИТЬСЯ`, 3 попадания в зелёную зону |
|
||||
| Отправка запроса | кнопка `ОТПРАВИТЬ` |
|
||||
| TCP/UDP | переключатель на карточке запроса до отправки |
|
||||
| TCP/UDP | переключатель на карточке запроса до соединения |
|
||||
| Мини-игра: действие | ЛКМ / `Space` / стрелки (по игре) |
|
||||
| Пропустить мини-игру | `ПРОПУСТИТЬ` + подтверждение |
|
||||
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
|
||||
| Пропустить мини-игру | `ПРОПУСТИТЬ`/`СДАТЬСЯ` + второй клик |
|
||||
| Результат мини-игры | `ПРОДОЛЖИТЬ` или `Esc` |
|
||||
| Магазин: закрыть | `ЗАКРЫТЬ` или `Esc` |
|
||||
| Пауза | `Esc` |
|
||||
@@ -118,7 +133,10 @@ python -m http.server 8000 --directory build/web
|
||||
|
||||
## Тесты
|
||||
|
||||
Полный прогон — **25 тестовых наборов + parse-check (итого 26/26 PASS)**:
|
||||
Актуальный список — **25 наборов (включая `scripts/ui/ui_smoke.gd`) +
|
||||
parse-check = 26/26 PASS** в `scripts/run_all_tests.sh` (см. также `tests/`;
|
||||
счётчик наборов может меняться между волнами). Полный прогон перепроверен
|
||||
после финальных правок кода: `PASS=26 FAIL=0 SKIP=0`.
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
@@ -149,8 +167,18 @@ Windows-раннер сам ищет Godot (сначала `GODOT_BIN`, зате
|
||||
python tests/sim_balance.py
|
||||
```
|
||||
|
||||
Фактический итог v2: **26/26 PASS** (25 наборов + parse-check), симулятор
|
||||
завершается с кодом 0 и вердиктом `PASS`. Подробности — в [AI_REPORT](AI_REPORT.md).
|
||||
Отчёт `tests/SIM_BALANCE_REPORT.md` (240 сидов, 9 часов, `q=0.85`, TCP): все
|
||||
9 часов в ±20 % (H1 — **149 $** при цели **150**), средний итог **7 533 $**
|
||||
(payout 6 495 + `recv_fee` 410 + атаки 628), расход магазина **4 681 $** =
|
||||
**57,9 %** дерева 8 085 $, **28 поражений из 240**. Вердикт `PASS`.
|
||||
Подробности — в [AI_REPORT](AI_REPORT.md).
|
||||
|
||||
Финальная проверка после всех правок: headless `PASS=26 FAIL=0 SKIP=0`
|
||||
(25 наборов + parse-check), E2E на реальной сцене `Main` в 1920×1080 —
|
||||
**190/190** проверок PASS, веб-сборка (Godot 4.7.2, no-threads, Chromium +
|
||||
SwiftShader) загружается с WebGL2 и нулём ошибок в консоли, проходят
|
||||
меню → «Новый забег» → «НАЧАТЬ ЧАС», drag пакета в папку и файла в чип
|
||||
запроса, открытие модалки соединения.
|
||||
|
||||
## Структура
|
||||
|
||||
@@ -160,7 +188,7 @@ Game-JAM/
|
||||
├─ export_presets.cfg # Web (no threads)
|
||||
├─ scenes/ # Main + UI/мини-игры
|
||||
├─ scripts/
|
||||
│ ├─ main.gd # UI, ввод, модалки, drag&drop
|
||||
│ ├─ main.gd # UI, ввод, модалки, drag&drop, туториал
|
||||
│ ├─ game_controller.gd # цикл menu → prep → hour → results
|
||||
│ ├─ run_all_tests.ps1/.sh # полный headless-прогон
|
||||
│ ├─ autoload/ # Game, Data, Audio, Rng, Ambient
|
||||
@@ -168,9 +196,10 @@ Game-JAM/
|
||||
│ ├─ entities/ # файлы, папки, секции, пакеты, запросы, слоты
|
||||
│ ├─ systems/ # Shop, AttackDirector, SaveManager
|
||||
│ ├─ ui/ # Theme90, FileGrid, SortingTable, ShopModal, VHS
|
||||
│ └─ minigames/ # MiniGame, factory, send/, attack/
|
||||
│ └─ 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/ # Golos Text, Press Start 2P + OFL
|
||||
├─ art/icons/ # пиксельные иконки + generate_icons.py
|
||||
├─ audio/ # sfx/, music/, amb/, generate_*py
|
||||
├─ shaders/ # vhs.gdshader (только главное меню)
|
||||
├─ tests/ # headless-наборы, sim_balance.py
|
||||
@@ -187,18 +216,20 @@ Game-JAM/
|
||||
|
||||
## Документация
|
||||
|
||||
- [GDD](docs/GDD.md) — правила и числа v2.0.
|
||||
- [DECISIONS](docs/DECISIONS.md) — решения D01–D44 (актуальны D29–D44).
|
||||
- [GDD](docs/GDD.md) — правила и числа v3.0.
|
||||
- [DECISIONS](docs/DECISIONS.md) — решения D01–D52 (актуальны D45–D52).
|
||||
- [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 и матрица тестов.
|
||||
исторические контракты 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).
|
||||
(`art/fonts/OFL-*.txt`). UI, пиксельные иконки (`art/icons/`) и иконка — MIT.
|
||||
SFX и `hum.wav` — MIT/CC0, сгенерированы `audio/generate_sfx.py`.
|
||||
Подробности — в [ASSETS.md](ASSETS.md).
|
||||
@@ -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={}
|
||||
@@ -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.
|
||||
@@ -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/GolosText-VF.ttf" id="1_fum4e"]
|
||||
[ext_resource type="FontFile" path="res://art/fonts/PressStart2P-Regular.ttf" id="2_fum4e"]
|
||||
|
||||
[sub_resource type="FontVariation" id="FontVariation_fum4e"]
|
||||
base_font = ExtResource("1_hjthv")
|
||||
[sub_resource type="FontVariation" id="FontVariation_lr7a5"]
|
||||
base_font = ExtResource("1_fum4e")
|
||||
variation_opentype = {
|
||||
1162626898: 2.0,
|
||||
1162629960: 2.0,
|
||||
2003265652: 700.0
|
||||
2003265652: 600.0
|
||||
}
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lr7a5"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cg7jb"]
|
||||
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_dc2yr"]
|
||||
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_6m2gx"]
|
||||
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_x3s7b"]
|
||||
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_ukyte"]
|
||||
bg_color = Color(0.847059, 0.827451, 0.788235, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -82,7 +80,7 @@ corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="Image" id="Image_ukyte"]
|
||||
[sub_resource type="Image" id="Image_wjr02"]
|
||||
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",
|
||||
@@ -91,10 +89,10 @@ data = {
|
||||
"width": 14
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_wjr02"]
|
||||
image = SubResource("Image_ukyte")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_4munx"]
|
||||
image = SubResource("Image_wjr02")
|
||||
|
||||
[sub_resource type="Image" id="Image_4munx"]
|
||||
[sub_resource type="Image" id="Image_oc7r5"]
|
||||
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",
|
||||
@@ -103,10 +101,10 @@ data = {
|
||||
"width": 14
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_oc7r5"]
|
||||
image = SubResource("Image_4munx")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_y0w1v"]
|
||||
image = SubResource("Image_oc7r5")
|
||||
|
||||
[sub_resource type="Image" id="Image_y0w1v"]
|
||||
[sub_resource type="Image" id="Image_8m7xh"]
|
||||
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",
|
||||
@@ -115,10 +113,10 @@ data = {
|
||||
"width": 14
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_8m7xh"]
|
||||
image = SubResource("Image_y0w1v")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_dywbi"]
|
||||
image = SubResource("Image_8m7xh")
|
||||
|
||||
[sub_resource type="Image" id="Image_dywbi"]
|
||||
[sub_resource type="Image" id="Image_47b07"]
|
||||
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",
|
||||
@@ -127,15 +125,15 @@ data = {
|
||||
"width": 14
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_47b07"]
|
||||
image = SubResource("Image_dywbi")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_danrt"]
|
||||
image = SubResource("Image_47b07")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_danrt"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1j4f6"]
|
||||
bg_color = Color(0, 0, 0, 0)
|
||||
border_color = Color(0, 0, 0, 0)
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="Image" id="Image_1j4f6"]
|
||||
[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, 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",
|
||||
@@ -144,10 +142,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_djhhc"]
|
||||
image = SubResource("Image_1j4f6")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_a0hrt"]
|
||||
image = SubResource("Image_djhhc")
|
||||
|
||||
[sub_resource type="Image" id="Image_a0hrt"]
|
||||
[sub_resource type="Image" id="Image_jvde3"]
|
||||
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",
|
||||
@@ -156,10 +154,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_jvde3"]
|
||||
image = SubResource("Image_a0hrt")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_372il"]
|
||||
image = SubResource("Image_jvde3")
|
||||
|
||||
[sub_resource type="Image" id="Image_372il"]
|
||||
[sub_resource type="Image" id="Image_i4ifm"]
|
||||
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",
|
||||
@@ -168,10 +166,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_i4ifm"]
|
||||
image = SubResource("Image_372il")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_11awt"]
|
||||
image = SubResource("Image_i4ifm")
|
||||
|
||||
[sub_resource type="Image" id="Image_11awt"]
|
||||
[sub_resource type="Image" id="Image_loh4e"]
|
||||
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",
|
||||
@@ -180,10 +178,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_loh4e"]
|
||||
image = SubResource("Image_11awt")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_a1mnj"]
|
||||
image = SubResource("Image_loh4e")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_a1mnj"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4uvxq"]
|
||||
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_petse"]
|
||||
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_j2rjp"]
|
||||
bg_color = Color(0.847059, 0.827451, 0.788235, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -214,7 +212,7 @@ corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="Image" id="Image_j2rjp"]
|
||||
[sub_resource type="Image" id="Image_7om8y"]
|
||||
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",
|
||||
@@ -223,10 +221,10 @@ data = {
|
||||
"width": 12
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_7om8y"]
|
||||
image = SubResource("Image_j2rjp")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_o55pb"]
|
||||
image = SubResource("Image_7om8y")
|
||||
|
||||
[sub_resource type="Image" id="Image_o55pb"]
|
||||
[sub_resource type="Image" id="Image_wl44i"]
|
||||
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",
|
||||
@@ -235,10 +233,10 @@ data = {
|
||||
"width": 12
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_wl44i"]
|
||||
image = SubResource("Image_o55pb")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_mni32"]
|
||||
image = SubResource("Image_wl44i")
|
||||
|
||||
[sub_resource type="Image" id="Image_mni32"]
|
||||
[sub_resource type="Image" id="Image_8vjq1"]
|
||||
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",
|
||||
@@ -247,10 +245,10 @@ data = {
|
||||
"width": 12
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_8vjq1"]
|
||||
image = SubResource("Image_mni32")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_w06qd"]
|
||||
image = SubResource("Image_8vjq1")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w06qd"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_barbq"]
|
||||
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_dvi42"]
|
||||
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_foj1q"]
|
||||
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_jyq6k"]
|
||||
content_margin_left = 2.0
|
||||
content_margin_top = 2.0
|
||||
content_margin_right = 2.0
|
||||
@@ -298,7 +296,7 @@ corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jyq6k"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cjati"]
|
||||
bg_color = Color(0.968627, 0.960784, 0.933333, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -314,7 +312,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_m7381"]
|
||||
content_margin_left = 2.0
|
||||
content_margin_top = 2.0
|
||||
content_margin_right = 2.0
|
||||
@@ -327,7 +325,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_of6nr"]
|
||||
content_margin_left = 5.0
|
||||
content_margin_top = 5.0
|
||||
content_margin_right = 5.0
|
||||
@@ -347,7 +345,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_s7l1c"]
|
||||
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 +354,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_s7l1c"]
|
||||
image = SubResource("Image_of6nr")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_7j4rs"]
|
||||
image = SubResource("Image_s7l1c")
|
||||
|
||||
[sub_resource type="Image" id="Image_7j4rs"]
|
||||
[sub_resource type="Image" id="Image_05v8i"]
|
||||
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 +366,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_05v8i"]
|
||||
image = SubResource("Image_7j4rs")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_7ph3p"]
|
||||
image = SubResource("Image_05v8i")
|
||||
|
||||
[sub_resource type="Image" id="Image_7ph3p"]
|
||||
[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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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 +378,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_2b66g"]
|
||||
image = SubResource("Image_7ph3p")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_1tdcs"]
|
||||
image = SubResource("Image_2b66g")
|
||||
|
||||
[sub_resource type="Image" id="Image_1tdcs"]
|
||||
[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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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 +390,74 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_hv5yg"]
|
||||
image = SubResource("Image_1tdcs")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_yjsxn"]
|
||||
image = SubResource("Image_hv5yg")
|
||||
|
||||
[resource]
|
||||
default_font = SubResource("FontVariation_fum4e")
|
||||
default_font_size = 12
|
||||
default_font = SubResource("FontVariation_lr7a5")
|
||||
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_lr7a5")
|
||||
Button/styles/disabled = SubResource("StyleBoxFlat_cg7jb")
|
||||
Button/styles/focus = SubResource("StyleBoxFlat_dc2yr")
|
||||
Button/styles/hover = SubResource("StyleBoxFlat_6m2gx")
|
||||
Button/styles/normal = SubResource("StyleBoxFlat_x3s7b")
|
||||
Button/styles/pressed = SubResource("StyleBoxFlat_ukyte")
|
||||
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_4munx")
|
||||
CheckBox/icons/radio_checked = SubResource("ImageTexture_y0w1v")
|
||||
CheckBox/icons/radio_unchecked = SubResource("ImageTexture_dywbi")
|
||||
CheckBox/icons/unchecked = SubResource("ImageTexture_danrt")
|
||||
CheckBox/styles/focus = SubResource("StyleBoxFlat_1j4f6")
|
||||
CheckBox/styles/hover = SubResource("StyleBoxFlat_1j4f6")
|
||||
CheckBox/styles/normal = SubResource("StyleBoxFlat_1j4f6")
|
||||
CheckBox/styles/pressed = SubResource("StyleBoxFlat_1j4f6")
|
||||
HScrollBar/icons/decrement = SubResource("ImageTexture_a0hrt")
|
||||
HScrollBar/icons/decrement_highlight = SubResource("ImageTexture_372il")
|
||||
HScrollBar/icons/increment = SubResource("ImageTexture_11awt")
|
||||
HScrollBar/icons/increment_highlight = SubResource("ImageTexture_a1mnj")
|
||||
HScrollBar/styles/grabber = SubResource("StyleBoxFlat_4uvxq")
|
||||
HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_petse")
|
||||
HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_petse")
|
||||
HScrollBar/styles/scroll = SubResource("StyleBoxFlat_j2rjp")
|
||||
HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_j2rjp")
|
||||
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_o55pb")
|
||||
HSlider/icons/grabber_disabled = SubResource("ImageTexture_mni32")
|
||||
HSlider/icons/grabber_highlight = SubResource("ImageTexture_w06qd")
|
||||
HSlider/styles/grabber_area = SubResource("StyleBoxFlat_barbq")
|
||||
HSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_dvi42")
|
||||
HSlider/styles/slider = SubResource("StyleBoxFlat_j2rjp")
|
||||
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_lr7a5")
|
||||
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_lr7a5")
|
||||
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_lr7a5")
|
||||
LineEdit/styles/focus = SubResource("StyleBoxFlat_foj1q")
|
||||
LineEdit/styles/normal = SubResource("StyleBoxFlat_jyq6k")
|
||||
LogoLabel/base_type = &"Label"
|
||||
LogoLabel/colors/font_color = Color(0.172549, 0.360784, 0.407843, 1)
|
||||
LogoLabel/font_sizes/font_size = 24
|
||||
LogoLabel/font_sizes/font_size = 26
|
||||
LogoLabel/fonts/font = ExtResource("2_fum4e")
|
||||
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_cjati")
|
||||
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 +469,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_lr7a5")
|
||||
PopupMenu/styles/hover = SubResource("StyleBoxFlat_m7381")
|
||||
PopupMenu/styles/panel = SubResource("StyleBoxFlat_of6nr")
|
||||
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_lr7a5")
|
||||
TooltipPanel/styles/panel = SubResource("StyleBoxFlat_of6nr")
|
||||
VScrollBar/icons/decrement = SubResource("ImageTexture_7j4rs")
|
||||
VScrollBar/icons/decrement_highlight = SubResource("ImageTexture_7ph3p")
|
||||
VScrollBar/icons/increment = SubResource("ImageTexture_1tdcs")
|
||||
VScrollBar/icons/increment_highlight = SubResource("ImageTexture_yjsxn")
|
||||
VScrollBar/styles/grabber = SubResource("StyleBoxFlat_4uvxq")
|
||||
VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_petse")
|
||||
VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_petse")
|
||||
VScrollBar/styles/scroll = SubResource("StyleBoxFlat_j2rjp")
|
||||
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_j2rjp")
|
||||
|
After Width: | Height: | Size: 294 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 213 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 190 B |
@@ -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
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 370 B |
@@ -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
|
||||
@@ -11,8 +11,8 @@
|
||||
"money_start": 50,
|
||||
"sla_start": 60,
|
||||
"sla_base": 60,
|
||||
"sla_per_level": 35,
|
||||
"sla_hard_cap": 200,
|
||||
"sla_per_level": 40,
|
||||
"sla_hard_cap": 220,
|
||||
"sla_regen_per_hour": 15,
|
||||
"slots_start": 1,
|
||||
"slots_max": 5,
|
||||
@@ -23,14 +23,14 @@
|
||||
"buffer_per_level": 1,
|
||||
"buffer_max": 12,
|
||||
"request_timer": 30,
|
||||
"request_timer_per_level": 5,
|
||||
"request_timer_max": 55,
|
||||
"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": 100,
|
||||
"folder_new_cost": 180,
|
||||
"max_sections": 1,
|
||||
"section_start": "C",
|
||||
"folders_per_section": 12,
|
||||
@@ -40,7 +40,7 @@
|
||||
"required_name_char_limit": 12,
|
||||
"file_cooldown_after_send_sec": 0,
|
||||
"consume_files_on_send": true,
|
||||
"required_per_request": { "min": 1, "max": 3 },
|
||||
"required_per_request": { "min": 1, "max": 4 },
|
||||
"spawn_window_ratio": 0.75,
|
||||
"spawn_jitter": 0.2,
|
||||
"buffer_per_upgrade": 1
|
||||
@@ -60,17 +60,17 @@
|
||||
"speed_mult": 1.0
|
||||
},
|
||||
"formulas": {
|
||||
"max_sla": "clamp(60 + 35 * sla_level, 60, 200)",
|
||||
"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(55, 30 + 5 * timer_level)",
|
||||
"request_timer": "min(65, 30 + 7 * timer_level)",
|
||||
"packet_timer": "20 (не покупается)",
|
||||
"speed": "pow(0.92, speed_level)",
|
||||
"speed": "pow(0.90, speed_level)",
|
||||
"money_mult": "1 + 0.05 * money_level",
|
||||
"folder_dims": "[[5,4],[6,4],[6,5],[7,5],[7,6],[8,6]][folder_level]",
|
||||
"transfer_time": "base_time_sec * pow(0.92, speed_level) * max(1.0, load / capacity)",
|
||||
"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 (только отправка)"
|
||||
}
|
||||
},
|
||||
@@ -82,7 +82,7 @@
|
||||
"size_kb": 64,
|
||||
"cells": 1,
|
||||
"base_time_sec": 3,
|
||||
"base_pay": 20,
|
||||
"base_pay": 18,
|
||||
"recv_fee": 1,
|
||||
"weight": 1,
|
||||
"minigame_coeff": 1,
|
||||
@@ -96,7 +96,7 @@
|
||||
"size_kb": 3072,
|
||||
"cells": 2,
|
||||
"base_time_sec": 5,
|
||||
"base_pay": 34,
|
||||
"base_pay": 30,
|
||||
"recv_fee": 2,
|
||||
"weight": 2,
|
||||
"minigame_coeff": 2,
|
||||
@@ -110,7 +110,7 @@
|
||||
"size_kb": 5120,
|
||||
"cells": 3,
|
||||
"base_time_sec": 8,
|
||||
"base_pay": 52,
|
||||
"base_pay": 44,
|
||||
"recv_fee": 4,
|
||||
"weight": 4,
|
||||
"minigame_coeff": 4,
|
||||
@@ -124,7 +124,7 @@
|
||||
"size_kb": 20480,
|
||||
"cells": 4,
|
||||
"base_time_sec": 12,
|
||||
"base_pay": 80,
|
||||
"base_pay": 69,
|
||||
"recv_fee": 6,
|
||||
"weight": 8,
|
||||
"minigame_coeff": 8,
|
||||
@@ -135,27 +135,27 @@
|
||||
"hours": [
|
||||
{
|
||||
"hour": 1,
|
||||
"files": 4,
|
||||
"request_cap": 2,
|
||||
"active_requests": 1,
|
||||
"files": 7,
|
||||
"request_cap": 6,
|
||||
"active_requests": 2,
|
||||
"attacks": 0,
|
||||
"income_target": 65,
|
||||
"income_target": 150,
|
||||
"recv_interval": 12,
|
||||
"send_interval": 22,
|
||||
"send_interval": 12,
|
||||
"type_weights": { "text": 0.7, "sound": 0.3, "photo": 0.0, "video": 0.0 },
|
||||
"request_mix": { "1": 0.7, "2": 0.3, "3": 0.0 }
|
||||
"request_mix": { "1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 2,
|
||||
"files": 8,
|
||||
"request_cap": 5,
|
||||
"request_cap": 6,
|
||||
"active_requests": 2,
|
||||
"attacks": 1,
|
||||
"income_target": 220,
|
||||
"recv_interval": 11,
|
||||
"send_interval": 20,
|
||||
"type_weights": { "text": 0.55, "sound": 0.3, "photo": 0.15, "video": 0.0 },
|
||||
"request_mix": { "1": 0.6, "2": 0.35, "3": 0.05 }
|
||||
"request_mix": { "1": 0.63, "2": 0.32, "3": 0.05, "4": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 3,
|
||||
@@ -167,7 +167,7 @@
|
||||
"recv_interval": 10,
|
||||
"send_interval": 18,
|
||||
"type_weights": { "text": 0.4, "sound": 0.3, "photo": 0.22, "video": 0.08 },
|
||||
"request_mix": { "1": 0.5, "2": 0.4, "3": 0.1 }
|
||||
"request_mix": { "1": 0.53, "2": 0.37, "3": 0.10, "4": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 4,
|
||||
@@ -179,7 +179,7 @@
|
||||
"recv_interval": 9,
|
||||
"send_interval": 17,
|
||||
"type_weights": { "text": 0.3, "sound": 0.25, "photo": 0.28, "video": 0.17 },
|
||||
"request_mix": { "1": 0.5, "2": 0.4, "3": 0.1 }
|
||||
"request_mix": { "1": 0.50, "2": 0.36, "3": 0.12, "4": 0.02 }
|
||||
},
|
||||
{
|
||||
"hour": 5,
|
||||
@@ -191,7 +191,7 @@
|
||||
"recv_interval": 8,
|
||||
"send_interval": 15,
|
||||
"type_weights": { "text": 0.22, "sound": 0.2, "photo": 0.32, "video": 0.26 },
|
||||
"request_mix": { "1": 0.4, "2": 0.45, "3": 0.15 }
|
||||
"request_mix": { "1": 0.42, "2": 0.38, "3": 0.16, "4": 0.04 }
|
||||
},
|
||||
{
|
||||
"hour": 6,
|
||||
@@ -203,7 +203,7 @@
|
||||
"recv_interval": 7.5,
|
||||
"send_interval": 14,
|
||||
"type_weights": { "text": 0.15, "sound": 0.15, "photo": 0.35, "video": 0.35 },
|
||||
"request_mix": { "1": 0.4, "2": 0.45, "3": 0.15 }
|
||||
"request_mix": { "1": 0.40, "2": 0.38, "3": 0.17, "4": 0.05 }
|
||||
},
|
||||
{
|
||||
"hour": 7,
|
||||
@@ -215,7 +215,7 @@
|
||||
"recv_interval": 7,
|
||||
"send_interval": 13,
|
||||
"type_weights": { "text": 0.1, "sound": 0.12, "photo": 0.35, "video": 0.43 },
|
||||
"request_mix": { "1": 0.3, "2": 0.5, "3": 0.2 }
|
||||
"request_mix": { "1": 0.32, "2": 0.40, "3": 0.21, "4": 0.07 }
|
||||
},
|
||||
{
|
||||
"hour": 8,
|
||||
@@ -227,19 +227,19 @@
|
||||
"recv_interval": 6.5,
|
||||
"send_interval": 12,
|
||||
"type_weights": { "text": 0.06, "sound": 0.08, "photo": 0.32, "video": 0.54 },
|
||||
"request_mix": { "1": 0.3, "2": 0.5, "3": 0.2 }
|
||||
"request_mix": { "1": 0.30, "2": 0.39, "3": 0.22, "4": 0.09 }
|
||||
},
|
||||
{
|
||||
"hour": 9,
|
||||
"files": 20,
|
||||
"request_cap": 8,
|
||||
"request_cap": 7,
|
||||
"active_requests": 4,
|
||||
"attacks": 4,
|
||||
"income_target": 1700,
|
||||
"recv_interval": 6,
|
||||
"send_interval": 12,
|
||||
"type_weights": { "text": 0.04, "sound": 0.04, "photo": 0.3, "video": 0.62 },
|
||||
"request_mix": { "1": 0.3, "2": 0.5, "3": 0.2 }
|
||||
"request_mix": { "1": 0.27, "2": 0.38, "3": 0.23, "4": 0.12 }
|
||||
}
|
||||
],
|
||||
"payout": {
|
||||
@@ -248,7 +248,7 @@
|
||||
"quality_base": 0.5,
|
||||
"quality_bonus": 0.5,
|
||||
"multi_step": 0.15,
|
||||
"protocol_mult": { "TCP": 1.15, "UDP": 1.0 },
|
||||
"protocol_mult": { "TCP": 1.15, "UDP": 0.85 },
|
||||
"udp_time_mult": 0.8,
|
||||
"streak_step": 0.02,
|
||||
"streak_cap": 10,
|
||||
@@ -284,7 +284,7 @@
|
||||
"view_h": 470,
|
||||
"folder_cols": 5,
|
||||
"folder_rows": 4,
|
||||
"folder_dims": [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]],
|
||||
"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)",
|
||||
@@ -341,40 +341,40 @@
|
||||
"effect": "connection_slots",
|
||||
"value": 1,
|
||||
"max_level": 4,
|
||||
"prices": [120, 300, 650, 1200],
|
||||
"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": "Скорость приёма и отправки +8% за уровень (умножается).",
|
||||
"desc_ru": "Скорость приёма и отправки +10% за уровень (умножается).",
|
||||
"effect": "transfer_speed",
|
||||
"value": 0.08,
|
||||
"value": 0.10,
|
||||
"max_level": 6,
|
||||
"prices": [90, 130, 180, 250, 340, 450],
|
||||
"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 +35 и мгновенное восстановление +35.",
|
||||
"desc_ru": "Держит узел живым: кап SLA +40 и мгновенное восстановление +40.",
|
||||
"effect": "sla_cap",
|
||||
"value": 35,
|
||||
"value": 40,
|
||||
"max_level": 4,
|
||||
"prices": [120, 260, 480, 800],
|
||||
"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": "Автоматически гасит атаку: 50% награды, без штрафа.",
|
||||
"desc_ru": "Автоматически гасит атаку: 70% награды, без штрафа.",
|
||||
"effect": "shield_charge",
|
||||
"value": 1,
|
||||
"max_level": 3,
|
||||
"prices": [150, 400, 800],
|
||||
"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]
|
||||
},
|
||||
@@ -385,18 +385,18 @@
|
||||
"effect": "money_mult",
|
||||
"value": 0.05,
|
||||
"max_level": 8,
|
||||
"prices": [80, 110, 150, 200, 260, 340, 440, 560],
|
||||
"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 клетки на первом уровне.",
|
||||
"desc_ru": "Больше места под фигуры: 20 → 24 клетки на первом уровне (до 54).",
|
||||
"effect": "folder_cells",
|
||||
"value": 1,
|
||||
"max_level": 5,
|
||||
"prices": [80, 180, 360, 650, 1000],
|
||||
"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]
|
||||
},
|
||||
@@ -408,36 +408,37 @@
|
||||
"value": 6,
|
||||
"buffer_value": 1,
|
||||
"max_level": 6,
|
||||
"prices": [70, 100, 140, 190, 250, 330],
|
||||
"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 → 35 с.",
|
||||
"desc_ru": "Больше времени на поиск файлов и прикрепление: 30 → 37 с (до 65).",
|
||||
"effect": "request_timer",
|
||||
"value": 5,
|
||||
"value": 7,
|
||||
"max_level": 5,
|
||||
"prices": [70, 110, 160, 230, 320],
|
||||
"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]
|
||||
}
|
||||
],
|
||||
"minigames": {
|
||||
"selection": {
|
||||
"order": ["dots", "sound", "wires", "text", "video", "photo", "maze"],
|
||||
"achievable_sums": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 20, 24],
|
||||
"max_index": 27,
|
||||
"formula": "S=sum(coeff); rank=index of S in achievable_sums (or largest listed <= S); tier=clamp(1+floor(rank*4.0/count),1,4); id=order[rank%7]",
|
||||
"note": "Ровно 3 файла максимум: суммы 15/19/21/22/23 недостижимы, поэтому используется плотный список достижимых сумм (B4). Все 7 игр и все 4 ступени достижимы."
|
||||
"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": [3, 3], "obstacles": 0, "undo_penalty": 0.05 },
|
||||
{ "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 }
|
||||
@@ -447,27 +448,15 @@
|
||||
"name_ru": "Уровни",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "channels": 3, "tolerance": 0.16, "drift": 0.0, "drift_hz": 0.0 },
|
||||
{ "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 }
|
||||
]
|
||||
},
|
||||
"wires": {
|
||||
"name_ru": "Провода",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "wires": 3, "sockets": 3, "box": 36, "pitch": 42, "empty_sockets": 0, "lookalike_pairs": 0 },
|
||||
{ "wires": 4, "sockets": 4, "box": 36, "pitch": 42, "empty_sockets": 0, "lookalike_pairs": 2 },
|
||||
{ "wires": 5, "sockets": 6, "box": 36, "pitch": 42, "empty_sockets": 1, "lookalike_pairs": 1 },
|
||||
{ "wires": 6, "sockets": 6, "box": 34, "pitch": 40, "empty_sockets": 0, "lookalike_pairs": 2 }
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"name_ru": "Чужое слово",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "words": 11, "aliens": 1, "lines": 2, "alien_kind": "foreign_language" },
|
||||
{ "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" }
|
||||
@@ -477,7 +466,6 @@
|
||||
"name_ru": "Раскадровка",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "frames": 3, "slots": 3, "decoys": 0, "mirror": false },
|
||||
{ "frames": 4, "slots": 4, "decoys": 0, "mirror": false },
|
||||
{ "frames": 5, "slots": 4, "decoys": 1, "mirror": false },
|
||||
{ "frames": 6, "slots": 5, "decoys": 2, "mirror": true }
|
||||
@@ -487,7 +475,6 @@
|
||||
"name_ru": "Кадр",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "speed": 90, "frame_w": 220, "frame_h": 150, "shots": 3, "retarget_min": 0.8, "retarget_max": 1.2 },
|
||||
{ "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 }
|
||||
@@ -497,7 +484,6 @@
|
||||
"name_ru": "Лабиринт",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "cols": 9, "rows": 4, "width": 360, "height": 160, "speed": 420, "collision_penalty": 0.05, "dead_end_penalty": 0.02 },
|
||||
{ "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 }
|
||||
@@ -549,7 +535,7 @@
|
||||
"schedule": [0, 1, 1, 2, 2, 3, 3, 4, 4],
|
||||
"tier_formula": "clamp(1 + floor((hour - 2) / 2.0), 1, 4)",
|
||||
"spawn_window": { "start_sec": 60, "end_sec": 240 },
|
||||
"shield_reward_scale": 0.5,
|
||||
"shield_reward_scale": 0.7,
|
||||
"money_scale": 1.0,
|
||||
"kinds": {
|
||||
"junk": {
|
||||
@@ -615,7 +601,7 @@
|
||||
"spawn_speedup_after_end": 0.0,
|
||||
"spawn_speedup_period_hours": 2,
|
||||
"type_availability": "веса типов — hours[].type_weights (единственный источник правды); H1 только text/sound, photo с H2, video с H3",
|
||||
"timer_grace": "v2: таймеры не масштабируются по часу (packet 20, request 30+5*level)"
|
||||
"timer_grace": "v2: таймеры не масштабируются по часу (packet 20, request 30+7*level, cap 65)"
|
||||
},
|
||||
"progression": {
|
||||
"unlocks": {
|
||||
@@ -675,11 +661,11 @@
|
||||
"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.92, speed_level) * factor"
|
||||
"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, "sound": 2, "photo": 4, "video": 8 },
|
||||
"formula": "см. minigames.selection.formula (плотный список достижимых сумм, B4)",
|
||||
"formula": "см. minigames.selection.formula (27 достижимых сумм для 1-4 файлов, 3 ступени, rank_games)",
|
||||
"quality_stub": 1.0
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,6 @@
|
||||
"weak": "СЛАБО",
|
||||
"dots": "Одна линия",
|
||||
"sound": "Уровни",
|
||||
"wires": "Провода",
|
||||
"text": "Чужое слово",
|
||||
"video": "Раскадровка",
|
||||
"photo": "Кадр",
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
# DECISIONS — решения v2.0
|
||||
# DECISIONS — решения v3.0
|
||||
|
||||
> Каждое решение: ID, что решили, что меняется в GDD. Дедлайн
|
||||
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md` (v2.0).
|
||||
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md` (v3.0).
|
||||
>
|
||||
> **v2.0 (25.10.2026):** актуальны решения **D29–D44**. Решения D01–D28
|
||||
> сохранены как история; конфликтующие с v2.0 помечены пометкой
|
||||
> «**Статус v2.0: устарело**» с указанием замены. Ничего не удалено.
|
||||
> **v3.0 (26.10.2026):** актуальны решения **D45–D52**. Решения **D01–D44**
|
||||
> сохранены как история; конфликтующие с v3.0 помечены пометкой
|
||||
> «**Статус v3.0: история**» или «**заменено**» с указанием замены. Ничего не
|
||||
> удалено.
|
||||
>
|
||||
> **Сдвиг ID:** диапазон D26–D28 уже занят решениями v1.2 (аудит, VHS,
|
||||
> ambient), поэтому 16 новых решений v2.0 идут **D29–D44** — это эквивалент
|
||||
> запрошенного диапазона D26–D41 со сдвигом +3.
|
||||
> **Сдвиг ID:** диапазон D26–D28 занят решениями v1.2 (аудит, VHS, ambient),
|
||||
> D29–D44 — решения v2.0, поэтому восемь новых решений v3.0 идут **D45–D52**.
|
||||
>
|
||||
> **Статус решений v1.x в v2.0 (кратко):** D01→D32, D02→D33/D44,
|
||||
> D03→D34/D38, D04→D33, D05→D29, D06→D32, D07→D38/D39, D08→D38,
|
||||
> D09→D32, D10→D34, D12→D29/D40/D42, D13→D36/D37, D16→D43,
|
||||
> D17→D34/D35, D19→D29/D36, D20→D29, D21→D29, D22→D33, D23→D29/D38,
|
||||
> D25→D43. D11, D14, D15, D18, D24 (кроме магазина), D27, D28 остаются
|
||||
> в силе.
|
||||
> **Где смотреть актуальные числа:** GDD v3.0 и `data/balance.json`; этот файл
|
||||
> хранит историю формулировок. Прежние суммы дерева, кап SLA, число файлов,
|
||||
> состав игр отправки и множитель скорости в телах D01–D44 — **история
|
||||
> v1/v2**, а не текущая спецификация. Замены: D45–D52.
|
||||
>
|
||||
> **Статус решений 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→D51, 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 — карточка запроса
|
||||
**Решение:** в карточке запроса показывать имя требуемого файла
|
||||
@@ -57,6 +61,9 @@
|
||||
**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); зона — только
|
||||
@@ -109,6 +116,9 @@
|
||||
**Статус 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; в панели соединения —
|
||||
@@ -118,6 +128,8 @@ legacy и всегда `true` (полоски нет). Резервации пр
|
||||
**Статус 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)`; авто-пауза при
|
||||
@@ -135,8 +147,8 @@ legacy и всегда `true` (полоски нет). Резервации пр
|
||||
**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» не подтверждён (44,1 % дерева).
|
||||
Штраф `−(10+2×hour)` сохранён (D29/D40/D42, GDD §12–13).
|
||||
награды атак. «Полный выкуп к 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,
|
||||
@@ -146,7 +158,8 @@ legacy и всегда `true` (полоски нет). Резервации пр
|
||||
**GDD:** §14.
|
||||
**v1.2:** добавлена 10-я позиция «SLA +24» (300, max 3); открытия — D23.
|
||||
**Статус v2.0: устарело.** 8 лотов, 3 оффера, рероллы и замки; зоны/секции/
|
||||
поиска в магазине нет. Полное дерево — 13 100 $ (D36/D37, GDD §14).
|
||||
поиска в магазине нет. Полное дерево — **8 085 $** в v3 (D50); прежние
|
||||
числа v2 — история.
|
||||
|
||||
## D14 — фон
|
||||
**Решение:** шейдер фона — статичный градиент + слабый шум 320×180,
|
||||
@@ -165,6 +178,8 @@ 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.
|
||||
|
||||
## D16 — порядок P0
|
||||
**Решение:** порядок P0: данные/реестр → слоты → приём/отправка → UI.
|
||||
@@ -205,9 +220,9 @@ D43, GDD §23.
|
||||
10-я позиция **«SLA +24»** — 300, max 3 уровня. Остальные 9 позиций D13
|
||||
сохраняются; итого 10.
|
||||
**GDD:** §4, §12, §14.
|
||||
**Статус v2.0: устарело.** Старт SLA 60/60; лота «SLA +24» нет, кап растёт
|
||||
на +35 за уровень `lot_sla` до 200. Потеря пакета по типу сохранена
|
||||
(D29/D40, GDD §12).
|
||||
**Статус v2.0: устарело.** Старт SLA 60/60; лота «SLA +24» нет, кап в v3
|
||||
растёт на +40 за уровень `lot_sla` до 220. Потеря пакета по типу сохранена
|
||||
(D29/D40/D50, GDD §12).
|
||||
|
||||
## D20 — таймеры и grace
|
||||
|
||||
@@ -330,14 +345,16 @@ fallback `SLA +24` неактуальны: соединения и лота SLA+
|
||||
## D29 — стартовые статы и формулы
|
||||
|
||||
**Решение:** забег начинается с **50 $** и **SLA 60/60**. Кап SLA —
|
||||
`60 + 35×sla_level`, жёсткий максимум **200**. Слоты — `1 + slot_level`
|
||||
`60 + 40×sla_level`, жёсткий максимум **220**. Слоты — `1 + slot_level`
|
||||
(1…5), канал — `10 + 6×channel_level` (10…46, он же даёт буфер
|
||||
`6 + уровень`, 6…12), таймер запроса — `30 + 5×timer_level` (30…55),
|
||||
`6 + уровень`, 6…12), таймер запроса — `30 + 7×timer_level` (30…65),
|
||||
таймер пакета — фиксированные **20 с**. Сетка папки —
|
||||
`[5×4, 6×4, 6×5, 7×5, 7×6, 8×6]`. На старте 2 папки, максимум 6, новая —
|
||||
**100 $**. В начале часа SLA +15 и заряды щита восстанавливаются.
|
||||
`[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 — сетки папок и файлы-полимино
|
||||
|
||||
@@ -363,7 +380,7 @@ fallback `SLA +24` неактуальны: соединения и лота SLA+
|
||||
|
||||
## D32 — запросы и прикрепление
|
||||
|
||||
**Решение:** запрос требует 1–3 файла (слоты контейнера), конкретные id не
|
||||
**Решение:** запрос требует до трёх файлов (слоты контейнера), конкретные id не
|
||||
зашиты — игрок сам выбирает файлы. Файл прикреплён только к одному запросу;
|
||||
со стола не прикрепляется. При `locked`/`failed`/`malicious` файл
|
||||
открепляется, контейнер нужно собрать снова. Таймер запроса замирает в его
|
||||
@@ -371,23 +388,28 @@ fallback `SLA +24` неактуальны: соединения и лота SLA+
|
||||
`ОТКЛОНИТЬ` — −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, выплата ×1.0. После
|
||||
TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, выплата **×0.85**. После
|
||||
мини-игры запрос занимает out-слот или встаёт в очередь «ОЖИДАНИЕ СЛОТА»
|
||||
(таймер заморожен), пока слот не освободится. При успешной отправке файлы
|
||||
**расходуются**; при отмене/таймауте/конце часа сохраняются. Полоски
|
||||
соединения нет; `connected` — legacy.
|
||||
**расходуются**; при отмене/таймауте/конце часа сохраняются. Полоска
|
||||
соединения в 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 игр — 7 отправки (`dots`, `sound`, `wires`, `text`, `video`,
|
||||
`photo`, `maze`) и 4 атаки (`junk`, `ddos`, `phishing`, `ransomware`). Общий
|
||||
**Решение:** 11 игр — семь отправки (включая удалённую в v3 игру с проводами)
|
||||
и 4 атаки (`junk`, `ddos`, `phishing`, `ransomware`). Общий
|
||||
контракт: без таймеров, конечная цель, локальный seeded RNG, `q ∈ 0…1`,
|
||||
экран результата `q%`/вердикт и `ПРОДОЛЖИТЬ`; `finished` эмитится только
|
||||
после `ПРОДОЛЖИТЬ` в модальном режиме (награда не применяется раньше).
|
||||
@@ -399,18 +421,25 @@ TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, вы
|
||||
**Заменяет:** 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)` по 1–3 файлам. `rank` — позиция S в плотном списке
|
||||
`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 = `dots, sound,
|
||||
wires, text, video, photo, maze`. Суммы 15/19/21/22/23 недостижимы, поэтому
|
||||
28-элементный массив не перечисляется. Все 7 игр и 4 ступени достижимы;
|
||||
`S=24 → video/T4` (старый пример S−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 оффера
|
||||
|
||||
@@ -418,9 +447,12 @@ wires, text, video, photo, maze`. Суммы 15/19/21/22/23 недостижим
|
||||
`lot_money`, `lot_folder`, `lot_channel`, `lot_timer`), ровно **3**
|
||||
предложения в подготовке. Магазин — только в подготовке с H2, в H1 и во
|
||||
время часа недоступен. Максимальные лоты не предлагаются. Сумма всех
|
||||
уровней — **13 100 $**. Крестика нет: `ЗАКРЫТЬ`/`Esc`.
|
||||
уровней v2 — историческая; в v3 дерево стоит **8 085 $** (D50). Крестика нет:
|
||||
`ЗАКРЫТЬ`/`Esc`.
|
||||
**GDD:** §3, §14.
|
||||
**Заменяет:** D13, D19 (10 позиций).
|
||||
**Статус v3.0: история в числах.** 8 лотов и 3 оффера сохранены, но цены,
|
||||
эффекты и сумма дерева заменены D50 (**8 085 $**).
|
||||
|
||||
## D37 — рероллы, замки, веса и сохранение офферов
|
||||
|
||||
@@ -456,16 +488,21 @@ DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случ
|
||||
папка заблокирована на 15 с. SLA/деньги — из `attacks.kinds.*.tiers`.
|
||||
**GDD:** §9, §12, §12.1.
|
||||
**Заменяет:** D07 (таблица урона).
|
||||
**Статус v3.0: история в числах.** Щит теперь даёт **70 %** награды (D50);
|
||||
остальные эффекты провала сохранены.
|
||||
|
||||
## D40 — SLA, реген и поражение
|
||||
|
||||
**Решение:** старт 60/60; в начале часа +15 (не выше капа), кап растёт на
|
||||
+35 за `lot_sla` до 200. Потеря пакета — 2/4/8/16 по типу; таймаут запроса
|
||||
**Решение:** старт 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 и миграция
|
||||
|
||||
@@ -481,15 +518,16 @@ DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случ
|
||||
|
||||
## D42 — баланс v2
|
||||
|
||||
**Решение:** цели дохода H1–H9 — **65/220/380/560/760/980/1200/1450/
|
||||
1700 $**, `request_cap` — 2/5/5/6/6/7/7/7/8. Симулятор `tests/sim_balance.py`
|
||||
на **240 сидах** даёт средний итог **8 184 $**, все часы в **±20 %**,
|
||||
аналитические капы не ниже целей. Средний расход магазина — **5 771 $**,
|
||||
это **44,1 %** дерева 13 100 $; после H9 остаётся в среднем 2 463 $.
|
||||
Цель 55–65 % **не достигнута** и не декларируется: доход H9 частично
|
||||
остаётся неистраченным, забег заканчивается. Это принято как честное
|
||||
ограничение текущей политики покупок, а не как «полный выкуп».
|
||||
**Решение:** цели дохода 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`.
|
||||
|
||||
## D43 — проверки, запуск и Web
|
||||
|
||||
@@ -515,8 +553,153 @@ DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случ
|
||||
- лимит папок **6**, новая папка **100 $** (review H5);
|
||||
- любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
|
||||
защита от soft-lock «нет денег/нет места» (review M16);
|
||||
- `wires` T4: `box 34`, `pitch 40`, верх `56`, чтобы низ был ≤320 (review M11);
|
||||
- удалённая в 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)
|
||||
|
||||
## 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 (часть про отсутствие соединения).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## D52 — перестройка туториала
|
||||
|
||||
**Решение:** вместо ленты подсказок — **5 событийных шагов**:
|
||||
`packet` → `attach` → `connect` → `send` → `sla`. Каждый шаг ждёт своё
|
||||
действие (аварийные таймауты 30/40/45/40/12 с), вокруг цели рисуется
|
||||
пульсирующая рамка, кнопка **«ДАЛЕЕ»** пропускает шаг.
|
||||
|
||||
- Завершение пишется в `Game.stats["tutorial_done"]` и сохраняется в сейве.
|
||||
- Автозапуск — только в **первом часу первого забега**, если обучение ещё не
|
||||
пройдено.
|
||||
- Кнопка **«ОБУЧЕНИЕ»** в главном меню сбрасывает флаг и запускает повтор:
|
||||
в новом забеге — с подготовки, в идущем — с текущего часа.
|
||||
**GDD:** §18.1.
|
||||
**Заменяет:** старый «таймерный» туториал v2.
|
||||
@@ -1,37 +1,49 @@
|
||||
# PEAK HOUR — GDD (v2.0)
|
||||
# PEAK HOUR — GDD (v3.0)
|
||||
|
||||
> **Статус:** актуальная спецификация **v2.0** (25.10.2026). Источник чисел —
|
||||
> `data/balance.json` (version 2.0) и код `scripts/core`, `scripts/systems`.
|
||||
> Актуальные решения — **D29–D44** в `docs/DECISIONS.md`; D01–D28 сохранены
|
||||
> **Статус:** актуальная спецификация **v3.0** (26.10.2026). Источник чисел —
|
||||
> `data/balance.json` (version 2.0, разделы v3) и код `scripts/core`,
|
||||
> `scripts/systems`, `scripts/minigames`.
|
||||
> Актуальные решения — **D45–D52** в `docs/DECISIONS.md`; D01–D44 сохранены
|
||||
> как история и в спорных местах помечены как устаревшие.
|
||||
> Godot **4.7.2** → **Web (HTML5, no threads)**, RU-only, окно **1920×1080**,
|
||||
> логический viewport **1280×720** (`canvas_items/keep/fractional`),
|
||||
> 4 человека, джем FEIS, дедлайн 26.10.2026 23:59 Minsk.
|
||||
|
||||
## v2.0 — что изменилось
|
||||
## v3.0 — что изменилось
|
||||
|
||||
- **Экономика перезапущена:** старт **50 $**, SLA **60/60** (кап +35 за уровень
|
||||
`lot_sla`, максимум 200), 1 слот (макс. 5), канал 10 (макс. 46), буфер 6
|
||||
(макс. 12), таймер запроса 30 с (макс. 55), таймер пакета 20 с, сетка папки
|
||||
**5×4** (макс. **8×6**), 2 папки на старте (макс. 6, новая — 100 $).
|
||||
- **Магазин v2:** 8 лотов, ровно 3 предложения, рероллы 40/80/120 (макс. 3 за
|
||||
подготовку), замок отдельного предложения, веса и pity. Полное дерево
|
||||
стоит **13 100 $**. Магазин появляется только в подготовке с H2.
|
||||
- **Файлы — полимино:** текст 1 клетка, звук 2, фото 3, видео 4; поворот
|
||||
`R`/ПКМ во время drag; подпись — в верхней левой **занятой** клетке.
|
||||
- **Мини-игры:** 11 игр (7 отправки + 4 атаки), без таймеров, с конечной целью
|
||||
и локальным сидом; экран результата `q%`/вердикт и `ПРОДОЛЖИТЬ`.
|
||||
- **Отправка:** явная кнопка `ОТПРАВИТЬ` (автостарта нет), переключатель
|
||||
TCP/UDP; файлы **расходуются** при успешной отправке.
|
||||
- **Атаки:** 20 за забег по расписанию `0,1,1,2,2,3,3,4,4`; каждая атака —
|
||||
мини-игра, которая ставит **весь час** на паузу.
|
||||
- **Save VERSION 2:** позиции хранятся только в `folder.files`/`table`,
|
||||
метаданные — в `files`; v1-сейвы мигрируют без потери файлов и без
|
||||
уменьшения сеток.
|
||||
- **Баланс:** цели дохода H1–H9 = **65/220/380/560/760/980/1200/1450/1700 $**;
|
||||
симулятор на 240 сидах даёт средний итог **8 184 $**, все часы в ±20 %,
|
||||
аналитические капы проходят. Средний расход магазина — **44,1 %** дерева
|
||||
(ниже старой цели 55–65 %; это зафиксировано честно, см. §13).
|
||||
- **Запросы — конкретные файлы.** Запрос требует **1–4 именованных файла**
|
||||
(не анонимные слоты), показывает их имена на карточке сеткой 2×2, проверяет
|
||||
прикрепление по точному `file_id`, пишет «ПОТЕРЯН» для пропавшего файла и
|
||||
жёстко резервирует обязательные файлы. Новая таблица `request_mix` H1–H9 и
|
||||
`request_cap`; H1-затравка и анти-голодание.
|
||||
- **TCP-соединение вернулось.** TCP перед мини-игрой отправки требует пройти
|
||||
полоску соединения (3 попадания), UDP — нет. Есть промах/отмена с разными
|
||||
штрафами и кулдаунами, формула зоны, три скорости палочек и блокировка
|
||||
протокола после соединения.
|
||||
- **6 игр отправки, 3 ступени.** Игра с соединением проводов удалена. Старая
|
||||
ступень T1 исчезла: прежние T2/T3/T4 стали T1/T2/T3. У атак по-прежнему
|
||||
4 ступени. Выбор игры — по сумме коэффициентов и фиксированной таблице
|
||||
`rank_games` на 27 достижимых сумм.
|
||||
- **Магазин усилен.** 8 лотов, полное дерево **8 085 $**, новые цены и более
|
||||
сильные эффекты: SLA до **220**, ускорение `×0.90` за уровень, щит с **70 %**
|
||||
награды, папки до **9×6**, новая папка — **180 $**.
|
||||
- **UI v3.** Основной шрифт — **Golos Text**, Press Start 2P — только
|
||||
логотип/цифры; кегли **11/13/17/26**. Пиксельные иконки файлов из
|
||||
`art/icons/`. Карточка запроса **400×128** с сеткой обязательных файлов,
|
||||
подсказкой полного имени и прогнозом мини-игры.
|
||||
- **Туториал перестроен.** 5 пошаговых шагов, пульсирующие рамки вокруг цели,
|
||||
кнопка «ДАЛЕЕ», сохранение `Game.stats["tutorial_done"]`, автозапуск только
|
||||
в первом часу первого забега и повтор из главного меню.
|
||||
- **Полировка.** Убрана подпись занятости диска, добавлено пустое состояние
|
||||
панели запросов с подсказкой, полоса слотов переименована в «СЛОТЫ»,
|
||||
подсказки атак перенесены внутрь окна, исправлено голодание запросов в H1.
|
||||
|
||||
## v2.0 — история
|
||||
|
||||
В v2 экономика была перезапущена, появились файлы-полимино, 8 лотов магазина,
|
||||
атаки-мини-игры и save v2. Конкретные числа v2 заменены в v3 и сохранены
|
||||
только в `docs/DECISIONS.md` как история решений D29–D44. Актуальные значения
|
||||
ниже — из `data/balance.json` и кода v3.
|
||||
|
||||
## 1–2. Обзор и платформа
|
||||
|
||||
@@ -41,9 +53,11 @@
|
||||
|
||||
Ограничения: окно 1920×1080, логический viewport 1280×720 (`canvas_items`,
|
||||
`keep`, `fractional`), nearest, RU-only, Web no-threads (без `Thread` и
|
||||
блокирующего IO), шрифт **Handjet** (D15). UI v2 «Kaleidos»: тёплая бумага,
|
||||
1 px контуры, мягкие тени и бирюзовый акцент (D18). Игровой UI без CRT;
|
||||
VHS-слой — только в главном меню (D27).
|
||||
блокирующего IO). Основной шрифт — **Golos Text** (OFL 1.1, полная
|
||||
кириллица), Press Start 2P — только логотип и числовые акценты.
|
||||
UI v3 «Kaleidos»: тёплая бумага, 1 px контуры, мягкие тени и бирюзовый
|
||||
акцент (D18, D51). Игровой UI без CRT; VHS-слой — только в главном меню
|
||||
(D27).
|
||||
|
||||
## 3. Структура смены
|
||||
|
||||
@@ -55,7 +69,7 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
|
||||
```
|
||||
|
||||
- **Подготовка:** таймеров нет. Доступны папки, стол сортировки 4×6, новая
|
||||
папка (100 $, максимум 6) и магазин (только с H2).
|
||||
папка (**180 $**, максимум 6) и магазин (только с H2).
|
||||
- **Час:** 300 с; спавн файлов идёт в первые **75 %** часа (225 с), атаки —
|
||||
в окно **60–240 с**. В конце часа активные запросы проваливаются по
|
||||
таймауту (штраф `−(10 + 2×hour)`), файлы при этом сохраняются; пакеты
|
||||
@@ -64,8 +78,12 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
|
||||
- **Старт часа:** SLA **+15** (не выше капа), заряды щита восстанавливаются,
|
||||
файлы со стола автоматически возвращаются в папки. Если хотя бы одному
|
||||
файлу не хватило места, час **не начинается** — игрок освобождает место.
|
||||
- **H1-затравка:** в первой папке создаётся один seed-файл `text`, сразу же
|
||||
появляется запрос ровно на него, первый пакет приходит в `t = 0`. Пока
|
||||
запросов нет и есть свободный файл, анти-голодание создаёт запрос, не ждя
|
||||
полный `send_interval`.
|
||||
- **Победа:** отработаны все 9 часов. **Поражение:** SLA ≤ 0 во время часа.
|
||||
Отдельного «конца забега по складу» в v2 нет.
|
||||
Отдельного «конца забега по складу» нет.
|
||||
|
||||
## 4. Файлы и типы
|
||||
|
||||
@@ -73,29 +91,29 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
|
||||
|
||||
| Тип | Клетки | Формы | `base_time_sec` | `base_pay` | `recv_fee` | `weight` | `sla_loss` |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| `text` (текст) | 1 | 1 | 3 | 20 | 1 | 1 | 2 |
|
||||
| `sound` (звук) | 2 | 2 | 5 | 34 | 2 | 2 | 4 |
|
||||
| `photo` (фото) | 3 | 6 | 8 | 52 | 4 | 4 | 8 |
|
||||
| `video` (видео) | 4 | 17 | 12 | 80 | 6 | 8 | 16 |
|
||||
| `text` (текст) | 1 | 1 | 3 | 18 | 1 | 1 | 2 |
|
||||
| `sound` (звук) | 2 | 2 | 5 | 30 | 2 | 2 | 4 |
|
||||
| `photo` (фото) | 3 | 6 | 8 | 44 | 4 | 4 | 8 |
|
||||
| `video` (видео) | 4 | 17 | 12 | 69 | 6 | 8 | 16 |
|
||||
|
||||
- Имена уникальны в пределах забега, генерируются из `data/names_ru.json`.
|
||||
- `weight` входит в нагрузку канала; `sla_loss` — штраф за потерю пакета.
|
||||
- Пакет в приёме показывает тип/расширение и фигуру; зловредных пакетов и
|
||||
«файлов-ловушек» в v2 нет — атаки вынесены в мини-игры (§12.1).
|
||||
- Качество отправки больше не заглушка: его даёт мини-игра (§10).
|
||||
«файлов-ловушек» нет — атаки вынесены в мини-игры (§12.1).
|
||||
- Качество отправки даёт мини-игра (§10).
|
||||
|
||||
## 5. Папки, сетка и секции
|
||||
|
||||
- Одна секция **C**. На старте **2 папки** (`Разное`, `Входящие`), игрок может
|
||||
создать новую за **100 $**, всего до **6** папок (review H5). Имена
|
||||
уникальны внутри секции (`Имя`, `Имя 2`).
|
||||
создать новую за **180 $**, всего до **6** папок. Имена уникальны внутри
|
||||
секции (`Имя`, `Имя 2`).
|
||||
- Каждая папка — клеточная сетка. Размер общий для всех папок и растёт лотом
|
||||
`lot_folder`:
|
||||
|
||||
| Уровень | 0 | 1 | 2 | 3 | 4 | 5 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Сетка | 5×4 | 6×4 | 6×5 | 7×5 | 7×6 | 8×6 |
|
||||
| Клеток | 20 | 24 | 30 | 35 | 42 | 48 |
|
||||
| Сетка | 5×4 | 6×4 | 7×5 | 8×5 | 8×6 | 9×6 |
|
||||
| Клеток | 20 | 24 | 35 | 40 | 48 | 54 |
|
||||
|
||||
- Файл занимает несколько клеток по форме; пересечения запрещены. При
|
||||
перемещении доступен точный drop или auto-fit (first-fit по строкам, затем
|
||||
@@ -106,7 +124,8 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
|
||||
- Файл в `attached`/`locked`/`failed`/`malicious` нельзя перемещать.
|
||||
- «Уничтожить содержимое» папки удаляет все её файлы после подтверждения и
|
||||
снимает **5 SLA**; пустая папка — без штрафа. Обычный не прикреплённый файл
|
||||
можно уничтожить в подготовке без штрафа (review M16).
|
||||
можно уничтожить в подготовке без штрафа (review M16), но файл, закреплённый
|
||||
за запросом, удалить нельзя.
|
||||
- Загрузка сейва не уменьшает сетку: при нехватке места папка растёт до
|
||||
ближайшего размера из таблицы, иначе файл уходит на стол или в `registry`
|
||||
без потери.
|
||||
@@ -120,42 +139,60 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
|
||||
прошлого часа, но не больше **20** файлов за час. Интервал спавна —
|
||||
`225 с / бюджет` с джиттером ±20 %.
|
||||
- Пакет ждёт в буфере **20 с** (фиксировано, не покупается). После переноса в
|
||||
папку начинается загрузка: `base_time_sec × 0.92^speed_level × channel_factor`.
|
||||
папку начинается загрузка: `base_time_sec × 0.90^speed_level × channel_factor`.
|
||||
Пока идёт загрузка, таймер пакета не тикает.
|
||||
- Папка резервирует клетки фигуры **во время приёма**; отмена/таймаут
|
||||
освобождает резервацию. Это исключает двойную выдачу места.
|
||||
|
||||
## 7. Запросы
|
||||
|
||||
- Запрос требует **1–3 файла** (веса — `hours[].request_mix`). В v2 `required`
|
||||
— это слоты контейнера, а не заранее выбранные id: игрок сам решает, какие
|
||||
файлы положить.
|
||||
- Запрос требует **1–4 конкретных файла**. Число файлов разыгрывается по
|
||||
`hours[].request_mix`; при создании запрос выбирает реальные файлы из пула
|
||||
(свободные, не прикреплённые, не затребованные другим запросом) и хранит их
|
||||
`file_id` в `required`.
|
||||
- На карточке показаны имена обязательных файлов (сетка 2×2). Прикрепление
|
||||
принимается только для файла из `required` этого запроса; чужой файл
|
||||
отклоняется с подсказкой «Нужен файл: …».
|
||||
- **Жёсткая резервация:** обязательный файл нельзя уничтожить, унести на стол
|
||||
или прикрепить к другому запросу. Пока запрос жив, файл закреплён за ним
|
||||
(`required_by`).
|
||||
- Если обязательный файл исчез (`failed`/`malicious`/удалён), карточка пишет
|
||||
**«ПОТЕРЯН»**. Директор пытается подставить свободный файл того же типа; если
|
||||
замены нет, запрос остаётся с дыркой — его можно отклонить, но отправить
|
||||
нельзя.
|
||||
- Файл может быть прикреплён только к одному запросу. Прикрепить файл со
|
||||
стола нельзя — сначала верните его в папку.
|
||||
- Если файл стал `locked`/`failed`/`malicious`, он автоматически открепляется
|
||||
от запроса; контейнер снова нужно собрать.
|
||||
- Таймер запроса: **30 + 5×timer_level**, максимум **55 с**. Он замирает,
|
||||
пока открыта мини-игра этого запроса, пока запрос ждёт out-слот и пока идёт
|
||||
передача. Время часа при этом продолжается.
|
||||
- Таймер запроса: **30 + 7×timer_level**, максимум **65 с**. Он замирает,
|
||||
пока открыта мини-игра этого запроса, пока идёт соединение TCP, пока запрос
|
||||
ждёт out-слот и пока идёт передача. Время часа при этом продолжается.
|
||||
- `ОТКЛОНИТЬ` — штраф **−5 $ и −5 SLA**, запрос удаляется, файлы остаются.
|
||||
- Максимум запросов и одновременно активных запросов — из `hours[]`
|
||||
(`request_cap` / `active_requests`), см. §13.
|
||||
(`request_cap` / `active_requests`), см. §13. `request_cap` в v3 —
|
||||
**6/6/5/6/6/7/7/7/7** (H1 поднят до 6 вместе с H1-правкой).
|
||||
- Анти-голодание: если активных запросов нет, а подходящий файл есть,
|
||||
директор создаёт запрос, не дожидаясь полного интервала (минимальный зазор
|
||||
3 с). Это исправляет простой H1.
|
||||
- В окне отправки видны 4 карточки, дальше — скролл; высота карточки 56 px.
|
||||
Пустой список показывает плашку «ЖДЁМ ЗАПРОС» и подсказку
|
||||
`next_request_hint()`: «Следующий запрос ~N с», «Все запросы часа выданы»
|
||||
или «Нет свободных файлов для запроса».
|
||||
|
||||
## 8. Таймеры и фазы
|
||||
|
||||
| Таймер | Значение | Примечание |
|
||||
| --- | --- | --- |
|
||||
| Пакет | 20 с | фиксировано; загрузка считается отдельно |
|
||||
| Запрос | 30 + 5×уровень, макс. 55 с | без per-hour grace |
|
||||
| Запрос | 30 + 7×уровень, макс. 65 с | без per-hour grace |
|
||||
| Час | 300 с | 9 часов на забег |
|
||||
| Мини-игра | нет | конечная цель, не время |
|
||||
| Соединение TCP | нет | таймер запроса заморожен |
|
||||
| Атака | 60–240 с от начала часа | окно спавна |
|
||||
|
||||
- `delta = min(delta, 0.1)`, `Engine.max_fps = 60`, авто-пауза при потере
|
||||
фокуса окна (D11).
|
||||
- **Мини-игра отправки** замораживает спавн пакетов, загрузку, спавн запросов
|
||||
и таймер своего запроса; час и таймеры других запросов продолжают идти.
|
||||
- **Мини-игра соединения TCP** тоже замораживает таймер своего запроса.
|
||||
- **Мини-игра атаки** ставит на паузу весь час (все таймеры), пауза
|
||||
выставляется до показа окна.
|
||||
- Модалки подготовки (магазин, пауза, настройки) не тикают час, потому что
|
||||
@@ -164,43 +201,41 @@ menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
|
||||
### 8.4. Мини-игры запросов: выбор
|
||||
|
||||
Коэффициенты типов: `text 1`, `sound 2`, `photo 4`, `video 8`.
|
||||
`S = sum(coeff)` по фактически прикреплённым файлам (1–3 файла). Выбор — по
|
||||
**плотному списку достижимых сумм**, а не по `S−1`:
|
||||
`S = sum(coeff)` по **1–4** фактически прикреплённым (или обязательным)
|
||||
файлам. Достижимо ровно **27 сумм**:
|
||||
|
||||
```
|
||||
ACHIEVABLE_SUMS = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,16,17,18,20,24]
|
||||
ACHIEVABLE_SUMS = [1..22, 24, 25, 26, 28, 32]
|
||||
rank = индекс S в списке (или ближайшей меньшей достижимой)
|
||||
tier = clamp(1 + floor(rank * 4 / 19), 1, 4)
|
||||
id = [dots, sound, wires, text, video, photo, maze][rank % 7]
|
||||
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
|
||||
id = RANK_GAMES[rank]
|
||||
```
|
||||
|
||||
Суммы 15/19/21/22/23 недостижимы при максимум 3 файлах, поэтому старый
|
||||
28-элементный массив не перечисляется буквально. Это фикс **B4**: в
|
||||
отображаемой таблице нет «дыр» и недостижимых строк, все 7 игр и все 4
|
||||
ступени достижимы. Пример: `S=24 → rank 18 → video / T4` (старый пример
|
||||
`S−1` из спецификации отменён).
|
||||
`RANK_GAMES` — фиксированная таблица на 27 позиций (`scripts/core/economy.gd`),
|
||||
без modulo:
|
||||
|
||||
| rank | S | tier | игра |
|
||||
| ---: | ---: | ---: | --- |
|
||||
| 0 | 1 | T1 | dots |
|
||||
| 1 | 2 | T1 | sound |
|
||||
| 2 | 3 | T1 | wires |
|
||||
| 3 | 4 | T1 | text |
|
||||
| 4 | 5 | T1 | video |
|
||||
| 5 | 6 | T2 | photo |
|
||||
| 6 | 7 | T2 | maze |
|
||||
| 7 | 8 | T2 | dots |
|
||||
| 8 | 9 | T2 | sound |
|
||||
| 9 | 10 | T2 | wires |
|
||||
| 10 | 11 | T3 | text |
|
||||
| 11 | 12 | T3 | video |
|
||||
| 12 | 13 | T3 | photo |
|
||||
| 13 | 14 | T3 | maze |
|
||||
| 14 | 16 | T3 | dots |
|
||||
| 15 | 17 | T4 | sound |
|
||||
| 16 | 18 | T4 | wires |
|
||||
| 17 | 20 | T4 | text |
|
||||
| 18 | 24 | T4 | video |
|
||||
| rank | S | tier | игра | rank | S | tier | игра |
|
||||
| ---: | ---: | ---: | --- | ---: | ---: | ---: | --- |
|
||||
| 0 | 1 | T1 | text | 14 | 15 | T3 | video |
|
||||
| 1 | 2 | T1 | sound | 15 | 16 | T3 | text |
|
||||
| 2 | 3 | T1 | dots | 16 | 17 | T3 | maze |
|
||||
| 3 | 4 | T1 | photo | 17 | 18 | T3 | sound |
|
||||
| 4 | 5 | T1 | maze | 18 | 19 | T3 | video |
|
||||
| 5 | 6 | T1 | dots | 19 | 20 | T3 | sound |
|
||||
| 6 | 7 | T1 | photo | 20 | 21 | T3 | photo |
|
||||
| 7 | 8 | T1 | video | 21 | 22 | T3 | dots |
|
||||
| 8 | 9 | T2 | dots | 22 | 24 | T3 | dots |
|
||||
| 9 | 10 | T2 | text | 23 | 25 | T3 | maze |
|
||||
| 10 | 11 | T2 | video | 24 | 26 | T3 | maze |
|
||||
| 11 | 12 | T2 | maze | 25 | 28 | T3 | photo |
|
||||
| 12 | 13 | T2 | photo | 26 | 32 | T3 | video |
|
||||
| 13 | 14 | T2 | sound | | | | |
|
||||
|
||||
- **Каждая из 6 игр достижима в каждой из 3 ступеней.**
|
||||
- H1 — всегда T1 (в пуле только text/sound, максимум два звука = S4).
|
||||
H2 — практически всегда T1: T2 возможен только у редкого запроса из трёх
|
||||
фото (S12). H9 даёт примерно **33 / 22 / 46 %** запросов T1 / T2 / T3.
|
||||
- `S = 0` (нет файлов) → `dots` / T1.
|
||||
|
||||
## 9. Канал
|
||||
|
||||
@@ -212,7 +247,7 @@ id = [dots, sound, wires, text, video, photo, maze][rank % 7]
|
||||
load = Σ weight(active_transfers)
|
||||
capacity = 10 + 6 × channel_level # 10 … 46
|
||||
factor = max(1.0, load / capacity)
|
||||
time = base_time_sec × 0.92^speed_level × factor
|
||||
time = base_time_sec × 0.90^speed_level × factor
|
||||
```
|
||||
|
||||
- `lot_channel` даёт **+6 канала и +1 буфера** за уровень, максимум 6.
|
||||
@@ -221,80 +256,112 @@ time = base_time_sec × 0.92^speed_level × factor
|
||||
|
||||
## 10. Мини-игры
|
||||
|
||||
Всего **11** игр: 7 отправки и 4 атаки. Общие правила:
|
||||
Всего **10** игр: **6 отправки** и **4 атаки**. Общие правила:
|
||||
|
||||
- **Нет таймеров** и поражения по времени; игра заканчивается действием
|
||||
игрока или кнопкой отказа.
|
||||
- Локальный seeded RNG: сид выдаёт `HourDirector`/`AttackDirector`, состояние
|
||||
не зависит от глобального `randi()`.
|
||||
- Экран результата: **`q%` + вердикт** (`ЧИСТО` ≥75 %, `НОРМАЛЬНО` ≥45 %,
|
||||
`СЛАБО` <45 %) и кнопка **`ПРОДОЛЖИТЬ`**. Сигнал `finished` эмитится
|
||||
только после `ПРОДОЛЖИТЬ` (в модальном режиме), поэтому награда/штраф не
|
||||
применяются, пока игрок не закрыл результат.
|
||||
- Кнопка отказа с подтверждением: в мини-играх отправки — `ПРОПУСТИТЬ`,
|
||||
в мини-играх атак — `СДАТЬСЯ`; она даёт `q = 0`.
|
||||
- `Esc` во время игры отправки — no-op; в игре атаки — `СДАТЬСЯ`
|
||||
с двухшаговым подтверждением. На экране результата `Esc` = `ПРОДОЛЖИТЬ`.
|
||||
- Шапка мини-игры — две строки: название и цель. В шапке же полоса прогресса;
|
||||
цель дополнительно продублирована строкой с акцентной чертой. Отказ —
|
||||
`ПРОПУСТИТЬ` (отправка) или `СДАТЬСЯ` (атака) — требует **двух кликов**:
|
||||
первый взводит кнопку, второй подтверждает.
|
||||
- Экран результата: название игры, крупный `q%`, полоса качества, вердикт
|
||||
(`ЧИСТО` ≥75 %, `НОРМАЛЬНО` ≥45 %, `СЛАБО` <45 %), детальная строка и
|
||||
кнопка **`ПРОДОЛЖИТЬ`**. `Esc` на результате = продолжить. Сигнал
|
||||
`finished` эмитится только после `ПРОДОЛЖИТЬ` (в модальном режиме), поэтому
|
||||
награда/штраф не применяются, пока игрок не закрыл результат.
|
||||
- `Esc` во время игры отправки — no-op; в игре атаки — двухшаговое `СДАТЬСЯ`.
|
||||
- Все формулы клампятся в `0…1`. Параметры ступеней (сетки, допуски, число
|
||||
карточек, пороги) — `data/balance.json → minigames.games`.
|
||||
|
||||
| Игра | Тип | Цель | `q` |
|
||||
| --- | --- | --- | --- |
|
||||
| `dots` Одна линия | send | пройти все клетки без повторов | `1 − undo_penalty×undos − 0.1×invalid` |
|
||||
| `dots` Одна линия | send | провести непрерывную линию по всем клеткам | `1 − undo_penalty×undos − 0.1×invalid` |
|
||||
| `sound` Уровни | send | выставить каналы в целевые значения | среднее `1 − abs(v−target)/(2×tol)` |
|
||||
| `wires` Провода | send | соединить провода с гнёздами | `(connected − 0.25×wrong) / total` |
|
||||
| `text` Чужое слово | send | найти все чужие слова | `(found − false_positive) / aliens` |
|
||||
| `video` Раскадровка | send | разложить кадры по слотам | `(correct − 0.5×wrong − decoys) / slots` |
|
||||
| `photo` Кадр | send | сделать все снимки | `captures / shots` |
|
||||
| `maze` Лабиринт | send | дойти до выхода | `1 − 0.04×collisions − 0.02×dead_ends` |
|
||||
| `maze` Лабиринт | send | дойти до выхода | `1 − penalty×collisions − penalty×dead_ends` |
|
||||
| `junk` Фильтр | attack | убрать весь мусор | `(removed − false_positive) / total_junk` |
|
||||
| `ddos` Щит | attack | заблокировать все пакеты | `blocked / total_packets` |
|
||||
| `phishing` Паспорт | attack | проверить всех клиентов | `correct / total_clients` |
|
||||
| `ransomware` Карантин | attack | найти все `.exe` | `(found_exe − 0.5×wrong) / total_exe` |
|
||||
|
||||
Все формулы клампятся в `0…1`. Параметры ступеней (сетки, допуски, число
|
||||
карточек, пороги) — `data/balance.json → minigames.games`.
|
||||
`dots` управляется **нажатием и протяжкой**: линия растёт непрерывно, пока
|
||||
курсор идёт по соседним свободным клеткам. Быстрое движение сэмплируется
|
||||
шагами ~полклетки, поэтому курсор не «перескакивает» клетку. Недопустимое
|
||||
движение по чужой/занятой клетке **молча игнорируется** и не штрафует;
|
||||
ошибкой считается только осознанный клик. `R` сбрасывает линию.
|
||||
|
||||
Для `wires` T4 используется геометрия `box 34`, `pitch 40`, верх `56`, чтобы
|
||||
нижняя граница была `≤ 320` (review M11); T1–T3 — `36/42`.
|
||||
Подсказки атак находятся **внутри окна** игры (в нижней полосе), поэтому не
|
||||
обрезаются границей модалки и не уходят за экран.
|
||||
|
||||
## 11. Протоколы и явная отправка
|
||||
|
||||
Полоски соединения и «трёх попаданий» в v2 **нет**. TCP/UDP — это выбор
|
||||
экономики/скорости, а не мини-игра.
|
||||
TCP и UDP различаются экономикой, временем и наличием соединения.
|
||||
|
||||
- Переключатель TCP/UDP доступен до старта отправки; во время мини-игры,
|
||||
очереди или передачи менять протокол нельзя.
|
||||
- **TCP:** выплата `×1.15`, время передачи `×1.0`.
|
||||
- **UDP:** время передачи `×0.8`, выплата `×1.0`.
|
||||
- Отправка начинается только по кнопке **`ОТПРАВИТЬ`**. Прикрепление файлов
|
||||
автостарт не запускает.
|
||||
- Переключатель TCP/UDP доступен до старта отправки; во время соединения,
|
||||
мини-игры, очереди или передачи менять протокол нельзя. После успешного
|
||||
соединения TCP протокол **заблокирован**.
|
||||
- **TCP:** сначала мини-игра соединения, затем мини-игра отправки.
|
||||
Выплата `×1.15`, время передачи `×1.0`.
|
||||
- **UDP:** соединения нет, мини-игра отправки открывается сразу.
|
||||
Время передачи `×0.8`, выплата `×0.85`.
|
||||
- Отправка начинается только по кнопке **`СОЕДИНИТЬСЯ`** (TCP без связи) или
|
||||
**`ОТПРАВИТЬ`**. Прикрепление файлов автостарт не запускает. Успешное
|
||||
соединение сразу открывает мини-игру отправки, если контейнер собран.
|
||||
- После мини-игры запрос занимает свободный **out-слот**; если слота нет —
|
||||
встаёт в очередь `ОЖИДАНИЕ СЛОТА` (таймер запроса заморожен) и стартует,
|
||||
когда слот освободится. Это продолжение уже начатой отправки, а не
|
||||
автоматический старт нового запроса.
|
||||
- Слоты общие для приёма и отправки: `1 + slot_level`, максимум 5.
|
||||
- Выплата:
|
||||
когда слот освободится.
|
||||
- Слоты общие для приёма и отправки: `1 + slot_level`, максимум 5. Полоса
|
||||
слотов подписана **«СЛОТЫ»**.
|
||||
|
||||
### 11.1. Мини-игра TCP-соединения
|
||||
|
||||
`scripts/minigames/connection_game.gd`, окно 640×240, полоса 480×40.
|
||||
|
||||
```
|
||||
W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes + 0.05×timer_level,
|
||||
0.18, 0.36)
|
||||
```
|
||||
|
||||
- Три палочки по очереди, скорости **440 / 340 / 280 px/с**; нужно три
|
||||
попадания в зелёную зону. Клик — ЛКМ или `Пробел`.
|
||||
- **Промах / уход за край:** `−4 SLA` и кулдаун повтора **8 с**. Панель
|
||||
закрывается, автоповтора нет — игрок сам жмёт «СОЕДИНИТЬСЯ» снова.
|
||||
- **`Esc`:** отмена без штрафа SLA, кулдаун **4 с**.
|
||||
- Таймер запроса на время соединения **заморожен**; час продолжает идти.
|
||||
- Успех увеличивает счётчик `connect_successes` (влияет на ширину зоны),
|
||||
открывает мини-игру отправки и блокирует протокол.
|
||||
- Счётчик успешных TCP-соединений **сбрасывается на новом забеге**
|
||||
(`reset_carry()`), а не каждый час.
|
||||
|
||||
### 11.2. Выплата
|
||||
|
||||
```
|
||||
payout = round(base_sum × speed × quality × multi × protocol × streak × money_mult)
|
||||
speed = 0.6 + 0.4 × clamp(time_left / timer_total, 0, 1) # на старте отправки
|
||||
quality = 0.5 + 0.5 × q
|
||||
multi = 1 + 0.15 × (n − 1)
|
||||
protocol = 1.15 для TCP, 1.0 для UDP
|
||||
protocol = 1.15 для TCP, 0.85 для UDP
|
||||
streak = 1 + 0.02 × min(10, streak)
|
||||
money_mult = 1 + 0.05 × money_level
|
||||
```
|
||||
|
||||
`recv_fee` начисляется при приёме и не умножается на эти коэффициенты.
|
||||
`base_sum` — сумма `base_pay` прикреплённых файлов.
|
||||
`base_sum` — сумма `base_pay` прикреплённых файлов. При успешной отправке
|
||||
файлы **расходуются**; при отмене/таймауте/конце часа сохраняются.
|
||||
|
||||
## 12. SLA и штрафы
|
||||
|
||||
- Старт забега: **SLA 60 / 60**. Кап = `60 + 35×sla_level`, максимум **200**;
|
||||
покупка уровня сразу добавляет +35 к текущему SLA.
|
||||
- Старт забега: **SLA 60 / 60**. Кап = `60 + 40×sla_level`, максимум **220**;
|
||||
покупка уровня сразу добавляет **+40** к текущему SLA.
|
||||
- В начале каждого часа: **+15 SLA** (не выше капа), затем восстанавливаются
|
||||
заряды щита.
|
||||
- Потеря пакета: `text 2`, `sound 4`, `photo 8`, `video 16` SLA.
|
||||
- Таймаут запроса и провал запросов в конце часа: `−(10 + 2×hour)` SLA.
|
||||
- Промах TCP-соединения: **−4 SLA**; отмена по `Esc` — без штрафа.
|
||||
- Отклонение запроса: `−5 $`, `−5 SLA`.
|
||||
- Уничтожение содержимого папки: `−5 SLA` (пустая папка — 0).
|
||||
- Провал атаки: SLA/деньги по таблице `attacks.kinds.*.tiers` и эффекты §12.1.
|
||||
@@ -307,12 +374,12 @@ money_mult = 1 + 0.05 × money_level
|
||||
- Виды: `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)`).
|
||||
(`clamp(1 + floor((hour−2)/2), 1, 4)`). У атак **4 ступени**.
|
||||
- Каждая атака — мини-игра (§10); на время неё **весь час** на паузе.
|
||||
Карточек, пакетов, запросов и файлов атака не создаёт.
|
||||
- **Щит «Заземление»:** заряды = уровень `lot_shield` (макс. 3),
|
||||
восстанавливаются в начале часа. Заряд тратится автоматически **до**
|
||||
открытия мини-игры: атака считается отражённой, награда **50 %**, штрафа
|
||||
открытия мини-игры: атака считается отражённой, награда **70 %**, штрафа
|
||||
нет.
|
||||
- Успех/провал дают деньги и SLA из `attacks.kinds.*.tiers`; порог качества
|
||||
(`threshold`) свой у каждого вида/ступени. Деньги масштабируются:
|
||||
@@ -332,23 +399,39 @@ money_mult = 1 + 0.05 × money_level
|
||||
|
||||
| Час | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| Доход, $ | 65 | 220 | 380 | 560 | 760 | 980 | 1 200 | 1 450 | 1 700 |
|
||||
| `request_cap` | 2 | 5 | 5 | 6 | 6 | 7 | 7 | 7 | 8 |
|
||||
| `active_requests` | 1 | 2 | 2 | 3 | 3 | 3 | 4 | 4 | 4 |
|
||||
| Доход, $ | 150 | 220 | 380 | 560 | 760 | 980 | 1 200 | 1 450 | 1 700 |
|
||||
| `files` | 7 | 8 | 12 | 16 | 20 | 20 | 20 | 20 | 20 |
|
||||
| `request_cap` | 6 | 6 | 5 | 6 | 6 | 7 | 7 | 7 | 7 |
|
||||
| `active_requests` | 2 | 2 | 2 | 3 | 3 | 3 | 4 | 4 | 4 |
|
||||
| `attacks` | 0 | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 |
|
||||
|
||||
Распределение числа обязательных файлов (`request_mix`, 1–4):
|
||||
|
||||
| Час | 1 файл | 2 файла | 3 файла | 4 файла |
|
||||
| ---: | ---: | ---: | ---: | ---: |
|
||||
| H1 | 0.76 | 0.24 | 0.00 | 0.00 |
|
||||
| H2 | 0.63 | 0.32 | 0.05 | 0.00 |
|
||||
| H3 | 0.53 | 0.37 | 0.10 | 0.00 |
|
||||
| H4 | 0.50 | 0.36 | 0.12 | 0.02 |
|
||||
| H5 | 0.42 | 0.38 | 0.16 | 0.04 |
|
||||
| H6 | 0.40 | 0.38 | 0.17 | 0.05 |
|
||||
| H7 | 0.32 | 0.40 | 0.21 | 0.07 |
|
||||
| H8 | 0.30 | 0.39 | 0.22 | 0.09 |
|
||||
| H9 | 0.27 | 0.38 | 0.23 | 0.12 |
|
||||
|
||||
Доход складывается из выплат за отправки, `recv_fee` за принятые файлы и
|
||||
денежных наград за отражённые атаки. Магазин — основной сток денег.
|
||||
|
||||
Проверка `tests/sim_balance.py` (240 сидов, 9 часов, `dt=0.25`, `q=0.85`, TCP):
|
||||
Проверка `tests/sim_balance.py` (240 сидов, 9 часов, `dt=0.25`, `q=0.85`,
|
||||
TCP; отчёт — `tests/SIM_BALANCE_REPORT.md`):
|
||||
|
||||
- средний итог **8 184 $** (payout 7 060 + `recv_fee` 445 + атаки 679);
|
||||
- все часы внутри **±20 %** от цели, аналитические капы не ниже целей;
|
||||
- средний расход магазина — **5 771 $**, это **44,1 %** дерева 13 100 $;
|
||||
после H9 в среднем остаётся **2 463 $**.
|
||||
- 44,1 % **ниже** старой цели 55–65 %. Причина: доход H9 частично остаётся
|
||||
неистраченным, а забег заканчивается; политика симулятора докупает лоты в
|
||||
финальной подготовке, но не успевает выкупить дерево. Это зафиксированное
|
||||
отклонение, а не «полный выкуп к H8–H9».
|
||||
- Средний итог — **7 533 $** (payout 6 495 + `recv_fee` 410 + атаки 628),
|
||||
расход магазина **4 681 $** — **57,9 %** дерева **8 085 $**, **28 поражений
|
||||
из 240**, SLA в конце H9 — **62,2**; в среднем 54,4 запроса, 18,7 отражённых
|
||||
атак и 4,4 атаки щитом. Все 9 часов в ±20 % (H1 — 149 $ при цели 150),
|
||||
вердикт `PASS`.
|
||||
- Аналитические капы не ниже целей, а расход магазина попадает в полосу
|
||||
55–65 %.
|
||||
|
||||
## 14. Магазин
|
||||
|
||||
@@ -366,16 +449,17 @@ money_mult = 1 + 0.05 × money_level
|
||||
|
||||
| Лот | Эффект | Уровни | Цены |
|
||||
| --- | --- | ---: | --- |
|
||||
| `lot_slots` Порты коммутатора | +1 слот | 4 | 120 / 300 / 650 / 1 200 |
|
||||
| `lot_speed` Оптоволокно | время передачи `×0.92` за уровень | 6 | 90 / 130 / 180 / 250 / 340 / 450 |
|
||||
| `lot_sla` Резервный ИБП | кап +35 и сразу +35 SLA | 4 | 120 / 260 / 480 / 800 |
|
||||
| `lot_shield` Заземление | +1 заряд щита | 3 | 150 / 400 / 800 |
|
||||
| `lot_money` Корпоративный контракт | выплата +5 % за уровень | 8 | 80 / 110 / 150 / 200 / 260 / 340 / 440 / 560 |
|
||||
| `lot_folder` Складской стеллаж | следующая сетка папки | 5 | 80 / 180 / 360 / 650 / 1 000 |
|
||||
| `lot_channel` Пропускная способность | канал +6, буфер +1 | 6 | 70 / 100 / 140 / 190 / 250 / 330 |
|
||||
| `lot_timer` Регламент обработки | таймер запроса +5 с | 5 | 70 / 110 / 160 / 230 / 320 |
|
||||
| `lot_slots` Порты коммутатора | +1 слот | 4 | 90 / 200 / 400 / 700 |
|
||||
| `lot_speed` Оптоволокно | время передачи `×0.90` за уровень | 6 | 55 / 75 / 100 / 135 / 180 / 245 |
|
||||
| `lot_sla` Резервный ИБП | кап +40 и сразу +40 SLA (до 220) | 4 | 80 / 160 / 270 / 430 |
|
||||
| `lot_shield` Заземление | +1 заряд щита, 70 % награды | 3 | 100 / 220 / 410 |
|
||||
| `lot_money` Корпоративный контракт | выплата +5 % за уровень | 8 | 70 / 95 / 130 / 170 / 220 / 285 / 365 / 465 |
|
||||
| `lot_folder` Складской стеллаж | следующая сетка папки | 5 | 50 / 100 / 180 / 290 / 440 |
|
||||
| `lot_channel` Пропускная способность | канал +6, буфер +1 | 6 | 45 / 70 / 95 / 130 / 175 / 230 |
|
||||
| `lot_timer` Регламент обработки | таймер запроса +7 с (до 65) | 5 | 45 / 75 / 115 / 165 / 230 |
|
||||
|
||||
Сумма всех уровней — **13 100 $**.
|
||||
Сумма всех уровней — **8 085 $**. Новая папка стоит **180 $** (максимум 6).
|
||||
Сетки папок: `5×4 → 6×4 → 7×5 → 8×5 → 8×6 → 9×6`.
|
||||
|
||||
## 15. Save
|
||||
|
||||
@@ -385,10 +469,11 @@ money_mult = 1 + 0.05 × money_level
|
||||
- **Единый источник позиций:** `folder.files[]` (id, shape_id, origin,
|
||||
rotation) и `table[]` (file_id, origin, rotation). В `files{}` хранятся
|
||||
только метаданные (тип, имя, location, attached_request, lock/cooldown,
|
||||
shape_id и т.д.), без дублирования origin/rotation.
|
||||
- В сейв входят деньги, SLA, час, фаза, streak, stats, upgrades, мета-открытия,
|
||||
`run_meta_awards`, seed/state RNG, реестр, офферы/рероллы/час офферов/
|
||||
`last_seen`/замки, заряды щита и час последнего SLA-регена.
|
||||
`required_by`, shape_id и т.д.), без дублирования origin/rotation.
|
||||
- В сейв входят деньги, SLA, час, фаза, streak, stats (включая
|
||||
`tutorial_done`), upgrades, мета-открытия, `run_meta_awards`, seed/state RNG,
|
||||
реестр, офферы/рероллы/час офферов/`last_seen`/замки, заряды щита и час
|
||||
последнего SLA-регена.
|
||||
- Миграция v1: позиции восстанавливаются first-fit, папки не уменьшаются
|
||||
(`resize_folders` только растёт, при нехватке — ближайший больший размер),
|
||||
лишние файлы уходят на стол или в `registry` — файлы не теряются.
|
||||
@@ -408,12 +493,13 @@ money_mult = 1 + 0.05 × money_level
|
||||
|
||||
- `FileItem.location` ∈ `registry` / `folder` / `table` / `attached` /
|
||||
`locked` / `malicious` / `failed`;
|
||||
- `attached_request`, `malicious_kind`, `lock_until`, `cooldown_until`
|
||||
(`cooldown` в v2 равен 0, файлы расходуются), `shape_id`, `origin`,
|
||||
`rotation`;
|
||||
- `attached_request`, `required_by`, `malicious_kind`, `lock_until`,
|
||||
`cooldown_until` (`cooldown` в v3 равен 0, файлы расходуются), `shape_id`,
|
||||
`origin`, `rotation`;
|
||||
- у запроса: `sending`, `slot_reserved`, `minigame_active`, `quality`,
|
||||
`send_sum`, `minigame_id`, `minigame_tier`; `required_count`/`queued` —
|
||||
метаданные. `connected` оставлен legacy-полем и всегда `true`: полоски нет.
|
||||
`send_sum`, `minigame_id`, `minigame_tier`, `connected`, `connecting`,
|
||||
`connect_attempts`, `connect_cooldown_until`; `required_count`/`queued` —
|
||||
метаданные. `connected` снова рабочий флаг TCP-соединения (D45);
|
||||
- у пакета: `waiting` / `loading`; у папки — `reserved` (резервации приёма).
|
||||
|
||||
## 18. Esc и паузирование
|
||||
@@ -421,10 +507,34 @@ money_mult = 1 + 0.05 × money_level
|
||||
- В час — пауза часа.
|
||||
- В мини-игре отправки во время игры — no-op; в мини-игре атаки —
|
||||
двухшаговое подтверждение `СДАТЬСЯ`. На экране результата — `ПРОДОЛЖИТЬ`.
|
||||
- В мини-игре TCP-соединения — отмена: `0 SLA`, кулдаун 4 с, запрос остаётся.
|
||||
- В магазине — закрыть.
|
||||
- Панели соединения больше нет; отменять нечего.
|
||||
- На итогах забега — в главное меню (не soft-lock).
|
||||
|
||||
## 18.1. Туториал v3
|
||||
|
||||
Туториал — не таймерная лента фраз, а **5 шагов, каждый из которых ждёт
|
||||
событие** (с аварийным таймаутом):
|
||||
|
||||
| Шаг | Событие | Текст | Таймаут |
|
||||
| ---: | --- | --- | ---: |
|
||||
| 1 | `packet` | Перетащи пакет из «ПРИЁМА» в папку | 30 с |
|
||||
| 2 | `attach` | Открой папку и перетащи файл в карточку запроса | 40 с |
|
||||
| 3 | `connect` | Нажми «СОЕДИНИТЬСЯ» и попади в зелёную зону три раза | 45 с |
|
||||
| 4 | `send` | Пройди мини-игру: качество влияет на оплату | 40 с |
|
||||
| 5 | `sla` | Следи за SLA и отражай атаки | 12 с |
|
||||
|
||||
- Вокруг целевого элемента рисуется **пульсирующая рамка** (маскот БАЙТ
|
||||
подсказывает текстом); рамка не перехватывает мышь.
|
||||
- Кнопка **«ДАЛЕЕ»** пропускает текущий шаг; после шага — короткая пауза
|
||||
подтверждения, затем следующий.
|
||||
- Завершение сохраняется в `Game.stats["tutorial_done"]` и переживает
|
||||
перезапуск/загрузку сейва.
|
||||
- Автозапуск — только в **первом часу первого забега**, если обучение ещё не
|
||||
пройдено. В подготовке H1 показывается вводная фраза БАЙТА.
|
||||
- Кнопка **«ОБУЧЕНИЕ»** в главном меню сбрасывает `tutorial_done` и запускает
|
||||
повтор: в новом забеге — с подготовки, в идущем — с текущего часа.
|
||||
|
||||
## 19. Тексты UI
|
||||
|
||||
Строки игрока — из `data/strings_ru.json`, ключи `btn.*`, `msg.*`, `ui.*`,
|
||||
@@ -433,25 +543,42 @@ Fallback-строки в коде совпадают по смыслу с JSON.
|
||||
|
||||
## 20. Читаемость
|
||||
|
||||
- Пакет показывает тип, расширение и фигуру; при drag виден ghost и
|
||||
количество клеток.
|
||||
- Файлы в папке/на столе различаются цветом типа и подписью в занятой клетке.
|
||||
- Запрос показывает имя клиента, `client_id`, протокол, число слотов,
|
||||
таймер и кнопки `ОТПРАВИТЬ`/`ОТКЛОНИТЬ`.
|
||||
- У атаки нет «карточки с теллом»: игрок сразу видит мини-игру и её цель.
|
||||
- Пакет показывает тип, расширение, фигуру и пиксельную иконку; при drag
|
||||
виден ghost, иконка и количество клеток.
|
||||
- Файлы в папке/на столе различаются цветом типа, иконкой и подписью в
|
||||
занятой клетке; кегль подписи — 12/14/16 px в зависимости от клетки.
|
||||
Иконки — `art/icons/*.png` (32×32, nearest), показываются в сетке папки,
|
||||
drag-preview, чипах запроса и карточках пакетов.
|
||||
- Карточка запроса — **400×128**: имя клиента, `client_id`, протокол, таймер,
|
||||
сетка обязательных файлов **2×2** с полными именами, счётчик
|
||||
«ПРИКРЕПЛЕНО X/Y», прогноз `МИНИ-ИГРА: … · Tn` / `СЛОЖНОСТЬ: N` и кнопки
|
||||
`СОЕДИНИТЬСЯ` (TCP без связи) / `ОТПРАВИТЬ`, `ОТКРЕПИТЬ`, `ОТКЛОНИТЬ`.
|
||||
Имена файлов и клиентов набраны крупнее прежнего. При наведении на чип
|
||||
полное имя показывается строкой внутри карточки и в tooltip; длинные имена
|
||||
не обрезаются до одной буквы.
|
||||
- Потерянный обязательный файл подсвечивается красным и подписан
|
||||
**«ПОТЕРЯН»**.
|
||||
- У атаки нет «карточки с теллом»: игрок сразу видит мини-игру, её цель и
|
||||
подсказки внутри окна.
|
||||
- В подготовке правая колонка — стол сортировки; в час — окна `ПРИЁМ` и
|
||||
`ОТПРАВКА`.
|
||||
`ОТПРАВКА`. Подпись занятости диска убрана из панели файлов и подвала сетки,
|
||||
чтобы не дублировать счётчик клеток.
|
||||
- Полоса слотов подписана **«СЛОТЫ»** (общие для приёма и отправки).
|
||||
|
||||
## 21. Арт и шрифты
|
||||
|
||||
- Основной шрифт — **Handjet** (OFL 1.1, полная кириллица; вариативный
|
||||
`wght 100–900`, `ELGR 1–2`, `ELSH 0–16`; средняя ширина ≈0.36 em).
|
||||
- Press Start 2P — только крупные заголовки/логотип.
|
||||
- Pixelify Sans не используется (нет глифов `О`, `П`).
|
||||
- Допустимые кегли: **10 / 12 / 16 / 24**.
|
||||
- UI v2 «Kaleidos»: бумага `#E9E6DD`, 1 px контуры, тени `#00000018`,
|
||||
- Основной шрифт — **Golos Text** (`art/fonts/GolosText-VF.ttf`, SIL OFL 1.1,
|
||||
полная кириллица; вариативный `wght 400–900`, в теме вес 600).
|
||||
- Press Start 2P — только логотип и числовые акценты, не тело текста.
|
||||
- Типографическая шкала: **11 / 13 / 17 / 26 px** (`FS_MICRO`, `FS_BODY`,
|
||||
`FS_HEAD`, `FS_LOGO`). Отдельно: подписи файлов 12–16 px по размеру клетки.
|
||||
- Handjet и Pixelify Sans в теме **не используются**; файлы оставлены как
|
||||
история (см. `ASSETS.md`).
|
||||
- UI v3 «Kaleidos»: бумага `#E9E6DD`, 1 px контуры, тени `#00000018`,
|
||||
скругления 3–4 px, акцент `#2F7F8F`; спокойный фон — плоская бумага.
|
||||
- Маскот **БАЙТ** ведёт короткий туториал первого часа.
|
||||
- Пиксельные иконки файлов сгенерированы `art/icons/generate_icons.py`
|
||||
(32×32, палитра Kaleidos, nearest). Лицензия — MIT, команда проекта.
|
||||
- Маскот **БАЙТ** ведёт пошаговый туториал.
|
||||
- VHS — только главное меню: `shaders/vhs.gdshader` +
|
||||
`scripts/ui/vhs_overlay.gd`; чекбокс в настройках, HEADLESS не рендерит.
|
||||
|
||||
@@ -469,46 +596,51 @@ Fallback-строки в коде совпадают по смыслу с JSON.
|
||||
|
||||
```
|
||||
scripts/
|
||||
├─ main.gd # UI, ввод, модалки, drag&drop
|
||||
├─ main.gd # UI, ввод, модалки, drag&drop, туториал
|
||||
├─ game_controller.gd # фазы menu → prep → hour → results
|
||||
├─ autoload/ # Game, Data, Audio, Rng, Ambient
|
||||
├─ core/ # HourDirector, FileRegistry, SlotManager, Economy
|
||||
├─ entities/ # файлы, папки, секции, пакеты, запросы, слоты
|
||||
├─ systems/ # Shop, AttackDirector, SaveManager
|
||||
├─ ui/ # Theme90, FileGrid, SortingTable, ShopModal, …
|
||||
└─ minigames/ # MiniGame, factory, send/, attack/
|
||||
└─ minigames/ # MiniGame, factory, connection_game, send/, attack/
|
||||
```
|
||||
|
||||
Проверки: `bash scripts/run_all_tests.sh` — **25 наборов + parse-check**,
|
||||
актуальный список и статус — в `scripts/run_all_tests.sh` и `tests/`.
|
||||
Баланс: `python tests/sim_balance.py` → `tests/SIM_BALANCE_REPORT.md`.
|
||||
|
||||
## 24. Приёмка (definition of done)
|
||||
|
||||
Полный забег из 9 часов; 4 типа файлов-полимино; 7 мини-игр отправки и 4
|
||||
атаки; 8 лотов магазина с 3 офферами/рероллами/замками; явная отправка с
|
||||
TCP/UDP; save v2 с миграцией v1; RU-тексты; HTML5-сборка работает на itch.io.
|
||||
Полный забег из 9 часов; 4 типа файлов-полимино; **6 мини-игр отправки**
|
||||
(3 ступени) и **4 атаки** (4 ступени); 8 лотов магазина с 3 офферами,
|
||||
рероллами и замками; явная отправка с TCP-соединением и UDP; запросы на
|
||||
1–4 именованных файла; save v2 с миграцией v1; RU-тексты; пошаговый туториал;
|
||||
HTML5-сборка работает на itch.io.
|
||||
|
||||
## 25. Решения v2.0
|
||||
## 25. Решения v3.0
|
||||
|
||||
Кратко; полные формулировки — `docs/DECISIONS.md`:
|
||||
|
||||
- **D29** стартовые статы и формулы; **D30** сетки/полимино;
|
||||
**D31** приём и стол; **D32** запросы и прикрепление; **D33** отправка и
|
||||
протоколы; **D34** мини-игры; **D35** выбор B4; **D36** 8 лотов;
|
||||
**D37** рероллы/замки/веса; **D38** атаки; **D39** щит и последствия;
|
||||
**D40** SLA/поражение; **D41** save v2; **D42** баланс; **D43** тесты;
|
||||
**D44** известные отклонения.
|
||||
- Устаревшие числа v1 (SLA 128/200, 10 позиций, буфер 4–8, канал 80+25,
|
||||
grace-таймеры, полоска соединения, карточки атак) заменены и помечены в
|
||||
DECISIONS.
|
||||
- **D45** возврат TCP-соединения; **D46** запросы на конкретные файлы (1–4);
|
||||
**D47** удаление игры с проводами; **D48** сдвиг ступеней отправки;
|
||||
**D49** коэффициенты и таблица выбора; **D50** усиление магазина;
|
||||
**D51** Golos Text и пиксельные иконки; **D52** перестройка туториала.
|
||||
- Решения v2 (D29–D44) сохранены как история; их числа заменены в v3 и
|
||||
помечены в DECISIONS.
|
||||
|
||||
## 26. Известные отклонения v2.0
|
||||
## 26. Известные отклонения v3.0
|
||||
|
||||
- `ПРОПУСТИТЬ`/`СДАТЬСЯ` дают `q = 0`; `quality = 0.5 + 0.5×0 = 0.5`, то есть
|
||||
половину quality-множителя, а не нулевую выплату. Это осознанно.
|
||||
- `speed` в выплате использует `time_left / timer_total`, зафиксированное
|
||||
**на старте отправки**, пока таймер запроса был заморожен мини-игрой.
|
||||
Быстрый игрок видит `speed ≈ 1.0`; это принятое упрощение.
|
||||
- Лимит папок — **6**, новая папка — **100 $** (review H5).
|
||||
- Лимит папок — **6**, новая папка — **180 $**.
|
||||
- Любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
|
||||
защита от soft-lock «нет денег и нет места» (review M16).
|
||||
- `wires` T4: `box 34`, `pitch 40`, верх `56` (review M11).
|
||||
защита от soft-lock «нет денег и нет места» (review M16). Файл, закреплённый
|
||||
за запросом, не удаляется.
|
||||
- Денежные награды/штрафы атак масштабируются по часу:
|
||||
`max(0.75, hour/3) × attacks.money_scale` (review M8).
|
||||
- H2 теоретически может выдать T2-запрос из трёх фото (S12); на практике это
|
||||
редкий случай, и кривая сложности H2 остаётся почти полностью T1.
|
||||
@@ -7,6 +7,37 @@
|
||||
> Язык: GDScript. UI строится кодом; `.tscn` — только корень сцены.
|
||||
> Комментарии/UI — на русском, идентификаторы — на английском.
|
||||
|
||||
## 0. Дельты v3.0 (читать первым)
|
||||
|
||||
Этот файл — исторический контракт v1. Актуальная спецификация — `docs/GDD.md`
|
||||
v3.0 и `docs/DECISIONS.md` D45–D52. Ниже — что в контракте уже неверно:
|
||||
|
||||
- **Запросы:** `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 с.
|
||||
- **Мини-игры:** не 11, а **10** (6 отправки + 4 атаки); игры с проводами нет.
|
||||
У отправки **3 ступени**, у атак 4; выбор — `Economy.selection_for_sum()` по
|
||||
27 суммам и таблице `rank_games`, без `rank % 7`. См. GDD §8.4, §10.
|
||||
- **Магазин:** не 9 позиций, а **8 лотов**; дерево **8 085 $**; SLA-кап
|
||||
**220**; цены/эффекты — GDD §14, D50. Поля «9 позиций D13», «SLA +24»,
|
||||
«секция/поиск/зона» устарели.
|
||||
- **Экономика/буфер:** SLA-кап +40 до 220 (прежняя схема v2 — история),
|
||||
таймер запроса +7 с до 65 (не +5 до 55), буфер 6+уровень (не 4+2×),
|
||||
папка 180 $ (не 100), сетки 5×4 → 9×6. Ускорение передачи `×0.90` за
|
||||
уровень (прежний множитель v2 — история).
|
||||
- **Шрифты:** Golos Text — тело текста, Press Start 2P — только логотип/цифры;
|
||||
шкала 11/13/17/26; иконки — `art/icons/*.png`. Handjet — legacy.
|
||||
- **Туториал:** 5 событийных шагов с рамками и «ДАЛЕЕ», а не лента подсказок;
|
||||
прогресс в `Game.stats["tutorial_done"]`.
|
||||
|
||||
Исторические разделы ниже (9 позиций, 4+2 буфера, анонимные слоты, `rank % 7`,
|
||||
панель соединения как «удалённая») оставлены для провенанса и не являются
|
||||
текущим контрактом.
|
||||
|
||||
## 1. Автолоады
|
||||
|
||||
| Автолоад | Файл | Ответственность |
|
||||
|
||||
@@ -21,39 +21,43 @@ SLA — и не дайте атакам положить канал.
|
||||
повернуть и плотно уложить в сетку папки; на столе сортировки всегда есть
|
||||
место, чтобы разобраться с завалом.
|
||||
|
||||
Клиенты присылают запросы на 1–3 файла. Соберите контейнер, выберите
|
||||
протокол — TCP платит больше, UDP быстрее — и нажмите «ОТПРАВИТЬ».
|
||||
Отправка не абстрактная: перед ней открывается мини-игра. Всего их
|
||||
одиннадцать: семь для отправки и четыре для атак. В них нет таймеров —
|
||||
только конечная цель и качество выполнения, от которого зависит выплата.
|
||||
Результат показывает проценты и вердикт, а награда применяется после
|
||||
«ПРОДОЛЖИТЬ».
|
||||
Клиенты присылают запросы на 1–4 конкретных файла — их имена видны на
|
||||
карточке, и прикрепить нужно именно этот файл. Соберите контейнер, выберите
|
||||
протокол — TCP платит больше, UDP быстрее — и нажмите «СОЕДИНИТЬСЯ» или
|
||||
«ОТПРАВИТЬ». TCP сначала требует провести три палочки через зелёную зону;
|
||||
UDP отправляет сразу. Отправка не абстрактная: перед ней открывается
|
||||
мини-игра. Всего их десять: шесть для отправки и четыре для атак, у отправки
|
||||
три ступени, у атак четыре. В них нет таймеров — только конечная цель и
|
||||
качество выполнения, от которого зависит выплата. Результат показывает
|
||||
проценты, вердикт и детали, а награда применяется после «ПРОДОЛЖИТЬ».
|
||||
|
||||
Смена длится девять игровых часов по пять минут. На старте **SLA 60/60**;
|
||||
в начале часа восстанавливается 15 SLA. Потерянный пакет бьёт по типу
|
||||
файла: текст 2, звук 4, фото 8, видео 16. Первый час проходит без атак;
|
||||
дальше в узел лезут мусор, DDoS, фишинг и шифровальщик. Каждая атака — это
|
||||
отдельная мини-игра, которая ставит час на паузу: отбейте её, и получите
|
||||
деньги и SLA; провалите — получите штраф и временный дебафф. «Заземление»
|
||||
гасит атаку автоматически, но приносит только половину награды.
|
||||
Смена длится девять игровых часов по пять минут. На старте **SLA 60/60**,
|
||||
кап растёт до 220; в начале часа восстанавливается 15 SLA. Потерянный пакет
|
||||
бьёт по типу файла: текст 2, звук 4, фото 8, видео 16. Первый час проходит
|
||||
без атак; дальше в узел лезут мусор, DDoS, фишинг и шифровальщик. Каждая
|
||||
атака — это отдельная мини-игра, которая ставит час на паузу: отбейте её, и
|
||||
получите деньги и SLA; провалите — получите штраф и временный дебафф.
|
||||
«Заземление» гасит атаку автоматически, но приносит 70 % награды.
|
||||
|
||||
Магазин открывается после первого часа: **8 улучшений** — порты, оптика,
|
||||
ИБП, заземление, контракты, стеллажи, канал и регламент. Каждую подготовку
|
||||
предлагаются три лота; ассортимент можно обновить за 40/80/120 $ или
|
||||
закрепить нужное предложение. Полное дерево стоит 13 100 $ — выкупить всё
|
||||
закрепить нужное предложение. Полное дерево стоит 8 085 $ — выкупить всё
|
||||
за один забег не получится, придётся выбирать.
|
||||
|
||||
Дожить до конца смены — победа; уронить SLA — поражение. Сохранение
|
||||
работает между часами, включая офферы магазина и стол сортировки.
|
||||
|
||||
Интерфейс вдохновлён Kaleidos: тёплая бумага, тонкие 1 px контуры, мягкие
|
||||
тени и бирюзовый акцент. В первом часе рядом подсказывает маскот **БАЙТ**.
|
||||
В главном меню — лёгкий VHS-эффект, который отключается в настройках; в игре
|
||||
UI остаётся чистым. Фон — ambient машинного зала, включается после первого
|
||||
жеста и регулируется вместе с музыкой.
|
||||
тени и бирюзовый акцент. В первом часу БАЙТ ведёт пошаговый туториал:
|
||||
пакет → прикрепление → TCP-соединение → мини-игра → SLA и атаки; шаг можно
|
||||
пропустить кнопкой «ДАЛЕЕ», а повторить обучение — кнопкой «ОБУЧЕНИЕ» в
|
||||
меню. В главном меню — лёгкий VHS-эффект, который отключается в настройках;
|
||||
в игре UI остаётся чистым. Фон — ambient машинного зала, включается после
|
||||
первого жеста и регулируется вместе с музыкой.
|
||||
|
||||
UI рисуется кодом, без внешних текстур; Handjet держит плотный русский
|
||||
текст, Press Start 2P — логотип и цифры. Звуки синтезированы скриптом
|
||||
Тело интерфейса набрано Golos Text, Press Start 2P — только логотип и цифры;
|
||||
иконки файлов — пиксельные, из `art/icons/`. Звуки синтезированы скриптом
|
||||
проекта. Собрано на Godot 4.7.2 для Web без threads: игра запускается в
|
||||
браузере без COOP/COEP. Это играбельный джем-прототип полного цикла: меню,
|
||||
подготовка, девять часов, итоги, магазин, пауза, настройки, атаки,
|
||||
@@ -69,10 +73,11 @@ UI рисуется кодом, без внешних текстур; Handjet д
|
||||
| Пакет из «Приёма» → папка | drag&drop |
|
||||
| Файл из папки → карточка запроса | drag&drop |
|
||||
| Файл ↔ стол сортировки | drag&drop |
|
||||
| Отправка контейнера | кнопка «ОТПРАВИТЬ» |
|
||||
| Отправка контейнера | кнопка «СОЕДИНИТЬСЯ» (TCP) или «ОТПРАВИТЬ» |
|
||||
| TCP / UDP | переключатель на карточке запроса |
|
||||
| Мини-игра: действие | ЛКМ / Space / стрелки |
|
||||
| Пропустить мини-игру | «ПРОПУСТИТЬ» + подтверждение |
|
||||
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
|
||||
| Пропустить мини-игру | «ПРОПУСТИТЬ»/«СДАТЬСЯ» + второй клик |
|
||||
| Результат мини-игры | «ПРОДОЛЖИТЬ» или Esc |
|
||||
| Пауза | Esc |
|
||||
|
||||
@@ -89,10 +94,10 @@ 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. 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).
|
||||
|
||||
## Ссылки
|
||||
|
||||
@@ -136,10 +141,10 @@ godot game jam management simulation retro 90s pixel art singleplayer short web
|
||||
|
||||
- [ ] Пересобрать build/web из текущего кода и загрузить в itch.io:
|
||||
HTML5, 1920×1080, без fullscreen-lock.
|
||||
- [ ] Пересобрать скриншоты под v2 (сетки, стол, мини-игры, 3 оффера) и
|
||||
загрузить 6 кадров из docs/screenshots/: обязательные — main_menu.png,
|
||||
hour.png, attacks.png; дополнительно — prep.png, shop.png, settings.png;
|
||||
cover.png (630×500) — обложка страницы.
|
||||
- [ ] Пересобрать скриншоты под 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, проверить сохранение после
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
# 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 нет.
|
||||
- Шрифты: **Golos Text** (тело текста), **Press Start 2P** (логотип/цифры).
|
||||
Handjet и Pixelify Sans в теме не используются (D51).
|
||||
- Типографическая шкала: **11 / 13 / 17 / 26 px**. Подписи файлов — 12–16 px
|
||||
по размеру клетки.
|
||||
- Почти всё рисуется кодом; внешняя графика — только пиксельные иконки
|
||||
`art/icons/*.png` (32×32, `nearest`).
|
||||
|
||||
## Палитра
|
||||
|
||||
@@ -22,26 +26,26 @@ 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-выделение, светлые поверхности |
|
||||
| `file_card.gd` | белая карточка, пиксельная иконка, зелёная attached-галочка, tiny locked/malicious |
|
||||
| `request_card.gd` | **400×128**, сетка обязательных файлов **2×2**, счётчик, прогноз мини-игры, `СОЕДИНИТЬСЯ`/`ОТПРАВИТЬ`, `ПОТЕРЯН`, полное имя по hover |
|
||||
| `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-фон только здесь |
|
||||
| `shop_modal.gd` | 8 лотов v3, 3 оффера, рероллы/замки, без крестика; «Закрыть»/`Esc` |
|
||||
| `main_menu.gd` | логотип, слоган, кнопки (включая **«ОБУЧЕНИЕ»**), 3 шага смены, сводка; VHS-фон только здесь |
|
||||
| `vhs_overlay.gd` | нативный CRT/VHS-слой меню: скан-линии, зерно, виньетка, полоса |
|
||||
| `tutorial_mascot.gd` | маскот **БАЙТ** 64–96 px + облачко, не ловит мышь |
|
||||
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма |
|
||||
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма, шаги туториала и пульсирующие рамки |
|
||||
|
||||
## Ключевые сценарии
|
||||
|
||||
@@ -53,18 +57,48 @@ 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:** кнопка `СОЕДИНИТЬСЯ` открывает полоску соединения 640×240
|
||||
(3 попадания, 440/340/280 px/с). После успеха сразу открывается мини-игра
|
||||
отправки, протокол блокируется. UDP идёт сразу в мини-игру.
|
||||
- **Attached:** зелёная галочка у файла и чипа запроса.
|
||||
- **Пустая отправка:** плашка «ЖДЁМ ЗАПРОС» и подсказка «Следующий запрос
|
||||
~N с» / «Все запросы часа выданы» / «Нет свободных файлов для запроса».
|
||||
- **Туториал:** 5 шагов (пакет → прикрепление → TCP → мини-игра → SLA/атаки),
|
||||
пульсирующая рамка вокруг цели, кнопка «ДАЛЕЕ», сохранение
|
||||
`tutorial_done`, повтор из меню кнопкой «ОБУЧЕНИЕ».
|
||||
- **Настройки:** кнопка на главном экране открывает рабочую модалку.
|
||||
- **Магазин без X:** закрытие только «Закрыть» или `Esc`.
|
||||
- **Calm-фон:** при включённом «Спокойном фоне» Backdrop рисует плоскую
|
||||
`PAPER` без dither; серый `#C0C0C0` не появляется.
|
||||
- **Итоги забега:** строка «ОТКРЫТО: …» показывает полученные мета-награды;
|
||||
`Esc` ведёт в главное меню, без soft-lock.
|
||||
- **Drag пакета на слот:** перетаскивание пакета на TCP-слот запускает
|
||||
настоящее соединение, а не только визуальный перенос.
|
||||
- **Слоты:** полоса подписана «СЛОТЫ» — слоты общие для приёма и отправки.
|
||||
- **Подписи занятости диска** в панели файлов и подвале сетки больше нет:
|
||||
остаётся только счётчик файлов/клеток.
|
||||
- **Тосты:** сверху по центру (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` = продолжить.
|
||||
- Подсказки атак — внутри окна, в нижней полосе, без обрезки.
|
||||
|
||||
## VHS-эффект главного меню
|
||||
|
||||
@@ -116,5 +150,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).
|
||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 133 KiB |
@@ -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
|
||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 142 KiB |
@@ -15,28 +15,28 @@ const DEFAULT_TYPES := {
|
||||
"name_ru": "Текст", "ext": "txt", "icon": "text",
|
||||
"size_kb": 64,
|
||||
"cells": 1, "base_time_sec": 3, "recv_fee": 1,
|
||||
"base_pay": 20, "weight": 1, "minigame_coeff": 1, "sla_loss": 2,
|
||||
"base_pay": 18, "weight": 1, "minigame_coeff": 1, "sla_loss": 2,
|
||||
"shapes": ["O1"],
|
||||
},
|
||||
"sound": {
|
||||
"name_ru": "Звук", "ext": "mp3", "icon": "sound",
|
||||
"size_kb": 3072,
|
||||
"cells": 2, "base_time_sec": 5, "recv_fee": 2,
|
||||
"base_pay": 34, "weight": 2, "minigame_coeff": 2, "sla_loss": 4,
|
||||
"base_pay": 30, "weight": 2, "minigame_coeff": 2, "sla_loss": 4,
|
||||
"shapes": ["I2H", "I2V"],
|
||||
},
|
||||
"photo": {
|
||||
"name_ru": "Фото", "ext": "jpg", "icon": "photo",
|
||||
"size_kb": 5120,
|
||||
"cells": 3, "base_time_sec": 8, "recv_fee": 4,
|
||||
"base_pay": 52, "weight": 4, "minigame_coeff": 4, "sla_loss": 8,
|
||||
"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",
|
||||
"size_kb": 20480,
|
||||
"cells": 4, "base_time_sec": 12, "recv_fee": 6,
|
||||
"base_pay": 80, "weight": 8, "minigame_coeff": 8, "sla_loss": 16,
|
||||
"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"],
|
||||
},
|
||||
@@ -54,8 +54,11 @@ const DEFAULT_DIFFICULTY := [
|
||||
{"hour": 8, "max_requests": 12, "spawn_sec": 4.0},
|
||||
]
|
||||
|
||||
const DEFAULT_SEND_ORDER := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
|
||||
const DEFAULT_ACHIEVABLE_SUMS := [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 20, 24]
|
||||
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,
|
||||
]
|
||||
|
||||
const DEFAULT_HOUR_FILES := [4, 8, 12, 16, 20, 20, 20, 20, 20]
|
||||
const DEFAULT_REQUEST_CAP := [2, 5, 7, 9, 10, 11, 11, 12, 12]
|
||||
@@ -76,18 +79,8 @@ const DEFAULT_TYPE_WEIGHTS := [
|
||||
[0.06, 0.08, 0.32, 0.54],
|
||||
[0.04, 0.04, 0.30, 0.62],
|
||||
]
|
||||
## Порядок: 1 файл, 2 файла, 3 файла (§2.6).
|
||||
const DEFAULT_REQUEST_MIX := [
|
||||
[0.70, 0.30, 0.00],
|
||||
[0.60, 0.35, 0.05],
|
||||
[0.50, 0.40, 0.10],
|
||||
[0.50, 0.40, 0.10],
|
||||
[0.40, 0.45, 0.15],
|
||||
[0.40, 0.45, 0.15],
|
||||
[0.30, 0.50, 0.20],
|
||||
[0.30, 0.50, 0.20],
|
||||
[0.30, 0.50, 0.20],
|
||||
]
|
||||
## Доли числа файлов в запросе: 1..4 (§2.6). Добавлен ключ "4" (4 файла).
|
||||
const DEFAULT_REQUEST_MIX := {"1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0}
|
||||
|
||||
const DEFAULT_LOT_IDS := [
|
||||
"lot_slots", "lot_speed", "lot_sla", "lot_shield",
|
||||
@@ -102,17 +95,17 @@ const DEFAULT_LOT_EFFECTS := [
|
||||
"connection_slots", "transfer_speed", "sla_cap", "shield_charge",
|
||||
"money_mult", "folder_cells", "channel_capacity", "request_timer",
|
||||
]
|
||||
const DEFAULT_LOT_VALUES := [1, 0.08, 35, 1, 0.05, 1, 6, 5]
|
||||
const DEFAULT_LOT_VALUES := [1, 0.10, 40, 1, 0.05, 1, 6, 7]
|
||||
const DEFAULT_LOT_MAX := [4, 6, 4, 3, 8, 5, 6, 5]
|
||||
const DEFAULT_LOT_PRICES := [
|
||||
[120, 300, 650, 1200],
|
||||
[90, 130, 180, 250, 340, 450],
|
||||
[120, 260, 480, 800],
|
||||
[150, 400, 800],
|
||||
[80, 110, 150, 200, 260, 340, 440, 560],
|
||||
[80, 180, 360, 650, 1000],
|
||||
[70, 100, 140, 190, 250, 330],
|
||||
[70, 110, 160, 230, 320],
|
||||
[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],
|
||||
]
|
||||
const DEFAULT_LOT_WEIGHTS := [
|
||||
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
|
||||
@@ -135,7 +128,7 @@ const DEFAULT_SHOP_RULES := {
|
||||
"pity_weight": 1.5,
|
||||
}
|
||||
|
||||
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
|
||||
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
|
||||
|
||||
var balance: Dictionary = {}
|
||||
var constants: Dictionary = {}
|
||||
@@ -355,7 +348,7 @@ func minigame_selection() -> Dictionary:
|
||||
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", 27))
|
||||
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)))
|
||||
@@ -363,9 +356,9 @@ func minigame_selection() -> Dictionary:
|
||||
return out
|
||||
|
||||
|
||||
## Legacy-ступень для индекса 0..27 (idx/7 + 1).
|
||||
## Legacy-ступень для индекса (idx / 6 + 1), три ступени отправки.
|
||||
func minigame_tier(index: int) -> int:
|
||||
return clampi(1 + int(floor(float(maxi(0, index)) / 7.0)), 1, 4)
|
||||
return clampi(1 + int(floor(float(maxi(0, index)) / 6.0)), 1, 3)
|
||||
|
||||
|
||||
# --- v2: атаки -------------------------------------------------------------
|
||||
@@ -602,11 +595,7 @@ func _default_hour_row(hour: int) -> Dictionary:
|
||||
"recv_interval": DEFAULT_RECV_INTERVAL[idx],
|
||||
"send_interval": DEFAULT_SEND_INTERVAL[idx],
|
||||
"type_weights": weights,
|
||||
"request_mix": {
|
||||
"1": DEFAULT_REQUEST_MIX[idx][0],
|
||||
"2": DEFAULT_REQUEST_MIX[idx][1],
|
||||
"3": DEFAULT_REQUEST_MIX[idx][2],
|
||||
},
|
||||
"request_mix": DEFAULT_REQUEST_MIX.duplicate(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ const START_MONEY := 50
|
||||
const START_SLA := 60.0
|
||||
const HOURS_PER_RUN := 9
|
||||
## Жёсткий кап SLA (main.gd показывает его в статус-баре). Рабочий кап —
|
||||
## sla_cap(): 60 + 35 * sla_level, не выше MAX_SLA.
|
||||
const MAX_SLA := 200.0
|
||||
## 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")
|
||||
@@ -38,18 +38,18 @@ const LOT_EFFECT_KEYS := {
|
||||
"request_timer": "timer_level",
|
||||
}
|
||||
|
||||
const FALLBACK_LOT_DIMS := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
|
||||
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", "max_level": 4, "prices": [120, 300, 650, 1200]},
|
||||
"lot_speed": {"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "max_level": 6, "prices": [90, 130, 180, 250, 340, 450]},
|
||||
"lot_sla": {"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "max_level": 4, "prices": [120, 260, 480, 800]},
|
||||
"lot_shield": {"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "max_level": 3, "prices": [150, 400, 800]},
|
||||
"lot_money": {"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "max_level": 8, "prices": [80, 110, 150, 200, 260, 340, 440, 560]},
|
||||
"lot_folder": {"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "max_level": 5, "prices": [80, 180, 360, 650, 1000]},
|
||||
"lot_channel": {"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "max_level": 6, "prices": [70, 100, 140, 190, 250, 330]},
|
||||
"lot_timer": {"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "max_level": 5, "prices": [70, 110, 160, 230, 320]},
|
||||
"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]},
|
||||
}
|
||||
|
||||
var money: int = START_MONEY
|
||||
@@ -236,8 +236,8 @@ func set_lot_level(id: String, new_level: int) -> int:
|
||||
if not agg.is_empty():
|
||||
upgrades[agg] = lvl
|
||||
if effect == "sla_cap" and lvl > old_level:
|
||||
# Кап уже вырос (sla_cap() читает новый уровень), добавляем +35 сразу.
|
||||
var gain := _lot_value(def, 35.0) * float(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:
|
||||
@@ -251,19 +251,20 @@ func set_lot_level(id: String, new_level: int) -> int:
|
||||
|
||||
func sla_cap() -> float:
|
||||
var base := _const("sla_base", START_SLA)
|
||||
var step := _const("sla_per_level", 35.0)
|
||||
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 := _const("money_mult_step", 0.05)
|
||||
var step := _lot_effect_value("lot_money", "value", _const("money_mult_step", 0.05))
|
||||
return 1.0 + step * float(level("money_level"))
|
||||
|
||||
|
||||
## pow(0.92, speed_level) — множитель времени передачи.
|
||||
## pow(0.90, speed_level) — множитель времени передачи (10% за уровень).
|
||||
func speed_mult() -> float:
|
||||
return pow(0.92, float(maxi(0, level("speed_level"))))
|
||||
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:
|
||||
@@ -285,21 +286,21 @@ func connection_slots() -> int:
|
||||
|
||||
func channel_capacity() -> float:
|
||||
var base := _const("channel_capacity", 10.0)
|
||||
var step := _const("channel_per_level", 6.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(_const("buffer_per_level", 1.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 := _const("request_timer_per_level", 5.0)
|
||||
var cap := _const("request_timer_max", 55.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)
|
||||
|
||||
|
||||
@@ -428,6 +429,23 @@ 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", {}))
|
||||
@@ -617,6 +635,14 @@ func _lot_value(def: Dictionary, fallback_value: float) -> float:
|
||||
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"):
|
||||
|
||||
@@ -15,15 +15,15 @@ const CHANNEL_CAPACITY_STEP := 6.0
|
||||
|
||||
const PACKET_TIMER_BASE := 20.0
|
||||
const REQUEST_TIMER_BASE := 30.0
|
||||
const REQUEST_TIMER_STEP := 5.0
|
||||
const REQUEST_TIMER_MAX := 55.0
|
||||
const REQUEST_TIMER_STEP := 7.0
|
||||
const REQUEST_TIMER_MAX := 65.0
|
||||
|
||||
const BUFFER_BASE := 6
|
||||
const BUFFER_PER_LEVEL := 1
|
||||
const BUFFER_MAX := 12
|
||||
|
||||
## Множитель времени передачи за уровень «Оптоволокна».
|
||||
const SPEED_STEP := 0.92
|
||||
## Множитель времени передачи за уровень «Оптоволокна» (10% за уровень).
|
||||
const SPEED_STEP := 0.90
|
||||
|
||||
## Коэффициенты мини-игр: text 1, sound 2, photo 4, video 8.
|
||||
const MINIGAME_COEFF := {
|
||||
@@ -33,12 +33,27 @@ const MINIGAME_COEFF := {
|
||||
"video": 8,
|
||||
}
|
||||
|
||||
const SEND_ORDER := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
|
||||
## Игры отправки (wires удалена). Порядок совпадает с data.gd/minigame_factory.
|
||||
const SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
|
||||
|
||||
## Достижимые суммы коэффициентов максимум для 3 файлов (B4).
|
||||
## Суммы 15/19/21/22/23 недостижимы, поэтому используется плотный список:
|
||||
## все 7 игр и все 4 ступени становятся достижимыми.
|
||||
const ACHIEVABLE_SUMS := [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 20, 24]
|
||||
## Игра по 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
|
||||
@@ -46,6 +61,7 @@ const PAYOUT_QUALITY_BASE := 0.5
|
||||
const PAYOUT_QUALITY_BONUS := 0.5
|
||||
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
|
||||
@@ -87,7 +103,7 @@ static func buffer_limit(upgrades: Dictionary = {}) -> int:
|
||||
return clampi(BUFFER_BASE + BUFFER_PER_LEVEL * level, BUFFER_BASE, BUFFER_MAX)
|
||||
|
||||
|
||||
## pow(0.92, speed_level): 0 → 1.0, 6 → ≈0.606.
|
||||
## 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))))
|
||||
|
||||
@@ -101,7 +117,7 @@ static func type_base_time(type_def: Dictionary) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
## Время приёма: base_time_sec × pow(0.92, speed_level) × channel_factor.
|
||||
## Время приёма: 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)
|
||||
@@ -133,7 +149,7 @@ static func packet_timer(_hour: int = 1, _upgrades: Dictionary = {}) -> float:
|
||||
return PACKET_TIMER_BASE
|
||||
|
||||
|
||||
## v2: 30 + 5 × timer_level, не выше 55 с.
|
||||
## 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)
|
||||
@@ -172,35 +188,50 @@ static func minigame_type(types: Array) -> String:
|
||||
return "video"
|
||||
|
||||
|
||||
## Выбор мини-игры отправки по сумме коэффициентов (B4).
|
||||
## Выбор мини-игры отправки по сумме коэффициентов (1–4 файла).
|
||||
##
|
||||
## rank — позиция суммы S в плотном списке достижимых сумм (для
|
||||
## недостижимой S — позиция ближайшей меньшей достижимой).
|
||||
## tier = clamp(1 + floor(rank * 4.0 / count), 1, 4) — все 4 ступени
|
||||
## достижимы. game = order[rank % 7] — все 7 игр достижимы.
|
||||
## rank — позиция суммы S в списке ACHIEVABLE_SUMS (для недостижимой S —
|
||||
## позиция ближайшей меньшей достижимой). tier: rank 0..7 → T1, 8..13 → T2,
|
||||
## 14..26 → T3. Игра берётся напрямую из RANK_GAMES[rank], без modulo.
|
||||
##
|
||||
## Примечание: из-за плотного списка S=24 даёт rank 18 → "video"/T4
|
||||
## (а не "wires", как в старой формуле idx = S-1).
|
||||
## 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), 1))
|
||||
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]) <= total:
|
||||
if int(ACHIEVABLE_SUMS[i]) <= sum:
|
||||
rank = i
|
||||
else:
|
||||
break
|
||||
var tier := clampi(1 + int(floor(float(rank) * 4.0 / float(count))), 1, 4)
|
||||
var tier := 1
|
||||
if rank > int(TIER_MAX_RANK[1]):
|
||||
tier = 3
|
||||
elif rank > int(TIER_MAX_RANK[0]):
|
||||
tier = 2
|
||||
return {
|
||||
"id": str(SEND_ORDER[rank % SEND_ORDER.size()]),
|
||||
"id": str(RANK_GAMES[clampi(rank, 0, RANK_GAMES.size() - 1)]),
|
||||
"tier": tier,
|
||||
"index": rank,
|
||||
"sum": total,
|
||||
"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)
|
||||
@@ -209,7 +240,7 @@ static func attack_tier(hour: int) -> int:
|
||||
# --- выплата ---------------------------------------------------------------
|
||||
|
||||
## payout = round(base_sum × speed × quality × multi × protocol × streak ×
|
||||
## money_mult). money_level берётся из upgrades.
|
||||
## money_mult). TCP ×1.15, UDP ×0.85; money_level из upgrades.
|
||||
static func payout(
|
||||
base_sum: float,
|
||||
n: int,
|
||||
@@ -228,7 +259,7 @@ static func payout(
|
||||
var speed := PAYOUT_SPEED_BASE + PAYOUT_SPEED_BONUS * time_ratio
|
||||
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 money_level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
|
||||
var money_mult := 1.0 + PAYOUT_MONEY_STEP * float(money_level)
|
||||
|
||||
@@ -34,7 +34,7 @@ const DEFAULT_TABLE_ROWS = 6
|
||||
const DEFAULT_FOLDERS_START = 2
|
||||
|
||||
## Fallback, если data/balance.json ещё не содержит grid.folder_dims.
|
||||
const GRID_DIMS = [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
|
||||
const GRID_DIMS = [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
|
||||
|
||||
## Fallback-формы (совпадают с data/balance.json → grid.shapes).
|
||||
const SHAPES = {
|
||||
@@ -521,6 +521,11 @@ func auto_place_file(file_id: String, folder_id: String) -> bool:
|
||||
|
||||
|
||||
## Перемещение: origin=(-1,-1) → auto-place, иначе точное размещение.
|
||||
## Файл, зарезервированный под запрос (required_by), ПЕРЕМЕЩАТЬ МЕЖДУ ПАПКАМИ
|
||||
## можно: запрос ищет файл по id и остаётся выполнимым из любой папки, а карточка
|
||||
## запроса показывает актуальную папку. Блокируются только разрушительные
|
||||
## действия (destroy_file/remove_file/move_file_to_table) и прикрепление к
|
||||
## другому запросу (attach_file).
|
||||
func move_file(file_id: String, folder_id: String, origin: Vector2i = Vector2i(-1, -1), rotation: int = -1) -> bool:
|
||||
var file = files.get(file_id)
|
||||
if file == null:
|
||||
@@ -728,6 +733,8 @@ func move_file_to_table(file_id: String, origin: Vector2i = Vector2i(-1, -1), ro
|
||||
var file = files.get(file_id)
|
||||
if file == null:
|
||||
return false
|
||||
if not file.required_by.is_empty():
|
||||
return false
|
||||
if file.location == &"attached" or not file.attached_request.is_empty():
|
||||
return false
|
||||
if file.location == &"locked" or file.location == &"failed" or file.location == &"malicious":
|
||||
@@ -750,7 +757,6 @@ func move_file_to_table(file_id: String, origin: Vector2i = Vector2i(-1, -1), ro
|
||||
file.attached_request = ""
|
||||
if old_folder != null:
|
||||
folder_changed.emit(old_folder)
|
||||
grid_changed.emit(old_folder.id)
|
||||
file_changed.emit(file)
|
||||
table_changed.emit()
|
||||
registry_changed.emit()
|
||||
@@ -976,6 +982,8 @@ func attach_file(file_id: String, request_id: String, now: float = -1.0) -> bool
|
||||
return false
|
||||
if file.location == &"table":
|
||||
return false
|
||||
if not file.required_by.is_empty() and file.required_by != request_id:
|
||||
return false
|
||||
if not file.attached_request.is_empty():
|
||||
return false
|
||||
if not file.can_attach(now):
|
||||
@@ -1013,6 +1021,9 @@ func mark_failed(file_id: String) -> bool:
|
||||
return false
|
||||
file.location = &"failed"
|
||||
file.attached_request = ""
|
||||
# Проваленный файл больше не может обслужить запрос: снимаем резерв,
|
||||
# иначе он навсегда остаётся «занятым» и его нельзя уничтожить.
|
||||
file.required_by = ""
|
||||
file.lock_until = 0.0
|
||||
file.cooldown_until = 0.0
|
||||
file._location_before_lock = &"folder" if not file.folder_id.is_empty() else &"registry"
|
||||
@@ -1028,6 +1039,9 @@ func mark_malicious(file_id: String, kind: String) -> bool:
|
||||
file.location = &"malicious"
|
||||
file.malicious_kind = kind
|
||||
file.attached_request = ""
|
||||
# Заражённый файл не является валидной резервацией запроса: снимаем
|
||||
# required_by, иначе запрос навсегда ждёт мёртвый файл, а удалить его нельзя.
|
||||
file.required_by = ""
|
||||
file.lock_until = 0.0
|
||||
file.cooldown_until = 0.0
|
||||
file._location_before_lock = &"folder" if not file.folder_id.is_empty() else &"registry"
|
||||
@@ -1124,6 +1138,9 @@ func remove_file(file_id: String, force: bool = false) -> bool:
|
||||
var file = files.get(file_id)
|
||||
if file == null:
|
||||
return false
|
||||
# Файл зарезервирован под конкретный запрос: удалять нельзя.
|
||||
if not file.required_by.is_empty():
|
||||
return false
|
||||
if file.location == &"attached" or not file.attached_request.is_empty():
|
||||
return false
|
||||
if not force and file.location != &"malicious" and file.location != &"failed":
|
||||
@@ -1774,6 +1791,8 @@ func _clear_file_placement(file) -> void:
|
||||
if folder != null:
|
||||
folder.clear_owner(file.id)
|
||||
folder.file_ids.erase(file.id)
|
||||
# Старая папка тоже должна перерисоваться (перенос/расход файла).
|
||||
grid_changed.emit(folder.id)
|
||||
_clear_table_cells(file.id)
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ extends Node
|
||||
## Единственный тикер часа (v2).
|
||||
##
|
||||
## Час — это бюджет файлов, бюджет запросов, окно спавна 75% часа и атаки.
|
||||
## Никаких полосок соединения, мусорных/шифровальщицких пакетов и
|
||||
## фишинговых запросов: всё это заменено мини-играми атак (§5.7).
|
||||
## «Мусорные»/шифровальщицкие пакеты и фишинговые запросы заменены
|
||||
## мини-играми атак (§5.7); TCP-соединение — отдельная мини-игра-полоска.
|
||||
##
|
||||
## Важные правила:
|
||||
## - приём занимает слот `in` + резервирует клетки фигуры в папке;
|
||||
@@ -30,15 +30,15 @@ signal attack_changed(id: String)
|
||||
signal attack_removed(id: String)
|
||||
signal toast(text: String)
|
||||
signal hour_finished(report: Dictionary)
|
||||
## Legacy-сигнал: соединение удалено из игры. Оставлен, чтобы не ломать main.gd.
|
||||
## (поле-словарь соединений больше не нужно и удалено)
|
||||
signal show_connection(game)
|
||||
signal show_minigame(game)
|
||||
signal minigame_closed(req_id: String)
|
||||
signal minigame_cancelled(req_id: String)
|
||||
signal show_attack_minigame(game, kind: String)
|
||||
signal attack_resolved(kind: String, success: bool, tier: int)
|
||||
signal spawn_frozen(reason: String)
|
||||
## TCP: показать мини-игру установления соединения.
|
||||
## UI создаёт ConnectionGame и по завершении зовёт connection_finished().
|
||||
signal show_connection(request_id: String, hour: int, successes: int)
|
||||
|
||||
const PACKET_SCRIPT := preload("res://scripts/entities/packet.gd")
|
||||
const REQUEST_SCRIPT := preload("res://scripts/entities/request.gd")
|
||||
@@ -59,8 +59,11 @@ const MAX_SPAWN_STEPS := 32
|
||||
const REJECT_FINE_MONEY := -5
|
||||
const REJECT_FINE_SLA := -5.0
|
||||
const DEFAULT_PACKET_SLA_LOSS := 2.0
|
||||
## Минимальный зазор между попытками спавна запроса (анти-голодание H1).
|
||||
const REQUEST_ATTEMPT_MIN_GAP := 3.0
|
||||
const MAX_REQUIRED_FILES := 4
|
||||
const TYPE_ORDER: Array[String] = ["text", "sound", "photo", "video"]
|
||||
const DEFAULT_REQUEST_MIX := {"1": 0.7, "2": 0.3, "3": 0.0}
|
||||
const DEFAULT_REQUEST_MIX := {"1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0}
|
||||
const DEFAULT_TYPE_WEIGHTS := {"text": 1.0, "sound": 0.0, "photo": 0.0, "video": 0.0}
|
||||
|
||||
## Активные сущности часа.
|
||||
@@ -95,12 +98,16 @@ var _recv_interval_current: float = DEFAULT_RECV_INTERVAL
|
||||
var _send_interval_current: float = DEFAULT_SEND_INTERVAL
|
||||
var _next_spawn_at: float = -1.0
|
||||
var _spawn_frozen_notified: bool = false
|
||||
## Момент последней попытки создать запрос (hour_elapsed). Нужен анти-голоданию.
|
||||
var _last_request_attempt_t: float = -REQUEST_ATTEMPT_MIN_GAP
|
||||
|
||||
## Состояние/настройки.
|
||||
var game = null
|
||||
var registry = null
|
||||
var rng = null
|
||||
var successes_this_hour: int = 0
|
||||
## Успешных TCP-соединений за забег (вход в формулу зоны ConnectionGame).
|
||||
var connect_successes: int = 0
|
||||
var shield_until: float = 0.0
|
||||
var recv_disabled_until: float = 0.0
|
||||
var send_disabled_until: float = 0.0
|
||||
@@ -116,6 +123,7 @@ var _next_packet_num: int = 1
|
||||
var _next_request_num: int = 1
|
||||
var _order_counter: int = 0
|
||||
var _registry_accum: float = 0.0
|
||||
var _lost_check_accum: float = 0.0
|
||||
var _finish_guard: bool = false
|
||||
var _attack_paused: bool = false
|
||||
|
||||
@@ -145,6 +153,9 @@ func start_hour(p_hour: int) -> void:
|
||||
_bind()
|
||||
_clear_entities()
|
||||
hour = clampi(int(p_hour), 1, 24)
|
||||
# Строго до сброса счётчиков: не сеем заново при reload/continue.
|
||||
var seed_h1: bool = hour == 1 and requests_spawned == 0 \
|
||||
and registry != null and registry.files.is_empty()
|
||||
var def := _hour_def()
|
||||
var base_files := maxi(0, int(def.get("files", 4)))
|
||||
files_budget = mini(MAX_FILES_PER_HOUR, base_files + maxi(0, carry_files))
|
||||
@@ -166,8 +177,13 @@ func start_hour(p_hour: int) -> void:
|
||||
hour_elapsed = 0.0
|
||||
spawn_recv_accum = 0.0
|
||||
spawn_send_accum = 0.0
|
||||
_lost_check_accum = 0.0
|
||||
# Анти-голодание: первый запрос может появиться сразу, как только в папке
|
||||
# есть подходящий файл (а не через send_interval).
|
||||
_last_request_attempt_t = -REQUEST_ATTEMPT_MIN_GAP
|
||||
_recv_interval_current = _next_receive_interval()
|
||||
_next_spawn_at = _recv_interval_current
|
||||
# H1: первый пакет приходит сразу, чтобы час не начинался с простоя.
|
||||
_next_spawn_at = 0.0 if hour == 1 else _recv_interval_current
|
||||
_spawn_frozen_notified = false
|
||||
_send_interval_current = _send_interval()
|
||||
_modal_count = 0
|
||||
@@ -192,12 +208,79 @@ func start_hour(p_hour: int) -> void:
|
||||
|
||||
running = true
|
||||
_finish_guard = false
|
||||
if seed_h1:
|
||||
_bootstrap_h1()
|
||||
slot_changed.emit()
|
||||
|
||||
|
||||
## H1-затравка: один text-файл в первой папке + сразу запрос на него.
|
||||
func _bootstrap_h1() -> void:
|
||||
if registry == null or not registry.files.is_empty():
|
||||
# Защита от двойного сида: если в реестре уже есть файлы (например,
|
||||
# seed, созданный reset_for_prep()), новый не создаём.
|
||||
return
|
||||
var folder_id := _first_folder_id()
|
||||
if folder_id.is_empty():
|
||||
return
|
||||
var file = registry.create_file(_make_file_name(), "text", folder_id)
|
||||
if file == null:
|
||||
return
|
||||
var req_id := _create_request(1, "tcp", [str(file.id)])
|
||||
if req_id.is_empty():
|
||||
return
|
||||
requests_spawned += 1
|
||||
_last_request_attempt_t = hour_elapsed
|
||||
|
||||
|
||||
func _first_folder_id() -> String:
|
||||
if registry == null:
|
||||
return ""
|
||||
for section in registry.sections.values():
|
||||
for folder_id in section.folder_ids:
|
||||
if registry.folders.has(str(folder_id)):
|
||||
return str(folder_id)
|
||||
for folder_id in registry.folders.keys():
|
||||
return str(folder_id)
|
||||
return ""
|
||||
|
||||
|
||||
## Сброс переноса неразобранных файлов. Вызывается на новом забеге/загрузке.
|
||||
func reset_carry() -> void:
|
||||
carry_files = 0
|
||||
connect_successes = 0
|
||||
|
||||
|
||||
## Чистое состояние директора для экрана подготовки перед H1.
|
||||
##
|
||||
## Выполняет обычный start_hour(1) (создаёт seed-файл H1), но затем убирает
|
||||
## активные запросы/пакеты и все резервации required_by. В подготовке игрок
|
||||
## не видит запросов, поэтому зарезервированный seed нельзя оставлять:
|
||||
## «уничтожить содержимое» не смогло бы его удалить, а SLA списался бы.
|
||||
## Сам запрос появится при старте часа — через starvation guard, либо через
|
||||
## bootstrap, если игрок успел уничтожить seed и реестр опустел.
|
||||
func reset_for_prep() -> void:
|
||||
_bind()
|
||||
# BUG A: счётчики спавна сбрасываем ДО start_hour(1). start_hour сеет seed
|
||||
# H1 только при requests_spawned == 0, а после сыгранного часа он > 0 —
|
||||
# иначе второй и последующие «Новые забеги» оставались без seed-файла в
|
||||
# подготовке (асимметрия с первым забегом).
|
||||
_reset_spawn_counters()
|
||||
start_hour(1)
|
||||
_clear_entities()
|
||||
# Чистое состояние подготовки: bootstrap-запрос снят, счётчики пусты.
|
||||
_reset_spawn_counters()
|
||||
if registry != null:
|
||||
for file in registry.files.values():
|
||||
if file != null:
|
||||
file.required_by = ""
|
||||
running = false
|
||||
|
||||
|
||||
## Счётчики спавна, которые должны быть нулевыми перед/после подготовки.
|
||||
func _reset_spawn_counters() -> void:
|
||||
requests_spawned = 0
|
||||
spawned_this_hour = 0
|
||||
_last_request_attempt_t = -REQUEST_ATTEMPT_MIN_GAP
|
||||
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
@@ -249,6 +332,12 @@ func set_time_scale(value: float) -> void:
|
||||
|
||||
|
||||
func is_minigame_active() -> bool:
|
||||
# Пока TCP-запрос соединяется, открыта модалка мини-игры соединения.
|
||||
# Флаг мини-игры ещё не выставлен (_minigame == null), поэтому атаки
|
||||
# считали, что экран свободен, и могли наложить второй модал.
|
||||
for req in active_requests.values():
|
||||
if req != null and bool(req.get("connecting")):
|
||||
return true
|
||||
return _minigame != null
|
||||
|
||||
|
||||
@@ -305,6 +394,52 @@ func request_required_count(req_id: String) -> int:
|
||||
return _required_count(req)
|
||||
|
||||
|
||||
## Конкретные файлы, которые требует запрос (в порядке required).
|
||||
func request_required_ids(req_id: String) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null:
|
||||
return out
|
||||
for file_id in req.required:
|
||||
out.append(str(file_id))
|
||||
return out
|
||||
|
||||
|
||||
func request_required_names(req_id: String) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for file_id in request_required_ids(req_id):
|
||||
out.append(request_file_name(file_id))
|
||||
return out
|
||||
|
||||
|
||||
## Отображаемое имя файла или «ПОТЕРЯН», если файла уже нет в реестре.
|
||||
func request_file_name(file_id: String) -> String:
|
||||
if registry != null and registry.files.has(str(file_id)):
|
||||
var file = registry.files[str(file_id)]
|
||||
if file != null:
|
||||
return str(file.display_name())
|
||||
return "ПОТЕРЯН"
|
||||
|
||||
|
||||
## required-файлы, которые ещё не прикреплены (id).
|
||||
func request_missing(req_id: String) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null:
|
||||
return out
|
||||
for file_id in req.required:
|
||||
if not req.attached.has(str(file_id)):
|
||||
out.append(str(file_id))
|
||||
return out
|
||||
|
||||
|
||||
func request_missing_names(req_id: String) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for file_id in request_missing(req_id):
|
||||
out.append(request_file_name(file_id))
|
||||
return out
|
||||
|
||||
|
||||
## v2-готовность: собраны все слоты, запрос не занят мини-игрой/очередью.
|
||||
## Request.is_ready() остался legacy (проверяет TCP-connect) — не используем.
|
||||
func is_request_ready(req_id: String) -> bool:
|
||||
@@ -407,6 +542,55 @@ func spawn_request_now(count_override: int = 0) -> String:
|
||||
return _spawn_random_request(true, int(count_override))
|
||||
|
||||
|
||||
## Пул файлов, из которых можно собрать запрос: лежат в разблокированной
|
||||
## папке, не прикреплены и ещё не затребованы другим активным запросом.
|
||||
func _request_file_pool() -> Array:
|
||||
var pool: Array = []
|
||||
if registry == null:
|
||||
return pool
|
||||
var claimed: Dictionary = {}
|
||||
for req in active_requests.values():
|
||||
for file_id in req.required:
|
||||
claimed[str(file_id)] = true
|
||||
for file in registry.files.values():
|
||||
if str(file.location) != "folder":
|
||||
continue
|
||||
if not str(file.attached_request).is_empty():
|
||||
continue
|
||||
if claimed.has(str(file.id)):
|
||||
continue
|
||||
if not str(file.required_by).is_empty():
|
||||
continue
|
||||
if not file.is_available():
|
||||
continue
|
||||
if is_folder_locked(str(file.folder_id)):
|
||||
continue
|
||||
pool.append(file)
|
||||
return pool
|
||||
|
||||
|
||||
## Выбрать N конкретных файлов: один равномерный shuffle пула, первые N.
|
||||
func _pick_request_files(pool: Array, count: int) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
var want := clampi(int(count), 1, MAX_REQUIRED_FILES)
|
||||
if pool.is_empty():
|
||||
return out
|
||||
want = mini(want, pool.size())
|
||||
var shuffled := pool.duplicate()
|
||||
_shuffle_array(shuffled)
|
||||
for i in range(want):
|
||||
out.append(str(shuffled[i].id))
|
||||
return out
|
||||
|
||||
|
||||
func _shuffle_array(items: Array) -> void:
|
||||
for i in range(items.size() - 1, 0, -1):
|
||||
var j := _randi_range(0, i)
|
||||
var tmp = items[i]
|
||||
items[i] = items[j]
|
||||
items[j] = tmp
|
||||
|
||||
|
||||
func _spawn_random_request(force: bool = false, count_override: int = 0) -> String:
|
||||
if registry == null:
|
||||
return ""
|
||||
@@ -415,38 +599,56 @@ func _spawn_random_request(force: bool = false, count_override: int = 0) -> Stri
|
||||
return ""
|
||||
if _normal_request_count() >= active_request_limit:
|
||||
return ""
|
||||
var available: Array = []
|
||||
for file in registry.get_available_files():
|
||||
if not is_folder_locked(str(file.folder_id)):
|
||||
available.append(file)
|
||||
if available.is_empty():
|
||||
var pool := _request_file_pool()
|
||||
if pool.is_empty():
|
||||
return ""
|
||||
var count := 0
|
||||
if count_override > 0:
|
||||
if available.size() < count_override:
|
||||
return ""
|
||||
count = count_override
|
||||
else:
|
||||
count = _roll_required_count()
|
||||
if available.size() < count:
|
||||
return ""
|
||||
var count := count_override if count_override > 0 else _roll_required_count()
|
||||
count = clampi(count, 1, MAX_REQUIRED_FILES)
|
||||
count = mini(count, pool.size())
|
||||
if count <= 0:
|
||||
return ""
|
||||
var req_id := _create_request(count, "tcp")
|
||||
var file_ids := _pick_request_files(pool, count)
|
||||
if file_ids.is_empty():
|
||||
return ""
|
||||
var req_id := _create_request(count, "tcp", file_ids)
|
||||
if req_id.is_empty():
|
||||
return ""
|
||||
requests_spawned += 1
|
||||
return req_id
|
||||
|
||||
|
||||
## Legacy-хелпер: создать запрос под N слотов (файлы не прикрепляются).
|
||||
## Legacy-хелпер: создать запрос на переданные файлы (уже подключённый TCP).
|
||||
func _make_request(files: Array, protocol: String, _is_attack: bool,
|
||||
_attack_kind: String, _attack_id: String) -> String:
|
||||
var count := maxi(1, files.size())
|
||||
return _create_request(count, protocol)
|
||||
var file_ids: Array[String] = []
|
||||
for file in files:
|
||||
if file != null:
|
||||
file_ids.append(str(file.id))
|
||||
var count := maxi(1, file_ids.size())
|
||||
return _create_request(count, protocol, file_ids, true)
|
||||
|
||||
|
||||
func _create_request(count: int, protocol: String) -> String:
|
||||
## Создать запрос с КОНКРЕТНЫМИ required-файлами.
|
||||
## file_ids пустой → попытаться выбрать из пула (count будет зажат).
|
||||
## connected=false — для TCP потребуется мини-игра соединения.
|
||||
func _create_request(count: int, protocol: String, file_ids: Array = [],
|
||||
p_connected: bool = false) -> String:
|
||||
if registry == null:
|
||||
return ""
|
||||
var ids: Array[String] = []
|
||||
for file_id in file_ids:
|
||||
if ids.size() >= MAX_REQUIRED_FILES:
|
||||
break
|
||||
if registry.files.has(str(file_id)) and not ids.has(str(file_id)):
|
||||
ids.append(str(file_id))
|
||||
if ids.is_empty():
|
||||
var pool := _request_file_pool()
|
||||
if pool.is_empty():
|
||||
return ""
|
||||
var want := clampi(int(count), 1, MAX_REQUIRED_FILES)
|
||||
ids = _pick_request_files(pool, mini(want, pool.size()))
|
||||
if ids.is_empty():
|
||||
return ""
|
||||
var req = REQUEST_SCRIPT.new()
|
||||
req.id = "req_%d" % _next_request_num
|
||||
_next_request_num += 1
|
||||
@@ -460,14 +662,17 @@ func _create_request(count: int, protocol: String) -> String:
|
||||
req.attack_kind = ""
|
||||
req.attack_id = ""
|
||||
req.required.clear()
|
||||
for file_id in ids:
|
||||
req.required.append(file_id)
|
||||
var file = registry.files.get(str(file_id))
|
||||
if file != null and (file.required_by.is_empty() or file.required_by == req.id):
|
||||
file.required_by = req.id
|
||||
req.attached.clear()
|
||||
# required — это слоты контейнера (required_count), а не заранее выбранные
|
||||
# файлы: игрок сам решает, какие файлы положить (attach_file).
|
||||
for i in range(maxi(1, int(count))):
|
||||
req.required.append("__slot_%s_%d" % [req.id, i])
|
||||
# Соединения в v2 нет: TCP/UDP — выбор оплаты/скорости, а не мини-игра.
|
||||
req.connected = true
|
||||
req.set_meta("required_count", maxi(1, int(count)))
|
||||
req.required_types = _types_for_ids(ids)
|
||||
req.connected = bool(p_connected)
|
||||
req.connecting = false
|
||||
req.connect_attempts = 0
|
||||
req.set_meta("required_count", ids.size())
|
||||
req.set_meta("queued", false)
|
||||
active_requests[req.id] = req
|
||||
request_added.emit(req.id)
|
||||
@@ -475,18 +680,36 @@ func _create_request(count: int, protocol: String) -> String:
|
||||
return req.id
|
||||
|
||||
|
||||
func _types_for_ids(file_ids: Array[String]) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
if registry == null:
|
||||
return out
|
||||
for file_id in file_ids:
|
||||
var file = registry.files.get(str(file_id))
|
||||
if file != null:
|
||||
out.append(str(file.type))
|
||||
return out
|
||||
|
||||
|
||||
func attach_file(req_id: String, file_id: String) -> bool:
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null or registry == null:
|
||||
return false
|
||||
if req.sending or req.minigame_active or _send_queue.has(req_id):
|
||||
if req.sending or req.minigame_active or req.connecting or _send_queue.has(req_id):
|
||||
return false
|
||||
if req.attached.has(file_id):
|
||||
return true
|
||||
if not req.required.has(file_id):
|
||||
var missing := request_missing(req_id)
|
||||
var shown := file_id
|
||||
if not missing.is_empty():
|
||||
shown = str(missing[0])
|
||||
_emit_toast("Нужен файл: %s" % request_file_name(shown))
|
||||
return false
|
||||
var need := _required_count(req)
|
||||
if req.attached.size() >= need:
|
||||
_emit_toast("Контейнер заполнен")
|
||||
return false
|
||||
if req.attached.has(file_id):
|
||||
return true
|
||||
var file = registry.files.get(file_id)
|
||||
if file == null:
|
||||
return false
|
||||
@@ -502,8 +725,7 @@ func attach_file(req_id: String, file_id: String) -> bool:
|
||||
if not registry.attach_file(file_id, req_id):
|
||||
return false
|
||||
req.attached.append(file_id)
|
||||
_fill_required_slot(req, file_id)
|
||||
req.required_types = _attached_types(req)
|
||||
# M4: required_types описывает типы required-слотов, а не порядок вложений.
|
||||
request_changed.emit(req_id)
|
||||
return true
|
||||
|
||||
@@ -512,15 +734,14 @@ func detach_file(req_id: String, file_id: String) -> bool:
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null or registry == null:
|
||||
return false
|
||||
if req.sending or req.minigame_active or _send_queue.has(req_id):
|
||||
if req.sending or req.minigame_active or req.connecting or _send_queue.has(req_id):
|
||||
return false
|
||||
if not req.attached.has(file_id):
|
||||
return false
|
||||
if registry.files.has(file_id):
|
||||
registry.detach_file(file_id)
|
||||
req.attached.erase(file_id)
|
||||
_clear_required_slot(req, file_id)
|
||||
req.required_types = _attached_types(req)
|
||||
# M4: порядок вложений не должен переписывать типы required-слотов.
|
||||
request_changed.emit(req_id)
|
||||
return true
|
||||
|
||||
@@ -529,7 +750,7 @@ func reject_request(req_id: String) -> bool:
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null:
|
||||
return false
|
||||
if req.sending or req.minigame_active:
|
||||
if req.sending or req.minigame_active or req.connecting:
|
||||
_emit_toast("Запрос уже в работе")
|
||||
return false
|
||||
_add_money(REJECT_FINE_MONEY)
|
||||
@@ -541,21 +762,108 @@ func reject_request(req_id: String) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
## v2: соединения нет. Метод-заглушка, чтобы не ломать старый UI.
|
||||
## Legacy: соединения пакетов нет.
|
||||
func connect_packet(_packet_id: String) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
## v2: соединения нет. Метод-заглушка, чтобы не ломать старый UI.
|
||||
func connect_request(_req_id: String) -> bool:
|
||||
return false
|
||||
## Начать установку TCP-соединения (мини-игра «полоска»).
|
||||
func connect_request(req_id: String) -> bool:
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null:
|
||||
return false
|
||||
if not running or _finish_guard:
|
||||
return false
|
||||
if req.protocol != "tcp" or req.connected or req.connecting:
|
||||
return false
|
||||
if req.sending or req.minigame_active or _send_queue.has(req_id):
|
||||
return false
|
||||
# C1/C2: пока любая модалка владеет экраном, второй мини-игры не создаём.
|
||||
if modal_open:
|
||||
_emit_toast("Сначала закройте текущее окно")
|
||||
return false
|
||||
if _minigame != null:
|
||||
_emit_toast("Сначала закройте мини-игру")
|
||||
return false
|
||||
if hour_elapsed < send_disabled_until:
|
||||
_emit_toast("Отправка временно недоступна")
|
||||
return false
|
||||
if hour_elapsed < req.connect_cooldown_until:
|
||||
_emit_toast("СОЕДИНЕНИЕ: ПОВТОР ЧЕРЕЗ %d с" % int(ceil(req.connect_cooldown_until - hour_elapsed)))
|
||||
return false
|
||||
var problem := _send_ready_error(req)
|
||||
if not problem.is_empty():
|
||||
_emit_toast(problem)
|
||||
return false
|
||||
return _begin_connection(req)
|
||||
|
||||
|
||||
## Открыть мини-игру соединения. Возвращает false, если она уже идёт.
|
||||
func _begin_connection(req) -> bool:
|
||||
if req == null or req.connected or req.connecting:
|
||||
return false
|
||||
req.connecting = true
|
||||
req.connect_attempts += 1
|
||||
request_changed.emit(str(req.id))
|
||||
show_connection.emit(str(req.id), hour, connect_successes)
|
||||
return true
|
||||
|
||||
|
||||
## UI закрыл мини-игру соединения. missed=true — промах (штраф SLA и 8 с),
|
||||
## missed=false (Esc/отмена) — только 4 с паузы, без штрафа.
|
||||
## Совместимость: старый вызов с двумя аргументами трактуется как отмена.
|
||||
func connection_finished(req_id: String, success: bool, missed: bool = false) -> bool:
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null:
|
||||
return false
|
||||
if not req.connecting:
|
||||
return false
|
||||
req.connecting = false
|
||||
if not bool(success):
|
||||
if bool(missed):
|
||||
req.connect_cooldown_until = hour_elapsed + 8.0
|
||||
_add_sla(-4.0)
|
||||
else:
|
||||
req.connect_cooldown_until = hour_elapsed + 4.0
|
||||
request_changed.emit(req_id)
|
||||
_emit_toast("СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО")
|
||||
return true
|
||||
req.connected = true
|
||||
req.connect_cooldown_until = 0.0
|
||||
connect_successes += 1
|
||||
request_changed.emit(req_id)
|
||||
if not running or _finish_guard:
|
||||
return true
|
||||
var problem := _send_ready_error(req)
|
||||
if not problem.is_empty():
|
||||
_emit_toast(problem)
|
||||
return true
|
||||
if _minigame != null:
|
||||
return true
|
||||
return _launch_send_minigame(req_id)
|
||||
|
||||
|
||||
func _send_ready_error(req) -> String:
|
||||
if req == null:
|
||||
return "Контейнер не собран"
|
||||
var need := _required_count(req)
|
||||
if need <= 0 or req.attached.size() < need:
|
||||
return "Контейнер не собран"
|
||||
if not _attached_files_valid(req):
|
||||
return "Файл контейнера недоступен"
|
||||
return ""
|
||||
|
||||
|
||||
func is_request_connected(req_id: String) -> bool:
|
||||
var req = active_requests.get(req_id)
|
||||
return req != null and bool(req.connected)
|
||||
|
||||
|
||||
func set_request_protocol(req_id: String, protocol: String) -> bool:
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null:
|
||||
return false
|
||||
if req.sending or req.minigame_active or _send_queue.has(req_id):
|
||||
if req.sending or req.minigame_active or req.connecting or req.connected or _send_queue.has(req_id):
|
||||
return false
|
||||
var clean := _clean_protocol(protocol)
|
||||
if clean.is_empty():
|
||||
@@ -572,7 +880,11 @@ func start_send(req_id: String) -> bool:
|
||||
return false
|
||||
if not running or _finish_guard:
|
||||
return false
|
||||
if req.sending or req.minigame_active or _send_queue.has(req_id):
|
||||
if req.sending or req.minigame_active or req.connecting or _send_queue.has(req_id):
|
||||
return false
|
||||
# C1/C2: модалка (атака/отправка/соединение) уже занимает экран.
|
||||
if modal_open:
|
||||
_emit_toast("Сначала закройте текущее окно")
|
||||
return false
|
||||
if _minigame != null:
|
||||
_emit_toast("Сначала закройте мини-игру")
|
||||
@@ -580,23 +892,34 @@ func start_send(req_id: String) -> bool:
|
||||
if hour_elapsed < send_disabled_until:
|
||||
_emit_toast("Отправка временно недоступна")
|
||||
return false
|
||||
var need := _required_count(req)
|
||||
if need <= 0 or req.attached.size() < need:
|
||||
_emit_toast("Контейнер не собран")
|
||||
if req.protocol == "tcp" and hour_elapsed < req.connect_cooldown_until:
|
||||
_emit_toast("СОЕДИНЕНИЕ: ПОВТОР ЧЕРЕЗ %d с" % int(ceil(req.connect_cooldown_until - hour_elapsed)))
|
||||
return false
|
||||
if _attached_files_valid(req) == false:
|
||||
_emit_toast("Файл контейнера недоступен")
|
||||
var problem := _send_ready_error(req)
|
||||
if not problem.is_empty():
|
||||
_emit_toast(problem)
|
||||
return false
|
||||
# TCP сначала требует мини-игру соединения; UDP шлёт сразу.
|
||||
if req.protocol == "tcp" and not req.connected:
|
||||
return _begin_connection(req)
|
||||
return _launch_send_minigame(req_id)
|
||||
|
||||
|
||||
## Собранный запрос проходит в обычную мини-игру отправки.
|
||||
func _launch_send_minigame(req_id: String) -> bool:
|
||||
var req = active_requests.get(req_id)
|
||||
if req == null:
|
||||
return false
|
||||
var problem := _send_ready_error(req)
|
||||
if not problem.is_empty():
|
||||
_emit_toast(problem)
|
||||
return false
|
||||
var types := _attached_types(req)
|
||||
req.required_types = types
|
||||
var selection: Dictionary = ECONOMY.send_minigame_for(types)
|
||||
req.send_sum = int(selection.get("sum", 0))
|
||||
req.minigame_id = str(selection.get("id", "dots"))
|
||||
req.minigame_tier = clampi(int(selection.get("tier", 1)), 1, 4)
|
||||
req.minigame_active = true
|
||||
# Legacy Request.is_ready() перестаёт быть true на время мини-игры/отправки.
|
||||
_blank_required_slots(req)
|
||||
request_changed.emit(req_id)
|
||||
|
||||
var context := {
|
||||
@@ -884,11 +1207,15 @@ func report(packets_left_override: int = -1, requests_left_override: int = -1) -
|
||||
|
||||
func _tick_requests(dt: float) -> void:
|
||||
_check_failed_files()
|
||||
_lost_check_accum += dt
|
||||
if _lost_check_accum >= 0.5:
|
||||
_lost_check_accum = 0.0
|
||||
_check_lost_required()
|
||||
for id in active_requests.keys():
|
||||
if not active_requests.has(id):
|
||||
continue
|
||||
var req = active_requests[id]
|
||||
if req.minigame_active or _send_queue.has(str(id)):
|
||||
if req.minigame_active or req.connecting or _send_queue.has(str(id)):
|
||||
continue
|
||||
# D2: отправка уже началась — игрок не может на неё повлиять, поэтому
|
||||
# таймер запроса замирает и тикает только прогресс передачи. Таймаут
|
||||
@@ -954,20 +1281,71 @@ func _tick_spawns(dt: float) -> void:
|
||||
|
||||
|
||||
func _tick_request_spawns(dt: float) -> void:
|
||||
if requests_spawned >= request_cap or _normal_request_count() >= active_request_limit:
|
||||
if requests_spawned >= request_cap:
|
||||
spawn_send_accum = 0.0
|
||||
return
|
||||
spawn_send_accum += dt
|
||||
var steps := 0
|
||||
while spawn_send_accum >= _send_interval_current and steps < MAX_SPAWN_STEPS:
|
||||
steps += 1
|
||||
spawn_send_accum -= _send_interval_current
|
||||
_send_interval_current = _send_interval()
|
||||
if requests_spawned >= request_cap or _normal_request_count() >= active_request_limit:
|
||||
spawn_send_accum = 0.0
|
||||
return
|
||||
if not _spawn_random_request().is_empty():
|
||||
break
|
||||
if _normal_request_count() >= active_request_limit:
|
||||
# Слот занят: «заряд» не копим, чтобы после освобождения соблюсти
|
||||
# интервал между запросами.
|
||||
spawn_send_accum = 0.0
|
||||
else:
|
||||
spawn_send_accum += dt
|
||||
var steps := 0
|
||||
while spawn_send_accum >= _send_interval_current and steps < MAX_SPAWN_STEPS:
|
||||
steps += 1
|
||||
spawn_send_accum -= _send_interval_current
|
||||
_last_request_attempt_t = hour_elapsed
|
||||
var req_id := _spawn_random_request()
|
||||
_send_interval_current = _send_interval()
|
||||
if not req_id.is_empty():
|
||||
spawn_send_accum = 0.0
|
||||
break
|
||||
if requests_spawned >= request_cap \
|
||||
or _normal_request_count() >= active_request_limit:
|
||||
spawn_send_accum = 0.0
|
||||
break
|
||||
# Анти-голодание: пока запросов нет, не ждём полный интервал (22 с в H1).
|
||||
_maybe_starvation_spawn()
|
||||
|
||||
|
||||
func _request_starvation_ready() -> bool:
|
||||
if not active_requests.is_empty():
|
||||
return false
|
||||
if requests_spawned >= request_cap:
|
||||
return false
|
||||
if _normal_request_count() >= active_request_limit:
|
||||
return false
|
||||
if hour_elapsed - _last_request_attempt_t < REQUEST_ATTEMPT_MIN_GAP:
|
||||
return false
|
||||
return not _request_file_pool().is_empty()
|
||||
|
||||
|
||||
func _maybe_starvation_spawn() -> void:
|
||||
if not _request_starvation_ready():
|
||||
return
|
||||
_last_request_attempt_t = hour_elapsed
|
||||
if not _spawn_random_request().is_empty():
|
||||
spawn_send_accum = 0.0
|
||||
|
||||
|
||||
## Подсказка для пустого экрана запросов. "" — подсказка неуместна.
|
||||
func next_request_hint() -> String:
|
||||
if not running or _finish_guard:
|
||||
return ""
|
||||
if requests_spawned >= request_cap:
|
||||
return "Все запросы часа выданы"
|
||||
if _normal_request_count() >= active_request_limit:
|
||||
return ""
|
||||
if _request_file_pool().is_empty():
|
||||
return "Нет свободных файлов для запроса"
|
||||
if _request_starvation_ready():
|
||||
return "Следующий запрос ~0 с"
|
||||
var wait := maxf(0.0, _send_interval_current - spawn_send_accum)
|
||||
if active_requests.is_empty():
|
||||
var gap_left := maxf(0.0,
|
||||
REQUEST_ATTEMPT_MIN_GAP - (hour_elapsed - _last_request_attempt_t))
|
||||
wait = minf(wait, gap_left)
|
||||
return "Следующий запрос ~%d с" % int(ceil(wait))
|
||||
|
||||
|
||||
func _tick_attacks(dt: float) -> void:
|
||||
@@ -1091,7 +1469,14 @@ func _complete_send(req_id: String) -> void:
|
||||
if game != null:
|
||||
game.streak = streak + 1
|
||||
_add_stat("requests_done", 1)
|
||||
_emit_toast("Отправлено: +%d $" % payout)
|
||||
var files_word := "файлов"
|
||||
var n10 := n % 10
|
||||
var n100 := n % 100
|
||||
if n10 == 1 and n100 != 11:
|
||||
files_word = "файл"
|
||||
elif n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
|
||||
files_word = "файла"
|
||||
_emit_toast("ОТПРАВЛЕНО: %d %s · +%d $" % [n, files_word, payout])
|
||||
_play_sfx(&"money_up")
|
||||
|
||||
# §2.8: при успешной отправке файлы расходуются.
|
||||
@@ -1111,6 +1496,11 @@ func _cleanup_request(req_id: String, detach: bool) -> void:
|
||||
for file_id in req.attached.duplicate():
|
||||
if registry.files.has(str(file_id)):
|
||||
registry.detach_file(str(file_id))
|
||||
if registry != null:
|
||||
for file_id in req.required:
|
||||
var file = registry.files.get(str(file_id))
|
||||
if file != null and file.required_by == req_id:
|
||||
file.required_by = ""
|
||||
req.attached.clear()
|
||||
req.required_types.clear()
|
||||
_send_queue.erase(req_id)
|
||||
@@ -1123,6 +1513,7 @@ func _cleanup_request(req_id: String, detach: bool) -> void:
|
||||
req.sending = false
|
||||
req.slot_reserved = false
|
||||
req.minigame_active = false
|
||||
req.connecting = false
|
||||
req.set_meta("queued", false)
|
||||
active_requests.erase(req_id)
|
||||
request_removed.emit(req_id)
|
||||
@@ -1151,10 +1542,74 @@ func _check_failed_files() -> void:
|
||||
_clear_required_slot(req, str(file_id))
|
||||
changed = true
|
||||
if changed:
|
||||
req.required_types = _attached_types(req)
|
||||
request_changed.emit(str(req_id))
|
||||
|
||||
|
||||
func _check_lost_required() -> void:
|
||||
if registry == null or active_requests.is_empty():
|
||||
return
|
||||
for req_id in active_requests.keys().duplicate():
|
||||
if not active_requests.has(req_id):
|
||||
continue
|
||||
var req = active_requests[req_id]
|
||||
if req == null:
|
||||
continue
|
||||
# M5: во время активной передачи файлы уже «в пути» — потерю не
|
||||
# подменяем и не начисляем выплату за уничтоженный посреди отправки файл.
|
||||
if bool(req.sending):
|
||||
continue
|
||||
var changed := false
|
||||
for i in range(req.required.size()):
|
||||
var fid := str(req.required[i])
|
||||
var file = registry.files.get(fid)
|
||||
var lost := file == null
|
||||
if file != null:
|
||||
var loc := str(file.location)
|
||||
lost = loc == "failed" or loc == "malicious"
|
||||
if not lost:
|
||||
continue
|
||||
var lost_type := ""
|
||||
if file != null:
|
||||
lost_type = str(file.type)
|
||||
elif i < req.required_types.size() and req.required_types.size() == req.required.size():
|
||||
lost_type = str(req.required_types[i])
|
||||
if req.attached.has(fid):
|
||||
req.attached.erase(fid)
|
||||
if file != null and str(file.attached_request) == str(req_id):
|
||||
registry.detach_file(fid)
|
||||
changed = true
|
||||
if file != null and file.required_by == str(req_id):
|
||||
file.required_by = ""
|
||||
var replacement := _find_substitute(lost_type)
|
||||
if replacement.is_empty():
|
||||
# Оставляем дырку: запрос можно отклонить, отправка невозможна.
|
||||
continue
|
||||
req.required[i] = replacement
|
||||
var substitute = registry.files.get(replacement)
|
||||
if substitute != null:
|
||||
substitute.required_by = str(req_id)
|
||||
if i < req.required_types.size():
|
||||
req.required_types[i] = str(substitute.type)
|
||||
else:
|
||||
req.required_types.append(str(substitute.type))
|
||||
changed = true
|
||||
if changed:
|
||||
request_changed.emit(str(req_id))
|
||||
|
||||
|
||||
## Замена потерянного required-файла: равномерный пул, приоритет — тот же тип.
|
||||
func _find_substitute(preferred_type: String) -> String:
|
||||
var pool := _request_file_pool()
|
||||
if pool.is_empty():
|
||||
return ""
|
||||
_shuffle_array(pool)
|
||||
if not preferred_type.is_empty():
|
||||
for file in pool:
|
||||
if str(file.type) == preferred_type:
|
||||
return str(file.id)
|
||||
return str(pool[0].id)
|
||||
|
||||
|
||||
func force_close_send_minigame() -> void:
|
||||
var req_id := _minigame_request_id
|
||||
if _minigame == null and req_id.is_empty():
|
||||
@@ -1396,37 +1851,25 @@ func _default_type_weights(p_hour: int) -> Dictionary:
|
||||
func _required_count(req) -> int:
|
||||
if req == null:
|
||||
return 0
|
||||
if req.has_meta("required_count"):
|
||||
return maxi(0, int(req.get_meta("required_count")))
|
||||
return maxi(0, int(req.required_count()))
|
||||
var count := 0
|
||||
if req.required is Array:
|
||||
count = (req.required as Array).size()
|
||||
if count <= 0 and req.has_meta("required_count"):
|
||||
count = int(req.get_meta("required_count"))
|
||||
return clampi(count, 0, MAX_REQUIRED_FILES)
|
||||
|
||||
|
||||
## Совместимость со старым UI: required содержит N слотов; заполненный слот
|
||||
## несёт id файла, пустой — служебный placeholder "__slot_...".
|
||||
func _fill_required_slot(req, file_id: String) -> void:
|
||||
if req == null:
|
||||
return
|
||||
for i in range(req.required.size()):
|
||||
var value := str(req.required[i])
|
||||
if value.begins_with("__slot_"):
|
||||
req.required[i] = file_id
|
||||
return
|
||||
## Legacy-совместимость: required теперь хранит конкретные id, слоты не меняем.
|
||||
func _fill_required_slot(_req, _file_id: String) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _clear_required_slot(req, file_id: String) -> void:
|
||||
if req == null:
|
||||
return
|
||||
for i in range(req.required.size()):
|
||||
if str(req.required[i]) == file_id:
|
||||
req.required[i] = "__slot_%s_%d" % [req.id, i]
|
||||
return
|
||||
func _clear_required_slot(_req, _file_id: String) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _blank_required_slots(req) -> void:
|
||||
if req == null:
|
||||
return
|
||||
for i in range(req.required.size()):
|
||||
req.required[i] = "__slot_%s_%d" % [req.id, i]
|
||||
func _blank_required_slots(_req) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _request_ready(req) -> bool:
|
||||
@@ -1436,19 +1879,30 @@ func _request_ready(req) -> bool:
|
||||
return false
|
||||
if _send_queue.has(str(req.id)):
|
||||
return false
|
||||
return req.attached.size() >= _required_count(req) and _required_count(req) > 0
|
||||
var need := _required_count(req)
|
||||
if need <= 0:
|
||||
return false
|
||||
for file_id in req.required:
|
||||
if not req.attached.has(str(file_id)):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _attached_files_valid(req) -> bool:
|
||||
if registry == null:
|
||||
if req == null or registry == null:
|
||||
return false
|
||||
# Исчезнувший required-файл (consume/remove) делает запрос неотправляемым.
|
||||
for file_id in req.required:
|
||||
if not registry.files.has(str(file_id)):
|
||||
return false
|
||||
for file_id in req.attached:
|
||||
var file = registry.files.get(str(file_id))
|
||||
if file == null:
|
||||
return false
|
||||
if is_folder_locked(str(file.folder_id)):
|
||||
return false
|
||||
if str(file.location) == "locked" or str(file.location) == "table":
|
||||
var loc := str(file.location)
|
||||
if loc == "locked" or loc == "table" or loc == "failed" or loc == "malicious":
|
||||
return false
|
||||
return true
|
||||
|
||||
@@ -1489,8 +1943,8 @@ func _roll_required_count() -> int:
|
||||
for i in range(keys.size()):
|
||||
acc += float(weights[i])
|
||||
if roll < acc:
|
||||
return clampi(int(str(keys[i])), 1, 3)
|
||||
return clampi(int(str(keys[keys.size() - 1])), 1, 3)
|
||||
return clampi(int(str(keys[i])), 1, MAX_REQUIRED_FILES)
|
||||
return clampi(int(str(keys[keys.size() - 1])), 1, MAX_REQUIRED_FILES)
|
||||
|
||||
|
||||
# --- канал -----------------------------------------------------------------
|
||||
@@ -1652,9 +2106,14 @@ func _clear_entities() -> void:
|
||||
for file_id in req.attached.duplicate():
|
||||
if registry.files.has(str(file_id)):
|
||||
registry.detach_file(str(file_id))
|
||||
for file_id in req.required:
|
||||
var file = registry.files.get(str(file_id))
|
||||
if file != null and file.required_by == str(req_id):
|
||||
file.required_by = ""
|
||||
req.attached.clear()
|
||||
req.sending = false
|
||||
req.minigame_active = false
|
||||
req.connecting = false
|
||||
req.slot_reserved = false
|
||||
for packet_id in active_packets.keys().duplicate():
|
||||
_remove_packet(str(packet_id))
|
||||
@@ -1721,9 +2180,7 @@ func _on_show_attack_minigame(game_node, kind: String) -> void:
|
||||
show_attack_minigame.emit(game_node, kind)
|
||||
|
||||
|
||||
## Legacy-хук: соединения больше нет.
|
||||
func _on_connection_finished(_success: bool, _entity_id: String) -> void:
|
||||
pass
|
||||
## Legacy-хук больше не используется: UI зовёт connection_finished(req_id, success).
|
||||
|
||||
|
||||
func _add_money(delta: int) -> void:
|
||||
|
||||
@@ -31,6 +31,8 @@ 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
|
||||
|
||||
@@ -18,6 +18,12 @@ var timer_total: float = 0.0
|
||||
|
||||
var slot_reserved: bool = false
|
||||
var connected: bool = false
|
||||
## 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
|
||||
|
||||
@@ -91,6 +97,9 @@ func to_dict() -> Dictionary:
|
||||
"timer_total": timer_total,
|
||||
"slot_reserved": slot_reserved,
|
||||
"connected": connected,
|
||||
"connecting": connecting,
|
||||
"connect_attempts": connect_attempts,
|
||||
"connect_cooldown_until": connect_cooldown_until,
|
||||
"minigame_active": minigame_active,
|
||||
"sending": sending,
|
||||
"quality": quality,
|
||||
@@ -120,6 +129,9 @@ 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.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.quality = clampf(float(d.get("quality", 1.0)), 0.0, 1.0)
|
||||
|
||||
@@ -38,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")
|
||||
@@ -399,28 +400,38 @@ 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:
|
||||
modal_open = false
|
||||
set_modal_open(false)
|
||||
minigame_closed.emit(req_id)
|
||||
|
||||
|
||||
func _on_show_attack_minigame(game_node, kind: String) -> void:
|
||||
modal_open = true
|
||||
set_modal_open(true)
|
||||
show_attack_minigame.emit(game_node, kind)
|
||||
|
||||
|
||||
func _on_attack_resolved(kind: String, success: bool, tier: int) -> void:
|
||||
modal_open = false
|
||||
set_modal_open(false)
|
||||
attack_resolved.emit(kind, success, tier)
|
||||
|
||||
|
||||
@@ -487,7 +498,9 @@ func _reset_director_state() -> void:
|
||||
if hour_director == null:
|
||||
return
|
||||
hour_director.reset_carry()
|
||||
hour_director.start_hour(1)
|
||||
# Подготовка перед H1: seed-файл создаём, но активный запрос/резервации
|
||||
# не оставляем — иначе игрок не видит запрос, а файл уже занят.
|
||||
hour_director.reset_for_prep()
|
||||
hour_director.running = false
|
||||
hour_director.paused = false
|
||||
hour_director.set_modal_open(false)
|
||||
|
||||
@@ -10,7 +10,8 @@ extends MiniGame
|
||||
const GAME_ID := "ddos"
|
||||
const TITLE_RU := "ЩИТ"
|
||||
|
||||
const FIELD := Rect2(18.0, 58.0, 604.0, 246.0)
|
||||
## Поле заканчивается на 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
|
||||
@@ -370,7 +371,8 @@ func _draw_game() -> void:
|
||||
|
||||
|
||||
func _draw_footer_hint() -> void:
|
||||
_draw_label("ЩИТ СЛЕДУЕТ ЗА МЫШЬЮ ПО X · СТРЕЛКИ ← → ТОЖЕ", Vector2(18.0, 350.0),
|
||||
# Подсказка живёт в свободной полосе между полем (y=294) и футером (y=320).
|
||||
_draw_label("ЩИТ СЛЕДУЕТ ЗА МЫШЬЮ ПО X · СТРЕЛКИ ← → ТОЖЕ", Vector2(18.0, 298.0),
|
||||
10, Theme90.SHADE, 604.0)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const COLS := 3
|
||||
const CARD_W := 190.0
|
||||
const CARD_GAP := 8.0
|
||||
const GRID_TOP := 58.0
|
||||
const GRID_BOTTOM := 306.0
|
||||
const GRID_BOTTOM := 294.0
|
||||
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
|
||||
|
||||
const TIERS := {
|
||||
@@ -533,14 +533,9 @@ func _card_rect(index: int) -> Rect2:
|
||||
|
||||
|
||||
func _draw_game() -> void:
|
||||
var font := Theme90.ui()
|
||||
var hints: Array = _data.get("hints", [])
|
||||
var marked_count := _marked.size()
|
||||
_draw_label("ОТМЕЧЕНО: %d" % marked_count, Vector2(24.0, 44.0), 12,
|
||||
Theme90.INK, 180.0)
|
||||
var hint_text := "· ".join(hints) if not hints.is_empty() else "СНИМАЙТЕ ТОЛЬКО МУСОР"
|
||||
_draw_label(Theme90.fit(font, hint_text, 10, 360.0), Vector2(226.0, 46.0), 10,
|
||||
Theme90.SHADE, 380.0, HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
|
||||
for i in range(_cards.size()):
|
||||
_draw_card(i)
|
||||
@@ -637,10 +632,11 @@ func _draw_card(index: int) -> void:
|
||||
|
||||
func _draw_footer_hint() -> void:
|
||||
var hints: Array = _data.get("hints", [])
|
||||
if hints.is_empty():
|
||||
return
|
||||
_draw_label("ПОДСКАЗКИ: " + " · ".join(hints), Vector2(18.0, 350.0), 10,
|
||||
Theme90.SHADE, 604.0)
|
||||
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)
|
||||
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
|
||||
@@ -13,10 +13,10 @@ extends MiniGame
|
||||
const GAME_ID := "phishing"
|
||||
const TITLE_RU := "ПАСПОРТ"
|
||||
|
||||
const ISSUE_RECT := Rect2(142.0, 274.0, 166.0, 30.0)
|
||||
const DENY_RECT := Rect2(332.0, 274.0, 166.0, 30.0)
|
||||
const FILE_RECT := Rect2(24.0, 62.0, 286.0, 206.0)
|
||||
const PASS_RECT := Rect2(330.0, 62.0, 286.0, 206.0)
|
||||
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 := {
|
||||
@@ -604,5 +604,6 @@ func _draw_footer_hint() -> void:
|
||||
var labels: Array = []
|
||||
for check in checks:
|
||||
labels.append(str(CHECK_LABELS.get(str(check), str(check))))
|
||||
_draw_label("СВЕРЬТЕ: " + " · ".join(labels), Vector2(18.0, 350.0), 10,
|
||||
Theme90.SHADE, 604.0)
|
||||
# Подсказка — в полосе между кнопками (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)
|
||||
@@ -14,7 +14,7 @@ const TITLE_RU := "КАРАНТИН"
|
||||
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
|
||||
|
||||
const GRID_TOP := 58.0
|
||||
const GRID_BOTTOM := 306.0
|
||||
const GRID_BOTTOM := 294.0
|
||||
const GRID_GAP := 8.0
|
||||
|
||||
const TIERS := {
|
||||
@@ -516,7 +516,8 @@ func _draw_footer_hint() -> void:
|
||||
text = "ИКОНКА ДОКУМЕНТА НИЧЕГО НЕ ЗНАЧИТ · НАВЕДИТЕ НА ФАЙЛ"
|
||||
elif masking == "similar_names":
|
||||
text = "«EXE» В ИМЕНИ — ЕЩЁ НЕ ПРИГОВОР · ПРОВЕРЬТЕ РАСШИРЕНИЕ"
|
||||
_draw_label(text, Vector2(18.0, 350.0), 10, Theme90.SHADE, 604.0)
|
||||
# Подсказка — в полосе между сеткой (y=294) и футером (y=320).
|
||||
_draw_label(text, Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
|
||||
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ extends Control
|
||||
signal finished(quality: float)
|
||||
|
||||
const WINDOW_SIZE := Vector2(640.0, 360.0)
|
||||
const HEADER_H := 30.0
|
||||
## Шапка двухстрочная: название + цель игры. Контент мини-игр начинается
|
||||
## с y=44, поэтому высота 42 не перекрывает жёстко заданные координаты.
|
||||
const HEADER_H := 42.0
|
||||
const FOOTER_Y := 320.0
|
||||
const CONTENT_RECT := Rect2(18.0, 40.0, 604.0, 270.0)
|
||||
const ACTION_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
|
||||
@@ -20,24 +22,39 @@ const SKIP_RECT := Rect2(18.0, 320.0, 148.0, 28.0)
|
||||
const CONTINUE_RECT := Rect2(245.0, 252.0, 150.0, 32.0)
|
||||
|
||||
const SEND_IDS: Array[String] = [
|
||||
"dots", "sound", "wires", "text", "video", "photo", "maze",
|
||||
"dots", "sound", "text", "video", "photo", "maze",
|
||||
]
|
||||
const ATTACK_IDS: Array[String] = [
|
||||
"junk", "ddos", "phishing", "ransomware",
|
||||
]
|
||||
const GAME_TITLES := {
|
||||
"dots": "ОДНА ЛИНИЯ",
|
||||
"sound": "УРОВНИ",
|
||||
"wires": "ПРОВОДА",
|
||||
"text": "ЧУЖОЕ СЛОВО",
|
||||
"video": "РАСКАДРОВКА",
|
||||
"photo": "КАДР",
|
||||
"maze": "ЛАБИРИНТ",
|
||||
"junk": "ФИЛЬТР",
|
||||
"ddos": "ЩИТ",
|
||||
"phishing": "ПАСПОРТ",
|
||||
"ransomware": "КАРАНТИН",
|
||||
}
|
||||
const LEGACY_TYPES := {
|
||||
"text": "text",
|
||||
"sound": "sound",
|
||||
"photo": "photo",
|
||||
"video": "video",
|
||||
"connection": "wires",
|
||||
"connect": "wires",
|
||||
# Старые id «проводов» после удаления игры ведут на ближайшую по смыслу.
|
||||
"connection": "dots",
|
||||
"connect": "dots",
|
||||
}
|
||||
## Короткие цели для игр атак (файлы атак не переопределяют _goal_text).
|
||||
## Игры отправки возвращают свою строку сами.
|
||||
const ATTACK_GOALS := {
|
||||
"junk": "СНИМИТЕ МУСОРНЫЕ ФАЙЛЫ · НОРМАЛЬНЫЕ НЕ ТРОГАЙТЕ",
|
||||
"ddos": "ДВИГАЙТЕ ЩИТ И ОТРАЖАЙТЕ ПАКЕТЫ",
|
||||
"phishing": "СВЕРЬТЕ ДАННЫЕ КЛИЕНТА С ЭТАЛОНОМ",
|
||||
"ransomware": "ИЗОЛИРУЙТЕ ФАЙЛЫ С ОПАСНЫМ РАСШИРЕНИЕМ",
|
||||
}
|
||||
|
||||
## Общие поля контракта.
|
||||
@@ -76,6 +93,10 @@ func _init() -> void:
|
||||
|
||||
func _ready() -> void:
|
||||
_center_on_screen()
|
||||
# Main ставит модалку через PRESET_CENTER уже после add_child, а в Godot
|
||||
# 4.7.2 этот пресет обнуляет offsets (окно уезжает в правый нижний угол).
|
||||
# Повторяем центрирование в конце кадра, когда родитель уже modal_root.
|
||||
call_deferred("_center_on_screen")
|
||||
var viewport := get_viewport()
|
||||
if viewport != null and not viewport.size_changed.is_connected(_center_on_screen):
|
||||
viewport.size_changed.connect(_center_on_screen)
|
||||
@@ -94,10 +115,12 @@ func start(type_or_id: String, p_difficulty: float = 1.0, p_seed: int = 0,
|
||||
|
||||
game_id = resolved
|
||||
difficulty = maxf(0.0, float(p_difficulty))
|
||||
# Старый вызов передавал 2.5 и т.п.; фабрика передаёт 1..4.
|
||||
tier = clampi(int(round(difficulty)), 1, 4)
|
||||
# Старый вызов передавал 2.5 и т.п.; фабрика передаёт 1..3 (отправка)
|
||||
# или 1..4 (атаки). Атаки сохраняют четыре ступени.
|
||||
var max_tier := 4 if is_attack_id(game_id) else 3
|
||||
tier = clampi(int(round(difficulty)), 1, max_tier)
|
||||
if difficulty >= 1.0 and difficulty <= 4.0 and is_equal_approx(difficulty, roundf(difficulty)):
|
||||
tier = clampi(int(difficulty), 1, 4)
|
||||
tier = clampi(int(difficulty), 1, max_tier)
|
||||
context = p_context.duplicate(true)
|
||||
|
||||
seed_value = p_seed
|
||||
@@ -146,15 +169,37 @@ func title() -> String:
|
||||
return str(GAME_TITLES.get(game_id, "МИНИ-ИГРА"))
|
||||
|
||||
|
||||
## Максимум ступеней: у отправки три (после удаления T1), у атак четыре.
|
||||
func max_tier() -> int:
|
||||
return 4 if is_attack or is_attack_id(game_id) else 3
|
||||
|
||||
|
||||
func tier_label() -> String:
|
||||
return "T%d" % tier
|
||||
return "СЛОЖНОСТЬ %d/%d" % [clampi(tier, 1, max_tier()), max_tier()]
|
||||
|
||||
|
||||
## Подпись кнопки отказа: атаки сдаются, отправки пропускаются.
|
||||
## Текст с подсказкой «2 клика» добавляется только при отрисовке, чтобы
|
||||
## контракт skip_label() оставался прежним для тестов и других систем.
|
||||
func skip_label() -> String:
|
||||
return "СДАТЬСЯ" if is_attack else "ПРОПУСТИТЬ"
|
||||
|
||||
|
||||
## Одна строка цели игры под шапкой. Переопределяется наследниками.
|
||||
func _goal_text() -> String:
|
||||
return str(ATTACK_GOALS.get(game_id, ""))
|
||||
|
||||
|
||||
## Прогресс 0..1 для тонкой полосы в шапке; -1 — не показывать.
|
||||
func _progress_ratio() -> float:
|
||||
return -1.0
|
||||
|
||||
|
||||
## Короткая статистика для экрана результата (одна строка).
|
||||
func _result_stats() -> String:
|
||||
return ""
|
||||
|
||||
|
||||
func quality() -> float:
|
||||
return quality_value
|
||||
|
||||
@@ -205,11 +250,17 @@ func _emit_finished_once() -> void:
|
||||
|
||||
|
||||
func _default_result_detail() -> String:
|
||||
var parts: Array[String] = []
|
||||
var stats := _result_stats()
|
||||
if not stats.is_empty():
|
||||
parts.append(stats)
|
||||
var reward := str(context.get("reward_text", ""))
|
||||
var penalty := str(context.get("penalty_text", ""))
|
||||
if not reward.is_empty():
|
||||
return reward
|
||||
return penalty
|
||||
parts.append(reward)
|
||||
var penalty := str(context.get("penalty_text", ""))
|
||||
if not penalty.is_empty():
|
||||
parts.append(penalty)
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
# --- ввод ------------------------------------------------------------------
|
||||
@@ -309,15 +360,29 @@ func _draw() -> void:
|
||||
Theme90.HAIRLINE)
|
||||
draw_circle(Vector2(tr.position.x + 14.0, tr.position.y + 14.0), 3.0,
|
||||
Theme90.ACCENT)
|
||||
Theme90.text(self, font, Vector2(tr.position.x + 24.0, tr.position.y + 7.0),
|
||||
title(), 16, Theme90.NAVY_DEEP, tr.size.x - 210.0)
|
||||
Theme90.text(self, font, Vector2(tr.position.x + 24.0, tr.position.y + 3.0),
|
||||
title(), 18, Theme90.NAVY_DEEP, tr.size.x - 400.0)
|
||||
|
||||
var badge := Rect2(tr.end.x - 196.0, tr.position.y + 7.0, 42.0, 16.0)
|
||||
var badge := Rect2(tr.end.x - 124.0, tr.position.y + 6.0, 116.0, 17.0)
|
||||
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.BORDER_SOFT, 2, 1), badge)
|
||||
Theme90.text_vcenter(self, font, badge, tier_label(), 10, Theme90.NAVY,
|
||||
Theme90.text_vcenter(self, font, badge, tier_label(), 11, Theme90.NAVY,
|
||||
-1, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
Theme90.text(self, font, Vector2(tr.end.x - 146.0, tr.position.y + 8.0),
|
||||
"БЕЗ ТАЙМЕРА", 10, Theme90.SHADE, 130.0, HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
|
||||
var ratio := _progress_ratio()
|
||||
if ratio >= 0.0:
|
||||
var bar := Rect2(tr.end.x - 252.0, tr.position.y + 16.0, 104.0, 6.0)
|
||||
Theme90.text(self, font, Vector2(bar.position.x - 38.0, bar.position.y - 2.0),
|
||||
"%d%%" % roundi(clampf(ratio, 0.0, 1.0) * 100.0), 10, Theme90.DARK,
|
||||
34.0, HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
_draw_bar(bar, ratio, Theme90.SUCCESS if ratio >= 0.999 else Theme90.ACCENT)
|
||||
|
||||
var goal := _goal_text()
|
||||
if not goal.is_empty():
|
||||
draw_rect(Rect2(tr.position.x + 14.0, tr.position.y + 26.0, 2.0, 12.0),
|
||||
Theme90.ACCENT_LIGHT)
|
||||
Theme90.text(self, font, Vector2(tr.position.x + 24.0, tr.position.y + 25.0),
|
||||
Theme90.fit(font, goal, 11, tr.size.x - 48.0), 11, Theme90.DARK,
|
||||
tr.size.x - 48.0)
|
||||
|
||||
_draw_game()
|
||||
_draw_skip_button()
|
||||
@@ -344,13 +409,15 @@ func _draw_skip_button() -> void:
|
||||
Theme90.SHADOW if not _skip_armed else Color(0, 0, 0, 0)), SKIP_RECT)
|
||||
var label := skip_label()
|
||||
if _skip_armed:
|
||||
label = "ТОЧНО СДАТЬСЯ?" if is_attack else "ТОЧНО ПРОПУСТИТЬ?"
|
||||
Theme90.text_vcenter(self, Theme90.ui(), SKIP_RECT, label, 10, fg,
|
||||
label = "ЕЩЁ КЛИК — СДАЧА" if is_attack else "ЕЩЁ КЛИК — ПРОПУСК"
|
||||
else:
|
||||
label = "%s · 2 КЛИКА" % label
|
||||
Theme90.text_vcenter(self, Theme90.ui(), SKIP_RECT, label, 11, fg,
|
||||
-1, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
|
||||
|
||||
func _draw_action_button(rect: Rect2, label: String, enabled: bool = true,
|
||||
primary: bool = true) -> void:
|
||||
primary: bool = true, size_px: int = 12) -> void:
|
||||
var face := Theme90.PAPER_HI
|
||||
var fg := Theme90.INK
|
||||
var border := Theme90.BORDER
|
||||
@@ -360,17 +427,17 @@ func _draw_action_button(rect: Rect2, label: String, enabled: bool = true,
|
||||
border = Theme90.ACCENT.darkened(0.18)
|
||||
elif not enabled:
|
||||
face = Theme90.PAPER
|
||||
fg = Theme90.SHADE
|
||||
fg = Theme90.DARK
|
||||
border = Theme90.BORDER_SOFT
|
||||
draw_style_box(Theme90.box(face, border, 3, 1,
|
||||
Theme90.SHADOW if enabled else Color(0, 0, 0, 0)), rect)
|
||||
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 12, fg,
|
||||
Theme90.text_vcenter(self, Theme90.ui(), rect, label, size_px, fg,
|
||||
-1, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
|
||||
|
||||
func _draw_secondary_button(rect: Rect2, label: String) -> void:
|
||||
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 3, 1), rect)
|
||||
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 10, Theme90.DARK,
|
||||
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 11, Theme90.DARK,
|
||||
-1, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
|
||||
|
||||
@@ -404,31 +471,39 @@ func _result_continue_rect() -> Rect2:
|
||||
|
||||
|
||||
func _result_panel_rect() -> Rect2:
|
||||
return Rect2((size.x - 380.0) * 0.5, (size.y - 196.0) * 0.5, 380.0, 196.0)
|
||||
return Rect2((size.x - 400.0) * 0.5, (size.y - 232.0) * 0.5, 400.0, 232.0)
|
||||
|
||||
|
||||
func _draw_result_overlay() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, size), Color(0.08, 0.10, 0.10, 0.58))
|
||||
var panel := _result_panel_rect()
|
||||
_draw_modal_panel(panel)
|
||||
Theme90.text(self, Theme90.logo(), Vector2(panel.position.x, panel.position.y + 18.0),
|
||||
"РЕЗУЛЬТАТ", 16, Theme90.NAVY_DEEP, panel.size.x,
|
||||
Theme90.text(self, Theme90.logo(), Vector2(panel.position.x, panel.position.y + 10.0),
|
||||
"РЕЗУЛЬТАТ", 13, Theme90.NAVY_DEEP, panel.size.x,
|
||||
HORIZONTAL_ALIGNMENT_CENTER)
|
||||
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x, panel.position.y + 30.0),
|
||||
Theme90.fit(Theme90.ui(), title(), 12, panel.size.x - 36.0), 12, Theme90.NAVY,
|
||||
panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
var q_text := "%d%%" % roundi(quality_value * 100.0)
|
||||
Theme90.text(self, Theme90.logo(), Vector2(panel.position.x, panel.position.y + 50.0),
|
||||
q_text, 24, Theme90.INK, panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
var verdict := verdict_for(quality_value)
|
||||
var vcol := Theme90.SUCCESS if quality_value >= 0.75 else (
|
||||
Theme90.text(self, Theme90.logo(), Vector2(panel.position.x, panel.position.y + 46.0),
|
||||
q_text, 30, Theme90.INK, panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
var qcol := Theme90.SUCCESS if quality_value >= 0.75 else (
|
||||
Theme90.WARN if quality_value >= 0.45 else Theme90.ERROR)
|
||||
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x, panel.position.y + 104.0),
|
||||
verdict, 24, vcol, panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
_draw_bar(Rect2(panel.position.x + 26.0, panel.position.y + 86.0,
|
||||
panel.size.x - 52.0, 8.0), quality_value, qcol)
|
||||
var verdict := verdict_for(quality_value)
|
||||
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x, panel.position.y + 102.0),
|
||||
verdict, 22, qcol, panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
if not _result_detail.is_empty():
|
||||
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x + 18.0,
|
||||
panel.position.y + 142.0), Theme90.fit(Theme90.ui(), _result_detail, 10,
|
||||
panel.size.x - 36.0), 10, Theme90.SHADE, panel.size.x - 36.0,
|
||||
panel.position.y + 134.0), Theme90.fit(Theme90.ui(), _result_detail, 11,
|
||||
panel.size.x - 36.0), 11, Theme90.DARK, panel.size.x - 36.0,
|
||||
HORIZONTAL_ALIGNMENT_CENTER)
|
||||
Theme90.text(self, Theme90.ui(), Vector2(panel.position.x,
|
||||
panel.position.y + 154.0), "Esc — продолжить", 10, Theme90.DARK,
|
||||
panel.size.x, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
var cont := _result_continue_rect()
|
||||
_draw_action_button(cont, "ПРОДОЛЖИТЬ", true, true)
|
||||
_draw_action_button(cont, "ПРОДОЛЖИТЬ", true, true, 13)
|
||||
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
@@ -437,9 +512,23 @@ static func resolve_game_id(value: String) -> String:
|
||||
var v := value.strip_edges().to_lower()
|
||||
if SEND_IDS.has(v):
|
||||
return v
|
||||
if ATTACK_IDS.has(v):
|
||||
return v
|
||||
return str(LEGACY_TYPES.get(v, ""))
|
||||
|
||||
|
||||
static func is_attack_id(value: String) -> bool:
|
||||
var v := str(value).strip_edges().to_lower()
|
||||
var loop := Engine.get_main_loop()
|
||||
if loop is SceneTree:
|
||||
var data = (loop as SceneTree).root.get_node_or_null("Data")
|
||||
if data != null and data.has_method("minigame_def"):
|
||||
var def = data.call("minigame_def", v)
|
||||
if def is Dictionary and not (def as Dictionary).is_empty():
|
||||
return str((def as Dictionary).get("kind", "")).to_lower() == "attack"
|
||||
return ATTACK_IDS.has(v)
|
||||
|
||||
|
||||
## Безопасное чтение data/minigame_def(id) с fallback на balance.json.
|
||||
## Возвращает копию словаря; при отсутствии данных — пустой словарь.
|
||||
static func data_minigame_def(id: String) -> Dictionary:
|
||||
@@ -466,20 +555,32 @@ static func data_minigame_def(id: String) -> Dictionary:
|
||||
|
||||
## Строка tier из Data.minigame_def(id). Поддерживает tiers как Array
|
||||
## (текущий формат balance.json) и как Dictionary (старый/альтернативный).
|
||||
##
|
||||
## Отправка теперь имеет 3 ступени, атаки — 4. Если balance.json ещё хранит
|
||||
## старые 4 строки для отправки, tier 1 читает вторую строку (старый T2):
|
||||
## так игра работает и до, и после синхронного сдвига таблиц.
|
||||
static func data_minigame_tier(id: String, p_tier: int) -> Dictionary:
|
||||
var def := data_minigame_def(id)
|
||||
if def.is_empty():
|
||||
return {}
|
||||
var t := clampi(p_tier, 1, 4)
|
||||
var attack_def := is_attack_id(id) \
|
||||
or str(def.get("kind", "")).to_lower() == "attack"
|
||||
var t := clampi(p_tier, 1, 4 if attack_def else 3)
|
||||
var tiers = def.get("tiers", null)
|
||||
var row: Dictionary = {}
|
||||
if tiers is Array:
|
||||
var arr := tiers as Array
|
||||
if t - 1 < arr.size() and arr[t - 1] is Dictionary:
|
||||
row = (arr[t - 1] as Dictionary).duplicate(true)
|
||||
var index := t - 1
|
||||
if not attack_def and arr.size() >= 4:
|
||||
index = clampi(t, 0, arr.size() - 1)
|
||||
if index >= 0 and index < arr.size() and arr[index] is Dictionary:
|
||||
row = (arr[index] as Dictionary).duplicate(true)
|
||||
elif tiers is Dictionary:
|
||||
var map := tiers as Dictionary
|
||||
var value = map.get(str(t), map.get(t, null))
|
||||
var key := t
|
||||
if not attack_def and map.size() >= 4:
|
||||
key = clampi(t + 1, 1, 4)
|
||||
var value = map.get(str(key), map.get(key, null))
|
||||
if value is Dictionary:
|
||||
row = (value as Dictionary).duplicate(true)
|
||||
if row.is_empty():
|
||||
@@ -491,11 +592,19 @@ static func data_minigame_tier(id: String, p_tier: int) -> Dictionary:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ extends RefCounted
|
||||
##
|
||||
## create_send() загружает res://scripts/minigames/send/<id>.gd.
|
||||
## create_attack() — заготовка: возвращает null, если скрипта атаки ещё нет.
|
||||
## send_for_types() сначала спрашивает Economy.send_minigame_for(), если этот
|
||||
## метод уже реализован другим агентом, иначе использует локальную формулу.
|
||||
## selection_for_sum()/send_for_types() делегируют Economy (единственный
|
||||
## источник правды: 6 игр, 3 ступени, 27 достижимых сумм).
|
||||
|
||||
const SEND_IDS: Array[String] = [
|
||||
"dots", "sound", "wires", "text", "video", "photo", "maze",
|
||||
"dots", "sound", "text", "video", "photo", "maze",
|
||||
]
|
||||
const SEND_TIERS := 3
|
||||
const ATTACK_IDS: Array[String] = [
|
||||
"junk", "ddos", "phishing", "ransomware",
|
||||
]
|
||||
@@ -22,6 +23,7 @@ const COEFF := {
|
||||
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,
|
||||
@@ -42,7 +44,7 @@ static func create_send(game_id: String, tier: int, seed: int,
|
||||
if node != null:
|
||||
node.free()
|
||||
return null
|
||||
node.start(id, clampi(tier, 1, 4), seed, context)
|
||||
node.start(id, clampi(tier, 1, SEND_TIERS), seed, context)
|
||||
return node
|
||||
|
||||
|
||||
@@ -76,20 +78,22 @@ static func send_ids() -> Array[String]:
|
||||
|
||||
|
||||
static func tier_for_hour(hour: int) -> int:
|
||||
return clampi(1 + int(floor(float(maxi(1, hour) - 2) / 2.0)), 1, 4)
|
||||
return clampi(1 + int(floor(float(maxi(1, hour) - 2) / 2.0)), 1, SEND_TIERS)
|
||||
|
||||
|
||||
## Единая формула выбора: idx = S-1, tier = floor(idx/7)+1,
|
||||
## игра = SEND_ORDER[idx % 7].
|
||||
## Канонический выбор: полностью делегируется 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 idx := clampi(sum - 1, 0, 27)
|
||||
return {
|
||||
"id": SEND_IDS[idx % 7],
|
||||
"tier": int(idx / 7) + 1,
|
||||
"index": idx,
|
||||
"sum": sum,
|
||||
}
|
||||
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:
|
||||
@@ -97,12 +101,13 @@ static func send_for_types(types: Array) -> Dictionary:
|
||||
if not delegated.is_empty():
|
||||
if not delegated.has("id") and delegated.has("game"):
|
||||
delegated["id"] = str(delegated["game"])
|
||||
if delegated.has("id"):
|
||||
var id := str(delegated["id"])
|
||||
if SEND_IDS.has(id):
|
||||
if not delegated.has("tier"):
|
||||
delegated["tier"] = 1
|
||||
return delegated
|
||||
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))
|
||||
|
||||
@@ -6,10 +6,9 @@ extends MiniGame
|
||||
## новый локальный RNG и ограниченное число попыток; последний рубеж — змейка.
|
||||
|
||||
const TIERS := {
|
||||
1: {"cols": 3, "rows": 3, "obstacles": 0, "undo_penalty": 0.05},
|
||||
2: {"cols": 4, "rows": 3, "obstacles": 2, "undo_penalty": 0.04},
|
||||
3: {"cols": 4, "rows": 4, "obstacles": 4, "undo_penalty": 0.04},
|
||||
4: {"cols": 5, "rows": 4, "obstacles": 6, "undo_penalty": 0.03},
|
||||
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
|
||||
@@ -20,6 +19,7 @@ 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
|
||||
@@ -35,13 +35,30 @@ func _setup() -> void:
|
||||
_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, 4), TIERS[1]) as Dictionary).duplicate(true)
|
||||
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
|
||||
@@ -352,47 +369,104 @@ func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
|
||||
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 free := _free_map()
|
||||
var last := int(_path[_path.size() - 1])
|
||||
if _path.size() >= 2 and id == int(_path[_path.size() - 2]):
|
||||
_path.pop_back()
|
||||
_undos += 1
|
||||
_play_sfx(&"tick")
|
||||
queue_redraw()
|
||||
_pop_path()
|
||||
_dragging = true
|
||||
return true
|
||||
if id == last:
|
||||
_dragging = true
|
||||
return true
|
||||
if not free.has(id) or _path.has(id):
|
||||
if _try_append_cell(id):
|
||||
_dragging = true
|
||||
else:
|
||||
# Ошибкой считается только осознанный клик, не движение курсора.
|
||||
_invalid += 1
|
||||
queue_redraw()
|
||||
return true
|
||||
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):
|
||||
_invalid += 1
|
||||
queue_redraw()
|
||||
return true
|
||||
if id == int(board.get("finish", -1)) and _path.size() < int(board.get("free_count", 0)) - 1:
|
||||
return false
|
||||
if id == int(board.get("finish", -1)) \
|
||||
and _path.size() < int(board.get("free_count", 0)) - 1:
|
||||
# Финиш можно только последним: иначе линия не покроет все порты.
|
||||
_invalid += 1
|
||||
queue_redraw()
|
||||
return true
|
||||
return false
|
||||
_path.append(id)
|
||||
_play_sfx(&"tick")
|
||||
if _path.size() == int(board.get("free_count", 0)) and id == int(board.get("finish", -1)):
|
||||
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()
|
||||
@@ -404,6 +478,7 @@ func _reset_line() -> void:
|
||||
if not _path.is_empty():
|
||||
_undos += 1
|
||||
_path.clear()
|
||||
_dragging = false
|
||||
_play_sfx(&"tick")
|
||||
queue_redraw()
|
||||
|
||||
@@ -421,7 +496,7 @@ func _draw_game() -> void:
|
||||
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.SHADE, 300.0, HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
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)))
|
||||
@@ -455,9 +530,6 @@ func _draw_game() -> void:
|
||||
if i == _path.size() - 1:
|
||||
draw_circle(b, 4.0, Theme90.NAVY_DEEP)
|
||||
|
||||
if _path.is_empty():
|
||||
_draw_label("НАЧНИТЕ С «С» · КЛИК ПО ПРЕДЫДУЩЕМУ УЗЛУ — ОТКАТ · R — СБРОС",
|
||||
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
|
||||
_draw_secondary_button(RESET_RECT, "СБРОС (R)")
|
||||
|
||||
|
||||
|
||||
@@ -4,13 +4,11 @@ extends MiniGame
|
||||
## Качество: 1 - 0.04*collisions - 0.02*dead_ends (без нижнего порога 0.4).
|
||||
|
||||
const TIERS := {
|
||||
1: {"cols": 9, "rows": 4, "field_w": 360.0, "field_h": 160.0,
|
||||
"corridor": 28.0, "speed": 420.0},
|
||||
2: {"cols": 11, "rows": 4, "field_w": 440.0, "field_h": 160.0,
|
||||
1: {"cols": 11, "rows": 4, "field_w": 440.0, "field_h": 160.0,
|
||||
"corridor": 24.0, "speed": 470.0},
|
||||
3: {"cols": 13, "rows": 4, "field_w": 494.0, "field_h": 152.0,
|
||||
2: {"cols": 13, "rows": 4, "field_w": 494.0, "field_h": 152.0,
|
||||
"corridor": 20.0, "speed": 520.0},
|
||||
4: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0,
|
||||
3: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0,
|
||||
"corridor": 18.0, "speed": 560.0},
|
||||
}
|
||||
const HITBOX := 9.0
|
||||
@@ -73,10 +71,24 @@ func _setup() -> void:
|
||||
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, 4), TIERS[1]) as Dictionary).duplicate(true)
|
||||
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
|
||||
@@ -410,10 +422,10 @@ func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
|
||||
|
||||
func _draw_game() -> void:
|
||||
var font := Theme90.ui()
|
||||
_draw_label("МАРШРУТ · ВЫХОД СПРАВА", Vector2(24.0, 34.0), 10,
|
||||
Theme90.NAVY, 280.0)
|
||||
_draw_label("МАРШРУТ · ВЫХОД СПРАВА", Vector2(24.0, 46.0), 10,
|
||||
Theme90.DARK, 280.0)
|
||||
_draw_label("СТОЛКНОВЕНИЙ %d · ТУПИКОВ %d" % [_collisions, _dead_end_hits],
|
||||
Vector2(320.0, 34.0), 10, Theme90.SHADE, 280.0,
|
||||
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),
|
||||
@@ -441,7 +453,5 @@ func _draw_game() -> void:
|
||||
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.SHADE,
|
||||
Vector2(_field.position.x, _field.end.y + 12.0), 10, Theme90.DARK,
|
||||
_field.size.x)
|
||||
_draw_label("ВЕДИТЕ ПАКЕТ МЫШЬЮ · СТЕНЫ СКОЛЬЗЯТ · ВЫХОД — ЗЕЛЁНЫЙ",
|
||||
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
|
||||
@@ -11,13 +11,11 @@ const INSET := 4.0
|
||||
const SIM_DT := 1.0 / 120.0
|
||||
const TWITCH_RATE := 1.25
|
||||
const TIERS := {
|
||||
1: {"speed": 90.0, "frame_w": 220.0, "frame_h": 150.0, "shots": 3,
|
||||
"switch_min": 0.8, "switch_max": 1.2, "turn_rate": 210.0},
|
||||
2: {"speed": 140.0, "frame_w": 180.0, "frame_h": 120.0, "shots": 4,
|
||||
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},
|
||||
3: {"speed": 200.0, "frame_w": 150.0, "frame_h": 100.0, "shots": 5,
|
||||
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},
|
||||
4: {"speed": 260.0, "frame_w": 120.0, "frame_h": 80.0, "shots": 6,
|
||||
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},
|
||||
}
|
||||
|
||||
@@ -47,10 +45,25 @@ func _setup() -> void:
|
||||
_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, 4), TIERS[1]) as Dictionary).duplicate(true)
|
||||
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
|
||||
@@ -289,11 +302,6 @@ 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_label("СКАН КЛИЕНТА · КАДР %d / %d" % [_shots_taken, shots],
|
||||
Vector2(24.0, 34.0), 10, Theme90.NAVY, 280.0)
|
||||
_draw_label("УДАЧНЫХ %d · ОСТАЛОСЬ %d" % [_captures, shots - _shots_taken],
|
||||
Vector2(320.0, 34.0), 10, Theme90.SHADE, 280.0,
|
||||
HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
|
||||
draw_style_box(Theme90.box(Theme90.DARK, Color(0, 0, 0, 0), 4, 1), SCAN)
|
||||
# Мягкий фон скана: окно/стол, на игру не влияет.
|
||||
@@ -303,6 +311,12 @@ func _draw_game() -> void:
|
||||
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)
|
||||
@@ -331,10 +345,8 @@ func _draw_game() -> void:
|
||||
16, Theme90.SUCCESS if _last_capture_ok else Theme90.ERROR, 120.0,
|
||||
HORIZONTAL_ALIGNMENT_CENTER)
|
||||
|
||||
_draw_label("ВЕДИТЕ РАМКУ МЫШЬЮ · ЛКМ / ПРОБЕЛ — СНИМОК",
|
||||
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
|
||||
_draw_label("рамка %d×%d" % [roundi(_frame_size().x), roundi(_frame_size().y)],
|
||||
Vector2(466.0, 344.0), 10, Theme90.SHADE, 156.0,
|
||||
Vector2(466.0, 344.0), 10, Theme90.DARK, 156.0,
|
||||
HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
extends MiniGame
|
||||
## «Уровни» (sound). Вертикальные фейдеры, зелёные целевые зоны.
|
||||
## T1–T4: 3/4/5/6 каналов, допуск 16/11/8/6%, на T3/T4 — синусоидальный дрейф.
|
||||
## T1–T3: 4/5/6 каналов, допуск 11/8/6%, на T2/T3 — синусоидальный дрейф.
|
||||
## Таймера нет; качество фиксируется кнопкой «ЗАПИСАТЬ».
|
||||
|
||||
const TIERS := {
|
||||
1: {"count": 3, "tolerance": 0.16, "drift_amp": 0.0, "drift_hz": 0.0},
|
||||
2: {"count": 4, "tolerance": 0.11, "drift_amp": 0.0, "drift_hz": 0.0},
|
||||
3: {"count": 5, "tolerance": 0.08, "drift_amp": 0.02, "drift_hz": 0.15},
|
||||
4: {"count": 6, "tolerance": 0.06, "drift_amp": 0.03, "drift_hz": 0.25},
|
||||
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К"],
|
||||
@@ -46,10 +45,24 @@ func _setup() -> void:
|
||||
_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, 4), TIERS[1]) as Dictionary).duplicate(true)
|
||||
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
|
||||
@@ -119,7 +132,7 @@ static func generate(p_tier: int, seed: int) -> Dictionary:
|
||||
starts.append(start)
|
||||
phases.append(rng.randf_range(0.0, TAU))
|
||||
return {
|
||||
"tier": clampi(p_tier, 1, 4),
|
||||
"tier": clampi(p_tier, 1, 3),
|
||||
"count": count,
|
||||
"tolerance": tol,
|
||||
"drift_amp": amp,
|
||||
@@ -272,7 +285,7 @@ func _draw_game() -> void:
|
||||
_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.SHADE, 320.0,
|
||||
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)
|
||||
@@ -283,7 +296,7 @@ func _draw_game() -> void:
|
||||
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.SHADE, 34.0,
|
||||
"%d" % roundi(v * 100.0), 10, Theme90.DARK, 34.0,
|
||||
HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
|
||||
for i in range(_values.size()):
|
||||
@@ -312,8 +325,6 @@ func _draw_game() -> void:
|
||||
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.SHADE, rect.size.x + 20.0, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
10, Theme90.DARK, rect.size.x + 20.0, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
|
||||
_draw_label("СОВМЕСТИТЕ БЕГУНКИ С ЗЕЛЁНЫМИ ПОЛОСАМИ · СТРЕЛКИ — ТОЧНАЯ НАСТРОЙКА",
|
||||
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
|
||||
_draw_action_button(ACTION_RECT, "ЗАПИСАТЬ", true, true)
|
||||
@@ -5,10 +5,9 @@ extends MiniGame
|
||||
## учитывается не более одного раза.
|
||||
|
||||
const TIERS := {
|
||||
1: {"words": 11, "aliens": 1, "lines": 2, "types": ["foreign"]},
|
||||
2: {"words": 15, "aliens": 2, "lines": 2, "types": ["foreign", "deformed"]},
|
||||
3: {"words": 19, "aliens": 3, "lines": 3, "types": ["foreign", "deformed", "semantic"]},
|
||||
4: {"words": 24, "aliens": 4, "lines": 3, "types": ["foreign", "deformed", "semantic", "archaic"]},
|
||||
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 := [
|
||||
@@ -65,10 +64,26 @@ func _setup() -> void:
|
||||
_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, 4), TIERS[1]) as Dictionary).duplicate(true)
|
||||
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
|
||||
@@ -166,7 +181,7 @@ static func generate(p_tier: int, seed: int) -> Dictionary:
|
||||
if bool(w["alien"]):
|
||||
alien_count += 1
|
||||
return {
|
||||
"tier": clampi(p_tier, 1, 4),
|
||||
"tier": clampi(p_tier, 1, 3),
|
||||
"words": words,
|
||||
"count": words.size(),
|
||||
"aliens": alien_count,
|
||||
@@ -272,9 +287,16 @@ func _build_layout() -> void:
|
||||
break
|
||||
|
||||
var line_h := float(size_px) + 13.0
|
||||
var row_gap := maxf(6.0, (panel.size.y - 30.0 - line_h * float(rows_count))
|
||||
/ float(maxi(1, rows_count - 1)))
|
||||
var y := panel.position.y + 14.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 = []
|
||||
@@ -295,7 +317,7 @@ func _build_layout() -> void:
|
||||
})
|
||||
x += ww + 7.0
|
||||
_layout.append(row_layout)
|
||||
y += line_h + row_gap
|
||||
y += line_h + gap
|
||||
_layout.append(size_px)
|
||||
|
||||
|
||||
@@ -374,19 +396,19 @@ func _draw_game() -> void:
|
||||
var font := Theme90.ui()
|
||||
var fp := _false_positive_count()
|
||||
_draw_label("ПИСЬМО КЛИЕНТА · ЗАДАНИЕ 97", Vector2(24.0, 46.0), 10,
|
||||
Theme90.ACCENT, 260.0)
|
||||
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.SHADE,
|
||||
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 + 12.0,
|
||||
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 + 20.0), 10, Theme90.SHADE, panel.size.x - 40.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:
|
||||
@@ -421,7 +443,5 @@ func _draw_game() -> void:
|
||||
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.SHADE, 400.0)
|
||||
_draw_label("ЧУЖИЕ: ИНОСТРАННЫЕ, С ОШИБКОЙ, НЕУМЕСТНЫЕ, УСТАРЕВШИЕ",
|
||||
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
|
||||
Vector2(24.0, 300.0), 10, Theme90.DARK, 400.0)
|
||||
_draw_action_button(ACTION_RECT, ACTION_LABEL, true, true)
|
||||
@@ -1,16 +1,15 @@
|
||||
extends MiniGame
|
||||
## «Раскадровка» (video). Разложить кадры по слотам.
|
||||
##
|
||||
## T1 3/3 (силуэт), T2 4/4 (таймкод), T3 5 карточек/4 слота (1 скрытый
|
||||
## брак), T4 6 карточек/5 слотов (1 брак + 1 зеркальный кадр).
|
||||
## T1 4/4 (таймкод), T2 5 карточек/4 слота (1 скрытый брак),
|
||||
## T3 6 карточек/5 слотов (1 брак + 1 зеркальный кадр).
|
||||
## Таймкоды случайны для каждой раздачи, брак дублирует ровно один реальный
|
||||
## таймкод и отличается силуэтом.
|
||||
|
||||
const TIERS := {
|
||||
1: {"cards": 3, "slots": 3, "decoy": false, "mirror": false, "hint": "shape"},
|
||||
2: {"cards": 4, "slots": 4, "decoy": false, "mirror": false, "hint": "timecode"},
|
||||
3: {"cards": 5, "slots": 4, "decoy": true, "mirror": false, "hint": "ghost"},
|
||||
4: {"cards": 6, "slots": 5, "decoy": true, "mirror": true, "hint": "timecode"},
|
||||
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"]
|
||||
@@ -46,10 +45,27 @@ func _setup() -> void:
|
||||
_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, 4), TIERS[1]) as Dictionary).duplicate(true)
|
||||
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
|
||||
@@ -147,7 +163,7 @@ static func generate(p_tier: int, seed: int) -> Dictionary:
|
||||
tray[safe_idx] = tmp
|
||||
|
||||
return {
|
||||
"tier": clampi(p_tier, 1, 4),
|
||||
"tier": clampi(p_tier, 1, 3),
|
||||
"slots": slots,
|
||||
"cards": tray,
|
||||
"card_count": tray.size(),
|
||||
@@ -206,7 +222,7 @@ static func decoy_info(data: Dictionary) -> Dictionary:
|
||||
func _build_slot_rects() -> void:
|
||||
_slot_rects.clear()
|
||||
var slots := int(generated.get("slots", 3))
|
||||
var area := Rect2(40.0, 54.0, 560.0, 128.0)
|
||||
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):
|
||||
@@ -314,7 +330,7 @@ func _draw_game() -> void:
|
||||
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.SHADE, 300.0,
|
||||
Vector2(300.0, 46.0), 10, Theme90.DARK, 300.0,
|
||||
HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
|
||||
var slot_shapes: Array = generated.get("slot_shapes", [])
|
||||
@@ -334,7 +350,7 @@ func _draw_game() -> void:
|
||||
_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.SHADE, rect.size.x, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
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)
|
||||
@@ -353,7 +369,7 @@ func _draw_game() -> void:
|
||||
else:
|
||||
_draw_centered(str(ghost.get("timecode", "")),
|
||||
Rect2(rect.position.x, rect.end.y - 30.0, rect.size.x, 20.0),
|
||||
10, Theme90.SHADE)
|
||||
10, Theme90.DARK)
|
||||
|
||||
_recompute_tray()
|
||||
for entry in _tray_rects:
|
||||
@@ -365,8 +381,6 @@ func _draw_game() -> void:
|
||||
if int(entry["card"]) == _selected:
|
||||
draw_rect(rect.grow(3.0), Theme90.NAVY_DEEP, false, 2.0)
|
||||
|
||||
_draw_label("КЛИК ПО КАДРУ, ЗАТЕМ ПО СЛОТУ · КЛИК ПО СЛОТУ — ВЕРНУТЬ КАДР",
|
||||
Vector2(24.0, 344.0), 10, Theme90.SHADE, 430.0)
|
||||
_draw_action_button(ACTION_RECT, "ОТПРАВИТЬ", true, true)
|
||||
|
||||
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
extends MiniGame
|
||||
## «Провода» (wires). FIX M11: порты не накладываются.
|
||||
##
|
||||
## Геометрия 640×360: верх стойки 56, порт 34×34, шаг 40 (34+6),
|
||||
## высота стойки n*34 + (n-1)*6 + 16, низ <= 320. Левый центр x=140,
|
||||
## правый x=466: 326 px между центрами, чистый коридор 292 px.
|
||||
## Провода — квадратичные Безье с отдельной «полосой» на каждый провод
|
||||
## (шаг 12 px), поэтому линии не сливаются.
|
||||
|
||||
const COLORS := {
|
||||
"accent": Color(0.184314, 0.498039, 0.560784),
|
||||
"navy": Color(0.172549, 0.360784, 0.407843),
|
||||
"warn": Color(0.772549, 0.541176, 0.180392),
|
||||
"error": Color(0.760784, 0.356863, 0.290196),
|
||||
"success": Color(0.247059, 0.560784, 0.368627),
|
||||
}
|
||||
const COLOR_ORDER := ["accent", "navy", "warn", "error", "success"]
|
||||
const SHAPES := ["circle", "triangle", "square", "diamond", "cross"]
|
||||
const TIER_SPEC := {
|
||||
1: {"wires": 3, "sockets": 3, "empty": 0},
|
||||
2: {"wires": 4, "sockets": 4, "empty": 0},
|
||||
3: {"wires": 5, "sockets": 6, "empty": 1},
|
||||
4: {"wires": 6, "sockets": 6, "empty": 0},
|
||||
}
|
||||
const BOX := 34.0
|
||||
const PITCH := 40.0
|
||||
const LEFT_X := 140.0
|
||||
const RIGHT_X := 466.0
|
||||
const RACK_TOP := 56.0
|
||||
const LANE_STEP := 12.0
|
||||
|
||||
var generated: Dictionary = {}
|
||||
var geo: Dictionary = {}
|
||||
var _wire_sockets: Array[int] = []
|
||||
var _socket_owner: Array[int] = []
|
||||
var _wrong: int = 0
|
||||
var _drag_wire: int = -1
|
||||
var _drag_pos: Vector2 = Vector2.ZERO
|
||||
var _hover_socket: int = -1
|
||||
var _status: String = ""
|
||||
var _status_bad: bool = false
|
||||
var _key_wire: int = 0
|
||||
var _key_socket: int = 0
|
||||
var _keyboard_on: bool = false
|
||||
|
||||
|
||||
func _own_game_id() -> String:
|
||||
return "wires"
|
||||
|
||||
|
||||
func _setup() -> void:
|
||||
generated = generate(tier, seed_value)
|
||||
geo = geometry(tier)
|
||||
_wire_sockets.clear()
|
||||
_socket_owner.clear()
|
||||
for i in range(generated.get("wires", []).size()):
|
||||
_wire_sockets.append(-1)
|
||||
for i in range(generated.get("sockets", []).size()):
|
||||
_socket_owner.append(-1)
|
||||
_wrong = 0
|
||||
_drag_wire = -1
|
||||
_hover_socket = -1
|
||||
_status = ""
|
||||
_status_bad = false
|
||||
_key_wire = 0
|
||||
_key_socket = 0
|
||||
_keyboard_on = false
|
||||
|
||||
|
||||
# --- параметры/чистая логика ----------------------------------------------
|
||||
|
||||
static func tier_params(p_tier: int) -> Dictionary:
|
||||
var base: Dictionary = (TIER_SPEC.get(clampi(p_tier, 1, 4), TIER_SPEC[1]) as Dictionary).duplicate(true)
|
||||
var row := MiniGame.data_minigame_tier("wires", p_tier)
|
||||
if row.is_empty():
|
||||
return base
|
||||
for key in ["wires", "sockets"]:
|
||||
if row.has(key):
|
||||
base[key] = int(row[key])
|
||||
if row.has("empty"):
|
||||
base["empty"] = int(row["empty"])
|
||||
if row.has("empty_sockets"):
|
||||
base["empty"] = int(row["empty_sockets"])
|
||||
return base
|
||||
|
||||
|
||||
static func generate(p_tier: int, seed: int) -> Dictionary:
|
||||
var p := tier_params(p_tier)
|
||||
var wire_count := clampi(int(p.get("wires", 3)), 2, 6)
|
||||
var socket_count := clampi(int(p.get("sockets", wire_count)), wire_count, 6)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = seed
|
||||
|
||||
var colors := COLOR_ORDER.duplicate()
|
||||
var shapes := SHAPES.duplicate()
|
||||
_shuffle(colors, rng)
|
||||
_shuffle(shapes, rng)
|
||||
var combos: Array = []
|
||||
|
||||
match clampi(p_tier, 1, 4):
|
||||
1:
|
||||
for i in range(wire_count):
|
||||
combos.append({"color": colors[i % colors.size()],
|
||||
"shape": shapes[i % shapes.size()]})
|
||||
2:
|
||||
# Две пары похожих (повторяющихся) цветов, формы внутри пар разные.
|
||||
var c0: String = colors[0]
|
||||
var c1: String = colors[1]
|
||||
var s: Array = shapes.slice(0, 4)
|
||||
combos = [
|
||||
{"color": c0, "shape": s[0]},
|
||||
{"color": c0, "shape": s[1]},
|
||||
{"color": c1, "shape": s[2]},
|
||||
{"color": c1, "shape": s[3]},
|
||||
]
|
||||
3:
|
||||
# Один общий цвет и одна общая форма у разных пар.
|
||||
var s3: Array = shapes.slice(0, 5)
|
||||
combos = [
|
||||
{"color": colors[0], "shape": s3[0]},
|
||||
{"color": colors[0], "shape": s3[1]},
|
||||
{"color": colors[1], "shape": s3[0]},
|
||||
{"color": colors[2], "shape": s3[2]},
|
||||
{"color": colors[3], "shape": s3[3]},
|
||||
]
|
||||
_:
|
||||
# T4: 2 общих цвета + 2 общие формы, все пары уникальны.
|
||||
var s4: Array = shapes.slice(0, 4)
|
||||
combos = [
|
||||
{"color": colors[0], "shape": s4[0]},
|
||||
{"color": colors[0], "shape": s4[1]},
|
||||
{"color": colors[1], "shape": s4[0]},
|
||||
{"color": colors[1], "shape": s4[2]},
|
||||
{"color": colors[2], "shape": s4[2]},
|
||||
{"color": colors[3], "shape": s4[3]},
|
||||
]
|
||||
|
||||
combos = combos.slice(0, wire_count)
|
||||
_shuffle(combos, rng)
|
||||
|
||||
var wires: Array = []
|
||||
for i in range(combos.size()):
|
||||
wires.append({
|
||||
"id": i,
|
||||
"color": str(combos[i]["color"]),
|
||||
"shape": str(combos[i]["shape"]),
|
||||
})
|
||||
|
||||
var socket_defs: Array = []
|
||||
for c in combos:
|
||||
socket_defs.append({"color": str(c["color"]), "shape": str(c["shape"]),
|
||||
"empty": false})
|
||||
while socket_defs.size() < socket_count:
|
||||
socket_defs.append({"color": "", "shape": "", "empty": true})
|
||||
_shuffle(socket_defs, rng)
|
||||
var sockets: Array = []
|
||||
for i in range(socket_defs.size()):
|
||||
sockets.append({
|
||||
"id": i,
|
||||
"color": str(socket_defs[i]["color"]),
|
||||
"shape": str(socket_defs[i]["shape"]),
|
||||
"empty": bool(socket_defs[i]["empty"]),
|
||||
})
|
||||
|
||||
return {
|
||||
"tier": clampi(p_tier, 1, 4),
|
||||
"wires": wires,
|
||||
"sockets": sockets,
|
||||
"wire_count": wires.size(),
|
||||
"socket_count": sockets.size(),
|
||||
}
|
||||
|
||||
|
||||
static func geometry(p_tier: int) -> Dictionary:
|
||||
var p := tier_params(p_tier)
|
||||
var n := clampi(int(p.get("wires", 3)), 2, 6)
|
||||
var s := clampi(int(p.get("sockets", n)), n, 6)
|
||||
var rack_h := float(n) * BOX + float(n - 1) * 6.0 + 16.0
|
||||
var right_h := float(s) * BOX + float(s - 1) * 6.0 + 16.0
|
||||
var left_ports: Array[Vector2] = []
|
||||
var right_ports: Array[Vector2] = []
|
||||
for i in range(n):
|
||||
left_ports.append(Vector2(LEFT_X, RACK_TOP + 8.0 + BOX * 0.5 + float(i) * PITCH))
|
||||
for i in range(s):
|
||||
right_ports.append(Vector2(RIGHT_X, RACK_TOP + 8.0 + BOX * 0.5 + float(i) * PITCH))
|
||||
var left_rects: Array[Rect2] = []
|
||||
var right_rects: Array[Rect2] = []
|
||||
for center in left_ports:
|
||||
left_rects.append(Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX)))
|
||||
for center in right_ports:
|
||||
right_rects.append(Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX)))
|
||||
var lanes: Array[float] = []
|
||||
for i in range(n):
|
||||
lanes.append((float(i) - float(n - 1) * 0.5) * LANE_STEP)
|
||||
return {
|
||||
"box": BOX,
|
||||
"pitch": PITCH,
|
||||
"left_x": LEFT_X,
|
||||
"right_x": RIGHT_X,
|
||||
"top": RACK_TOP,
|
||||
"left_rack": Rect2(LEFT_X - BOX * 0.5, RACK_TOP, BOX, rack_h),
|
||||
"right_rack": Rect2(RIGHT_X - BOX * 0.5, RACK_TOP, BOX, right_h),
|
||||
"rack_h": rack_h,
|
||||
"right_rack_h": right_h,
|
||||
"left_ports": left_ports,
|
||||
"right_ports": right_ports,
|
||||
"left_rects": left_rects,
|
||||
"right_rects": right_rects,
|
||||
"lanes": lanes,
|
||||
"gap": RIGHT_X - LEFT_X,
|
||||
"edge_gap": (RIGHT_X - BOX * 0.5) - (LEFT_X + BOX * 0.5),
|
||||
}
|
||||
|
||||
|
||||
static func matching_sockets(wire: Dictionary, sockets: Array) -> Array:
|
||||
var out: Array = []
|
||||
for socket in sockets:
|
||||
if bool(socket.get("empty", false)):
|
||||
continue
|
||||
if str(socket.get("color", "")) == str(wire.get("color", "")) \
|
||||
and str(socket.get("shape", "")) == str(wire.get("shape", "")):
|
||||
out.append(int(socket.get("id", out.size())))
|
||||
return out
|
||||
|
||||
|
||||
static func validate_pairs(data: Dictionary) -> bool:
|
||||
var wires: Array = data.get("wires", [])
|
||||
var sockets: Array = data.get("sockets", [])
|
||||
if wires.is_empty() or sockets.size() < wires.size():
|
||||
return false
|
||||
var seen_pairs := {}
|
||||
for wire in wires:
|
||||
var key := "%s|%s" % [str(wire.get("color", "")), str(wire.get("shape", ""))]
|
||||
if seen_pairs.has(key):
|
||||
return false
|
||||
seen_pairs[key] = true
|
||||
var matches := matching_sockets(wire, sockets)
|
||||
if matches.size() != 1:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func quality_for(connected: int, wrong: int, total: int) -> float:
|
||||
if total <= 0:
|
||||
return 0.0
|
||||
return clampf((float(connected) - 0.25 * float(maxi(0, wrong))) / float(total),
|
||||
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 _left_center(i: int) -> Vector2:
|
||||
var ports: Array = geo.get("left_ports", [])
|
||||
return ports[i] if i >= 0 and i < ports.size() else Vector2(LEFT_X, RACK_TOP)
|
||||
|
||||
|
||||
func _right_center(i: int) -> Vector2:
|
||||
var ports: Array = geo.get("right_ports", [])
|
||||
return ports[i] if i >= 0 and i < ports.size() else Vector2(RIGHT_X, RACK_TOP)
|
||||
|
||||
|
||||
func _wire_at(local: Vector2) -> int:
|
||||
var rects: Array = geo.get("left_rects", [])
|
||||
for i in range(rects.size()):
|
||||
if (rects[i] as Rect2).grow(6.0).has_point(local):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
func _socket_at(local: Vector2) -> int:
|
||||
var rects: Array = geo.get("right_rects", [])
|
||||
for i in range(rects.size()):
|
||||
if (rects[i] as Rect2).grow(6.0).has_point(local):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
func _connected_count() -> int:
|
||||
var n := 0
|
||||
for s in _wire_sockets:
|
||||
if s >= 0:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _matches(wi: int, si: int) -> bool:
|
||||
var wires: Array = generated.get("wires", [])
|
||||
var sockets: Array = generated.get("sockets", [])
|
||||
if wi < 0 or wi >= wires.size() or si < 0 or si >= sockets.size():
|
||||
return false
|
||||
var wire: Dictionary = wires[wi]
|
||||
var socket: Dictionary = sockets[si]
|
||||
if bool(socket.get("empty", false)):
|
||||
return false
|
||||
if _socket_owner[si] >= 0:
|
||||
return false
|
||||
return str(wire["color"]) == str(socket["color"]) \
|
||||
and str(wire["shape"]) == str(socket["shape"])
|
||||
|
||||
|
||||
func _connect(wi: int, si: int) -> bool:
|
||||
if _matches(wi, si):
|
||||
if _wire_sockets[wi] >= 0:
|
||||
_socket_owner[_wire_sockets[wi]] = -1
|
||||
_wire_sockets[wi] = si
|
||||
_socket_owner[si] = wi
|
||||
_status = "ПОДКЛЮЧЕНО: %d ИЗ %d" % [_connected_count(), _wire_sockets.size()]
|
||||
_status_bad = false
|
||||
_play_sfx(&"minigame_win")
|
||||
queue_redraw()
|
||||
if _connected_count() == _wire_sockets.size():
|
||||
_finish(quality_for(_connected_count(), _wrong, _wire_sockets.size()))
|
||||
return true
|
||||
_wrong += 1
|
||||
_status_bad = true
|
||||
var sockets: Array = generated.get("sockets", [])
|
||||
if si >= 0 and si < sockets.size() and bool((sockets[si] as Dictionary).get("empty", false)):
|
||||
_status = "ГНЕЗДО ПУСТОЕ · +1 ОШИБКА"
|
||||
elif si >= 0 and si < _socket_owner.size() and _socket_owner[si] >= 0:
|
||||
_status = "ГНЕЗДО ЗАНЯТО · +1 ОШИБКА"
|
||||
else:
|
||||
_status = "НЕ СОВПАЛО: НУЖНЫ ТОТ ЖЕ ЦВЕТ И ФОРМА · +1 ОШИБКА"
|
||||
_play_sfx(&"minigame_fail")
|
||||
queue_redraw()
|
||||
return false
|
||||
|
||||
|
||||
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
|
||||
var wi := _wire_at(local)
|
||||
if wi < 0:
|
||||
return false
|
||||
_keyboard_on = false
|
||||
if _wire_sockets[wi] >= 0:
|
||||
_socket_owner[_wire_sockets[wi]] = -1
|
||||
_wire_sockets[wi] = -1
|
||||
_drag_wire = wi
|
||||
_drag_pos = local
|
||||
_hover_socket = -1
|
||||
_play_sfx(&"tick")
|
||||
queue_redraw()
|
||||
return true
|
||||
|
||||
|
||||
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
|
||||
if _drag_wire < 0:
|
||||
return
|
||||
_drag_pos = local
|
||||
_hover_socket = _socket_at(local)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _on_pointer_released(local: Vector2, _event: InputEvent) -> void:
|
||||
if _drag_wire < 0:
|
||||
return
|
||||
var wi := _drag_wire
|
||||
_drag_wire = -1
|
||||
_hover_socket = -1
|
||||
var si := _socket_at(local)
|
||||
if si < 0:
|
||||
# Отпускание в пустоту — бесплатно.
|
||||
queue_redraw()
|
||||
return
|
||||
_connect(wi, si)
|
||||
|
||||
|
||||
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
|
||||
if keycode == KEY_E or keycode == KEY_ENTER:
|
||||
if _keyboard_on:
|
||||
_connect(_key_wire, _key_socket)
|
||||
else:
|
||||
var wi := _wire_at(get_local_mouse_position())
|
||||
var si := _socket_at(get_local_mouse_position())
|
||||
if wi >= 0 and si >= 0:
|
||||
_connect(wi, si)
|
||||
return true
|
||||
if keycode == KEY_UP or keycode == KEY_W or keycode == KEY_TAB:
|
||||
_keyboard_on = true
|
||||
_key_wire = posmod(_key_wire - 1, maxi(1, _wire_sockets.size()))
|
||||
queue_redraw()
|
||||
return true
|
||||
if keycode == KEY_DOWN or keycode == KEY_S:
|
||||
_keyboard_on = true
|
||||
_key_wire = (_key_wire + 1) % maxi(1, _wire_sockets.size())
|
||||
queue_redraw()
|
||||
return true
|
||||
if keycode == KEY_LEFT or keycode == KEY_A:
|
||||
_keyboard_on = true
|
||||
_key_socket = posmod(_key_socket - 1, maxi(1, _socket_owner.size()))
|
||||
queue_redraw()
|
||||
return true
|
||||
if keycode == KEY_RIGHT or keycode == KEY_D:
|
||||
_keyboard_on = true
|
||||
_key_socket = (_key_socket + 1) % maxi(1, _socket_owner.size())
|
||||
queue_redraw()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# --- отрисовка -------------------------------------------------------------
|
||||
|
||||
func _draw_game() -> void:
|
||||
var left_rack: Rect2 = geo.get("left_rack", Rect2())
|
||||
var right_rack: Rect2 = geo.get("right_rack", Rect2())
|
||||
_draw_label("ПОДКЛЮЧЕНО %d / %d" % [_connected_count(), _wire_sockets.size()],
|
||||
Vector2(24.0, 46.0), 10, Theme90.NAVY, 240.0)
|
||||
_draw_label("ОШИБОК %d · ПРОВОДОВ %d · ГНЁЗД %d" % [_wrong,
|
||||
_wire_sockets.size(), _socket_owner.size()],
|
||||
Vector2(320.0, 46.0), 10, Theme90.SHADE, 280.0,
|
||||
HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
|
||||
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
|
||||
left_rack.grow(6.0))
|
||||
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
|
||||
right_rack.grow(6.0))
|
||||
|
||||
# Сначала уже подключённые кабели.
|
||||
var wires: Array = generated.get("wires", [])
|
||||
var sockets: Array = generated.get("sockets", [])
|
||||
var lanes: Array = geo.get("lanes", [])
|
||||
for wi in range(_wire_sockets.size()):
|
||||
var si := _wire_sockets[wi]
|
||||
if si < 0:
|
||||
continue
|
||||
var wire: Dictionary = wires[wi]
|
||||
var lane := float(lanes[wi]) if wi < lanes.size() else 0.0
|
||||
_draw_cable(_left_center(wi) + Vector2(BOX * 0.5, 0.0),
|
||||
_right_center(si) - Vector2(BOX * 0.5, 0.0),
|
||||
_color_of(str(wire["color"])), lane)
|
||||
|
||||
if _drag_wire >= 0 and _drag_wire < wires.size():
|
||||
var wire: Dictionary = wires[_drag_wire]
|
||||
var lane := float(lanes[_drag_wire]) if _drag_wire < lanes.size() else 0.0
|
||||
_draw_cable(_left_center(_drag_wire) + Vector2(BOX * 0.5, 0.0),
|
||||
_drag_pos, _color_of(str(wire["color"])), lane * 0.8)
|
||||
|
||||
for wi in range(wires.size()):
|
||||
var wire: Dictionary = wires[wi]
|
||||
var center := _left_center(wi)
|
||||
var rect := Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX))
|
||||
var col := _color_of(str(wire["color"]))
|
||||
var connected := _wire_sockets[wi] >= 0
|
||||
draw_style_box(Theme90.box(col.lightened(0.70), col, 4, 1,
|
||||
Theme90.SHADOW), rect)
|
||||
_draw_shape(center, 18.0, str(wire["shape"]), col)
|
||||
if connected:
|
||||
draw_circle(Vector2(rect.end.x - 5.0, rect.position.y + 5.0), 3.0,
|
||||
Theme90.SUCCESS)
|
||||
if wi == _drag_wire:
|
||||
draw_rect(rect.grow(3.0), Theme90.NAVY_DEEP, false, 2.0)
|
||||
if _keyboard_on and wi == _key_wire:
|
||||
Theme90.draw_dashed_box(self, rect.grow(4.0), Theme90.NAVY, 3.0, 1.0)
|
||||
|
||||
for si in range(sockets.size()):
|
||||
var socket: Dictionary = sockets[si]
|
||||
var center := _right_center(si)
|
||||
var rect := Rect2(center - Vector2(BOX, BOX) * 0.5, Vector2(BOX, BOX))
|
||||
if bool(socket.get("empty", false)):
|
||||
Theme90.draw_dashed_box(self, rect, Theme90.SHADE, 3.0, 1.0)
|
||||
_draw_label("—", Vector2(center.x - 5.0, center.y - 7.0), 16,
|
||||
Theme90.SHADE)
|
||||
continue
|
||||
var col := _color_of(str(socket["color"]))
|
||||
var hover := si == _hover_socket and _drag_wire >= 0
|
||||
draw_style_box(Theme90.box(Theme90.PAPER_HI if not hover else Theme90.ACCENT_PALE,
|
||||
col if not hover else Theme90.ACCENT, 4, 2 if hover else 1), rect)
|
||||
_draw_shape(center, 18.0, str(socket["shape"]), col.darkened(0.05))
|
||||
if _socket_owner[si] >= 0:
|
||||
draw_circle(Vector2(rect.end.x - 5.0, rect.position.y + 5.0), 3.0,
|
||||
Theme90.SUCCESS)
|
||||
if _keyboard_on and si == _key_socket:
|
||||
Theme90.draw_dashed_box(self, rect.grow(4.0), Theme90.NAVY, 3.0, 1.0)
|
||||
|
||||
if _status.is_empty():
|
||||
_status = "ТЯНИТЕ ПРОВОД СЛЕВА В ПРАВОЕ ГНЕЗДО: ЦВЕТ + ФОРМА"
|
||||
_draw_label(_status, Vector2(24.0, 344.0), 10,
|
||||
Theme90.ERROR if _status_bad else Theme90.SHADE, 430.0)
|
||||
if _status_bad:
|
||||
_draw_label("+0.25 ШТРАФА", Vector2(466.0, 344.0), 10, Theme90.ERROR,
|
||||
156.0, HORIZONTAL_ALIGNMENT_RIGHT)
|
||||
|
||||
|
||||
func _draw_cable(from: Vector2, to: Vector2, color: Color, lane: float) -> void:
|
||||
var mid := (from + to) * 0.5
|
||||
var control := Vector2(mid.x, mid.y + lane)
|
||||
var points := PackedVector2Array()
|
||||
for i in range(25):
|
||||
var t := float(i) / 24.0
|
||||
var p := from.lerp(control, t).lerp(control.lerp(to, t), t)
|
||||
points.append(p)
|
||||
draw_polyline(points, color, 3.0, true)
|
||||
|
||||
|
||||
func _draw_shape(center: Vector2, size: float, shape: String, color: Color) -> void:
|
||||
match shape:
|
||||
"circle":
|
||||
draw_circle(center, size * 0.55, color)
|
||||
"triangle":
|
||||
draw_colored_polygon(PackedVector2Array([
|
||||
center + Vector2(0, -size * 0.62),
|
||||
center + Vector2(size * 0.60, size * 0.42),
|
||||
center + Vector2(-size * 0.60, size * 0.42)]), color)
|
||||
"square":
|
||||
draw_rect(Rect2(center - Vector2(size, size) * 0.48,
|
||||
Vector2(size, size) * 0.96), color)
|
||||
"diamond":
|
||||
draw_colored_polygon(PackedVector2Array([
|
||||
center + Vector2(0, -size * 0.66),
|
||||
center + Vector2(size * 0.66, 0),
|
||||
center + Vector2(0, size * 0.66),
|
||||
center + Vector2(-size * 0.66, 0)]), color)
|
||||
_:
|
||||
draw_rect(Rect2(center - Vector2(size * 0.16, size * 0.62),
|
||||
Vector2(size * 0.32, size * 1.24)), color)
|
||||
draw_rect(Rect2(center - Vector2(size * 0.62, size * 0.16),
|
||||
Vector2(size * 1.24, size * 0.32)), color)
|
||||
|
||||
|
||||
func _color_of(id: String) -> Color:
|
||||
return COLORS.get(id, Theme90.ACCENT)
|
||||
@@ -1 +0,0 @@
|
||||
uid://b2cghsoxycui0
|
||||
@@ -34,28 +34,28 @@ const DEFAULT_RULES := {
|
||||
## Резерв, если data/balance.json недоступен. Совпадает с §3.2/3.3.
|
||||
const FALLBACK_ITEMS := [
|
||||
{"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1,
|
||||
"max_level": 4, "prices": [120, 300, 650, 1200],
|
||||
"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.08,
|
||||
"max_level": 6, "prices": [90, 130, 180, 250, 340, 450],
|
||||
{"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": 35,
|
||||
"max_level": 4, "prices": [120, 260, 480, 800],
|
||||
{"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": [150, 400, 800],
|
||||
"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": [80, 110, 150, 200, 260, 340, 440, 560],
|
||||
"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": [80, 180, 360, 650, 1000],
|
||||
"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": [70, 100, 140, 190, 250, 330],
|
||||
"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": 5,
|
||||
"max_level": 5, "prices": [70, 110, 160, 230, 320],
|
||||
{"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]},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,8 +1,59 @@
|
||||
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)
|
||||
## --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 запустить забег и показать маскота-туториал
|
||||
## --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 +67,23 @@ var _folder := ""
|
||||
var _filemenu := false
|
||||
var _main: Node = null
|
||||
|
||||
var _run := 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
|
||||
|
||||
## Снимок делается только после того, как сцена и запрошенные экраны готовы.
|
||||
var _setup_done := false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
for a in OS.get_cmdline_user_args():
|
||||
@@ -33,51 +101,153 @@ 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 == "--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 == "--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 _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)
|
||||
|
||||
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)
|
||||
|
||||
# Прежние сценарии меню/тостов/файлового меню.
|
||||
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 +258,346 @@ 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
|
||||
|
||||
|
||||
## Демо-файлы для часа: без них запросы не спавнятся (реестр пуст).
|
||||
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
|
||||
|
||||
|
||||
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(" --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 +659,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
|
||||
|
||||
@@ -12,12 +12,17 @@ extends Node
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -29,10 +34,20 @@ func _ready() -> void:
|
||||
set_process(false)
|
||||
|
||||
|
||||
func begin(preview_control: Control, p_mode: String = "shape") -> void:
|
||||
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
|
||||
if is_instance_valid(_preview):
|
||||
_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_preview.top_level = true
|
||||
@@ -44,6 +59,7 @@ func begin(preview_control: Control, p_mode: String = "shape") -> void:
|
||||
else:
|
||||
_preview.set("mode", _mode)
|
||||
_preview.visible = true
|
||||
_sync_preview_rotation()
|
||||
_preview.queue_redraw()
|
||||
_place()
|
||||
set_process(true)
|
||||
@@ -69,6 +85,8 @@ func end() -> void:
|
||||
parent.remove_child(_preview)
|
||||
_preview.queue_free()
|
||||
_preview = null
|
||||
_rotation = 0
|
||||
_rotate_frame = -1
|
||||
reset_hover()
|
||||
if had:
|
||||
drag_ended.emit()
|
||||
@@ -83,6 +101,54 @@ 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()
|
||||
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()
|
||||
|
||||
@@ -61,8 +61,10 @@ var _drag_active: bool = false
|
||||
var _last_drop_pos: Vector2 = Vector2.ZERO
|
||||
var _ghost_cells: Array = []
|
||||
var _ghost_origin: Vector2i = Vector2i(-1, -1)
|
||||
var _ghost_target: Vector2i = Vector2i(-1, -1)
|
||||
var _ghost_rotation: int = 0
|
||||
var _ghost_ok: bool = false
|
||||
var _ghost_auto: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -183,10 +185,12 @@ func stats() -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
## Подвал: счётчик файлов и размер сетки. Диск-«занято X из Y» убран.
|
||||
func footer_text() -> String:
|
||||
var s := stats()
|
||||
return "занято %d из %d · свободно %d" % [
|
||||
int(s["occupied"]), int(s["total"]), int(s["free"])]
|
||||
var files := int(s["files"])
|
||||
return "%d %s · %d %s" % [
|
||||
files, plural_files(files), int(s["total"]), plural_cells(int(s["total"]))]
|
||||
|
||||
|
||||
func grid_metrics() -> Dictionary:
|
||||
@@ -270,6 +274,38 @@ static func cell_rect_of(origin: Vector2, cell: float, gap: float,
|
||||
return Rect2(origin + Vector2(float(c) * step, float(r) * step), Vector2(cell, cell))
|
||||
|
||||
|
||||
## Пиксельная позиция bbox-угла фигуры (её origin) внутри сетки. Раньше
|
||||
## FileShape.paint() получал _grid_origin и все файлы рисовались в (0,0).
|
||||
static func shape_origin(grid_origin: Vector2, cell: float, gap: float,
|
||||
cell_pos: Vector2i) -> Vector2:
|
||||
var step := cell + gap
|
||||
return grid_origin + Vector2(float(cell_pos.x) * step, float(cell_pos.y) * step)
|
||||
|
||||
|
||||
static func shape_origin_pair(grid_origin: Vector2, cell: Vector2, gap: float,
|
||||
cell_pos: Vector2i) -> Vector2:
|
||||
var step := cell + Vector2(gap, gap)
|
||||
return grid_origin + Vector2(float(cell_pos.x) * step.x,
|
||||
float(cell_pos.y) * step.y)
|
||||
|
||||
|
||||
## R (физическая клавиша — работает и в русской раскладке) / ПКМ.
|
||||
static func is_rotate_key(event: InputEvent) -> bool:
|
||||
if not (event is InputEventKey):
|
||||
return false
|
||||
var key := event as InputEventKey
|
||||
if not key.pressed or key.echo:
|
||||
return false
|
||||
return key.physical_keycode == KEY_R or key.keycode == KEY_R
|
||||
|
||||
|
||||
static func is_rotate_click(event: InputEvent) -> bool:
|
||||
if not (event is InputEventMouseButton):
|
||||
return false
|
||||
var mouse := event as InputEventMouseButton
|
||||
return mouse.pressed and mouse.button_index == MOUSE_BUTTON_RIGHT
|
||||
|
||||
|
||||
## Прямоугольная клетка стола: ширина и высота считаются независимо
|
||||
## (416×548 → 99×86), каждая зажата в [lo, hi].
|
||||
static func cell_size_pair(view: Vector2, cols: int, rows: int, gap: float = 6.0,
|
||||
@@ -431,6 +467,16 @@ static func file_state(file) -> String:
|
||||
return "stored"
|
||||
|
||||
|
||||
static func plural_files(n: int) -> String:
|
||||
var n10 := n % 10
|
||||
var n100 := n % 100
|
||||
if n10 == 1 and n100 != 11:
|
||||
return "файл"
|
||||
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
|
||||
return "файла"
|
||||
return "файлов"
|
||||
|
||||
|
||||
static func plural_cells(n: int) -> String:
|
||||
var n10 := n % 10
|
||||
var n100 := n % 100
|
||||
@@ -460,6 +506,10 @@ func _cell_rect(c: int, r: int) -> Rect2:
|
||||
return cell_rect_of(_grid_origin, _cell, GAP, c, r)
|
||||
|
||||
|
||||
func _shape_pos(cell_pos: Vector2i) -> Vector2:
|
||||
return shape_origin(_grid_origin, _cell, GAP, cell_pos)
|
||||
|
||||
|
||||
# --- hit test / hover ------------------------------------------------------
|
||||
|
||||
func _file_at(pos: Vector2) -> String:
|
||||
@@ -526,6 +576,10 @@ func _gui_input(event: InputEvent) -> void:
|
||||
file_activated.emit(id)
|
||||
accept_event()
|
||||
elif event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
# ПКМ во время drag — это поворот, а не контекстное меню.
|
||||
if _drag_in_progress():
|
||||
accept_event()
|
||||
return
|
||||
var id := _file_at(event.position)
|
||||
if not id.is_empty():
|
||||
context_requested.emit(id)
|
||||
@@ -533,31 +587,58 @@ func _gui_input(event: InputEvent) -> void:
|
||||
accept_event()
|
||||
|
||||
|
||||
## Идёт ли drag (источник или цель). Опираемся на DragController, а не только
|
||||
## на локальный _drag_active: у цели drop он вообще не выставлялся.
|
||||
func _drag_in_progress() -> bool:
|
||||
if _drag_active:
|
||||
return true
|
||||
var dc := _existing_drag_controller()
|
||||
if dc != null and dc.has_method("is_active"):
|
||||
return bool(dc.is_active())
|
||||
return _drop_active
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _drag_active:
|
||||
var vp := get_viewport()
|
||||
if vp != null and vp.is_input_handled():
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo \
|
||||
and event.keycode == KEY_R:
|
||||
var dc := _existing_drag_controller()
|
||||
# R/ПКМ работают и у цели drop: локальный _drag_active выставляет только
|
||||
# источник, а пакетный drag его вообще не трогает.
|
||||
if dc == null or not dc.has_method("is_active") or not bool(dc.is_active()):
|
||||
return
|
||||
if is_rotate_key(event) or is_rotate_click(event):
|
||||
_rotate_drag()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventMouseButton and event.pressed \
|
||||
and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_rotate_drag()
|
||||
get_viewport().set_input_as_handled()
|
||||
vp.set_input_as_handled()
|
||||
|
||||
|
||||
func _rotate_drag() -> void:
|
||||
_drag_rotation = posmod(_drag_rotation + 1, 4)
|
||||
var dc := _drag_controller()
|
||||
if dc != null and dc.has_method("rotate_cw"):
|
||||
_drag_rotation = int(dc.rotate_cw())
|
||||
else:
|
||||
_drag_rotation = posmod(_drag_rotation + 1, 4)
|
||||
if _drop_active and not _drag_payload.is_empty():
|
||||
_refresh_ghost(_last_drop_pos)
|
||||
var dc := _drag_controller()
|
||||
if dc != null and dc.preview() != null:
|
||||
var p = dc.preview()
|
||||
if p.has_method("setup") and p.get("shape_id") != null:
|
||||
p.setup(p.file_type, p.shape_id, _drag_rotation, p.file_name, p.ext, p.cell_size)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## DragController — единый источник поворота. Ghost пересчитывается даже
|
||||
## если событие R получила другая сетка.
|
||||
func _on_drag_rotation_changed(value: int) -> void:
|
||||
_drag_rotation = posmod(int(value), 4)
|
||||
if _drop_active and not _drag_payload.is_empty():
|
||||
_refresh_ghost(_last_drop_pos)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _bind_rotation_signal(dc: Node) -> void:
|
||||
if dc == null or not dc.has_signal("rotation_changed"):
|
||||
return
|
||||
if not dc.is_connected("rotation_changed", Callable(self, "_on_drag_rotation_changed")):
|
||||
dc.connect("rotation_changed", Callable(self, "_on_drag_rotation_changed"))
|
||||
|
||||
|
||||
# --- drag source -----------------------------------------------------------
|
||||
|
||||
func _get_drag_data(at_position: Vector2) -> Variant:
|
||||
@@ -580,13 +661,14 @@ func _get_drag_data(at_position: Vector2) -> Variant:
|
||||
preview.setup(str(file.type), str(file.shape_id), int(file.rotation),
|
||||
str(file.name), str(file.ext), clampf(_cell * 0.55, 28.0, 56.0))
|
||||
preview.set_mode("shape")
|
||||
dc.begin(preview, "shape")
|
||||
dc.begin(preview, "shape", int(file.rotation))
|
||||
queue_redraw()
|
||||
return payload
|
||||
|
||||
|
||||
func _drag_controller() -> Node:
|
||||
if drag_controller != null and is_instance_valid(drag_controller):
|
||||
_bind_rotation_signal(drag_controller)
|
||||
return drag_controller
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
@@ -596,15 +678,18 @@ func _drag_controller() -> Node:
|
||||
dc.name = "DragControllerAuto"
|
||||
get_tree().root.add_child(dc)
|
||||
drag_controller = dc
|
||||
_bind_rotation_signal(dc)
|
||||
return dc
|
||||
|
||||
|
||||
func _existing_drag_controller() -> Node:
|
||||
var dc: Node = null
|
||||
if drag_controller != null and is_instance_valid(drag_controller):
|
||||
return drag_controller
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().get_first_node_in_group("drag_controller")
|
||||
dc = drag_controller
|
||||
elif is_inside_tree():
|
||||
dc = get_tree().get_first_node_in_group("drag_controller")
|
||||
_bind_rotation_signal(dc)
|
||||
return dc
|
||||
|
||||
|
||||
# --- drop target -----------------------------------------------------------
|
||||
@@ -616,15 +701,25 @@ func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
|
||||
var kind := str(payload.get("kind", ""))
|
||||
if kind != "file" and kind != "packet":
|
||||
return false
|
||||
var dc := _drag_controller()
|
||||
var key := kind + ":" + str(payload.get("id", ""))
|
||||
if key != _drag_key:
|
||||
_drag_key = key
|
||||
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
|
||||
# Поворот — состояние DragController: после R/ПКМ payload уже устарел.
|
||||
# Если контроллер пуст (пакет без превью), засеваем из payload.
|
||||
if dc != null and dc.has_method("is_active") and bool(dc.is_active()):
|
||||
_drag_rotation = int(dc.rotation()) if dc.has_method("rotation") else \
|
||||
posmod(int(payload.get("rotation", 0)), 4)
|
||||
else:
|
||||
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
|
||||
if dc != null and dc.has_method("set_rotation"):
|
||||
dc.set_rotation(_drag_rotation)
|
||||
elif dc != null and dc.has_method("rotation"):
|
||||
_drag_rotation = int(dc.rotation())
|
||||
_drop_active = true
|
||||
_drag_payload = payload
|
||||
_last_drop_pos = at_position
|
||||
_refresh_ghost(at_position)
|
||||
var dc := _drag_controller()
|
||||
if dc != null:
|
||||
dc.set_mode("shape")
|
||||
return true
|
||||
@@ -643,32 +738,42 @@ func _refresh_ghost(pos: Vector2) -> void:
|
||||
_ghost_rotation = int(fit.get("rotation", _drag_rotation))
|
||||
_ghost_origin = fit.get("origin", target)
|
||||
_ghost_ok = bool(fit.get("ok", false))
|
||||
_ghost_auto = bool(fit.get("auto", false))
|
||||
_ghost_target = target
|
||||
_ghost_cells = FR.shape_cells(shape_id, _ghost_rotation)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Сначала пробуем ВЫБРАННЫЙ пользователем поворот. Остальные — только если
|
||||
## он не влезает; тогда fit.auto = true, и ghost честно пишет про автоповорот.
|
||||
func _resolve_placement(payload: Dictionary, target: Vector2i,
|
||||
rotation: int) -> Dictionary:
|
||||
var shape_id := str(payload.get("shape_id", "O1"))
|
||||
if shape_id.is_empty():
|
||||
shape_id = "O1"
|
||||
var preferred := posmod(int(rotation), 4)
|
||||
var ignore := ""
|
||||
if str(payload.get("kind", "")) == "file":
|
||||
ignore = str(payload.get("id", ""))
|
||||
if target.x < 0 or target.y < 0:
|
||||
var auto := _find_fit(shape_id, rotation, ignore)
|
||||
var auto := _find_fit(shape_id, preferred, ignore)
|
||||
if not auto.is_empty():
|
||||
return {"ok": true, "origin": auto["origin"], "rotation": auto["rotation"]}
|
||||
return {"ok": false, "origin": Vector2i(-1, -1), "rotation": posmod(rotation, 4)}
|
||||
var auto_rot := int(auto["rotation"])
|
||||
return {"ok": true, "origin": auto["origin"], "rotation": auto_rot,
|
||||
"auto": auto_rot != preferred}
|
||||
return {"ok": false, "origin": Vector2i(-1, -1), "rotation": preferred,
|
||||
"auto": false}
|
||||
for i in 4:
|
||||
var rot := posmod(rotation + i, 4)
|
||||
var rot := posmod(preferred + i, 4)
|
||||
var origin := anchor_origin(target, shape_id, rot)
|
||||
if _can_place(shape_id, origin, rot, ignore):
|
||||
return {"ok": true, "origin": origin, "rotation": rot}
|
||||
return {"ok": true, "origin": origin, "rotation": rot,
|
||||
"auto": rot != preferred}
|
||||
return {
|
||||
"ok": false,
|
||||
"origin": anchor_origin(target, shape_id, posmod(rotation, 4)),
|
||||
"rotation": posmod(rotation, 4),
|
||||
"origin": anchor_origin(target, shape_id, preferred),
|
||||
"rotation": preferred,
|
||||
"auto": false,
|
||||
}
|
||||
|
||||
|
||||
@@ -702,7 +807,16 @@ func _drop_data(at_position: Vector2, data: Variant) -> void:
|
||||
var payload: Dictionary = data
|
||||
var kind := str(payload.get("kind", ""))
|
||||
var target := nearest_cell(at_position, _grid_origin, _cell, GAP, _cols, _rows)
|
||||
var fit := _resolve_placement(payload, target, _drag_rotation)
|
||||
var dc := _existing_drag_controller()
|
||||
if dc != null and dc.has_method("rotation"):
|
||||
_drag_rotation = int(dc.rotation())
|
||||
# Роняем ровно туда и с тем поворотом, который показал ghost.
|
||||
var fit: Dictionary
|
||||
if _ghost_target == target and not _ghost_cells.is_empty():
|
||||
fit = {"ok": _ghost_ok, "origin": _ghost_origin,
|
||||
"rotation": _ghost_rotation, "auto": _ghost_auto}
|
||||
else:
|
||||
fit = _resolve_placement(payload, target, _drag_rotation)
|
||||
if kind == "packet":
|
||||
if not bool(fit.get("ok", false)):
|
||||
_reject("НЕТ МЕСТА В ПАПКЕ")
|
||||
@@ -756,7 +870,10 @@ func _notify(text: String) -> void:
|
||||
func _end_drop() -> void:
|
||||
_drop_active = false
|
||||
_drag_key = ""
|
||||
_drag_rotation = 0
|
||||
_ghost_cells.clear()
|
||||
_ghost_target = Vector2i(-1, -1)
|
||||
_ghost_auto = false
|
||||
mouse_default_cursor_shape = Control.CURSOR_ARROW
|
||||
queue_redraw()
|
||||
|
||||
@@ -820,7 +937,7 @@ func _draw_file(file) -> void:
|
||||
if not _filter.is_empty() and not _matches_filter(file):
|
||||
return
|
||||
var cells := FR.shape_cells(str(file.shape_id), int(file.rotation))
|
||||
FILE_SHAPE.paint(self, _grid_origin, cells, _cell, {
|
||||
FILE_SHAPE.paint(self, _shape_pos(Vector2i(file.origin)), cells, _cell, {
|
||||
"type": str(file.type),
|
||||
"name": str(file.name),
|
||||
"ext": str(file.ext),
|
||||
@@ -839,7 +956,7 @@ func _draw_reservation(res: Dictionary) -> void:
|
||||
var origin: Vector2i = res.get("origin", Vector2i.ZERO)
|
||||
var cells := FR.shape_cells(shape_id, rot)
|
||||
var fill := Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.10)
|
||||
FILE_SHAPE.paint(self, _grid_origin, cells, _cell, {
|
||||
FILE_SHAPE.paint(self, _shape_pos(origin), cells, _cell, {
|
||||
"type": "text",
|
||||
"state": "stored",
|
||||
"alpha": 0.9,
|
||||
@@ -862,7 +979,7 @@ func _draw_ghost(f: Font) -> void:
|
||||
return
|
||||
var color := Theme90.SUCCESS if _ghost_ok else Theme90.ERROR
|
||||
var fill := Color(color.r, color.g, color.b, 0.16)
|
||||
FILE_SHAPE.paint(self, _grid_origin, _ghost_cells, _cell, {
|
||||
FILE_SHAPE.paint(self, _shape_pos(_ghost_origin), _ghost_cells, _cell, {
|
||||
"type": "text",
|
||||
"alpha": 0.9,
|
||||
"show_label": false,
|
||||
@@ -872,11 +989,12 @@ func _draw_ghost(f: Font) -> void:
|
||||
})
|
||||
var b := FR.shape_bounds(_ghost_cells)
|
||||
var step := _cell + GAP
|
||||
var box := Rect2(_grid_origin + Vector2(float(_ghost_origin.x) * step,
|
||||
float(_ghost_origin.y) * step), Vector2(
|
||||
var box := Rect2(_shape_pos(_ghost_origin), Vector2(
|
||||
float(b.x) * step - GAP, float(b.y) * step - GAP))
|
||||
Theme90.draw_dashed_box(self, box.grow(2.0), color, 4.0, 1.2)
|
||||
var hint := "ОТПУСТИТЕ · R ПОВОРОТ" if _ghost_ok else "НЕ ВЛЕЗАЕТ · R ПОВОРОТ"
|
||||
if _ghost_ok and _ghost_auto:
|
||||
hint = "ОТПУСТИТЕ · АВТОПОВОРОТ · R ПОВОРОТ"
|
||||
var hw := f.get_string_size(hint, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
|
||||
var pill := Rect2(size.x - hw - 20.0, 6.0, hw + 14.0, 18.0)
|
||||
draw_style_box(Theme90.box(Theme90.WHITE, color, 4, 1), pill)
|
||||
|
||||
@@ -273,6 +273,138 @@ static func paint(ci: CanvasItem, pos: Vector2, cells: Array, cell_px,
|
||||
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:
|
||||
@@ -284,30 +416,59 @@ static func _paint_cell_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
|
||||
var f := Theme90.ui()
|
||||
var ink := Theme90.SHADE if locked else Theme90.INK
|
||||
var shade := Theme90.SHADE
|
||||
var pad := 3.0
|
||||
var icon := Rect2(lr.position + Vector2(pad, pad), Vector2(8, 8))
|
||||
draw_type_icon(ci, icon, file_type, base, alpha)
|
||||
# Поля: строки после первой идут во всю ширину клетки (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 tx := lr.position.x + 13.0
|
||||
var tw := maxf(8.0, lr.size.x - 16.0)
|
||||
var ty := lr.position.y + 2.0
|
||||
if cell_min >= 72.0:
|
||||
var lines := Theme90.wrap(f, name, 10, tw, 2)
|
||||
for i in lines.size():
|
||||
Theme90.text(ci, f, Vector2(tx, ty + float(i) * 11.0), lines[i], 10,
|
||||
_with_alpha(ink, alpha), tw)
|
||||
var ey := ty + float(maxi(1, lines.size())) * 11.0 + 1.0
|
||||
if not ext_value.is_empty():
|
||||
Theme90.text(ci, f, Vector2(lr.position.x + pad, ey),
|
||||
Theme90.fit(f, ext_value, 10, lr.size.x - pad * 2.0), 10,
|
||||
_with_alpha(shade, alpha), lr.size.x - pad * 2.0)
|
||||
else:
|
||||
var line := Theme90.fit(f, name, 10, tw)
|
||||
Theme90.text(ci, f, Vector2(tx, ty), line, 10, _with_alpha(ink, alpha), tw)
|
||||
if not ext_value.is_empty():
|
||||
Theme90.text(ci, f, Vector2(lr.position.x + pad, ty + 12.0),
|
||||
Theme90.fit(f, ext_value, 10, lr.size.x - pad * 2.0), 10,
|
||||
_with_alpha(shade, alpha), lr.size.x - pad * 2.0)
|
||||
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)
|
||||
@@ -330,19 +491,25 @@ static func _paint_square_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
|
||||
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.34, 9.0, 18.0)
|
||||
var icon_y := lr.get_center().y - icon_size * 0.5
|
||||
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_y -= 6.0
|
||||
var icon := Rect2(lr.get_center().x - icon_size * 0.5, icon_y,
|
||||
icon_size, icon_size)
|
||||
draw_type_icon(ci, icon, file_type, base, alpha)
|
||||
# Иконка слева от имени, выровнена по строке подписи.
|
||||
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
|
||||
Theme90.text(ci, f, Vector2(lr.position.x + 3.0, lr.end.y - 14.0),
|
||||
Theme90.fit(f, name, 10, lr.size.x - 6.0), 10, _with_alpha(col, alpha),
|
||||
lr.size.x - 6.0)
|
||||
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).
|
||||
|
||||
@@ -9,6 +9,7 @@ extends Control
|
||||
|
||||
signal new_run
|
||||
signal continue_run
|
||||
signal tutorial_requested
|
||||
signal settings
|
||||
signal about
|
||||
|
||||
@@ -29,6 +30,7 @@ var vhs = null
|
||||
|
||||
var _btn_continue: Button90
|
||||
var _btn_new: Button90
|
||||
var _btn_tutorial: Button90
|
||||
var _btn_settings: Button90
|
||||
var _btn_about: Button90
|
||||
|
||||
@@ -73,6 +75,12 @@ func _build() -> void:
|
||||
_btn_new.accent = true
|
||||
_btn_new.pressed.connect(func() -> void: new_run.emit())
|
||||
|
||||
# Обучение — вторичное действие сразу под «Новый забег»: та же форма,
|
||||
# чуть меньше кегль и высота, чтобы не спорить с главным CTA.
|
||||
_btn_tutorial = _make_button("ОБУЧЕНИЕ")
|
||||
_btn_tutorial.caption_size = 12
|
||||
_btn_tutorial.pressed.connect(func() -> void: tutorial_requested.emit())
|
||||
|
||||
_btn_settings = _make_button(_tr("btn.settings", "Настройки"))
|
||||
# визуально «активная» кнопка: логику подключит main.gd
|
||||
_btn_settings.active = true
|
||||
@@ -125,11 +133,12 @@ func _layout() -> void:
|
||||
var w := _window()
|
||||
var px := w.position.x + 660.0
|
||||
var py := w.position.y + 112.0
|
||||
var buttons: Array[Button90] = [_btn_continue, _btn_new, _btn_settings, _btn_about]
|
||||
var buttons: Array[Button90] = [_btn_continue, _btn_new, _btn_tutorial,
|
||||
_btn_settings, _btn_about]
|
||||
for b in buttons:
|
||||
if b != null:
|
||||
b.position = Vector2(px, py)
|
||||
b.size = Vector2(300, 38)
|
||||
b.size = Vector2(300, 34 if b == _btn_tutorial else 38)
|
||||
py += 52.0
|
||||
|
||||
|
||||
|
||||
@@ -120,11 +120,8 @@ func _display_name() -> String:
|
||||
|
||||
|
||||
func _update_tooltip() -> void:
|
||||
var shape_line := "%s · %s" % [_display_name(), cells_text().to_lower()]
|
||||
if state == "loading":
|
||||
tooltip_text = "%s\n%d КБ · %s · загрузка в папку" % [shape_line, size_kb, protocol]
|
||||
else:
|
||||
tooltip_text = "%s\n%d КБ · %s · перетащите в папку" % [shape_line, size_kb, protocol]
|
||||
# Полное имя одной строкой (без переноса): ОС-подсказка не обрезает его.
|
||||
tooltip_text = "%s · %s · %s" % [_display_name(), FileCard.type_title(type), protocol]
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
@@ -149,8 +146,12 @@ func _draw() -> void:
|
||||
|
||||
_draw_shape_preview(Rect2(4, 10, 30, 30))
|
||||
|
||||
Theme90.text(self, f, Vector2(40, 2), Theme90.fit(f, _display_name(), 12, 168), 12,
|
||||
Theme90.INK, 168)
|
||||
# Иконка типа 14×14 сразу слева от имени файла.
|
||||
if not type.is_empty():
|
||||
Theme90.draw_file_icon(self, Rect2(40, 2, 14, 14), type)
|
||||
|
||||
Theme90.text(self, f, Vector2(57, 2), Theme90.fit(f, _display_name(), 13, 151), 13,
|
||||
Theme90.INK, 151)
|
||||
|
||||
# бейдж протокола
|
||||
var badge := Rect2(214, 4, 30, 13)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class_name RequestCard
|
||||
extends Control
|
||||
## Карточка запроса клиента 400×108 (v2).
|
||||
## Карточка запроса клиента 400×128 (v3).
|
||||
##
|
||||
## Показывает квадратные чипы прикреплённых файлов, счётчик
|
||||
## «ПРИКРЕПЛЕНО X/Y», предсказанный выбор мини-игры (S → игра · T),
|
||||
## переключатель TCP/UDP, явную кнопку ОТПРАВИТЬ, ОТКРЕПИТЬ и ОТКЛОНИТЬ.
|
||||
## Атак/фишинга в карточке больше нет.
|
||||
## Сетка 2×2 обязательных файлов (конкретные REQUIRED-id из main), счётчик
|
||||
## «ПРИКРЕПЛЕНО X/Y», прогноз мини-игры (Economy.send_minigame_for по типам
|
||||
## обязательных файлов) и явные кнопки СОЕДИНИТЬСЯ/ОТПРАВИТЬ, ОТКРЕПИТЬ,
|
||||
## ОТКЛОНИТЬ. Наведение на чип показывает полное имя файла и папку, где он
|
||||
## лежит, в подсказке и строке внутри карточки. Атак/фишинга в карточке нет.
|
||||
|
||||
signal send_pressed(id: String)
|
||||
signal protocol_toggle_requested(id: String)
|
||||
@@ -15,21 +16,32 @@ signal detach_pressed(request_id: String, slot_index: int)
|
||||
## Legacy: соединение удалено. Сигнал оставлен, чтобы старые подписи не падали.
|
||||
signal connect_pressed(id: String)
|
||||
|
||||
const CARD := Vector2(400, 108)
|
||||
const CARD := Vector2(400, 128)
|
||||
const ECONOMY := preload("res://scripts/core/economy.gd")
|
||||
const SLOT_MAX := 4
|
||||
## Сетка 2×2: шаг 90×28, чип 86×26. Правый столбец (196..392) — счётчик,
|
||||
## прогноз и строка состояния.
|
||||
const SLOT_X := 8.0
|
||||
const SLOT_STEP := 90.0
|
||||
const SLOT_STEP_X := 90.0
|
||||
const SLOT_Y := 30.0
|
||||
const SLOT_STEP_Y := 28.0
|
||||
const SLOT_W := 86.0
|
||||
const SLOT_H := 26.0
|
||||
const PROTO_RECT := Rect2(178, 6, 44, 16)
|
||||
## Полоса полного имени при наведении на чип: правый верх, над счётчиком.
|
||||
const HOVER_STRIP := Rect2(196, 17, 196, 13)
|
||||
## Если имя и папка не влезают в одну строку — полоса выше на 9 px, папка
|
||||
## идёт второй мелкой строкой. Счётчик «ПРИКРЕПЛЕНО» в этот момент не рисуем:
|
||||
## на его месте оказывается вторая строка полосы.
|
||||
const HOVER_STRIP_TALL := 22.0
|
||||
const HOVER_FOLDER_SIZE := 7
|
||||
## Пояснение к исчезновению файлов после отправки (§2.8).
|
||||
const SEND_HINT := "После успешной отправки файлы списываются."
|
||||
|
||||
const COEFF := {"text": 1, "sound": 2, "photo": 4, "video": 8}
|
||||
const SEND_ORDER := ["dots", "sound", "wires", "text", "video", "photo", "maze"]
|
||||
const GAME_NAMES := {
|
||||
## Имена игр — только запасной вариант, если Data.minigame_def недоступен.
|
||||
const FALLBACK_NAMES := {
|
||||
"dots": "ОДНА ЛИНИЯ",
|
||||
"sound": "УРОВНИ",
|
||||
"wires": "ПРОВОДА",
|
||||
"text": "ЧУЖОЕ СЛОВО",
|
||||
"video": "РАСКАДРОВКА",
|
||||
"photo": "КАДР",
|
||||
@@ -48,6 +60,9 @@ var progress := 0.0
|
||||
var queued := false
|
||||
var sending := false
|
||||
var minigame_active := false
|
||||
## TCP-соединение: идёт мини-игра соединения / соединение уже установлено.
|
||||
var connecting := false
|
||||
var connected := false
|
||||
## Готовность контейнера (HourDirector.is_request_ready): файлы собраны.
|
||||
var send_ready := false
|
||||
var send_sum := 0
|
||||
@@ -70,6 +85,7 @@ var _drop_slot := -1
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_PASS
|
||||
clip_contents = false
|
||||
custom_minimum_size = CARD
|
||||
if size == Vector2.ZERO:
|
||||
size = CARD
|
||||
@@ -77,21 +93,22 @@ func _ready() -> void:
|
||||
mouse_entered.connect(func() -> void: hovered = true)
|
||||
mouse_exited.connect(func() -> void:
|
||||
hovered = false
|
||||
_hover_slot = -1)
|
||||
_set_hover_slot(-1))
|
||||
|
||||
_send_btn = _make_button("ОТПРАВИТЬ", Vector2(8, 86), Vector2(92, 18), true)
|
||||
_send_btn = _make_button("ОТПРАВИТЬ", Vector2(8, 104), Vector2(92, 18), true)
|
||||
_send_btn.pressed.connect(func() -> void:
|
||||
if can_send():
|
||||
send_pressed.emit(id))
|
||||
_detach_btn = _make_button("ОТКРЕПИТЬ", Vector2(104, 86), Vector2(86, 18), false)
|
||||
_detach_btn = _make_button("ОТКРЕПИТЬ", Vector2(104, 104), Vector2(86, 18), false)
|
||||
_detach_btn.pressed.connect(func() -> void:
|
||||
var index := _last_attached_index()
|
||||
if index >= 0:
|
||||
detach_pressed.emit(id, index))
|
||||
_reject_btn = _make_button("ОТКЛОНИТЬ", Vector2(194, 86), Vector2(90, 18), false)
|
||||
_reject_btn = _make_button("ОТКЛОНИТЬ", Vector2(194, 104), Vector2(90, 18), false)
|
||||
_reject_btn.danger = true
|
||||
_reject_btn.pressed.connect(func() -> void: reject_pressed.emit(id))
|
||||
set_process(true)
|
||||
_update_buttons()
|
||||
|
||||
|
||||
func _make_button(caption: String, pos: Vector2, sz: Vector2, accent: bool) -> Button90:
|
||||
@@ -101,16 +118,18 @@ func _make_button(caption: String, pos: Vector2, sz: Vector2, accent: bool) -> B
|
||||
b.accent = accent
|
||||
b.position = pos
|
||||
b.size = sz
|
||||
b.mouse_entered.connect(func() -> void: _set_hover_slot(-1))
|
||||
add_child(b)
|
||||
return b
|
||||
|
||||
|
||||
## Совместимые первые 8 аргументов v1 + v2-поля.
|
||||
## Совместимые первые 16 аргументов v2 + TCP-состояние.
|
||||
func setup(p_id: String, p_client: String, p_client_id: String, p_proto: String,
|
||||
p_slots: Array, p_left: float, p_total: float, p_progress: float = 0.0,
|
||||
p_required: int = 0, p_queued: bool = false, p_sending: bool = false,
|
||||
p_send_sum: int = 0, p_minigame_id: String = "", p_minigame_tier: int = 0,
|
||||
p_minigame_active: bool = false, p_ready: bool = false) -> void:
|
||||
p_minigame_active: bool = false, p_ready: bool = false,
|
||||
p_connecting: bool = false, p_connected: bool = false) -> void:
|
||||
id = p_id
|
||||
client_name = p_client
|
||||
client_id = p_client_id
|
||||
@@ -118,36 +137,65 @@ func setup(p_id: String, p_client: String, p_client_id: String, p_proto: String,
|
||||
if protocol != "UDP":
|
||||
protocol = "TCP"
|
||||
slots = p_slots.duplicate(true) if p_slots is Array else []
|
||||
required_count = maxi(int(p_required), slots.size())
|
||||
if required_count <= 0 and not slots.is_empty():
|
||||
required_count = slots.size()
|
||||
var req := clampi(int(p_required), 0, SLOT_MAX)
|
||||
if req > 0:
|
||||
required_count = req
|
||||
elif not slots.is_empty():
|
||||
required_count = mini(slots.size(), SLOT_MAX)
|
||||
else:
|
||||
required_count = 0
|
||||
time_left = p_left
|
||||
time_total = p_total
|
||||
progress = clampf(p_progress, 0.0, 1.0)
|
||||
queued = p_queued
|
||||
sending = p_sending
|
||||
minigame_active = p_minigame_active
|
||||
connecting = p_connecting
|
||||
connected = p_connected
|
||||
send_ready = p_ready
|
||||
send_sum = int(p_send_sum)
|
||||
minigame_id = str(p_minigame_id)
|
||||
minigame_tier = int(p_minigame_tier)
|
||||
tooltip_text = "%s · %s" % [client_name, client_id]
|
||||
custom_minimum_size = Vector2(CARD.x, 0)
|
||||
# Слот наведения переживает ежеминутные (покадровые) обновления карточки:
|
||||
# иначе полоса с именем/папкой успевала показаться лишь на один кадр.
|
||||
if _hover_slot >= required_count:
|
||||
_hover_slot = -1
|
||||
_refresh_tooltip()
|
||||
custom_minimum_size = CARD
|
||||
if size == Vector2.ZERO:
|
||||
size = CARD
|
||||
_update_buttons()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _update_buttons() -> void:
|
||||
if _send_btn != null:
|
||||
var caption := _send_caption()
|
||||
if _send_btn.caption != caption:
|
||||
_send_btn.caption = caption
|
||||
_send_btn.disabled = not can_send()
|
||||
_send_btn.tooltip_text = "Отправить контейнер" if can_send() \
|
||||
else _send_disabled_reason()
|
||||
if can_send():
|
||||
_send_btn.tooltip_text = ("Провести соединение, затем отправить" \
|
||||
if protocol == "TCP" and not connected else "Отправить контейнер") \
|
||||
+ "\n" + SEND_HINT
|
||||
else:
|
||||
_send_btn.tooltip_text = _send_disabled_reason() + "\n" + SEND_HINT
|
||||
if _detach_btn != null:
|
||||
_detach_btn.disabled = attached_count() <= 0 or sending or minigame_active or queued
|
||||
_detach_btn.disabled = attached_count() <= 0 or sending or minigame_active \
|
||||
or queued or connecting
|
||||
if _reject_btn != null:
|
||||
_reject_btn.disabled = sending
|
||||
|
||||
|
||||
## Текст главной кнопки: TCP без соединения сначала соединяется.
|
||||
func _send_caption() -> String:
|
||||
if connecting:
|
||||
return "СОЕДИНЕНИЕ…"
|
||||
if protocol == "TCP" and not connected:
|
||||
return "СОЕДИНИТЬСЯ"
|
||||
return "ОТПРАВИТЬ"
|
||||
|
||||
|
||||
func ratio() -> float:
|
||||
if time_total <= 0.0:
|
||||
return 0.0
|
||||
@@ -156,18 +204,29 @@ func ratio() -> float:
|
||||
|
||||
func attached_count() -> int:
|
||||
var n := 0
|
||||
for slot in slots:
|
||||
if slot is Dictionary and bool(slot.get("attached", false)):
|
||||
for i in mini(required_count, slots.size()):
|
||||
if _slot_attached(i):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _lost_count() -> int:
|
||||
var n := 0
|
||||
for i in required_count:
|
||||
if _slot_missing(i):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
## Все обязательные файлы прикреплены и нет блокирующего состояния.
|
||||
## TCP-соединение проверяет backend: кнопка остаётся доступной, чтобы
|
||||
## игрок мог нажать её и пройти мини-игру соединения.
|
||||
func can_send() -> bool:
|
||||
if required_count <= 0:
|
||||
return false
|
||||
if sending or queued or minigame_active:
|
||||
if connecting or sending or queued or minigame_active:
|
||||
return false
|
||||
return attached_count() >= required_count and send_ready
|
||||
return attached_count() >= required_count
|
||||
|
||||
|
||||
func send_disabled() -> bool:
|
||||
@@ -175,41 +234,52 @@ func send_disabled() -> bool:
|
||||
|
||||
|
||||
func _send_disabled_reason() -> String:
|
||||
if connecting:
|
||||
return "Идёт соединение…"
|
||||
if sending:
|
||||
return "Отправка уже идёт"
|
||||
if queued:
|
||||
return "Ожидание свободного слота"
|
||||
if minigame_active:
|
||||
return "Сначала завершите мини-игру"
|
||||
if required_count <= 0:
|
||||
return "Нет обязательных файлов"
|
||||
if _lost_count() > 0:
|
||||
return "Обязательный файл потерян"
|
||||
if attached_count() < required_count:
|
||||
return "Прикрепите файлы: %d из %d" % [attached_count(), required_count]
|
||||
return "Отправка недоступна"
|
||||
|
||||
|
||||
## Предсказание мини-игры: сумма коэффициентов → игра и ступень (§6).
|
||||
## Пока мини-игра не запущена, игра/ступень считаются тем же плотным
|
||||
## маппингом B4 (Economy.send_minigame_for), что и при реальной отправке.
|
||||
## Считается по типам ОБЯЗАТЕЛЬНЫХ файлов, поэтому видно ещё до прикрепления.
|
||||
## Если main передал send_sum/minigame_id, они приоритетнее локального расчёта.
|
||||
func prediction() -> Dictionary:
|
||||
var types: Array = []
|
||||
var computed_sum := 0
|
||||
for slot in slots:
|
||||
if slot is Dictionary and bool(slot.get("attached", false)):
|
||||
var t := str(slot.get("type", "text"))
|
||||
types.append(t)
|
||||
computed_sum += int(COEFF.get(t, 1))
|
||||
var sum := send_sum if send_sum > 0 else computed_sum
|
||||
var pick: Dictionary = ECONOMY.send_minigame_for(types)
|
||||
for i in required_count:
|
||||
if _slot_missing(i):
|
||||
continue
|
||||
var data: Variant = _slot_data(i)
|
||||
if data is Dictionary:
|
||||
var t := str((data as Dictionary).get("type", ""))
|
||||
if not t.is_empty():
|
||||
types.append(t)
|
||||
var pick: Dictionary = ECONOMY.selection_for_sum(send_sum) if send_sum > 0 \
|
||||
else ECONOMY.send_minigame_for(types)
|
||||
var game := str(pick.get("id", "dots"))
|
||||
var tier := clampi(int(pick.get("tier", 1)), 1, 4)
|
||||
var index := int(pick.get("index", 0))
|
||||
# В карточке только игры ОТПРАВКИ: у них три ступени (атаки не показываем).
|
||||
var tier := clampi(int(pick.get("tier", 1)), 1, 3)
|
||||
var sum := int(pick.get("sum", 0))
|
||||
if not minigame_id.is_empty():
|
||||
# Мини-игра уже активна: показываем выбор директора, не пересчитываем.
|
||||
game = minigame_id
|
||||
if minigame_tier > 0:
|
||||
tier = clampi(minigame_tier, 1, 4)
|
||||
tier = clampi(minigame_tier, 1, 3)
|
||||
if send_sum > 0:
|
||||
sum = send_sum
|
||||
return {
|
||||
"sum": sum,
|
||||
"index": index,
|
||||
"index": int(pick.get("index", 0)),
|
||||
"id": game,
|
||||
"name": _game_name(game),
|
||||
"tier": tier,
|
||||
@@ -218,13 +288,16 @@ func prediction() -> Dictionary:
|
||||
|
||||
func minigame_text() -> String:
|
||||
var p := prediction()
|
||||
if int(p.get("sum", 0)) <= 0:
|
||||
if int(p.get("sum", 0)) <= 0 and minigame_id.is_empty():
|
||||
return "МИНИ-ИГРА: —"
|
||||
return "МИНИ-ИГРА: %s · T%d" % [str(p.get("name", "")), int(p.get("tier", 1))]
|
||||
return "МИНИ-ИГРА: %s · T%d" % [str(p.get("name", "")),
|
||||
clampi(int(p.get("tier", 1)), 1, 3)]
|
||||
|
||||
|
||||
## Сумма коэффициентов типов обязательных файлов — «чем больше, тем сложнее».
|
||||
## Не путать со ступенью T1..T3: она уже показана в строке мини-игры выше.
|
||||
func difficulty_text() -> String:
|
||||
return "СЛОЖНОСТЬ: %d" % int(prediction().get("sum", 0))
|
||||
return "СУММА: %d" % int(prediction().get("sum", 0))
|
||||
|
||||
|
||||
func _game_name(game_id: String) -> String:
|
||||
@@ -234,7 +307,7 @@ func _game_name(game_id: String) -> String:
|
||||
var name_ru := str(def.get("name_ru", ""))
|
||||
if not name_ru.is_empty():
|
||||
return name_ru.to_upper()
|
||||
return str(GAME_NAMES.get(game_id, game_id.to_upper()))
|
||||
return str(FALLBACK_NAMES.get(game_id, game_id.to_upper()))
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
@@ -245,9 +318,133 @@ func _process(delta: float) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _slot_rect(i: int) -> Rect2:
|
||||
return Rect2(SLOT_X + i * SLOT_STEP, SLOT_Y, SLOT_W, SLOT_H)
|
||||
# --- слоты -----------------------------------------------------------------
|
||||
|
||||
func _slot_data(i: int) -> Variant:
|
||||
if i < 0 or i >= slots.size():
|
||||
return null
|
||||
return slots[i]
|
||||
|
||||
|
||||
func _slot_attached(i: int) -> bool:
|
||||
var data: Variant = _slot_data(i)
|
||||
return data is Dictionary and bool((data as Dictionary).get("attached", false))
|
||||
|
||||
|
||||
## Слот обязателен, но файла в реестре нет. null внутри required-диапазона —
|
||||
## тоже потерянный файл (main может отдать null либо name = "ПОТЕРЯН").
|
||||
func _slot_missing(i: int) -> bool:
|
||||
if i < 0 or i >= required_count:
|
||||
return false
|
||||
var data: Variant = _slot_data(i)
|
||||
if data == null:
|
||||
return true
|
||||
if data is Dictionary:
|
||||
var d: Dictionary = data
|
||||
if bool(d.get("missing", false)):
|
||||
return true
|
||||
return str(d.get("name", "")) == "ПОТЕРЯН"
|
||||
return true
|
||||
|
||||
|
||||
func _slot_status(i: int) -> String:
|
||||
if _slot_missing(i):
|
||||
return "ПОТЕРЯН"
|
||||
if _slot_attached(i):
|
||||
return "ПРИКРЕПЛЁН"
|
||||
return "ОБЯЗАТЕЛЕН"
|
||||
|
||||
|
||||
func _display_name(data: Dictionary) -> String:
|
||||
var nm := str(data.get("name", ""))
|
||||
var e := str(data.get("ext", ""))
|
||||
if e.is_empty():
|
||||
return nm
|
||||
return "%s.%s" % [nm, e]
|
||||
|
||||
|
||||
func _slot_full_label(i: int) -> String:
|
||||
if _slot_missing(i):
|
||||
return "ПОТЕРЯН · ФАЙЛ · ПОТЕРЯН · папка: —"
|
||||
var data: Variant = _slot_data(i)
|
||||
if not (data is Dictionary):
|
||||
return ""
|
||||
var d: Dictionary = data
|
||||
var line := "%s · %s · %s" % [_display_name(d),
|
||||
FileCard.type_title(str(d.get("type", "text"))), _slot_status(i)]
|
||||
var folder := _slot_folder(i)
|
||||
if not folder.is_empty():
|
||||
line += " · папка: " + folder
|
||||
return line
|
||||
|
||||
|
||||
## Папка, где сейчас лежит обязательный файл ("" — неизвестна или файла нет).
|
||||
func _slot_folder(i: int) -> String:
|
||||
if _slot_missing(i):
|
||||
return ""
|
||||
var data: Variant = _slot_data(i)
|
||||
if not (data is Dictionary):
|
||||
return ""
|
||||
return str((data as Dictionary).get("folder", "")).strip_edges()
|
||||
|
||||
|
||||
## Только имя файла — для полосы внутри карточки (тип/статус остаются в tooltip).
|
||||
func _slot_name_only(i: int) -> String:
|
||||
if _slot_missing(i):
|
||||
return "ПОТЕРЯН"
|
||||
var data: Variant = _slot_data(i)
|
||||
if not (data is Dictionary):
|
||||
return ""
|
||||
return _display_name(data)
|
||||
|
||||
|
||||
func _slot_rect(i: int) -> Rect2:
|
||||
return Rect2(SLOT_X + float(i % 2) * SLOT_STEP_X,
|
||||
SLOT_Y + floorf(float(i) / 2.0) * SLOT_STEP_Y, SLOT_W, SLOT_H)
|
||||
|
||||
|
||||
## Кегль имени в чипе: 10 px; 9 — только если это позволяет показать имя
|
||||
## целиком. Длинное имя остаётся 10 px одной строкой с многоточием (полное
|
||||
## имя показывает полоса наведения).
|
||||
func _chip_name_size(f: Font, name_text: String, width: float) -> int:
|
||||
if f.get_string_size(name_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x <= width:
|
||||
return 10
|
||||
if f.get_string_size(name_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 9).x <= width:
|
||||
return 9
|
||||
return 10
|
||||
|
||||
|
||||
## Кегль подписи типа/статуса: 8 px, если влезает, иначе прежние 7.
|
||||
func _chip_sub_size(f: Font, text: String, width: float) -> int:
|
||||
if f.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 8).x <= width:
|
||||
return 8
|
||||
return 7
|
||||
|
||||
|
||||
func _slot_at(pos: Vector2) -> int:
|
||||
for i in required_count:
|
||||
if _slot_rect(i).has_point(pos):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
## Первый слот, в который реально можно прикрепить файл: обязательный, ещё
|
||||
## не занятый и не потерянный. -1 — свободных нет (подсветку не рисуем).
|
||||
func _first_empty() -> int:
|
||||
for i in required_count:
|
||||
if _slot_data(i) != null and not _slot_attached(i) and not _slot_missing(i):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
func _last_attached_index() -> int:
|
||||
for i in range(mini(required_count, slots.size()) - 1, -1, -1):
|
||||
if _slot_attached(i):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
# --- отрисовка -------------------------------------------------------------
|
||||
|
||||
func _draw() -> void:
|
||||
var r := Rect2(Vector2.ZERO, size)
|
||||
@@ -261,17 +458,23 @@ func _draw() -> void:
|
||||
|
||||
Theme90.text(self, f, Vector2(32, 4), Theme90.fit(f, client_name, 12, 140), 12,
|
||||
Theme90.INK, 140)
|
||||
Theme90.text(self, f, Vector2(32, 17), Theme90.fit(f, client_id, 9, 140), 9,
|
||||
Theme90.text(self, f, Vector2(32, 18), Theme90.fit(f, client_id, 9, 140), 9,
|
||||
Theme90.SHADE, 140)
|
||||
|
||||
_draw_protocol_chip(f)
|
||||
_draw_timer(f)
|
||||
|
||||
for i in maxi(required_count, 1):
|
||||
var count := required_count if required_count > 0 else 1
|
||||
for i in count:
|
||||
_draw_slot(i, f)
|
||||
|
||||
_draw_counter(f)
|
||||
# Полное имя файла и папка — поверх правой части шапки, только при наведении на чип.
|
||||
var tall_strip := _hover_slot >= 0 and _hover_strip_tall(f)
|
||||
if not tall_strip:
|
||||
_draw_counter(f)
|
||||
_draw_prediction(f)
|
||||
if _hover_slot >= 0:
|
||||
_draw_hover_strip(f, tall_strip)
|
||||
|
||||
if hovered and _drop_slot < 0:
|
||||
draw_style_box(Theme90.box(Color(0, 0, 0, 0), Theme90.ACCENT_LIGHT, 4, 1),
|
||||
@@ -282,20 +485,33 @@ func _draw() -> void:
|
||||
|
||||
|
||||
func _draw_protocol_chip(f: Font) -> void:
|
||||
var pcol := Theme90.NAVY if protocol == "TCP" else Theme90.WARN
|
||||
var locked := connected
|
||||
var pcol := Theme90.SHADE if locked else (Theme90.NAVY if protocol == "TCP" else Theme90.WARN)
|
||||
draw_style_box(Theme90.box(Color(pcol.r, pcol.g, pcol.b, 0.13),
|
||||
Color(pcol.r, pcol.g, pcol.b, 0.45), 3, 1), PROTO_RECT)
|
||||
Theme90.text_vcenter(self, f, PROTO_RECT, protocol, 10, pcol, -1,
|
||||
HORIZONTAL_ALIGNMENT_CENTER)
|
||||
Theme90.draw_chevron(self, Vector2(PROTO_RECT.end.x - 5, PROTO_RECT.get_center().y),
|
||||
2, pcol, 2.0, 1.0)
|
||||
if locked:
|
||||
_draw_lock(Vector2(PROTO_RECT.end.x - 5, PROTO_RECT.get_center().y), pcol)
|
||||
else:
|
||||
Theme90.draw_chevron(self, Vector2(PROTO_RECT.end.x - 5, PROTO_RECT.get_center().y),
|
||||
2, pcol, 2.0, 1.0)
|
||||
|
||||
|
||||
func _draw_lock(center: Vector2, col: Color) -> void:
|
||||
draw_rect(Rect2(center.x - 2.5, center.y, 5.0, 4.0), col, true)
|
||||
draw_arc(Vector2(center.x, center.y), 2.0, PI, TAU, 6, col, 1.0, true)
|
||||
|
||||
|
||||
func _draw_timer(f: Font) -> void:
|
||||
var label := "ОСТАЛОСЬ"
|
||||
var value := "%d с" % int(ceilf(time_left))
|
||||
var col := _time_color()
|
||||
if sending:
|
||||
if connecting:
|
||||
label = "СОСТОЯНИЕ"
|
||||
value = "СВЯЗЬ…"
|
||||
col = Theme90.WARN
|
||||
elif sending:
|
||||
label = "ОТПРАВКА"
|
||||
value = "%d%%" % int(roundf(progress * 100.0))
|
||||
col = Theme90.ACCENT
|
||||
@@ -316,30 +532,108 @@ func _draw_timer(f: Font) -> void:
|
||||
|
||||
|
||||
func _draw_counter(f: Font) -> void:
|
||||
Theme90.text(self, f, Vector2(280, 31), "ПРИКРЕПЛЕНО", 8, Theme90.SHADE, 114)
|
||||
var have := attached_count()
|
||||
var cap := maxi(required_count, 0)
|
||||
var col := Theme90.SUCCESS if have >= cap and cap > 0 else Theme90.DARK
|
||||
Theme90.num_line(self, Vector2(280, 42), "%d/%d" % [have, cap], 12, col)
|
||||
var col := Theme90.SUCCESS if cap > 0 and have >= cap else Theme90.DARK
|
||||
if _lost_count() > 0:
|
||||
col = Theme90.ERROR
|
||||
Theme90.text(self, f, Vector2(196, 32), "ПРИКРЕПЛЕНО %d/%d" % [have, cap], 11,
|
||||
col, 196)
|
||||
|
||||
|
||||
func _draw_prediction(f: Font) -> void:
|
||||
var p := prediction()
|
||||
Theme90.text(self, f, Vector2(8, 62), minigame_text(), 10, Theme90.NAVY, 224)
|
||||
Theme90.text(self, f, Vector2(236, 62), difficulty_text(), 10, Theme90.DARK, 156)
|
||||
if queued:
|
||||
Theme90.text(self, f, Vector2(236, 73), "ОЖИДАНИЕ СЛОТА", 9, Theme90.WARN, 156)
|
||||
Theme90.text(self, f, Vector2(196, 50), Theme90.fit(f, minigame_text(), 10, 196), 10,
|
||||
Theme90.NAVY, 196)
|
||||
Theme90.text(self, f, Vector2(196, 64), "СУММА: %d" % int(p.get("sum", 0)), 9,
|
||||
Theme90.DARK, 196)
|
||||
|
||||
var hint := ""
|
||||
var hcol := Theme90.WARN
|
||||
if connecting:
|
||||
hint = "УСТАНОВКА СОЕДИНЕНИЯ"
|
||||
hcol = Theme90.WARN
|
||||
elif queued:
|
||||
hint = "ОЖИДАНИЕ СЛОТА"
|
||||
elif sending:
|
||||
Theme90.text(self, f, Vector2(236, 73), "ПЕРЕДАЧА ИДЁТ", 9, Theme90.ACCENT, 156)
|
||||
hint = "ПЕРЕДАЧА ИДЁТ"
|
||||
hcol = Theme90.ACCENT
|
||||
elif minigame_active:
|
||||
Theme90.text(self, f, Vector2(236, 73), "ИДЁТ МИНИ-ИГРА", 9, Theme90.ACCENT, 156)
|
||||
hint = "ИДЁТ МИНИ-ИГРА"
|
||||
hcol = Theme90.ACCENT
|
||||
elif _lost_count() > 0:
|
||||
hint = "ПОТЕРЯН ФАЙЛ"
|
||||
hcol = Theme90.ERROR
|
||||
elif _blink_on and attached_count() < required_count:
|
||||
Theme90.text(self, f, Vector2(236, 73), "НУЖНО ФАЙЛОВ: %d" % maxi(0,
|
||||
required_count - attached_count()), 9, Theme90.WARN, 156)
|
||||
hint = "НУЖНО ФАЙЛОВ: %d" % maxi(0, required_count - attached_count())
|
||||
elif protocol == "TCP" and not connected:
|
||||
hint = "СНАЧАЛА СОЕДИНЕНИЕ"
|
||||
hcol = Theme90.SHADE
|
||||
if not hint.is_empty():
|
||||
Theme90.text(self, f, Vector2(196, 76), Theme90.fit(f, hint, 9, 196), 9, hcol, 196)
|
||||
|
||||
|
||||
## Кегль имени в полосе: длинные имена уменьшаем на 1 px, чтобы показать целиком.
|
||||
func _hover_text_size(f: Font, name_text: String) -> int:
|
||||
var inner := HOVER_STRIP.size.x - 10.0
|
||||
if f.get_string_size(name_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 9).x > inner:
|
||||
return 8
|
||||
return 9
|
||||
|
||||
|
||||
## Влезают ли имя и папка в одну строку полосы при текущем кегле.
|
||||
func _hover_strip_tall(f: Font) -> bool:
|
||||
var folder := _slot_folder(_hover_slot)
|
||||
if folder.is_empty():
|
||||
return false
|
||||
var name_text := _slot_name_only(_hover_slot)
|
||||
if name_text.is_empty():
|
||||
return false
|
||||
var inner := HOVER_STRIP.size.x - 10.0
|
||||
var line := "%s · %s" % [name_text, folder]
|
||||
return f.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1,
|
||||
_hover_text_size(f, name_text)).x > inner
|
||||
|
||||
|
||||
func _draw_hover_strip(f: Font, tall: bool) -> void:
|
||||
var name_text := _slot_name_only(_hover_slot)
|
||||
if name_text.is_empty():
|
||||
return
|
||||
var border := Theme90.ACCENT_LIGHT
|
||||
if _slot_missing(_hover_slot):
|
||||
border = Theme90.ERROR
|
||||
elif _slot_attached(_hover_slot):
|
||||
border = Theme90.SUCCESS
|
||||
var text_size := _hover_text_size(f, name_text)
|
||||
var inner := HOVER_STRIP.size.x - 10.0
|
||||
var folder := _slot_folder(_hover_slot)
|
||||
if not tall:
|
||||
# Всё влезло в одну строку: имя и папка через « · ».
|
||||
var line := name_text if folder.is_empty() \
|
||||
else "%s · %s" % [name_text, folder]
|
||||
draw_style_box(Theme90.box(Theme90.WHITE, border, 3, 1), HOVER_STRIP)
|
||||
Theme90.text_vcenter(self, f, HOVER_STRIP,
|
||||
Theme90.fit(f, line, text_size, inner), text_size, Theme90.INK,
|
||||
inner, HORIZONTAL_ALIGNMENT_LEFT, 5.0)
|
||||
return
|
||||
# Полоса на строку выше: имя сверху, папка — мелкой второй строкой. Счётчик
|
||||
# «ПРИКРЕПЛЕНО» на это время не рисуется (_draw), иначе строки наложатся.
|
||||
var strip := Rect2(HOVER_STRIP.position,
|
||||
Vector2(HOVER_STRIP.size.x, HOVER_STRIP_TALL))
|
||||
draw_style_box(Theme90.box(Theme90.WHITE, border, 3, 1), strip)
|
||||
var name_rect := Rect2(strip.position, Vector2(strip.size.x, HOVER_STRIP.size.y))
|
||||
Theme90.text_vcenter(self, f, name_rect,
|
||||
Theme90.fit(f, name_text, text_size, inner), text_size, Theme90.INK,
|
||||
inner, HORIZONTAL_ALIGNMENT_LEFT, 5.0)
|
||||
var folder_rect := Rect2(strip.position + Vector2(0, 12),
|
||||
Vector2(strip.size.x, HOVER_STRIP_TALL - 12.0))
|
||||
Theme90.text_vcenter(self, f, folder_rect,
|
||||
Theme90.fit(f, "папка: " + folder, HOVER_FOLDER_SIZE, inner),
|
||||
HOVER_FOLDER_SIZE, Theme90.SHADE, inner, HORIZONTAL_ALIGNMENT_LEFT, 5.0)
|
||||
|
||||
|
||||
func _time_color() -> Color:
|
||||
if queued:
|
||||
if queued or connecting:
|
||||
return Theme90.WARN
|
||||
if ratio() < 0.2:
|
||||
return Theme90.ERROR
|
||||
@@ -370,80 +664,132 @@ func _monogram() -> String:
|
||||
|
||||
func _draw_slot(i: int, f: Font) -> void:
|
||||
var cell := _slot_rect(i)
|
||||
var data: Variant = slots[i] if i < slots.size() else null
|
||||
var drop := _drop_slot == i
|
||||
if data == null:
|
||||
Theme90.draw_dashed_box(self, cell, Color(Theme90.SHADE.r, Theme90.SHADE.g,
|
||||
Theme90.SHADE.b, 0.55), 2.5, 1.0)
|
||||
Theme90.text(self, f, Vector2(cell.position.x + 5, cell.position.y + 5), "+",
|
||||
12, Theme90.SHADE)
|
||||
if _blink_on:
|
||||
Theme90.text(self, f, Vector2(cell.position.x + 16, cell.position.y + 7),
|
||||
"ожидается", 9, Theme90.WARN, SLOT_W - 18.0)
|
||||
if drop:
|
||||
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
|
||||
if required_count <= 0:
|
||||
_draw_empty_slot(cell, i)
|
||||
return
|
||||
var d: Dictionary = data
|
||||
var t := str(d.get("type", "text"))
|
||||
if _slot_missing(i):
|
||||
_draw_missing_slot(cell, i, f)
|
||||
return
|
||||
var data: Dictionary = _slot_data(i)
|
||||
var t := str(data.get("type", "text"))
|
||||
var col := FileCard.type_color(t)
|
||||
draw_style_box(Theme90.box(Theme90.WHITE, Color(col.r, col.g, col.b, 0.55), 3, 1),
|
||||
cell)
|
||||
# квадратный чип: только иконка типа + имя + тип, без фигуры
|
||||
FileShape.draw_type_icon(self, Rect2(cell.position + Vector2(4, 6), Vector2(12, 12)),
|
||||
t, col)
|
||||
var nm := "%s.%s" % [str(d.get("name", "")), str(d.get("ext", ""))]
|
||||
Theme90.text(self, f, Vector2(cell.position.x + 20, cell.position.y + 3),
|
||||
Theme90.fit(f, nm, 9, SLOT_W - 24.0), 9, Theme90.INK, SLOT_W - 24.0)
|
||||
Theme90.text(self, f, Vector2(cell.position.x + 20, cell.position.y + 14),
|
||||
FileCard.type_title(t), 8, Theme90.SHADE, SLOT_W - 24.0)
|
||||
if bool(d.get("attached", false)):
|
||||
var attached := _slot_attached(i)
|
||||
var hov := _hover_slot == i
|
||||
var border: Color
|
||||
var fill: Color
|
||||
if attached:
|
||||
fill = Theme90.WHITE
|
||||
border = Theme90.ACCENT.lightened(0.16) if hov else Theme90.ACCENT
|
||||
draw_style_box(Theme90.box(fill, border, 3, 1), cell)
|
||||
else:
|
||||
# Ждём файл: приглушённая заливка и пунктир в цвете типа.
|
||||
fill = Theme90.PAPER
|
||||
border = Theme90.ACCENT_LIGHT if hov else Color(col.r, col.g, col.b, 0.62)
|
||||
draw_style_box(Theme90.box(fill, Color(0, 0, 0, 0), 3, 0), cell)
|
||||
Theme90.draw_dashed_box(self, cell, border, 3.0, 1.0)
|
||||
# Иконка файла слева; если текстуры нет — прежняя цветная полоса типа.
|
||||
var icon_drawn := false
|
||||
if not t.is_empty():
|
||||
icon_drawn = Theme90.draw_file_icon(self, Rect2(
|
||||
cell.position + Vector2(4, 4), Vector2(12, 12)), t)
|
||||
if not icon_drawn:
|
||||
draw_rect(Rect2(cell.position.x + 1, cell.position.y + 4, 3, cell.size.y - 8), col)
|
||||
|
||||
var text_x := 8.0 + (16.0 if icon_drawn else 0.0)
|
||||
var name_w := SLOT_W - text_x - 6.0
|
||||
if attached:
|
||||
name_w -= 8.0
|
||||
var full_name := _display_name(data)
|
||||
var name_size := _chip_name_size(f, full_name, name_w)
|
||||
var nm := Theme90.fit(f, full_name, name_size, name_w)
|
||||
Theme90.text(self, f, Vector2(cell.position.x + text_x, cell.position.y + 2), nm,
|
||||
name_size, Theme90.INK, name_w)
|
||||
var sub := FileCard.type_title(t)
|
||||
var sub_col := Theme90.SHADE
|
||||
if not attached:
|
||||
sub = "ЖДЁМ"
|
||||
sub_col = Theme90.WARN
|
||||
var sub_w := SLOT_W - text_x - 4.0
|
||||
Theme90.text(self, f, Vector2(cell.position.x + text_x, cell.position.y + 15), sub,
|
||||
_chip_sub_size(f, sub, sub_w), sub_col, sub_w)
|
||||
if attached:
|
||||
Theme90.draw_check_badge(self, Vector2(cell.end.x - 7, cell.position.y + 7), 4.5)
|
||||
if _hover_slot == i or drop:
|
||||
var xr := Rect2(cell.end.x - 12, cell.position.y + 14, 10, 10)
|
||||
draw_circle(xr.get_center(), 5.0, Color(Theme90.ERROR.r, Theme90.ERROR.g,
|
||||
Theme90.ERROR.b, 0.92))
|
||||
Theme90.draw_x(self, xr, Theme90.WHITE, 1.0)
|
||||
if drop:
|
||||
if _drop_slot == i:
|
||||
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
|
||||
|
||||
|
||||
func _draw_missing_slot(cell: Rect2, i: int, f: Font) -> void:
|
||||
var hov := _hover_slot == i
|
||||
var border := Theme90.ERROR.lightened(0.2) if hov else Theme90.ERROR
|
||||
draw_style_box(Theme90.box(Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.06),
|
||||
border, 3, 1), cell)
|
||||
draw_rect(Rect2(cell.position.x + 1, cell.position.y + 4, 3, cell.size.y - 8),
|
||||
Theme90.ERROR)
|
||||
Theme90.text(self, f, Vector2(cell.position.x + 8, cell.position.y + 2), "ПОТЕРЯН",
|
||||
_chip_name_size(f, "ПОТЕРЯН", SLOT_W - 14.0), Theme90.ERROR, SLOT_W - 14.0)
|
||||
Theme90.text(self, f, Vector2(cell.position.x + 8, cell.position.y + 15), "НЕТ ФАЙЛА",
|
||||
_chip_sub_size(f, "НЕТ ФАЙЛА", SLOT_W - 14.0),
|
||||
Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.8), SLOT_W - 14.0)
|
||||
if _drop_slot == i:
|
||||
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
|
||||
|
||||
|
||||
func _draw_empty_slot(cell: Rect2, i: int) -> void:
|
||||
var f := Theme90.ui()
|
||||
Theme90.draw_dashed_box(self, cell, Color(Theme90.SHADE.r, Theme90.SHADE.g,
|
||||
Theme90.SHADE.b, 0.55), 2.5, 1.0)
|
||||
Theme90.text(self, f, Vector2(cell.position.x + 5, cell.position.y + 5), "+",
|
||||
12, Theme90.SHADE)
|
||||
if _blink_on:
|
||||
Theme90.text(self, f, Vector2(cell.position.x + 16, cell.position.y + 7),
|
||||
"ожидается", 9, Theme90.WARN, SLOT_W - 18.0)
|
||||
if _drop_slot == i:
|
||||
Theme90.draw_dashed_box(self, cell.grow(1), Theme90.ACCENT, 2.5, 1.4)
|
||||
|
||||
|
||||
# --- подсказки и ввод ------------------------------------------------------
|
||||
|
||||
func _default_tooltip() -> String:
|
||||
return "%s · %s\n%s" % [client_name, client_id, SEND_HINT]
|
||||
|
||||
|
||||
func _refresh_tooltip() -> void:
|
||||
if _hover_slot >= 0 and _hover_slot < required_count:
|
||||
var line := _slot_full_label(_hover_slot)
|
||||
if not line.is_empty():
|
||||
tooltip_text = line
|
||||
return
|
||||
tooltip_text = _default_tooltip() + \
|
||||
"\nСумма коэффициентов файлов: чем выше, тем сложнее мини-игра"
|
||||
|
||||
|
||||
func _set_hover_slot(index: int) -> void:
|
||||
if index == _hover_slot:
|
||||
return
|
||||
_hover_slot = index
|
||||
_refresh_tooltip()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseMotion:
|
||||
var prev := _hover_slot
|
||||
_hover_slot = _slot_at(event.position)
|
||||
if prev != _hover_slot:
|
||||
queue_redraw()
|
||||
_set_hover_slot(_slot_at(event.position))
|
||||
elif event is InputEventMouseButton and event.pressed \
|
||||
and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
if PROTO_RECT.has_point(event.position):
|
||||
protocol_toggle_requested.emit(id)
|
||||
if not connected and not connecting:
|
||||
protocol_toggle_requested.emit(id)
|
||||
accept_event()
|
||||
return
|
||||
var i := _slot_at(event.position)
|
||||
if i >= 0:
|
||||
if event.double_click and i < slots.size() and slots[i] != null \
|
||||
and bool((slots[i] as Dictionary).get("attached", false)):
|
||||
if event.double_click and _slot_attached(i):
|
||||
detach_pressed.emit(id, i)
|
||||
accept_event()
|
||||
return
|
||||
accept_event()
|
||||
|
||||
|
||||
func _slot_at(pos: Vector2) -> int:
|
||||
for i in maxi(required_count, 1):
|
||||
if _slot_rect(i).has_point(pos):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
func _last_attached_index() -> int:
|
||||
for i in range(slots.size() - 1, -1, -1):
|
||||
var slot = slots[i]
|
||||
if slot is Dictionary and bool(slot.get("attached", false)):
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
|
||||
if not (data is Dictionary):
|
||||
return false
|
||||
@@ -455,7 +801,9 @@ func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
|
||||
var i := _slot_at(at_position)
|
||||
if i < 0:
|
||||
i = _first_empty()
|
||||
_drop_slot = i
|
||||
# Свободного слота может не быть (-1): подсветку не рисуем, но дроп
|
||||
# разрешаем — соответствие обязательному файлу проверит backend.
|
||||
_drop_slot = i if i >= 0 else -1
|
||||
var dc := _drag_controller()
|
||||
if dc != null:
|
||||
dc.set_mode("square")
|
||||
@@ -472,13 +820,6 @@ func _drop_data(at_position: Vector2, data: Variant) -> void:
|
||||
file_dropped.emit(id, i, data)
|
||||
|
||||
|
||||
func _first_empty() -> int:
|
||||
for i in maxi(required_count, 1):
|
||||
if i >= slots.size() or slots[i] == null:
|
||||
return i
|
||||
return maxi(0, required_count - 1)
|
||||
|
||||
|
||||
func _drag_controller() -> Node:
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
|
||||
@@ -38,7 +38,7 @@ const FOOTER_Y := H - 52.0
|
||||
const FOOTER_H := 28.0
|
||||
const MAX_CARDS := 3
|
||||
|
||||
const FALLBACK_DIMS := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
|
||||
const FALLBACK_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
|
||||
|
||||
## Текстовые fallback'и (ключи shop.* есть в data/strings_ru.json).
|
||||
const FALLBACK := {
|
||||
@@ -416,13 +416,13 @@ func _effect_values(item: Dictionary, level: int) -> Dictionary:
|
||||
var now_slots := int(game.connection_slots()) if game != null else 1 + level
|
||||
return {"now": str(now_slots), "after": str(now_slots + maxi(1, int(value)))}
|
||||
"transfer_speed":
|
||||
var now_time := float(game.speed_mult()) if game != null else pow(0.92, float(level))
|
||||
var now_time := float(game.speed_mult()) if game != null else pow(0.90, float(level))
|
||||
var after_time := now_time * (1.0 - clampf(value, 0.01, 0.5))
|
||||
return {"now": "×%.2f" % (1.0 / now_time), "after": "×%.2f" % (1.0 / after_time)}
|
||||
"sla_cap":
|
||||
var now_cap := int(game.sla_cap()) if game != null else 60 + 35 * level
|
||||
var now_cap := int(game.sla_cap()) if game != null else 60 + 40 * level
|
||||
var current := int(minf(float(game.sla), float(now_cap))) if game != null else now_cap
|
||||
var after_cap := mini(200, now_cap + int(value))
|
||||
var after_cap := mini(220, now_cap + int(value))
|
||||
var after_current := mini(after_cap, current + int(value))
|
||||
return {"now": "%d / %d" % [current, now_cap],
|
||||
"after": "%d / %d" % [after_current, after_cap]}
|
||||
@@ -456,7 +456,7 @@ func _effect_values(item: Dictionary, level: int) -> Dictionary:
|
||||
return {"now": "%d / %d" % [now_channel, now_buffer],
|
||||
"after": "%d / %d" % [now_channel + add_channel, now_buffer + add_buffer]}
|
||||
"request_timer":
|
||||
var now_timer := int(game.request_timer()) if game != null else 30 + 5 * level
|
||||
var now_timer := int(game.request_timer()) if game != null else 30 + 7 * level
|
||||
return {"now": "%d с" % now_timer, "after": "%d с" % (now_timer + maxi(1, int(value)))}
|
||||
return {"now": "—", "after": "—"}
|
||||
|
||||
@@ -799,11 +799,11 @@ func _effect_fallback_desc(effect: String) -> String:
|
||||
"connection_slots":
|
||||
return "Ещё один порт: приём и отправка могут идти параллельно."
|
||||
"transfer_speed":
|
||||
return "Скорость приёма и отправки выше на 8% за уровень."
|
||||
return "Скорость приёма и отправки выше на 10% за уровень."
|
||||
"sla_cap":
|
||||
return "Держит узел живым: кап SLA +35 и сразу +35 SLA."
|
||||
return "Держит узел живым: кап SLA +40 и сразу +40 SLA."
|
||||
"shield_charge":
|
||||
return "Автоматически гасит атаку: 50% награды, без штрафа."
|
||||
return "Автоматически гасит атаку: 70% награды, без штрафа."
|
||||
"money_mult":
|
||||
return "Оплата за контейнеры +5% за уровень."
|
||||
"folder_cells":
|
||||
|
||||
@@ -49,8 +49,10 @@ var _drag_active: bool = false
|
||||
var _last_drop_pos: Vector2 = Vector2.ZERO
|
||||
var _ghost_cells: Array = []
|
||||
var _ghost_origin: Vector2i = Vector2i(-1, -1)
|
||||
var _ghost_target: Vector2i = Vector2i(-1, -1)
|
||||
var _ghost_rotation: int = 0
|
||||
var _ghost_ok: bool = false
|
||||
var _ghost_auto: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -115,7 +117,7 @@ func file_count() -> int:
|
||||
|
||||
func footer_text() -> String:
|
||||
var total := _cols * _rows
|
||||
return "%d %s · занято %d" % [total, FG.plural_cells(total), _cells_owner.size()]
|
||||
return "%d %s" % [total, FG.plural_cells(total)]
|
||||
|
||||
|
||||
func grid_metrics() -> Dictionary:
|
||||
@@ -183,6 +185,10 @@ func _cell_rect(c: int, r: int) -> Rect2:
|
||||
return FG.cell_rect_pair(_grid_origin, _cell, GAP, c, r)
|
||||
|
||||
|
||||
func _shape_pos(cell_pos: Vector2i) -> Vector2:
|
||||
return FG.shape_origin_pair(_grid_origin, _cell, GAP, cell_pos)
|
||||
|
||||
|
||||
func _file_at(pos: Vector2) -> String:
|
||||
var c := FG.cell_at_rect(pos, _grid_origin, _cell, GAP, _cols, _rows)
|
||||
if c.x < 0:
|
||||
@@ -235,37 +241,61 @@ func _gui_input(event: InputEvent) -> void:
|
||||
queue_redraw()
|
||||
accept_event()
|
||||
elif event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
if _drag_in_progress():
|
||||
accept_event()
|
||||
return
|
||||
var id := _file_at(event.position)
|
||||
if not id.is_empty():
|
||||
context_requested.emit(id)
|
||||
accept_event()
|
||||
|
||||
|
||||
func _drag_in_progress() -> bool:
|
||||
if _drag_active:
|
||||
return true
|
||||
var dc := _existing_drag_controller()
|
||||
if dc != null and dc.has_method("is_active"):
|
||||
return bool(dc.is_active())
|
||||
return _drop_active
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _drag_active:
|
||||
var vp := get_viewport()
|
||||
if vp != null and vp.is_input_handled():
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo \
|
||||
and event.keycode == KEY_R:
|
||||
var dc := _existing_drag_controller()
|
||||
if dc == null or not dc.has_method("is_active") or not bool(dc.is_active()):
|
||||
return
|
||||
if FG.is_rotate_key(event) or FG.is_rotate_click(event):
|
||||
_rotate_drag()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventMouseButton and event.pressed \
|
||||
and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_rotate_drag()
|
||||
get_viewport().set_input_as_handled()
|
||||
vp.set_input_as_handled()
|
||||
|
||||
|
||||
func _rotate_drag() -> void:
|
||||
_drag_rotation = posmod(_drag_rotation + 1, 4)
|
||||
var dc := _drag_controller()
|
||||
if dc != null and dc.has_method("rotate_cw"):
|
||||
_drag_rotation = int(dc.rotate_cw())
|
||||
else:
|
||||
_drag_rotation = posmod(_drag_rotation + 1, 4)
|
||||
if _drop_active and not _drag_payload.is_empty():
|
||||
_refresh_ghost(_last_drop_pos)
|
||||
var dc := _drag_controller()
|
||||
if dc != null and dc.preview() != null:
|
||||
var p = dc.preview()
|
||||
if p.has_method("setup") and p.get("shape_id") != null:
|
||||
p.setup(p.file_type, p.shape_id, _drag_rotation, p.file_name, p.ext, p.cell_size)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _on_drag_rotation_changed(value: int) -> void:
|
||||
_drag_rotation = posmod(int(value), 4)
|
||||
if _drop_active and not _drag_payload.is_empty():
|
||||
_refresh_ghost(_last_drop_pos)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _bind_rotation_signal(dc: Node) -> void:
|
||||
if dc == null or not dc.has_signal("rotation_changed"):
|
||||
return
|
||||
if not dc.is_connected("rotation_changed", Callable(self, "_on_drag_rotation_changed")):
|
||||
dc.connect("rotation_changed", Callable(self, "_on_drag_rotation_changed"))
|
||||
|
||||
|
||||
# --- drag source -----------------------------------------------------------
|
||||
|
||||
func _get_drag_data(at_position: Vector2) -> Variant:
|
||||
@@ -289,13 +319,14 @@ func _get_drag_data(at_position: Vector2) -> Variant:
|
||||
str(file.name), str(file.ext),
|
||||
clampf(minf(_cell.x, _cell.y) * 0.55, 28.0, 56.0))
|
||||
preview.set_mode("shape")
|
||||
dc.begin(preview, "shape")
|
||||
dc.begin(preview, "shape", int(file.rotation))
|
||||
queue_redraw()
|
||||
return payload
|
||||
|
||||
|
||||
func _drag_controller() -> Node:
|
||||
if drag_controller != null and is_instance_valid(drag_controller):
|
||||
_bind_rotation_signal(drag_controller)
|
||||
return drag_controller
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
@@ -305,15 +336,18 @@ func _drag_controller() -> Node:
|
||||
dc.name = "DragControllerAuto"
|
||||
get_tree().root.add_child(dc)
|
||||
drag_controller = dc
|
||||
_bind_rotation_signal(dc)
|
||||
return dc
|
||||
|
||||
|
||||
func _existing_drag_controller() -> Node:
|
||||
var dc: Node = null
|
||||
if drag_controller != null and is_instance_valid(drag_controller):
|
||||
return drag_controller
|
||||
if not is_inside_tree():
|
||||
return null
|
||||
return get_tree().get_first_node_in_group("drag_controller")
|
||||
dc = drag_controller
|
||||
elif is_inside_tree():
|
||||
dc = get_tree().get_first_node_in_group("drag_controller")
|
||||
_bind_rotation_signal(dc)
|
||||
return dc
|
||||
|
||||
|
||||
# --- drop target -----------------------------------------------------------
|
||||
@@ -324,15 +358,23 @@ func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
|
||||
var payload: Dictionary = data
|
||||
if str(payload.get("kind", "")) != "file":
|
||||
return false
|
||||
var dc := _drag_controller()
|
||||
var key := "file:" + str(payload.get("id", ""))
|
||||
if key != _drag_key:
|
||||
_drag_key = key
|
||||
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
|
||||
if dc != null and dc.has_method("is_active") and bool(dc.is_active()):
|
||||
_drag_rotation = int(dc.rotation()) if dc.has_method("rotation") else \
|
||||
posmod(int(payload.get("rotation", 0)), 4)
|
||||
else:
|
||||
_drag_rotation = posmod(int(payload.get("rotation", 0)), 4)
|
||||
if dc != null and dc.has_method("set_rotation"):
|
||||
dc.set_rotation(_drag_rotation)
|
||||
elif dc != null and dc.has_method("rotation"):
|
||||
_drag_rotation = int(dc.rotation())
|
||||
_drop_active = true
|
||||
_drag_payload = payload
|
||||
_last_drop_pos = at_position
|
||||
_refresh_ghost(at_position)
|
||||
var dc := _drag_controller()
|
||||
if dc != null:
|
||||
dc.set_mode("shape")
|
||||
return true
|
||||
@@ -351,6 +393,8 @@ func _refresh_ghost(pos: Vector2) -> void:
|
||||
_ghost_rotation = int(fit.get("rotation", _drag_rotation))
|
||||
_ghost_origin = fit.get("origin", target)
|
||||
_ghost_ok = bool(fit.get("ok", false))
|
||||
_ghost_auto = bool(fit.get("auto", false))
|
||||
_ghost_target = target
|
||||
_ghost_cells = FR.shape_cells(shape_id, _ghost_rotation)
|
||||
queue_redraw()
|
||||
|
||||
@@ -360,21 +404,27 @@ func _resolve_placement(payload: Dictionary, target: Vector2i,
|
||||
var shape_id := str(payload.get("shape_id", "O1"))
|
||||
if shape_id.is_empty():
|
||||
shape_id = "O1"
|
||||
var preferred := posmod(int(rotation), 4)
|
||||
var ignore := str(payload.get("id", ""))
|
||||
if target.x < 0 or target.y < 0:
|
||||
var auto := _find_fit(shape_id, rotation, ignore)
|
||||
var auto := _find_fit(shape_id, preferred, ignore)
|
||||
if not auto.is_empty():
|
||||
return {"ok": true, "origin": auto["origin"], "rotation": auto["rotation"]}
|
||||
return {"ok": false, "origin": Vector2i(-1, -1), "rotation": posmod(rotation, 4)}
|
||||
var auto_rot := int(auto["rotation"])
|
||||
return {"ok": true, "origin": auto["origin"], "rotation": auto_rot,
|
||||
"auto": auto_rot != preferred}
|
||||
return {"ok": false, "origin": Vector2i(-1, -1), "rotation": preferred,
|
||||
"auto": false}
|
||||
for i in 4:
|
||||
var rot := posmod(rotation + i, 4)
|
||||
var rot := posmod(preferred + i, 4)
|
||||
var origin := FG.anchor_origin(target, shape_id, rot)
|
||||
if _can_place(shape_id, origin, rot, ignore):
|
||||
return {"ok": true, "origin": origin, "rotation": rot}
|
||||
return {"ok": true, "origin": origin, "rotation": rot,
|
||||
"auto": rot != preferred}
|
||||
return {
|
||||
"ok": false,
|
||||
"origin": FG.anchor_origin(target, shape_id, posmod(rotation, 4)),
|
||||
"rotation": posmod(rotation, 4),
|
||||
"origin": FG.anchor_origin(target, shape_id, preferred),
|
||||
"rotation": preferred,
|
||||
"auto": false,
|
||||
}
|
||||
|
||||
|
||||
@@ -411,8 +461,17 @@ func _drop_data(at_position: Vector2, data: Variant) -> void:
|
||||
if str(payload.get("kind", "")) != "file":
|
||||
_end_drop()
|
||||
return
|
||||
var target := FG.nearest_cell_rect(at_position, _grid_origin, _cell, GAP, _cols, _rows)
|
||||
var fit := _resolve_placement(payload, target, _drag_rotation)
|
||||
var target := FG.nearest_cell_rect(at_position, _grid_origin, _cell, GAP,
|
||||
_cols, _rows)
|
||||
var dc := _existing_drag_controller()
|
||||
if dc != null and dc.has_method("rotation"):
|
||||
_drag_rotation = int(dc.rotation())
|
||||
var fit: Dictionary
|
||||
if _ghost_target == target and not _ghost_cells.is_empty():
|
||||
fit = {"ok": _ghost_ok, "origin": _ghost_origin,
|
||||
"rotation": _ghost_rotation, "auto": _ghost_auto}
|
||||
else:
|
||||
fit = _resolve_placement(payload, target, _drag_rotation)
|
||||
if not bool(fit.get("ok", false)):
|
||||
_reject("СТОЛ ЗАПОЛНЕН")
|
||||
_end_drop()
|
||||
@@ -454,7 +513,10 @@ func _notify(text: String) -> void:
|
||||
func _end_drop() -> void:
|
||||
_drop_active = false
|
||||
_drag_key = ""
|
||||
_drag_rotation = 0
|
||||
_ghost_cells.clear()
|
||||
_ghost_target = Vector2i(-1, -1)
|
||||
_ghost_auto = false
|
||||
mouse_default_cursor_shape = Control.CURSOR_ARROW
|
||||
queue_redraw()
|
||||
|
||||
@@ -511,7 +573,7 @@ func _draw_grid_lines() -> void:
|
||||
|
||||
func _draw_file(file) -> void:
|
||||
var cells := FR.shape_cells(str(file.shape_id), int(file.rotation))
|
||||
FILE_SHAPE.paint(self, _grid_origin, cells, _cell, {
|
||||
FILE_SHAPE.paint(self, _shape_pos(Vector2i(file.origin)), cells, _cell, {
|
||||
"type": str(file.type),
|
||||
"name": str(file.name),
|
||||
"ext": str(file.ext),
|
||||
@@ -529,7 +591,7 @@ func _draw_ghost(f: Font) -> void:
|
||||
return
|
||||
var color := Theme90.SUCCESS if _ghost_ok else Theme90.ERROR
|
||||
var fill := Color(color.r, color.g, color.b, 0.16)
|
||||
FILE_SHAPE.paint(self, _grid_origin, _ghost_cells, _cell, {
|
||||
FILE_SHAPE.paint(self, _shape_pos(_ghost_origin), _ghost_cells, _cell, {
|
||||
"type": "text",
|
||||
"alpha": 0.9,
|
||||
"show_label": false,
|
||||
@@ -539,11 +601,12 @@ func _draw_ghost(f: Font) -> void:
|
||||
})
|
||||
var b := FR.shape_bounds(_ghost_cells)
|
||||
var step := _cell + Vector2(GAP, GAP)
|
||||
var box := Rect2(_grid_origin + Vector2(float(_ghost_origin.x) * step.x,
|
||||
float(_ghost_origin.y) * step.y), Vector2(
|
||||
var box := Rect2(_shape_pos(_ghost_origin), Vector2(
|
||||
float(b.x) * step.x - GAP, float(b.y) * step.y - GAP))
|
||||
Theme90.draw_dashed_box(self, box.grow(2.0), color, 4.0, 1.2)
|
||||
var hint := "ОТПУСТИТЕ · R ПОВОРОТ" if _ghost_ok else "СТОЛ ЗАПОЛНЕН · R ПОВОРОТ"
|
||||
if _ghost_ok and _ghost_auto:
|
||||
hint = "ОТПУСТИТЕ · АВТОПОВОРОТ · R ПОВОРОТ"
|
||||
var hw := f.get_string_size(hint, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
|
||||
var pill := Rect2(size.x - hw - 20.0, 6.0, hw + 14.0, 18.0)
|
||||
draw_style_box(Theme90.box(Theme90.WHITE, color, 4, 1), pill)
|
||||
|
||||
@@ -3,8 +3,9 @@ extends RefCounted
|
||||
## Палитра, шрифты и примитивы отрисовки UI. Версия 2 — «тёплая бумага»:
|
||||
## тонкие 1 px контуры вместо 2 px фасок, мягкие тени, скругления 3–4 px.
|
||||
##
|
||||
## Никаких внешних текстур: всё рисуется кодом. Единый источник цветов,
|
||||
## кеглей и StyleBox для всех UI-компонентов.
|
||||
## Почти всё рисуется кодом; исключение — пиксельные иконки файлов из
|
||||
## art/icons (их можно заменить или перегенерировать generate_icons.py).
|
||||
## Единый источник цветов, кеглей и StyleBox для всех UI-компонентов.
|
||||
|
||||
# --- Палитра ---------------------------------------------------------------
|
||||
const PAPER := Color(0.913725, 0.901961, 0.866667) # #E9E6DD
|
||||
@@ -38,20 +39,25 @@ const BORDER_SOFT := Color(0.254902, 0.239216, 0.203922, 0.12)
|
||||
const HAIRLINE := Color(0.254902, 0.239216, 0.203922, 0.10)
|
||||
|
||||
# --- Кегли ----------------------------------------------------------------
|
||||
const FS_MICRO := 10
|
||||
const FS_BODY := 12
|
||||
const FS_HEAD := 16
|
||||
const FS_LOGO := 24
|
||||
const FS_MICRO := 11
|
||||
const FS_BODY := 13
|
||||
const FS_HEAD := 17
|
||||
const FS_LOGO := 26
|
||||
|
||||
const FONT_HANDJET := "res://art/fonts/Handjet-VF.ttf"
|
||||
## Основной шрифт — Golos Text: гуманистический, с полной кириллицей
|
||||
## (О/П/Ё/№), хорошо читается в мелких кеглях 10–14 px.
|
||||
const FONT_UI := "res://art/fonts/GolosText-VF.ttf"
|
||||
const FONT_LOGO := "res://art/fonts/PressStart2P-Regular.ttf"
|
||||
|
||||
const ICON_DIR := "res://art/icons"
|
||||
|
||||
const H := 24.0 # высота строки дерева / кнопки
|
||||
const SCROLLBAR := 14.0 # ширина скроллбара
|
||||
const BEVEL := 1.0 # оставлено для совместимости: толщина линии
|
||||
const RADIUS := 3 # базовое скругление карточек и кнопок
|
||||
|
||||
static var _fonts: Dictionary = {}
|
||||
static var _icons: Dictionary = {}
|
||||
static var _dither: ImageTexture = null
|
||||
static var _boxes: Dictionary = {}
|
||||
|
||||
@@ -62,18 +68,19 @@ static func tag(name: String) -> int:
|
||||
return TextServerManager.get_primary_interface().name_to_tag(name)
|
||||
|
||||
|
||||
## Handjet с фиксированными осями: wght 700, ELGR 2, ELSH 2.
|
||||
static func ui(weight: float = 700.0, elgr: float = 2.0, elsh: float = 2.0) -> FontVariation:
|
||||
var key := "%d_%d_%d" % [weight, elgr, elsh]
|
||||
## Golos Text с осью wght (400–900), по умолчанию 600.
|
||||
## elgr/elsh оставлены в сигнатуре для совместимости со старыми вызовами
|
||||
## Handjet — у Golos Text таких осей нет, значения игнорируются.
|
||||
static func ui(weight: float = 600.0, _elgr: float = 1.0, _elsh: float = 1.0) -> FontVariation:
|
||||
var w := clampf(weight, 400.0, 900.0)
|
||||
var key := "ui_%d" % int(roundf(w))
|
||||
if _fonts.has(key):
|
||||
return _fonts[key]
|
||||
var base: FontFile = load(FONT_HANDJET)
|
||||
var base: FontFile = load(FONT_UI)
|
||||
var fv := FontVariation.new()
|
||||
fv.base_font = base
|
||||
fv.variation_opentype = {
|
||||
tag("wght"): weight,
|
||||
tag("ELGR"): elgr,
|
||||
tag("ELSH"): elsh,
|
||||
tag("wght"): w,
|
||||
}
|
||||
_fonts[key] = fv
|
||||
return fv
|
||||
@@ -372,6 +379,59 @@ static func ext_color(cat: String) -> Color:
|
||||
return ACCENT
|
||||
|
||||
|
||||
## Пиксельные иконки 32×32 из art/icons (generate_icons.py). Ключи — те же
|
||||
## имена типов, что и в FileShape (text/sound/photo/video), плюс folder/packet.
|
||||
const ICON_FILES := {
|
||||
"text": "file_text",
|
||||
"doc": "file_text",
|
||||
"pdf": "file_text",
|
||||
"sound": "file_sound",
|
||||
"audio": "file_sound",
|
||||
"photo": "file_photo",
|
||||
"image": "file_photo",
|
||||
"video": "file_video",
|
||||
"folder": "folder",
|
||||
"packet": "packet",
|
||||
}
|
||||
|
||||
|
||||
## Текстура иконки по типу (кэшируется). null — файла нет или тип неизвестен.
|
||||
static func file_icon(kind: String) -> Texture2D:
|
||||
var key := kind.strip_edges().to_lower()
|
||||
if _icons.has(key):
|
||||
return _icons[key]
|
||||
var tex: Texture2D = null
|
||||
var file_name: String = str(ICON_FILES.get(key, ""))
|
||||
if not file_name.is_empty():
|
||||
var path := ICON_DIR + "/" + file_name + ".png"
|
||||
if ResourceLoader.exists(path):
|
||||
tex = load(path) as Texture2D
|
||||
_icons[key] = tex
|
||||
return tex
|
||||
|
||||
|
||||
## Рисует иконку по центру rect с ближайшей фильтрацией (пиксели остаются
|
||||
## резкими при 16/24/32 px). false — если текстуры нет, рисуйте draw_doc_icon.
|
||||
static func draw_file_icon(ci: CanvasItem, rect: Rect2, kind: String,
|
||||
modulate: Color = Color.WHITE) -> bool:
|
||||
var tex := file_icon(kind)
|
||||
if tex == null:
|
||||
return false
|
||||
var s := minf(rect.size.x, rect.size.y)
|
||||
if s <= 0.0:
|
||||
return false
|
||||
var side := roundf(s)
|
||||
var top_left := (rect.position + (rect.size - Vector2(side, side)) * 0.5).round()
|
||||
var target := Rect2(top_left, Vector2(side, side))
|
||||
var prev := ci.texture_filter
|
||||
if prev != CanvasItem.TEXTURE_FILTER_NEAREST:
|
||||
ci.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
ci.draw_texture_rect(tex, target, false, modulate)
|
||||
if prev != CanvasItem.TEXTURE_FILTER_NEAREST:
|
||||
ci.texture_filter = prev
|
||||
return true
|
||||
|
||||
|
||||
## Документ с цветным глифом. ext (необязательный) выбирает значок по
|
||||
## расширению: строки, горы, волна, play, молния, терминал, сетка.
|
||||
## Пустое/неизвестное расширение — прежний глиф типа (text/sound/photo/video).
|
||||
@@ -983,7 +1043,7 @@ class DragGhost:
|
||||
10, Theme90.SHADE)
|
||||
|
||||
|
||||
## Числовая строка: цифры — Press Start 2P (чистые 0), суффикс — Handjet.
|
||||
## Числовая строка: цифры — Press Start 2P (чистые 0), суффикс — Golos Text.
|
||||
## Базовая линия суффикса совпадает с базовой линией цифр.
|
||||
static func num_line(ci: CanvasItem, top_left: Vector2, number: String, size: int, color: Color,
|
||||
suffix: String = "", suffix_size: int = -1) -> float:
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
class_name ToastLayer
|
||||
extends Control
|
||||
## Очередь сообщений внизу справа. Живут над статус-баром, не перекрывая его.
|
||||
## Светлые карточки с цветной точкой и мягкой тенью.
|
||||
## Очередь сообщений сверху по центру холста. Стопка растёт ВНИЗ от верхней
|
||||
## кромки рабочей области: не перекрывает карточки ОТПРАВКИ и их кнопки
|
||||
## ОТПРАВИТЬ/ОТКЛОНИТЬ. Светлые карточки с цветной точкой и мягкой тенью.
|
||||
##
|
||||
## Координаты — логические 1280×720 (canvas_items): тост шириной 400 стоит
|
||||
## ровно по центру (440…840) и не заходит в правое окно ОТПРАВКА (856…1272).
|
||||
|
||||
const W := 400.0
|
||||
const H := 30.0
|
||||
const GAP := 4.0
|
||||
const BOTTOM := 650.0
|
||||
## Первый тост: сразу под строкой меню, над заголовками ДИСК/ФАЙЛЫ.
|
||||
const TOP := 44.0
|
||||
## Точка входа: над кадром, карточка «въезжает» сверху вниз.
|
||||
const ENTER_Y := -40.0
|
||||
## Центр холста 1280 px.
|
||||
const LEFT := (1280.0 - W) * 0.5
|
||||
|
||||
var _items: Array[Control] = []
|
||||
|
||||
@@ -26,12 +35,15 @@ func toast(text: String, kind: String = "info") -> void:
|
||||
old.queue_free()
|
||||
var item := ToastItem.new(text, kind)
|
||||
item.size = Vector2(W, H)
|
||||
item.position = Vector2(1276.0, _slot_y(_items.size()))
|
||||
# Новый тост — последний в стопке, то есть самый нижний.
|
||||
item.position = Vector2(LEFT, ENTER_Y)
|
||||
item.modulate.a = 0.0
|
||||
add_child(item)
|
||||
_items.append(item)
|
||||
var target := _target_pos(_items.size() - 1)
|
||||
var tw := create_tween()
|
||||
tw.tween_property(item, "position", target, 0.22).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
var tw := create_tween().set_parallel(true)
|
||||
tw.tween_property(item, "position", target, 0.24).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
tw.tween_property(item, "modulate:a", 1.0, 0.16)
|
||||
var life := create_tween()
|
||||
life.tween_interval(3.0)
|
||||
life.tween_property(item, "modulate:a", 0.0, 0.45)
|
||||
@@ -57,12 +69,13 @@ func _stop_life(item: Control) -> void:
|
||||
(tw as Tween).kill()
|
||||
|
||||
|
||||
## Слот стопки: 44 / 78 / 112 — сверху вниз, свежий тост ниже старых.
|
||||
func _slot_y(index: int) -> float:
|
||||
return BOTTOM - H - (index) * (H + GAP)
|
||||
return TOP + float(index) * (H + GAP)
|
||||
|
||||
|
||||
func _target_pos(index: int) -> Vector2:
|
||||
return Vector2(1272.0 - W, _slot_y(index))
|
||||
return Vector2(LEFT, _slot_y(index))
|
||||
|
||||
|
||||
func _relayout() -> void:
|
||||
|
||||
@@ -127,13 +127,44 @@ func set_data(sections: Array) -> void:
|
||||
call_deferred("_bind_drag_controller")
|
||||
|
||||
|
||||
func _plural_folders(n: int) -> String:
|
||||
var n10 := n % 10
|
||||
var n100 := n % 100
|
||||
if n10 == 1 and n100 != 11:
|
||||
return "папка"
|
||||
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
|
||||
return "папки"
|
||||
return "папок"
|
||||
|
||||
|
||||
func _plural_files(n: int) -> String:
|
||||
var n10 := n % 10
|
||||
var n100 := n % 100
|
||||
if n10 == 1 and n100 != 11:
|
||||
return "файл"
|
||||
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
|
||||
return "файла"
|
||||
return "файлов"
|
||||
|
||||
|
||||
func _plural_cells(n: int) -> String:
|
||||
var n10 := n % 10
|
||||
var n100 := n % 100
|
||||
if n10 == 1 and n100 != 11:
|
||||
return "клетка"
|
||||
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
|
||||
return "клетки"
|
||||
return "клеток"
|
||||
|
||||
|
||||
func _section_free_text(sec: Dictionary) -> String:
|
||||
var raw = sec.get("free_text", null)
|
||||
if raw is String:
|
||||
return str(raw)
|
||||
var cells_raw = sec.get("free_cells", sec.get("free", null))
|
||||
if cells_raw is int or cells_raw is float:
|
||||
return "свободно %d клеток" % int(cells_raw)
|
||||
var free_cells := int(cells_raw)
|
||||
return "свободно %d %s" % [free_cells, _plural_cells(free_cells)]
|
||||
if cells_raw is String:
|
||||
return str(cells_raw)
|
||||
return ""
|
||||
@@ -161,7 +192,7 @@ func _pick_int(fol: Dictionary, keys: Array, fallback: int) -> int:
|
||||
|
||||
|
||||
func _folder_tooltip(files: int, occupied: int, total: int) -> String:
|
||||
return "%d файлов · %d из %d клеток" % [files, occupied, total]
|
||||
return "%d %s · %d из %d клеток" % [files, _plural_files(files), occupied, total]
|
||||
|
||||
|
||||
func _apply_layout() -> void:
|
||||
@@ -235,14 +266,12 @@ func update_stats(folder_id: String, files: int, occupied: int, total: int) -> v
|
||||
func footer_text() -> String:
|
||||
var folders := 0
|
||||
var files := 0
|
||||
var occupied := 0
|
||||
for e in _rows:
|
||||
var row: FolderRow = e["row"]
|
||||
if not row.is_section:
|
||||
folders += 1
|
||||
files += row.files_count
|
||||
occupied += row.count
|
||||
return "%d папок · %d файлов · %d клеток занято" % [folders, files, occupied]
|
||||
return "%d %s · %d %s" % [folders, _plural_folders(folders), files, _plural_files(files)]
|
||||
|
||||
|
||||
func _on_toggle(folder_id: String) -> void:
|
||||
|
||||
@@ -277,8 +277,14 @@ func _check_hour() -> void:
|
||||
|
||||
var recv: WindowFrame = _main.get_node("ReceiveWindow")
|
||||
var send: WindowFrame = _main.get_node("SendWindow")
|
||||
_check(recv.title_hint.contains("ПАКЕТОВ"), "ПРИЁМ переключён на час: «%s»" % recv.title_hint)
|
||||
_check(send.title_hint.contains("ЗАПРОСОВ"), "ОТПРАВКА переключена на час: «%s»" % send.title_hint)
|
||||
var packet_hint: String = str(_main.call("_plural_ru",
|
||||
(_main.get("packets") as Array).size(), "ПАКЕТ", "ПАКЕТА", "ПАКЕТОВ"))
|
||||
var request_hint: String = str(_main.call("_plural_ru",
|
||||
(_main.get("requests") as Array).size(), "ЗАПРОС", "ЗАПРОСА", "ЗАПРОСОВ"))
|
||||
_check(recv.title_hint == packet_hint,
|
||||
"ПРИЁМ переключён на час: «%s» (ожидалось «%s»)" % [recv.title_hint, packet_hint])
|
||||
_check(send.title_hint == request_hint,
|
||||
"ОТПРАВКА переключена на час: «%s» (ожидалось «%s»)" % [send.title_hint, request_hint])
|
||||
var toast: ToastLayer = _main.get_node("ToastLayer")
|
||||
_check(toast.get_child_count() >= 1, "тост «ЧАС N НАЧАЛСЯ» показан")
|
||||
|
||||
@@ -319,13 +325,13 @@ func _check_fonts() -> void:
|
||||
missing += cyr[i]
|
||||
_check(missing.is_empty(), "Handjet содержит кириллицу (%s)" % ("нет: " + missing if not missing.is_empty() else "ок"))
|
||||
_check(Theme90.logo().has_char(48), "Press Start 2P содержит цифру 0")
|
||||
_check([Theme90.FS_MICRO, Theme90.FS_BODY, Theme90.FS_HEAD, Theme90.FS_LOGO] == [10, 12, 16, 24],
|
||||
"кегли 10/12/16/24")
|
||||
_check([Theme90.FS_MICRO, Theme90.FS_BODY, Theme90.FS_HEAD, Theme90.FS_LOGO] == [11, 13, 17, 26],
|
||||
"кегли 11/13/17/26")
|
||||
var th: Theme = load("res://art/fonts/ui_theme.tres")
|
||||
_check(th != null, "art/fonts/ui_theme.tres загружается")
|
||||
if th != null:
|
||||
_check(th.default_font is FontVariation, "тема: default_font = FontVariation")
|
||||
_check(th.default_font_size == 12, "тема: базовый кегль 12")
|
||||
_check(th.default_font_size == 13, "тема: базовый кегль 13")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -1,16 +1,50 @@
|
||||
# tests/
|
||||
|
||||
Каталог под автотесты. Пока пусто: реализации, которую стоит тестировать, нет.
|
||||
Headless-автотесты и симулятор баланса.
|
||||
|
||||
План:
|
||||
|
||||
- чистая логика (`scripts/core`, `scripts/systems`) — юнит-тесты на GDScript,
|
||||
запуск headless;
|
||||
- данные (`data/*.json`) — валидация схемы/ключей;
|
||||
- дымовой тест — `--headless --quit` без ошибок парсинга (см. `docs/build.md`).
|
||||
|
||||
Запуск заготовки (когда появится фреймворк, например GUT/GdUnit4):
|
||||
## Полный прогон
|
||||
|
||||
```bash
|
||||
/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64 --headless --path . --quit
|
||||
bash scripts/run_all_tests.sh
|
||||
# Windows:
|
||||
powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
|
||||
```
|
||||
|
||||
Скрипт запускает **25 headless-наборов + parse-check** (итого 26 проверок),
|
||||
каждый в отдельном процессе Godot с таймаутом и в изолированных временных
|
||||
XDG-каталогах. Отсутствующие файлы тестов помечаются `SKIP`, а не `FAIL`.
|
||||
Путь к Godot задаётся через `GODOT_BIN` (иначе ищется `godot4`/`godot` в
|
||||
`PATH`). Подробности — в `README` §Тесты и в самом `scripts/run_all_tests.sh`.
|
||||
|
||||
## Что покрыто
|
||||
|
||||
- **Ядро и экономика:** `run_tests.gd`, `test_hour.gd`, `test_slots.gd`,
|
||||
`test_shop.gd`, `test_attacks.gd`, `test_fuzz.gd`, `test_integration.gd`,
|
||||
`test_controller.gd`.
|
||||
- **UI:** `scripts/ui/ui_smoke.gd`, `test_file_grid.gd`,
|
||||
`test_shop_modal.gd`, `test_toast.gd`.
|
||||
- **Мини-игры:** `test_minigame.gd`, `test_send_games.gd`,
|
||||
`test_attack_games.gd`.
|
||||
- **Сцены и полный забег:** `test_main_*.gd`, `test_full_playthrough.gd`.
|
||||
- **Аудио:** `test_audio.gd`, `test_ambient.gd`.
|
||||
- **Баланс:** `sim_balance.py` — 240 сидов, 9 часов; отчёт
|
||||
`tests/SIM_BALANCE_REPORT.md`.
|
||||
|
||||
Полный список наборов — в массиве `TESTS` внутри `scripts/run_all_tests.sh`.
|
||||
|
||||
## Одиночный запуск
|
||||
|
||||
```bash
|
||||
export XDG_DATA_HOME="$(mktemp -d)"
|
||||
"$GODOT" --headless --path . --script tests/run_tests.gd
|
||||
```
|
||||
|
||||
Используйте уникальный `XDG_DATA_HOME` на каждый запуск: сейвы и настройки
|
||||
Godot иначе пересекаются с реальным профилем и между прогонами, что даёт
|
||||
нестабильные результаты.
|
||||
|
||||
Симулятор баланса запускается обычным Python 3 без зависимостей:
|
||||
|
||||
```bash
|
||||
python3 tests/sim_balance.py
|
||||
```
|
||||
@@ -1,90 +1,82 @@
|
||||
# PEAK HOUR v2 - balance simulation report
|
||||
# PEAK HOUR v3 - balance simulation report
|
||||
|
||||
Generated by `tests/sim_balance.py` from `data/balance.json`.
|
||||
Seeds: **240**, hours: **9**, dt: **0.25 s**, send quality q=**0.85**, attack quality q=**0.85**, protocol: **TCP**.
|
||||
|
||||
## Model assumptions
|
||||
|
||||
- Spawn: file budget from `hours[].files` (+ carry), spawn only during 75% of the hour (225 s), interval 225/budget with +/-20% jitter. A full buffer freezes spawns without burning the budget.
|
||||
- Spawn: file budget from `hours[].files` (+ carry), spawn only during 75% of the hour (225 s), interval 225/budget with +/-20% jitter; H1 spawns its first packet at t=0. A full buffer freezes spawns without burning the budget.
|
||||
- Types: `hours[].type_weights` only; zero weight never spawns.
|
||||
- Unsent files persist across hours (the folder is not cleared); only files consumed by a successful send disappear. Unspawned budget is carried separately, capped at 20 files/hour.
|
||||
- Requests: `request_cap` and `active_requests` from `hours[]`; interval `hours[].send_interval`; count from `request_mix`; a request reserves available files at spawn and does not consume request budget when no files exist.
|
||||
- Player: competent, attaches after 1.5 s + 1 s/file, plays a no-timer send minigame for 7 + 1.5*tier seconds. The minigame pauses packet spawn/loading but not other request timers (production order).
|
||||
- Timers: request `30 + 5*timer_level` (cap 55), packet 20 s. Timers pause in the player's own minigame and in the out-slot queue; payout uses `time_left` snapshotted at send start.
|
||||
- Transfers: `base_time_sec * 0.92^speed_level * max(1, load/capacity)`; slots are shared by receive and send; files are consumed on successful send.
|
||||
- Payout: exact `round(base_sum * speed * quality * multi * protocol * streak * money_mult)`; recv_fee is added on receive and is not multiplied.
|
||||
- Folder: `grid.folder_dims[folder_level]`, 2 free folders at start, up to `constants.folders_max` (6) free folders created in prep. The spec allows up to 12; using 6 is the conservative reading and still makes `lot_folder` nearly worthless. Fragmentation is not modelled; cell capacity/occupancy is.
|
||||
- Player: competent, attaches after 1.5 s + 1 s/file, plays a no-timer send minigame for 7 + 1.5*(tier+1) seconds (3 tiers). TCP adds 4.2 s connection time and 4% of attempts miss it (-4 SLA). The minigame pauses packet spawn/loading but not other request timers (production order).
|
||||
- Timers: request `30 + 7*timer_level` (cap 65), packet 20 s. Timers pause in the player's own minigame and in the out-slot queue; payout uses `time_left` snapshotted at send start.
|
||||
- Transfers: `base_time_sec * 0.90^speed_level * max(1, load/capacity)`; slots are shared by receive and send; files are consumed on successful send.
|
||||
- Payout: exact `round(base_sum * speed * quality * multi * protocol * streak * money_mult)`; TCP x1.15, UDP x0.85; recv_fee is added on receive and is not multiplied.
|
||||
- Folder: `grid.folder_dims[folder_level]`, 2 free folders at start; each extra folder costs `constants.folder_new_cost` (180 $) in prep, up to `constants.folders_max` (6). `lot_folder` buys more cells; fragmentation is not modelled, cell capacity/occupancy is.
|
||||
- Shop: 3 weighted offers after each hour, `last_offer` x0.5, pity x1.5, no rerolls (locks are irrelevant), at least one affordable offer; buy the best affordable predicted-ROI lot while ROI >= 0.5 before H9, and any positive ROI in the final prep (there is no shop after H9).
|
||||
- ROI proxy: `money` = 5% of projected remaining income; `slots`/`speed`/`channel`/`timer`/`folder`/`sla`/`shield` use measured bottlenecks from the previous hour. It is deterministic, not an oracle.
|
||||
- ROI proxy: `money` = next `shop[].value` step of projected remaining income; `slots`/`speed`/`channel`/`timer`/`folder`/`sla`/`shield` use measured bottlenecks from the previous hour. It is deterministic, not an oracle.
|
||||
|
||||
## Analytical check (B1)
|
||||
|
||||
Expected base_sum assumes the full `request_cap` is reachable and the request mix/type weights are independent. `max income` is the hard upper bound (cap x 3 files, best allowed type, multiplier 1.4, recv fees).
|
||||
Expected base_sum assumes the full `request_cap` is reachable and the request mix/type weights are independent. `max income` is the hard upper bound (cap x 4 files, best allowed type, multiplier 1.4, recv fees).
|
||||
|
||||
| H | files | cap | act | E[n] | E[base/file] | E[base/req] | eff.reqs | E[base] | E[payout@1.15] | @1.25 | @1.40 | max base | max income | target | verdict |
|
||||
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|
|
||||
| 1 | 4 | 2 | 1 | 1.30 | 24.2 | 31.5 | 2 | 63 | 78 | 84 | 93 | 136 | 198 | 65 | ok |
|
||||
| 2 | 8 | 5 | 2 | 1.45 | 29.0 | 42.0 | 5 | 210 | 256 | 277 | 308 | 416 | 614 | 220 | ok |
|
||||
| 3 | 12 | 5 | 2 | 1.60 | 36.0 | 57.7 | 5 | 288 | 360 | 389 | 432 | 960 | 1416 | 380 | ok |
|
||||
| 4 | 16 | 6 | 3 | 1.60 | 42.7 | 68.3 | 6 | 410 | 518 | 559 | 620 | 1280 | 1888 | 560 | ok |
|
||||
| 5 | 20 | 6 | 3 | 1.75 | 48.6 | 85.1 | 6 | 511 | 657 | 708 | 784 | 1440 | 2124 | 760 | ok |
|
||||
| 6 | 20 | 7 | 3 | 1.75 | 54.3 | 95.0 | 7 | 665 | 844 | 910 | 1010 | 1600 | 2360 | 980 | ok |
|
||||
| 7 | 20 | 7 | 4 | 1.90 | 58.7 | 111.5 | 7 | 780 | 984 | 1062 | 1179 | 1600 | 2360 | 1200 | ok |
|
||||
| 8 | 20 | 7 | 4 | 1.90 | 63.8 | 121.1 | 7 | 848 | 1070 | 1155 | 1282 | 1600 | 2360 | 1450 | expected low |
|
||||
| 9 | 20 | 8 | 4 | 1.90 | 67.4 | 128.0 | 8 | 1024 | 1278 | 1381 | 1534 | 1600 | 2360 | 1700 | expected low |
|
||||
| 1 | 7 | 6 | 2 | 1.24 | 21.6 | 26.8 | 5 | 134 | 163 | 177 | 197 | 210 | 308 | 150 | ok |
|
||||
| 2 | 8 | 6 | 2 | 1.42 | 25.5 | 36.2 | 5 | 181 | 222 | 240 | 267 | 352 | 525 | 220 | ok |
|
||||
| 3 | 12 | 5 | 2 | 1.57 | 31.4 | 49.3 | 5 | 246 | 312 | 336 | 373 | 828 | 1231 | 380 | ok |
|
||||
| 4 | 16 | 6 | 3 | 1.66 | 37.0 | 61.3 | 6 | 368 | 470 | 507 | 562 | 1104 | 1642 | 560 | ok |
|
||||
| 5 | 20 | 6 | 3 | 1.82 | 42.0 | 76.4 | 6 | 458 | 596 | 642 | 711 | 1380 | 2052 | 760 | expected low |
|
||||
| 6 | 20 | 7 | 3 | 1.87 | 46.8 | 87.4 | 7 | 612 | 783 | 844 | 936 | 1380 | 2052 | 980 | expected low |
|
||||
| 7 | 20 | 7 | 4 | 2.03 | 50.5 | 102.5 | 7 | 717 | 911 | 983 | 1090 | 1380 | 2052 | 1200 | expected low |
|
||||
| 8 | 20 | 7 | 4 | 2.10 | 54.8 | 115.1 | 7 | 806 | 1022 | 1102 | 1223 | 1380 | 2052 | 1450 | expected low |
|
||||
| 9 | 20 | 7 | 4 | 2.20 | 57.9 | 127.4 | 7 | 892 | 1126 | 1215 | 1349 | 1380 | 2052 | 1700 | expected low |
|
||||
|
||||
## Simulation: per-hour income
|
||||
|
||||
| H | target | mean | median | P10 | P90 | dev | done | failed | files sent/left | cells peak/cap/free | SLA end | attacks B/F | spend | verdict |
|
||||
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|
|
||||
| 1 | 65 | 56 | 60 | 45 | 76 | -13.8% | 2.0 | 0.0 | 2.0/1.5 | 2/40/38 | 60 | 0.0/0.0 | 0 | PASS |
|
||||
| 2 | 220 | 209 | 206 | 159 | 260 | -5.0% | 5.0 | 0.0 | 5.6/3.4 | 6/40/34 | 60 | 1.0/0.0 | 51 | PASS |
|
||||
| 3 | 380 | 396 | 394 | 286 | 496 | +4.2% | 5.0 | 0.0 | 7.7/7.4 | 15/40/25 | 60 | 1.0/0.0 | 165 | PASS |
|
||||
| 4 | 560 | 661 | 654 | 475 | 864 | +18.1% | 6.0 | 0.0 | 9.6/13.5 | 31/66/36 | 60 | 2.0/0.0 | 353 | PASS |
|
||||
| 5 | 760 | 841 | 826 | 603 | 1 122 | +10.7% | 6.0 | 0.0 | 10.4/20.8 | 54/100/46 | 59 | 2.0/0.0 | 574 | PASS |
|
||||
| 6 | 980 | 1 146 | 1 144 | 884 | 1 437 | +16.9% | 6.9 | 0.1 | 12.0/26.3 | 74/120/46 | 59 | 3.0/0.0 | 703 | PASS |
|
||||
| 7 | 1200 | 1 422 | 1 399 | 1 041 | 1 869 | +18.5% | 6.9 | 0.1 | 13.3/30.0 | 91/120/29 | 58 | 3.0/0.0 | 620 | PASS |
|
||||
| 8 | 1450 | 1 586 | 1 589 | 1 218 | 1 932 | +9.4% | 6.9 | 0.0 | 13.2/33.0 | 104/121/17 | 55 | 4.0/0.0 | 272 | PASS |
|
||||
| 9 | 1700 | 1 948 | 1 974 | 1 534 | 2 399 | +14.6% | 7.8 | 0.1 | 14.7/33.7 | 115/173/58 | 60 | 3.9/0.0 | 3118 | PASS |
|
||||
| 1 | 150 | 149 | 151 | 125 | 167 | -0.9% | 6.0 | 0.0 | 6.0/1.0 | 2/40/38 | 59 | 0.0/0.0 | 0 | PASS |
|
||||
| 2 | 220 | 216 | 210 | 174 | 266 | -1.9% | 6.0 | 0.0 | 6.1/1.9 | 4/40/36 | 59 | 1.0/0.0 | 142 | PASS |
|
||||
| 3 | 380 | 342 | 337 | 244 | 445 | -9.9% | 5.0 | 0.0 | 7.1/6.4 | 13/40/27 | 59 | 1.0/0.0 | 197 | PASS |
|
||||
| 4 | 560 | 629 | 614 | 449 | 837 | +12.3% | 6.0 | 0.0 | 10.1/11.7 | 28/42/15 | 59 | 2.0/0.0 | 324 | PASS |
|
||||
| 5 | 760 | 789 | 760 | 568 | 1 064 | +3.8% | 6.0 | 0.0 | 10.8/17.1 | 41/49/8 | 47 | 2.0/0.0 | 561 | PASS |
|
||||
| 6 | 980 | 1 052 | 1 044 | 758 | 1 386 | +7.3% | 6.8 | 0.0 | 12.5/20.1 | 56/76/22 | 46 | 2.9/0.0 | 632 | PASS |
|
||||
| 7 | 1200 | 1 331 | 1 327 | 950 | 1 706 | +10.9% | 6.9 | 0.0 | 13.9/22.6 | 70/113/43 | 55 | 3.0/0.0 | 738 | PASS |
|
||||
| 8 | 1450 | 1 595 | 1 595 | 1 192 | 2 003 | +10.0% | 6.9 | 0.0 | 14.3/24.4 | 81/140/58 | 60 | 4.0/0.0 | 347 | PASS |
|
||||
| 9 | 1700 | 1 920 | 1 904 | 1 432 | 2 375 | +13.0% | 6.9 | 0.0 | 15.4/24.7 | 90/193/103 | 70 | 4.0/0.0 | 2064 | PASS |
|
||||
|
||||
`spend` is the gold spent in the prep before that hour; H9 is the final prep, where the policy spends on any positive ROI because no shop follows.
|
||||
|
||||
## Run totals
|
||||
|
||||
- Mean total income: **8 184 $** (payout 7 060 + recv_fee 445 + attacks 679). Spec band: 7 300-8 500 $.
|
||||
- Mean shop spend: **5 771 $** = **44.1%** of the 13 100 $ tree (target 55-65%). Mean money left after H9: **2 463 $**.
|
||||
- Mean requests done: **52.2**; files received **121.4**, sent **87.8**, left unsent **33.7**.
|
||||
- Mean SLA at end of H9: **58.8**; defeated runs: **14**.
|
||||
- Attacks blocked/failed/shielded per run: **19.7/0.0/2.3**.
|
||||
- Mean total income: **7 533 $** (payout 6 495 + recv_fee 410 + attacks 628). Spec band: 7 400 - 8 500 $.
|
||||
- Mean shop spend: **4 681 $** = **57.9%** of the 8 085 $ tree (target 55-65%). Mean money left after H9: **2 392 $**. Mean folder spend: **510 $**.
|
||||
- Mean requests done: **54.4**; files received **115.7**, sent **91.6**, left unsent **24.1**.
|
||||
- Mean SLA at end of H9: **62.2**; defeated runs: **28**.
|
||||
- Attacks blocked/failed/shielded per run: **18.7/0.0/4.4**.
|
||||
|
||||
## Purchases and ROI proxy
|
||||
|
||||
| lot | effect | levels | buys | spend | value proxy | ROI | share of spend |
|
||||
|---|---|---:|---:|---:|---:|---:|---:|
|
||||
| lot_money | money_mult | 6.6 | 6.6 | 1 494 | 1 563 | 1.05 | 25.9% |
|
||||
| lot_speed | transfer_speed | 4.1 | 4.1 | 800 | 475 | 0.59 | 13.9% |
|
||||
| lot_channel | channel_capacity | 4.7 | 4.7 | 776 | 6 931 | 8.94 | 13.4% |
|
||||
| lot_slots | connection_slots | 2.2 | 2.2 | 730 | 470 | 0.64 | 12.6% |
|
||||
| lot_shield | shield_charge | 1.4 | 1.4 | 522 | 64 | 0.12 | 9.1% |
|
||||
| lot_folder | folder_cells | 1.6 | 1.6 | 518 | 73 | 0.14 | 9.0% |
|
||||
| lot_sla | sla_cap | 1.5 | 1.5 | 506 | 30 | 0.06 | 8.8% |
|
||||
| lot_timer | request_timer | 2.9 | 2.9 | 425 | 169 | 0.40 | 7.4% |
|
||||
| lot_money | money_mult | 6.5 | 6.5 | 1 267 | 1 481 | 1.17 | 27.1% |
|
||||
| lot_speed | transfer_speed | 5.2 | 5.2 | 637 | 712 | 1.12 | 13.6% |
|
||||
| lot_channel | channel_capacity | 5.3 | 5.3 | 621 | 8 431 | 13.58 | 13.3% |
|
||||
| lot_slots | connection_slots | 2.3 | 2.3 | 522 | 405 | 0.77 | 11.2% |
|
||||
| lot_folder | folder_cells | 2.6 | 2.6 | 486 | 705 | 1.45 | 10.4% |
|
||||
| lot_sla | sla_cap | 2.0 | 2.0 | 443 | 219 | 0.50 | 9.5% |
|
||||
| lot_shield | shield_charge | 1.9 | 1.9 | 382 | 152 | 0.40 | 8.2% |
|
||||
| lot_timer | request_timer | 3.2 | 3.2 | 324 | 219 | 0.68 | 6.9% |
|
||||
|
||||
## Tuning recommendations (not applied)
|
||||
|
||||
- No blockers: every hour mean is inside +/-20% and every analytical maximum cap is at least its target.
|
||||
### Weak lots
|
||||
|
||||
These lots are bought only by the final-prep money dump (no shop after H9), so their spend is not evidence of value.
|
||||
|
||||
- `lot_shield` (shield_charge): mean level 1.43, ROI 0.12. q=0.85 clears every attack threshold, so auto-blocks only add 50% rewards. Lower the price or raise attack rewards/fail costs.
|
||||
- `lot_folder` (folder_cells): mean level 1.60, ROI 0.14. Cap free folders or make the grid the binding resource; with 2-6 free folders the lot has almost no marginal value.
|
||||
- `lot_sla` (sla_cap): mean level 1.46, ROI 0.06. SLA rarely binds for a competent player (2/240 defeats). Either lower the price/raise the effect or make timeouts hurt more.
|
||||
|
||||
### Best ROI is not the money lot
|
||||
|
||||
- Best ROI proxy is `lot_channel` (channel_capacity) at **8.94**, spend share 13.4%. `lot_money` is not the primary lever in this model, so a money-price change alone will not fix the tree coverage; check channel/slot/speed prices too.
|
||||
- `lot_money` ROI is **1.05** (spend share 25.9%). It is a good buy but not front-loaded enough to justify a new price curve from this run; if you want one anyway, [120, 170, 230, 300, 390, 500, 640, 800] is the mild version (total 3 150 $).
|
||||
- Best ROI proxy is `lot_channel` (channel_capacity) at **13.58**, spend share 13.3%. `lot_money` is not the primary lever in this model, so a money-price change alone will not fix the tree coverage; check channel/slot/speed prices too.
|
||||
- `lot_money` ROI is **1.17** (spend share 27.1%). It is a good buy but not front-loaded enough to justify a new price curve from this run.
|
||||
|
||||
## Verdict
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PEAK HOUR v2 balance simulator (pure Python 3, no third-party deps).
|
||||
"""PEAK HOUR v3 balance simulator (pure Python 3, no third-party deps).
|
||||
|
||||
Reads ``data/balance.json`` from the repository and models hours H1-H9:
|
||||
file budget/spawn window, type weights, request cap/active limit/request mix,
|
||||
@@ -68,12 +68,18 @@ HUMAN_ATTACH_PER_FILE = 1.0
|
||||
MINIGAME_BASE = 7.0 # no timer: competent player still needs time
|
||||
MINIGAME_PER_TIER = 1.5
|
||||
PROTOCOL = "tcp" # production default; TCP is +15% payout
|
||||
TCP_CONNECT_SEC = 4.2 # production TCP connection minigame, competent player
|
||||
TCP_CONNECT_FAIL = 0.04 # 4% missed TCP connection
|
||||
TCP_CONNECT_FAIL_SLA = 4.0 # and -4 SLA
|
||||
CHECK_TOL = 0.20 # +-20% per hour
|
||||
PURCHASE_ROI_MIN = 0.5 # regular prep: buy while predicted value >= 0.5x price
|
||||
FINAL_PREP_ROI_MIN = 0.0 # before H9 there is no future shop: spend on any positive ROI
|
||||
|
||||
TARGETS = {int(h): float(HOURS[h]["income_target"]) for h in HOURS}
|
||||
MAX_SLA = float(CONST.get("sla_hard_cap", 200))
|
||||
BAND_LOW = int(sum(TARGETS.values()))
|
||||
BAND_HIGH = 8500
|
||||
BAND_TEXT = f"{BAND_LOW:,} - {BAND_HIGH:,}".replace(",", " ")
|
||||
MAX_SLA = float(CONST.get("sla_hard_cap", 220))
|
||||
SLA_REGEN = float(CONST.get("sla_regen_per_hour", 15))
|
||||
|
||||
# type helpers
|
||||
@@ -87,6 +93,17 @@ MINIGAME_COEFF = {t: int(TYPES[t]["minigame_coeff"]) for t in TYPES}
|
||||
|
||||
ACHIEVABLE_SUMS = [int(v) for v in BALANCE["minigames"]["selection"]["achievable_sums"]]
|
||||
SEND_ORDER = list(BALANCE["minigames"]["selection"]["order"])
|
||||
SEL = BALANCE["minigames"]["selection"]
|
||||
TIER_MAX_RANK = [int(v) for v in SEL.get("tier_max_rank", [7, 13])]
|
||||
RANK_GAMES = list(SEL.get(
|
||||
"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"],
|
||||
))
|
||||
FOLDER_NEW_COST = int(CONST.get("folder_new_cost", 180))
|
||||
SHIELD_REWARD_SCALE = float(ATTACKS.get("shield_reward_scale", 0.7))
|
||||
|
||||
|
||||
def clamp(value: float, low: float, high: float) -> float:
|
||||
@@ -140,18 +157,24 @@ def weighted_index(rng: random.Random, values: list[str], weights: list[float])
|
||||
|
||||
|
||||
def send_minigame_for(types: list[str]) -> dict[str, int | str]:
|
||||
"""Dense-sum selection from balance.json (B4), not the old idx = S - 1."""
|
||||
total = sum(MINIGAME_COEFF.get(t, 1) for t in types)
|
||||
"""Economy.selection_for_sum: 27 dense sums, 3 tiers, direct rank table."""
|
||||
total = sum(MINIGAME_COEFF.get(str(t).lower(), 1) for t in types)
|
||||
if total <= 0:
|
||||
return {"id": "dots", "tier": 1, "index": 0, "sum": 0}
|
||||
rank = 0
|
||||
for i, value in enumerate(ACHIEVABLE_SUMS):
|
||||
if value <= total:
|
||||
rank = i
|
||||
else:
|
||||
break
|
||||
count = len(ACHIEVABLE_SUMS)
|
||||
tier = int(clamp(1 + math.floor(rank * 4.0 / count), 1, 4))
|
||||
if rank > TIER_MAX_RANK[1]:
|
||||
tier = 3
|
||||
elif rank > TIER_MAX_RANK[0]:
|
||||
tier = 2
|
||||
else:
|
||||
tier = 1
|
||||
return {
|
||||
"id": SEND_ORDER[rank % len(SEND_ORDER)],
|
||||
"id": RANK_GAMES[min(rank, len(RANK_GAMES) - 1)],
|
||||
"tier": tier,
|
||||
"index": rank,
|
||||
"sum": total,
|
||||
@@ -273,6 +296,7 @@ class Simulation:
|
||||
self.levels: dict[str, int] = {str(lot["id"]): 0 for lot in SHOP}
|
||||
self.lot_stats: dict[str, LotStat] = {str(lot["id"]): LotStat() for lot in SHOP}
|
||||
self.total_spent = 0
|
||||
self.folder_spend = 0
|
||||
self.earned = 0
|
||||
self.carry = 0
|
||||
self.stored_types: list[str] = []
|
||||
@@ -305,32 +329,35 @@ class Simulation:
|
||||
def slot_limit(self) -> int:
|
||||
return int(clamp(1 + self.levels["lot_slots"], 1, int(CONST.get("slots_max", 5))))
|
||||
|
||||
def lot_value(self, lot_id: str, fallback: float) -> float:
|
||||
return float(SHOP_BY_ID[lot_id].get("value", fallback))
|
||||
|
||||
def transfer_speed_mult(self) -> float:
|
||||
return float(0.92) ** self.levels["lot_speed"]
|
||||
cut = float(clamp(self.lot_value("lot_speed", 0.10), 0.01, 0.9))
|
||||
return float(1.0 - cut) ** self.levels["lot_speed"]
|
||||
|
||||
def channel_capacity(self) -> float:
|
||||
return float(CONST.get("channel_capacity", 10)) + \
|
||||
float(CONST.get("channel_per_level", 6)) * self.levels["lot_channel"]
|
||||
self.lot_value("lot_channel", 6.0) * self.levels["lot_channel"]
|
||||
|
||||
def buffer_limit(self) -> int:
|
||||
return int(clamp(int(CONST.get("buffer_base", 6)) + self.levels["lot_channel"],
|
||||
int(CONST.get("buffer_base", 6)),
|
||||
int(CONST.get("buffer_max", 12))))
|
||||
base = int(CONST.get("buffer_base", 6))
|
||||
step = int(self.lot_value("lot_channel", 1.0))
|
||||
return int(clamp(base + step * self.levels["lot_channel"],
|
||||
base, int(CONST.get("buffer_max", 12))))
|
||||
|
||||
def request_timer(self) -> float:
|
||||
return float(clamp(float(CONST.get("request_timer", 30)) +
|
||||
float(CONST.get("request_timer_per_level", 5)) *
|
||||
self.levels["lot_timer"],
|
||||
float(CONST.get("request_timer", 30)),
|
||||
float(CONST.get("request_timer_max", 55))))
|
||||
base = float(CONST.get("request_timer", 30))
|
||||
return float(clamp(base + self.lot_value("lot_timer", 7.0) * self.levels["lot_timer"],
|
||||
base, float(CONST.get("request_timer_max", 65))))
|
||||
|
||||
def sla_cap(self) -> float:
|
||||
return float(clamp(float(CONST.get("sla_base", 60)) +
|
||||
float(CONST.get("sla_per_level", 35)) * self.levels["lot_sla"],
|
||||
float(CONST.get("sla_base", 60)), MAX_SLA))
|
||||
base = float(CONST.get("sla_base", 60))
|
||||
return float(clamp(base + self.lot_value("lot_sla", 40.0) * self.levels["lot_sla"],
|
||||
base, MAX_SLA))
|
||||
|
||||
def money_mult(self) -> float:
|
||||
return 1.0 + 0.05 * self.levels["lot_money"]
|
||||
return 1.0 + self.lot_value("lot_money", 0.05) * self.levels["lot_money"]
|
||||
|
||||
def folder_cells(self) -> int:
|
||||
dims = GRID.get("folder_dims") or [[5, 4]]
|
||||
@@ -351,7 +378,7 @@ class Simulation:
|
||||
|
||||
# --- prep -------------------------------------------------------------
|
||||
def prepare_folders(self, hour: int, files_budget: int, occupied: int = 0) -> None:
|
||||
"""Competent player pre-creates free folders (spec 2.1: up to 12)."""
|
||||
"""Competent player buys free folders for folder_new_cost (spec 2.1)."""
|
||||
weights = HOURS[hour]["type_weights"]
|
||||
avg_cells = sum(float(weights.get(t, 0.0)) * CELLS[t] for t in TYPE_ORDER)
|
||||
expected = occupied + files_budget * max(1.0, avg_cells) * 0.9
|
||||
@@ -359,6 +386,10 @@ class Simulation:
|
||||
max_folders = int(CONST.get("folders_max", 6))
|
||||
guard = 0
|
||||
while cap < expected * 1.15 and self.folders < max_folders and guard < 20:
|
||||
if FOLDER_NEW_COST > self.money:
|
||||
break
|
||||
self.money -= FOLDER_NEW_COST
|
||||
self.folder_spend += FOLDER_NEW_COST
|
||||
self.folders += 1
|
||||
cap = self.cell_capacity()
|
||||
guard += 1
|
||||
@@ -422,11 +453,12 @@ class Simulation:
|
||||
penalty = max(0.0, self.last_metrics["channel_penalty"])
|
||||
|
||||
if effect == "money_mult":
|
||||
# Relative payout gain of the next +5% level.
|
||||
return 0.05 * proj / (1.0 + 0.05 * level)
|
||||
# Relative payout gain of the next level (shop[].value).
|
||||
step = self.lot_value("lot_money", 0.05)
|
||||
return step * proj / (1.0 + step * level)
|
||||
if effect == "transfer_speed":
|
||||
# 8% shorter transfers only matter when the pipe is the bottleneck.
|
||||
return 0.08 * proj * busy * 0.5
|
||||
# 10% shorter transfers only matter when the pipe is the bottleneck.
|
||||
return self.lot_value("lot_speed", 0.10) * proj * busy * 0.5
|
||||
if effect == "connection_slots":
|
||||
base = 0.06 if wait_sec > 0.5 else 0.02
|
||||
return proj * base / (1.0 + 0.35 * (self.slot_limit() - 1))
|
||||
@@ -439,11 +471,12 @@ class Simulation:
|
||||
if effect == "sla_cap":
|
||||
return proj * (0.12 if self.last_metrics["sla_ratio"] < 0.5 else 0.003)
|
||||
if effect == "shield_charge":
|
||||
# One auto-block per hour, 50% reward, no fail penalty.
|
||||
# One auto-block per hour, shield_reward_scale of the reward, no penalty.
|
||||
remaining_attacks = sum(int(HOURS[h].get("attacks", 0))
|
||||
for h in range(hour, RUN_HOURS + 1))
|
||||
avg_fail = 25.0 + 5.0 * max(0, hour - 2) / 2.0
|
||||
return min(float(remaining_attacks), float(hours_left)) * 0.5 * avg_fail
|
||||
return min(float(remaining_attacks), float(hours_left)) * \
|
||||
SHIELD_REWARD_SCALE * avg_fail
|
||||
return 0.0
|
||||
|
||||
def buy_best_roi(self, hour: int, offers: list[str]) -> None:
|
||||
@@ -534,7 +567,9 @@ class Simulation:
|
||||
requests_spawned = 0
|
||||
next_spawn_t = 0.0
|
||||
spawn_interval = 225.0 / max(1, files_budget)
|
||||
next_spawn_t = spawn_interval * self.rng.uniform(1.0 - SPAWN_JITTER, 1.0 + SPAWN_JITTER)
|
||||
if hour > 1:
|
||||
next_spawn_t = spawn_interval * self.rng.uniform(
|
||||
1.0 - SPAWN_JITTER, 1.0 + SPAWN_JITTER)
|
||||
request_accum = 0.0
|
||||
request_interval = max(0.5, float(hour_def.get("send_interval", 20.0)))
|
||||
|
||||
@@ -633,6 +668,11 @@ class Simulation:
|
||||
duration += RECV_TIME.get(file.type, 3.0) * self.transfer_speed_mult() * factor
|
||||
if str(request.protocol).lower() == "udp":
|
||||
duration *= float(PAYOUT.get("udp_time_mult", 0.8))
|
||||
if str(request.protocol).lower() == "tcp":
|
||||
# Production TCP: connection minigame before the send minigame.
|
||||
duration += TCP_CONNECT_SEC
|
||||
if self.rng.random() < TCP_CONNECT_FAIL:
|
||||
self.sla = max(0.0, self.sla - TCP_CONNECT_FAIL_SLA)
|
||||
request.state = "sending"
|
||||
request.send_left = max(0.1, duration)
|
||||
request.send_start_left = request.timer_left
|
||||
@@ -676,8 +716,8 @@ class Simulation:
|
||||
self.shield_charges -= 1
|
||||
self.attacks_shielded += 1
|
||||
self.attacks_blocked += 1
|
||||
reward = int(round(float(row["reward_money"]) * 0.5))
|
||||
reward_sla = float(row["reward_sla"]) * 0.5
|
||||
reward = int(round(float(row["reward_money"]) * SHIELD_REWARD_SCALE))
|
||||
reward_sla = float(row["reward_sla"]) * SHIELD_REWARD_SCALE
|
||||
self.money += reward
|
||||
self.earned += reward
|
||||
attack_income += reward
|
||||
@@ -715,7 +755,7 @@ class Simulation:
|
||||
if mode is not None and mode.state == "attaching" and now >= mode.attach_end:
|
||||
request = mode
|
||||
request.state = "minigame"
|
||||
request.mini_end = now + MINIGAME_BASE + MINIGAME_PER_TIER * request.tier
|
||||
request.mini_end = now + MINIGAME_BASE + MINIGAME_PER_TIER * (request.tier + 1)
|
||||
if mode is not None and mode.state == "minigame" and now >= mode.mini_end:
|
||||
request = mode
|
||||
mode = None
|
||||
@@ -948,8 +988,8 @@ def analytical_rows() -> list[dict[str, float]]:
|
||||
allowed = [t for t in TYPE_ORDER if weights.get(t, 0.0) > 0.0]
|
||||
max_base_file = max(BASE_PAY[t] for t in allowed)
|
||||
max_recv_file = max(RECV_FEE[t] for t in allowed)
|
||||
# Upper bound: request cap x 3 files, limited by the hour file budget.
|
||||
max_files = min(files_budget, cap * 3)
|
||||
# Upper bound: request cap x 4 files, limited by the hour file budget.
|
||||
max_files = min(files_budget, cap * 4)
|
||||
max_base = max_files * max_base_file
|
||||
max_income = max_base * 1.4 + max_files * max_recv_file
|
||||
rows.append({
|
||||
@@ -997,12 +1037,12 @@ def fmt_money(value: float) -> str:
|
||||
|
||||
|
||||
def suggested_caps(hour_means: dict[int, float]) -> dict[int, int]:
|
||||
"""Linear cap correction; H1 keeps cap 2 because the spec allows 1-2 requests."""
|
||||
"""Linear cap correction for every hour outside +/-20%."""
|
||||
caps: dict[int, int] = {}
|
||||
for hour in range(1, RUN_HOURS + 1):
|
||||
cap = int(HOURS[hour]["request_cap"])
|
||||
mean = hour_means.get(hour)
|
||||
if hour == 1 or not mean:
|
||||
if not mean:
|
||||
caps[hour] = cap
|
||||
continue
|
||||
if abs(mean - TARGETS[hour]) / TARGETS[hour] > CHECK_TOL:
|
||||
@@ -1063,7 +1103,7 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
for row in sim.hourly:
|
||||
per_hour[row.hour].append(row)
|
||||
|
||||
lines.append("# PEAK HOUR v2 - balance simulation report")
|
||||
lines.append("# PEAK HOUR v3 - balance simulation report")
|
||||
lines.append("")
|
||||
lines.append(f"Generated by `tests/sim_balance.py` from `data/balance.json`.")
|
||||
lines.append(f"Seeds: **{len(runs)}**, hours: **{RUN_HOURS}**, dt: **{DT} s**, "
|
||||
@@ -1073,8 +1113,9 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
lines.append("## Model assumptions")
|
||||
lines.append("")
|
||||
lines.append("- Spawn: file budget from `hours[].files` (+ carry), spawn only during "
|
||||
"75% of the hour (225 s), interval 225/budget with +/-20% jitter. "
|
||||
"A full buffer freezes spawns without burning the budget.")
|
||||
"75% of the hour (225 s), interval 225/budget with +/-20% jitter; H1 "
|
||||
"spawns its first packet at t=0. A full buffer freezes spawns without "
|
||||
"burning the budget.")
|
||||
lines.append("- Types: `hours[].type_weights` only; zero weight never spawns.")
|
||||
lines.append("- Unsent files persist across hours (the folder is not cleared); only "
|
||||
"files consumed by a successful send disappear. Unspawned budget is carried "
|
||||
@@ -1085,33 +1126,38 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
"when no files exist.")
|
||||
lines.append(f"- Player: competent, attaches after {HUMAN_ATTACH_BASE:g} s + "
|
||||
f"{HUMAN_ATTACH_PER_FILE:g} s/file, plays a no-timer send minigame for "
|
||||
f"{MINIGAME_BASE:g} + {MINIGAME_PER_TIER:g}*tier seconds. The minigame "
|
||||
"pauses packet spawn/loading but not other request timers (production order).")
|
||||
lines.append("- Timers: request `30 + 5*timer_level` (cap 55), packet 20 s. "
|
||||
f"{MINIGAME_BASE:g} + {MINIGAME_PER_TIER:g}*(tier+1) seconds "
|
||||
f"(3 tiers). TCP adds {TCP_CONNECT_SEC:g} s connection time and "
|
||||
f"{TCP_CONNECT_FAIL * 100:.0f}% of attempts miss it "
|
||||
f"(-{TCP_CONNECT_FAIL_SLA:g} SLA). "
|
||||
"The minigame pauses packet spawn/loading but not other request timers "
|
||||
"(production order).")
|
||||
lines.append("- Timers: request `30 + 7*timer_level` (cap 65), packet 20 s. "
|
||||
"Timers pause in the player's own minigame and in the out-slot queue; "
|
||||
"payout uses `time_left` snapshotted at send start.")
|
||||
lines.append("- Transfers: `base_time_sec * 0.92^speed_level * max(1, load/capacity)`; "
|
||||
lines.append("- Transfers: `base_time_sec * 0.90^speed_level * max(1, load/capacity)`; "
|
||||
"slots are shared by receive and send; files are consumed on successful send.")
|
||||
lines.append("- Payout: exact `round(base_sum * speed * quality * multi * protocol * "
|
||||
"streak * money_mult)`; recv_fee is added on receive and is not multiplied.")
|
||||
lines.append("- Folder: `grid.folder_dims[folder_level]`, 2 free folders at start, "
|
||||
"up to `constants.folders_max` (6) free folders created in prep. "
|
||||
"The spec allows up to 12; using 6 is the conservative reading and still "
|
||||
"makes `lot_folder` nearly worthless. Fragmentation is not modelled; cell "
|
||||
"capacity/occupancy is.")
|
||||
"streak * money_mult)`; TCP x1.15, UDP x0.85; recv_fee is added on receive "
|
||||
"and is not multiplied.")
|
||||
lines.append("- Folder: `grid.folder_dims[folder_level]`, 2 free folders at start; "
|
||||
f"each extra folder costs `constants.folder_new_cost` ({FOLDER_NEW_COST} $) "
|
||||
"in prep, up to `constants.folders_max` (6). `lot_folder` buys more cells; "
|
||||
"fragmentation is not modelled, cell capacity/occupancy is.")
|
||||
lines.append("- Shop: 3 weighted offers after each hour, `last_offer` x0.5, pity x1.5, "
|
||||
"no rerolls (locks are irrelevant), at least one affordable offer; buy the "
|
||||
"best affordable predicted-ROI lot while ROI >= 0.5 before H9, and any "
|
||||
"positive ROI in the final prep (there is no shop after H9).")
|
||||
lines.append("- ROI proxy: `money` = 5% of projected remaining income; `slots`/`speed`/"
|
||||
"`channel`/`timer`/`folder`/`sla`/`shield` use measured bottlenecks from the "
|
||||
"previous hour. It is deterministic, not an oracle.")
|
||||
lines.append("- ROI proxy: `money` = next `shop[].value` step of projected remaining "
|
||||
"income; `slots`/`speed`/`channel`/`timer`/`folder`/`sla`/`shield` use "
|
||||
"measured bottlenecks from the previous hour. It is deterministic, not an "
|
||||
"oracle.")
|
||||
lines.append("")
|
||||
lines.append("## Analytical check (B1)")
|
||||
lines.append("")
|
||||
lines.append("Expected base_sum assumes the full `request_cap` is reachable and the "
|
||||
"request mix/type weights are independent. `max income` is the hard upper "
|
||||
"bound (cap x 3 files, best allowed type, multiplier 1.4, recv fees).")
|
||||
"bound (cap x 4 files, best allowed type, multiplier 1.4, recv fees).")
|
||||
lines.append("")
|
||||
lines.append("| H | files | cap | act | E[n] | E[base/file] | E[base/req] | eff.reqs | "
|
||||
"E[base] | E[payout@1.15] | @1.25 | @1.40 | max base | max income | target | verdict |")
|
||||
@@ -1189,6 +1235,7 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
total_recv = statistics.fmean([sum(r.recv_income for r in sim.hourly) for sim in runs])
|
||||
total_attack = statistics.fmean([sum(r.attack_income for r in sim.hourly) for sim in runs])
|
||||
total_spent = statistics.fmean([sim.total_spent for sim in runs])
|
||||
folder_spend = statistics.fmean([sim.folder_spend for sim in runs])
|
||||
money_end = statistics.fmean([sim.money for sim in runs])
|
||||
total_done = statistics.fmean([sim.requests_done for sim in runs])
|
||||
total_received = statistics.fmean([sim.files_received for sim in runs])
|
||||
@@ -1205,10 +1252,11 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
lines.append("")
|
||||
lines.append(f"- Mean total income: **{fmt_money(total_income)} $** "
|
||||
f"(payout {fmt_money(total_payout)} + recv_fee {fmt_money(total_recv)} "
|
||||
f"+ attacks {fmt_money(total_attack)}). Spec band: 7 300-8 500 $.")
|
||||
f"+ attacks {fmt_money(total_attack)}). Spec band: {BAND_TEXT} $.")
|
||||
lines.append(f"- Mean shop spend: **{fmt_money(total_spent)} $** = "
|
||||
f"**{tree_pct:.1f}%** of the 13 100 $ tree (target 55-65%). "
|
||||
f"Mean money left after H9: **{fmt_money(money_end)} $**.")
|
||||
f"**{tree_pct:.1f}%** of the {fmt_money(TOTAL_TREE)} $ tree (target 55-65%). "
|
||||
f"Mean money left after H9: **{fmt_money(money_end)} $**. "
|
||||
f"Mean folder spend: **{fmt_money(folder_spend)} $**.")
|
||||
lines.append(f"- Mean requests done: **{total_done:.1f}**; files received "
|
||||
f"**{total_received:.1f}**, sent **{total_sent:.1f}**, left unsent "
|
||||
f"**{total_left:.1f}**.")
|
||||
@@ -1255,15 +1303,6 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
cap = int(HOURS[hour]["request_cap"])
|
||||
mean = stats["mean"]
|
||||
target = stats["target"]
|
||||
if hour == 1:
|
||||
lines.append(f"- **H1**: mean {mean:.0f} $ vs target {target:.0f} $ "
|
||||
f"({stats['dev']:+.1f}%). The 2-request cap is the spec "
|
||||
"constraint; 2 requests x ~1.3 files average cannot reach 90 $. "
|
||||
"Suggest lowering `hours[1].income_target` **90 -> 65** "
|
||||
"(still inside the §0.1 60-120 band) or raising H1 "
|
||||
"text/sound `base_pay` by ~1.3x. Do not raise `request_cap`: "
|
||||
"3 completed requests violates the H1 acceptance rule.")
|
||||
continue
|
||||
suggested = int(clamp(round(cap * target / max(1.0, mean)), 1,
|
||||
int(HOURS[hour]["files"])))
|
||||
if caps:
|
||||
@@ -1292,22 +1331,21 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
f"H{h}={int(HOURS[h]['request_cap'])}" for h in range(1, RUN_HOURS + 1)) + ".")
|
||||
lines.append("- Suggested caps from this run: " + ", ".join(
|
||||
f"H{h}={local_caps[h]}" for h in range(1, RUN_HOURS + 1)) + ".")
|
||||
lines.append("- H2 stays 5 and H3 uses 5, above the §2.4 reference 3/4: "
|
||||
"the spec caps produce roughly 126 $ / 260 $ in this model, far "
|
||||
"below the 220 $ / 380 $ targets. H4=5 matches the spec.")
|
||||
lines.append("- H2-H4 caps are the numbers from `hours[].request_cap`; the "
|
||||
"simulated means above show whether the spec reference caps "
|
||||
"[2, 3, 4, 5, 6, 7, 8, 9, 10] should be revisited.")
|
||||
else:
|
||||
lines.append("- H2-H4 are inside +/-20%; no early-cap change required by this run.")
|
||||
lines.append("")
|
||||
if total_income < 7300.0 or total_income > 8500.0:
|
||||
cap_failures = [h for h in hour_stats if h != 1 and not hour_stats[h]["ok"]]
|
||||
if total_income < BAND_LOW or total_income > BAND_HIGH:
|
||||
cap_failures = [h for h in hour_stats if not hour_stats[h]["ok"]]
|
||||
if cap_failures:
|
||||
lines.append(f"- Total income {total_income:.0f} $ is outside 7 300-8 500 $, "
|
||||
"but most of the gap is explained by the inflated H3-H8 caps "
|
||||
"above. Re-run after applying those caps before touching "
|
||||
"`base_pay`.")
|
||||
lines.append(f"- Total income {total_income:.0f} $ is outside {BAND_TEXT} $, "
|
||||
"but most of the gap is explained by the hourly caps above. "
|
||||
"Re-run after applying those caps before touching `base_pay`.")
|
||||
else:
|
||||
scale = 7800.0 / max(1.0, total_income)
|
||||
lines.append(f"- Total income {total_income:.0f} $ is outside 7 300-8 500 $. "
|
||||
scale = (BAND_LOW + BAND_HIGH) * 0.5 / max(1.0, total_income)
|
||||
lines.append(f"- Total income {total_income:.0f} $ is outside {BAND_TEXT} $. "
|
||||
f"Global `base_pay` scale for the band: **x{scale:.2f}** "
|
||||
f"(or adjust `income_target` instead).")
|
||||
if tree_pct < 55.0 or tree_pct > 65.0:
|
||||
@@ -1329,11 +1367,9 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
lines.append("### Money lot price curve")
|
||||
lines.append("")
|
||||
lines.append(f"- `lot_money` ROI proxy is **{money[6]:.2f}** and it takes "
|
||||
f"**{money[7]:.1f}%** of spend. The early 80/110/150 prices are "
|
||||
"front-loaded: +5% of remaining income is worth far more than the "
|
||||
"price. Suggested curve if you want later levels to compete: "
|
||||
"**[120, 170, 230, 300, 390, 500, 640, 800]** (total 3 150 $ instead "
|
||||
"of 2 140 $). This is a recommendation only.")
|
||||
f"**{money[7]:.1f}%** of spend. The early prices are front-loaded: "
|
||||
"one `+money_mult` step of remaining income is worth more than the "
|
||||
"first levels cost. This is a recommendation only.")
|
||||
lines.append("")
|
||||
|
||||
# Dead-lot checks.
|
||||
@@ -1347,14 +1383,15 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
lines.append("")
|
||||
for lot_id, effect, levels, _buys, _spend, _value, roi, _share in dead:
|
||||
if effect == "folder_cells":
|
||||
hint = ("Cap free folders or make the grid the binding resource; with 2-6 "
|
||||
"free folders the lot has almost no marginal value.")
|
||||
hint = ("Each extra folder costs folder_new_cost and the lot raises the "
|
||||
"cell ceiling; check whether cells are actually the bottleneck.")
|
||||
elif effect == "sla_cap":
|
||||
hint = ("SLA rarely binds for a competent player (2/240 defeats). Either "
|
||||
"lower the price/raise the effect or make timeouts hurt more.")
|
||||
hint = (f"SLA binds only in {defeated}/{len(runs)} runs. Either lower the "
|
||||
"price/raise the effect or make timeouts hurt more.")
|
||||
elif effect == "shield_charge":
|
||||
hint = ("q=0.85 clears every attack threshold, so auto-blocks only add "
|
||||
"50% rewards. Lower the price or raise attack rewards/fail costs.")
|
||||
f"{SHIELD_REWARD_SCALE * 100:.0f}% rewards. Lower the price or raise "
|
||||
"attack rewards/fail costs.")
|
||||
else:
|
||||
hint = "Strengthen the effect or lower the price."
|
||||
lines.append(f"- `{lot_id}` ({effect}): mean level {levels:.2f}, ROI {roi:.2f}. "
|
||||
@@ -1373,9 +1410,7 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
if money_row:
|
||||
lines.append(f"- `lot_money` ROI is **{money_row[6]:.2f}** "
|
||||
f"(spend share {money_row[7]:.1f}%). It is a good buy but not "
|
||||
"front-loaded enough to justify a new price curve from this run; "
|
||||
"if you want one anyway, [120, 170, 230, 300, 390, 500, 640, 800] "
|
||||
"is the mild version (total 3 150 $).")
|
||||
"front-loaded enough to justify a new price curve from this run.")
|
||||
lines.append("")
|
||||
|
||||
if projection and caps:
|
||||
@@ -1383,17 +1418,16 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
lines.append("")
|
||||
lines.append("Suggested `request_cap` vector: **[" +
|
||||
", ".join(str(caps[h]) for h in range(1, RUN_HOURS + 1)) + "]**; "
|
||||
"H1 `income_target` **90 -> 65** (marked *). "
|
||||
f"Projection seeds: {projection['seeds']}.")
|
||||
lines.append("")
|
||||
lines.append("| H | current cap | suggested cap | projected mean | target | dev | verdict |")
|
||||
lines.append("|---:|---:|---:|---:|---:|---:|---|")
|
||||
for hour in range(1, RUN_HOURS + 1):
|
||||
mean = float(projection["means"].get(hour, 0.0))
|
||||
target_disp = 65.0 if hour == 1 else TARGETS[hour]
|
||||
target_disp = TARGETS[hour]
|
||||
dev = (mean - target_disp) / target_disp * 100.0
|
||||
ok = abs(dev) <= CHECK_TOL * 100.0
|
||||
target_text = f"{target_disp:.0f}" + ("*" if hour == 1 else "")
|
||||
target_text = f"{target_disp:.0f}"
|
||||
lines.append(f"| {hour} | {int(HOURS[hour]['request_cap'])} | {caps[hour]} | "
|
||||
f"{fmt_money(mean)} | {target_text} | {dev:+.1f}% | "
|
||||
f"{'PASS' if ok else 'FAIL'} |")
|
||||
@@ -1411,8 +1445,8 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
"55-65% is reachable only near this ceiling."))
|
||||
lines.append("Note: recommended H9 cap can be lower than H8 because unsent files "
|
||||
"persist across hours and raise the effective supply in H9.")
|
||||
if abs(float(projection["total"]) - 7800.0) < 500.0:
|
||||
lines.append("The projected total is inside the 7 300-8 500 $ band.")
|
||||
if BAND_LOW <= float(projection["total"]) <= BAND_HIGH:
|
||||
lines.append(f"The projected total is inside the {BAND_TEXT} $ band.")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Verdict")
|
||||
@@ -1424,14 +1458,13 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
lines.append(f"- {failure}")
|
||||
if projection:
|
||||
projected_ok = all(
|
||||
abs(float(projection["means"].get(h, 0.0)) -
|
||||
(65.0 if h == 1 else TARGETS[h])) /
|
||||
(65.0 if h == 1 else TARGETS[h]) <= CHECK_TOL
|
||||
abs(float(projection["means"].get(h, 0.0)) - TARGETS[h]) /
|
||||
TARGETS[h] <= CHECK_TOL
|
||||
for h in range(1, RUN_HOURS + 1))
|
||||
if projected_ok and abs(float(projection["total"]) - 7800.0) < 500.0:
|
||||
if projected_ok and BAND_LOW <= float(projection["total"]) <= BAND_HIGH:
|
||||
lines.append("")
|
||||
lines.append("The in-memory projection with the recommended caps and H1 "
|
||||
"target passes all nine hours and lands in the income band. "
|
||||
lines.append("The in-memory projection with the recommended caps "
|
||||
"passes all nine hours and lands in the income band. "
|
||||
"No data file was modified.")
|
||||
else:
|
||||
lines.append("**PASS** - every hour is inside +/-20% and no analytical cap is below "
|
||||
@@ -1441,7 +1474,7 @@ def build_report(runs: list[Simulation], analytical: list[dict[str, float]],
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("PEAK HOUR v2 - balance simulation (no Godot)")
|
||||
print("PEAK HOUR v3 - balance simulation (no Godot)")
|
||||
print(f"data: {BALANCE_PATH}")
|
||||
print(f"seeds: {SEEDS}, hours: {RUN_HOURS}, dt: {DT}s, "
|
||||
f"q_send={Q_SEND}, q_attack={Q_ATTACK}, protocol={PROTOCOL.upper()}")
|
||||
@@ -1513,6 +1546,7 @@ def main() -> int:
|
||||
|
||||
total_income = statistics.fmean([sum(r.income for r in sim.hourly) for sim in runs])
|
||||
total_spent = statistics.fmean([sim.total_spent for sim in runs])
|
||||
folder_spend = statistics.fmean([sim.folder_spend for sim in runs])
|
||||
money_end = statistics.fmean([sim.money for sim in runs])
|
||||
total_done = statistics.fmean([sim.requests_done for sim in runs])
|
||||
total_received = statistics.fmean([sim.files_received for sim in runs])
|
||||
@@ -1525,9 +1559,9 @@ def main() -> int:
|
||||
attacks_shielded = statistics.fmean([sim.attacks_shielded for sim in runs])
|
||||
tree_pct = total_spent / TOTAL_TREE * 100.0
|
||||
print("")
|
||||
print(f"total income: {total_income:.0f} $ (spec 7300-8500)")
|
||||
print(f"shop spend: {total_spent:.0f} $ = {tree_pct:.1f}% of 13100 $ tree (target 55-65%); "
|
||||
f"money left {money_end:.0f} $")
|
||||
print(f"total income: {total_income:.0f} $ (spec {BAND_TEXT})")
|
||||
print(f"shop spend: {total_spent:.0f} $ = {tree_pct:.1f}% of {TOTAL_TREE} $ tree "
|
||||
f"(target 55-65%); folder spend {folder_spend:.0f} $; money left {money_end:.0f} $")
|
||||
print(f"requests done: {total_done:.1f}; files received {total_received:.1f}, "
|
||||
f"sent {total_sent:.1f}, left unsent {total_left:.1f}")
|
||||
print(f"SLA end: {sla_end:.1f}; defeated runs: {defeated}")
|
||||
@@ -1545,11 +1579,11 @@ def main() -> int:
|
||||
print(" caps: " + ", ".join(f"H{h}={caps[h]}" for h in range(1, RUN_HOURS + 1)))
|
||||
for hour in range(1, RUN_HOURS + 1):
|
||||
mean = float(projection["means"].get(hour, 0.0))
|
||||
target_disp = 65.0 if hour == 1 else TARGETS[hour]
|
||||
target_disp = TARGETS[hour]
|
||||
dev = (mean - target_disp) / target_disp * 100.0
|
||||
ok = abs(dev) <= CHECK_TOL * 100.0
|
||||
print(f" H{hour}: mean {mean:.0f} vs target {target_disp:.0f}"
|
||||
f"{'*' if hour == 1 else ''} ({dev:+.1f}%) {'PASS' if ok else 'FAIL'}")
|
||||
f" ({dev:+.1f}%) {'PASS' if ok else 'FAIL'}")
|
||||
print(f" total {float(projection['total']):.0f} $; "
|
||||
f"spend {float(projection['spend']):.0f} $ "
|
||||
f"({float(projection['spend']) / TOTAL_TREE * 100.0:.1f}% of tree)")
|
||||
@@ -1572,23 +1606,20 @@ def main() -> int:
|
||||
if abs(mean - target) / target <= CHECK_TOL:
|
||||
continue
|
||||
cap = int(HOURS[hour]["request_cap"])
|
||||
if hour == 1:
|
||||
print(f" H1: keep cap 2, income_target 90 -> 65 (or +30% H1 base_pay); "
|
||||
f"mean {mean:.0f} vs target {target:.0f}")
|
||||
continue
|
||||
suggested = int(clamp(round(cap * target / max(1.0, mean)), 1,
|
||||
int(HOURS[hour]["files"])))
|
||||
if caps:
|
||||
suggested = int(caps.get(hour, suggested))
|
||||
print(f" H{hour}: cap {cap} -> {suggested} (mean {mean:.0f} vs target {target:.0f})")
|
||||
if total_income < 7300.0 or total_income > 8500.0:
|
||||
cap_failures = [h for h in hour_means if h != 1 and
|
||||
abs(hour_means[h] - TARGETS[h]) / TARGETS[h] > CHECK_TOL]
|
||||
if total_income < BAND_LOW or total_income > BAND_HIGH:
|
||||
cap_failures = [h for h in hour_means
|
||||
if abs(hour_means[h] - TARGETS[h]) / TARGETS[h] > CHECK_TOL]
|
||||
if cap_failures:
|
||||
print(" total: outside 7300-8500 mainly due to H3-H8 caps; "
|
||||
print(f" total: outside {BAND_TEXT} mainly due to hourly caps; "
|
||||
"re-run after cap changes before scaling base_pay")
|
||||
else:
|
||||
print(f" total: scale base_pay by x{7800.0 / max(1.0, total_income):.2f} "
|
||||
print(f" total: scale base_pay by "
|
||||
f"x{(BAND_LOW + BAND_HIGH) * 0.5 / max(1.0, total_income):.2f} "
|
||||
f"or adjust targets")
|
||||
if tree_pct < 55.0 or tree_pct > 65.0:
|
||||
ceiling = (total_income - statistics.fmean(
|
||||
|
||||
@@ -220,7 +220,8 @@ func _test_shield_auto_block() -> void:
|
||||
_attack_shows = 0
|
||||
_resolved_events.clear()
|
||||
var row := _tier_row("ddos", ECONOMY.attack_tier(2))
|
||||
var expected := int(round(float(row.get("reward_money", 0)) * _money_scale(2) * 0.5))
|
||||
var shield_scale := _shield_scale()
|
||||
var expected := int(round(float(row.get("reward_money", 0)) * _money_scale(2) * shield_scale))
|
||||
var money_before: int = _game.money
|
||||
var sla_before: float = _game.sla
|
||||
var id := str(ad.spawn_attack("ddos"))
|
||||
@@ -230,9 +231,10 @@ func _test_shield_auto_block() -> void:
|
||||
_ok(not _hd.is_attack_paused(), "щит не ставит час на паузу")
|
||||
_eq(int(ad.shield_charges), 0, "заряд израсходован")
|
||||
_eq(int(_game.shield_charges), 0, "Game.shield_charges синхронизирован")
|
||||
_eq(_game.money, money_before + expected, "50% награды начислено")
|
||||
_approx(_game.sla, sla_before + float(row.get("reward_sla", 0)) * 0.5, 0.001,
|
||||
"50% SLA-награды")
|
||||
_eq(_game.money, money_before + expected,
|
||||
"награда щита: x%.2f от денег атаки" % shield_scale)
|
||||
_approx(_game.sla, sla_before + float(row.get("reward_sla", 0)) * shield_scale, 0.001,
|
||||
"награда щита: x%.2f от SLA атаки" % shield_scale)
|
||||
_ok(_has_toast("ЩИТ ПОГЛОТИЛ АТАКУ"), "тост «ЩИТ ПОГЛОТИЛ АТАКУ»")
|
||||
_ok(_resolved_events.size() >= 1, "resolved(true) при щите")
|
||||
if not _resolved_events.is_empty():
|
||||
@@ -400,6 +402,15 @@ func _money_scale(hour: int) -> float:
|
||||
return scale
|
||||
|
||||
|
||||
## Множитель награды щита — из balance.json → attacks.shield_reward_scale.
|
||||
func _shield_scale() -> float:
|
||||
if _data != null and _data.get("balance") is Dictionary:
|
||||
var attacks = (_data.balance as Dictionary).get("attacks", null)
|
||||
if attacks is Dictionary:
|
||||
return maxf(0.0, float((attacks as Dictionary).get("shield_reward_scale", 0.5)))
|
||||
return 0.5
|
||||
|
||||
|
||||
func _new_file(name: String, type: String):
|
||||
var section = _game.registry.sections.get("C")
|
||||
if section == null or section.folder_ids.is_empty():
|
||||
|
||||
@@ -9,6 +9,19 @@ extends SceneTree
|
||||
const GC := preload("res://scripts/game_controller.gd")
|
||||
const SM := preload("res://scripts/systems/save_system.gd")
|
||||
|
||||
|
||||
## Минимальная заглушка мини-игры отправки: только сигнал finished.
|
||||
class FakeSend:
|
||||
extends Node
|
||||
signal finished(quality: float)
|
||||
|
||||
|
||||
## Заглушка ConnectionGame для проверки промаха: Main читает `missed`.
|
||||
class FakeConnection:
|
||||
extends Node
|
||||
var missed := true
|
||||
|
||||
|
||||
var _checks: int = 0
|
||||
var _fails: int = 0
|
||||
var _case: String = ""
|
||||
@@ -52,6 +65,7 @@ func _init() -> void:
|
||||
|
||||
_test_empty_continue()
|
||||
_test_new_run()
|
||||
_test_second_new_run_seed()
|
||||
_test_table_return_success()
|
||||
_test_table_return_block()
|
||||
_test_hour_start_and_modal()
|
||||
@@ -61,6 +75,9 @@ func _init() -> void:
|
||||
_test_reroll_reset()
|
||||
_test_full_run()
|
||||
_test_save_load()
|
||||
_test_send_modal_freeze()
|
||||
await _test_destroy_reserved_folder()
|
||||
await _test_connection_failure_toast()
|
||||
_finish()
|
||||
|
||||
|
||||
@@ -93,6 +110,48 @@ func _test_new_run() -> void:
|
||||
_ok(sections is Array and not (sections as Array).is_empty(), "есть секции")
|
||||
|
||||
|
||||
## BUG A: второй «Новый забег» должен иметь seed в подготовке, как первый.
|
||||
## Регрессия: reset_for_prep() сбрасывал requests_spawned ПОСЛЕ start_hour(1),
|
||||
## поэтому seed H1 создавался только в первом забеге.
|
||||
func _test_second_new_run_seed() -> void:
|
||||
_set_case("second_new_run_seed")
|
||||
_c.new_run(9001)
|
||||
_eq(String(_c.phase), "prep", "забег 1: new_run → prep")
|
||||
_eq(_game.registry.files.size(), 1, "забег 1: в подготовке 1 seed-файл")
|
||||
_ok(_c.start_hour(), "забег 1: старт H1")
|
||||
var hd = _c.hour_director
|
||||
hd.set_time_scale(1.0)
|
||||
var h1_req := false
|
||||
for i in range(50):
|
||||
_c.tick(0.1)
|
||||
if not hd.active_requests.is_empty():
|
||||
h1_req = true
|
||||
break
|
||||
_ok(h1_req, "забег 1: H1 выдал запрос")
|
||||
_ok(int(hd.requests_spawned) > 0, "забег 1: requests_spawned > 0 после часа")
|
||||
|
||||
_set_case("second_new_run_seed/run2")
|
||||
_c.new_run(9002)
|
||||
_eq(String(_c.phase), "prep", "забег 2: new_run → prep")
|
||||
_eq(_game.registry.files.size(), 1, "забег 2: в подготовке 1 seed-файл")
|
||||
var seed_files: Array = _game.registry.files.values()
|
||||
_ok(seed_files.size() == 1 and str(seed_files[0].required_by) == "",
|
||||
"забег 2: seed без required_by")
|
||||
_ok(hd.active_requests.is_empty(), "забег 2: нет активных запросов")
|
||||
_ok(seed_files.size() == 1 and String(seed_files[0].location) == "folder",
|
||||
"забег 2: seed лежит в папке")
|
||||
|
||||
_ok(_c.start_hour(), "забег 2: старт H1")
|
||||
_eq(_game.registry.files.size(), 1, "забег 2: старт H1 не создал второй seed")
|
||||
var h1_req2 := false
|
||||
for i in range(50):
|
||||
_c.tick(0.1)
|
||||
if not hd.active_requests.is_empty():
|
||||
h1_req2 = true
|
||||
break
|
||||
_ok(h1_req2, "забег 2: H1 снова выдал запрос")
|
||||
|
||||
|
||||
func _test_table_return_success() -> void:
|
||||
_set_case("table_return_ok")
|
||||
_c.new_run(11)
|
||||
@@ -157,8 +216,9 @@ func _test_hour_start_and_modal() -> void:
|
||||
_ok(not packet_id.is_empty(), "пакет создан")
|
||||
_c.tick(0.1)
|
||||
var hd_data: Dictionary = _c.hour_data()
|
||||
_eq((hd_data.get("packets", []) as Array).size(), 1, "hour_data: 1 пакет")
|
||||
_eq((hd_data.get("requests", []) as Array).size(), 1, "hour_data: 1 запрос")
|
||||
# H1-бутстрап: к этому моменту уже есть стартовый пакет и запрос.
|
||||
_eq((hd_data.get("packets", []) as Array).size(), 2, "hour_data: 2 пакета (ботстрап + тест)")
|
||||
_eq((hd_data.get("requests", []) as Array).size(), 2, "hour_data: 2 запроса (ботстрап + тест)")
|
||||
var req_data: Dictionary = (hd_data.get("requests", []) as Array)[0]
|
||||
_eq(int(req_data.get("required_count", 0)), 1, "hour_data: required_count")
|
||||
_eq(int(req_data.get("missing", -1)), 1, "hour_data: missing = 1")
|
||||
@@ -301,6 +361,169 @@ func _test_save_load() -> void:
|
||||
_ok(int(_game.hour) >= 1, "continue_run восстановил час")
|
||||
|
||||
|
||||
## BUG 1: открытие мини-игры отправки через контроллер обязано поднять
|
||||
## modal_open и в HourDirector; пакетный таймер стоит под модалкой.
|
||||
func _test_send_modal_freeze() -> void:
|
||||
_set_case("send_modal_freeze")
|
||||
_c.new_run(731)
|
||||
_ok(_c.start_hour(), "старт часа для модалки отправки")
|
||||
var hd = _c.hour_director
|
||||
_c.tick(0.1)
|
||||
var packet_id := ""
|
||||
if not hd.active_packets.is_empty():
|
||||
packet_id = str(hd.active_packets.keys()[0])
|
||||
_ok(not packet_id.is_empty(), "пакет для проверки таймера есть")
|
||||
var packet = hd.get_packet(packet_id)
|
||||
var folder_id := _first_folder_id()
|
||||
var file = _game.registry.create_file("Модалка отправки", "text", folder_id)
|
||||
_ok(file != null, "файл создан")
|
||||
if file == null or packet == null:
|
||||
return
|
||||
var req_id := str(hd._create_request(1, "udp", [str(file.id)], true))
|
||||
_ok(not req_id.is_empty(), "UDP-запрос создан")
|
||||
_ok(hd.attach_file(req_id, str(file.id)), "файл прикреплён")
|
||||
var holder := {"game": null}
|
||||
hd.send_game_factory = func(_id, _tier, _seed, _ctx):
|
||||
var g := FakeSend.new()
|
||||
holder["game"] = g
|
||||
return g
|
||||
_ok(hd.start_send(req_id), "мини-игра отправки открыта")
|
||||
_ok(bool(_c.modal_open), "controller.modal_open = true")
|
||||
_ok(bool(hd.modal_open), "director.modal_open = true (BUG 1)")
|
||||
var t_before := float(packet.time_left)
|
||||
for i in range(10):
|
||||
_c.tick(0.1)
|
||||
_approx(float(packet.time_left), t_before, 0.0001,
|
||||
"пакетный таймер заморожен под мини-игрой отправки")
|
||||
var fake = holder.get("game")
|
||||
_ok(fake != null, "подменная мини-игра создана")
|
||||
if fake != null:
|
||||
fake.finished.emit(0.8)
|
||||
await process_frame
|
||||
_ok(not bool(_c.modal_open), "controller.modal_open = false после закрытия")
|
||||
_ok(not bool(hd.modal_open), "director.modal_open = false после закрытия")
|
||||
t_before = float(packet.time_left)
|
||||
for i in range(10):
|
||||
_c.tick(0.1)
|
||||
_ok(float(packet.time_left) < t_before, "пакетный таймер снова идёт после закрытия")
|
||||
hd._cleanup_request(req_id, true)
|
||||
hd._remove_packet(packet_id)
|
||||
hd.send_game_factory = Callable()
|
||||
|
||||
|
||||
## BUG 3c: папка, где остался только зарезервированный файл, не уничтожается
|
||||
## и НЕ списывает SLA. Обычные файлы в той же папке по-прежнему удаляются.
|
||||
func _test_destroy_reserved_folder() -> void:
|
||||
_set_case("destroy_reserved_folder")
|
||||
var packed := load("res://scenes/Main.tscn") as PackedScene
|
||||
if packed == null:
|
||||
_ok(false, "Main.tscn загружается")
|
||||
return
|
||||
var main = packed.instantiate()
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
main._start_new_run()
|
||||
await process_frame
|
||||
var folder_id := ""
|
||||
var section = _game.registry.sections.get("C")
|
||||
if section != null and section.folder_ids.size() >= 2:
|
||||
folder_id = str(section.folder_ids[1])
|
||||
_ok(not folder_id.is_empty(), "вторая папка для теста найдена")
|
||||
if folder_id.is_empty():
|
||||
return
|
||||
var reserved = _game.registry.create_file("Резерв destroy", "text", folder_id)
|
||||
_ok(reserved != null, "зарезервированный файл создан")
|
||||
if reserved == null:
|
||||
return
|
||||
var rid := str(reserved.id)
|
||||
reserved.required_by = "req_destroy_test"
|
||||
var sla_before := float(_game.sla)
|
||||
main._destroy_folder_contents(folder_id)
|
||||
_ok(_game.registry.files.has(rid), "зарезервированный файл пережил уничтожение папки")
|
||||
_approx(float(_game.sla), sla_before, 0.001,
|
||||
"папка только из резерва: SLA не списан")
|
||||
_eq(str(_game.registry.files[rid].location), "folder",
|
||||
"зарезервированный файл не помечен malicious")
|
||||
|
||||
# Контроль: обычный файл рядом — уничтожается, SLA −5.
|
||||
var normal = _game.registry.create_file("Обычный destroy", "text", folder_id)
|
||||
_ok(normal != null, "обычный файл создан")
|
||||
if normal == null:
|
||||
return
|
||||
main._destroy_folder_contents(folder_id)
|
||||
_ok(not _game.registry.files.has(str(normal.id)), "обычный файл уничтожен")
|
||||
_ok(_game.registry.files.has(rid), "резерв остался нетронутым")
|
||||
_approx(float(_game.sla), sla_before - 5.0, 0.001,
|
||||
"реальное удаление списало SLA −5")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
## BUG B: неуспешное TCP-соединение даёт РОВНО один тост
|
||||
## «СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО» (от HourDirector), а не два (директор + Main).
|
||||
func _test_connection_failure_toast() -> void:
|
||||
_set_case("connection_failure_toast")
|
||||
var packed := load("res://scenes/Main.tscn") as PackedScene
|
||||
if packed == null:
|
||||
_ok(false, "Main.tscn загружается")
|
||||
return
|
||||
var main = packed.instantiate()
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
main._start_new_run()
|
||||
await process_frame
|
||||
var director = main.controller.hour_director
|
||||
if director == null:
|
||||
_ok(false, "hour_director доступен")
|
||||
main.queue_free()
|
||||
return
|
||||
var folder_id := ""
|
||||
for section in _game.registry.sections.values():
|
||||
if not section.folder_ids.is_empty():
|
||||
folder_id = str(section.folder_ids[0])
|
||||
break
|
||||
_ok(not folder_id.is_empty(), "папка для TCP-запроса найдена")
|
||||
if folder_id.is_empty():
|
||||
main.queue_free()
|
||||
return
|
||||
main._on_start_hour()
|
||||
await process_frame
|
||||
_eq(String(main.controller.phase), "hour", "час запущен (сигналы UI подключены)")
|
||||
var file = _game.registry.create_file("conn_fail", "text", folder_id)
|
||||
_ok(file != null, "файл для TCP-запроса создан")
|
||||
if file == null:
|
||||
main.queue_free()
|
||||
return
|
||||
var rid := str(director._create_request(1, "tcp", [str(file.id)], false))
|
||||
_ok(not rid.is_empty(), "TCP-запрос создан")
|
||||
var req = director.active_requests.get(rid)
|
||||
if req == null:
|
||||
_ok(false, "TCP-запрос активен")
|
||||
main.queue_free()
|
||||
return
|
||||
req.connecting = true
|
||||
var fail_before := _count_toasts(main, "СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО")
|
||||
var sla_before := float(_game.sla)
|
||||
var fake := FakeConnection.new()
|
||||
main._on_connection_finished(false, rid, fake)
|
||||
await process_frame
|
||||
_eq(_count_toasts(main, "СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО") - fail_before, 1,
|
||||
"неуспешное соединение: ровно 1 тост (BUG B)")
|
||||
_approx(float(_game.sla), sla_before - 4.0, 0.001,
|
||||
"штраф за промах −4 SLA сохранён")
|
||||
|
||||
# Успешный тост Main остаётся (директор его не дублирует).
|
||||
req.connecting = true
|
||||
var ok_before := _count_toasts(main, "СОЕДИНЕНИЕ УСТАНОВЛЕНО")
|
||||
main._on_connection_finished(true, rid, null)
|
||||
await process_frame
|
||||
_eq(_count_toasts(main, "СОЕДИНЕНИЕ УСТАНОВЛЕНО") - ok_before, 1,
|
||||
"успешное соединение: тост Main сохранён")
|
||||
director._cleanup_request(rid, true)
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
|
||||
func _finish_current_hour() -> void:
|
||||
@@ -344,6 +567,17 @@ func _has_toast(fragment: String) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
## Считает реально показанные UI-тосты Main, содержащие fragment.
|
||||
func _count_toasts(main, fragment: String) -> int:
|
||||
var count := 0
|
||||
if main == null or main.toast_layer == null:
|
||||
return count
|
||||
for item in main.toast_layer._items:
|
||||
if is_instance_valid(item) and str(item.txt).contains(fragment):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# --- обработчики -----------------------------------------------------------
|
||||
|
||||
func _on_hour_finished(report: Dictionary) -> void:
|
||||
|
||||
@@ -43,7 +43,7 @@ func _test_data() -> void:
|
||||
var video: Dictionary = data.type_def("video")
|
||||
_eq(int(video.get("cells", 0)), 4, "video: 4 клетки")
|
||||
_approx(float(video.get("base_time_sec", 0.0)), 12.0, EPS, "video: base_time_sec 12")
|
||||
_approx(float(video.get("base_pay", 0.0)), 80.0, EPS, "video: base_pay 80")
|
||||
_approx(float(video.get("base_pay", 0.0)), 69.0, EPS, "video: base_pay 69")
|
||||
_approx(float(video.get("weight", 0.0)), 8.0, EPS, "video: weight 8")
|
||||
_approx(float(video.get("minigame_coeff", 0.0)), 8.0, EPS, "video: coeff 8")
|
||||
_approx(float(video.get("sla_loss", 0.0)), 16.0, EPS, "video: sla_loss 16")
|
||||
@@ -52,23 +52,25 @@ func _test_data() -> void:
|
||||
_eq(int(data.type_def("nope").get("cells", 0)), 1, "неизвестный тип → text (merge defaults)")
|
||||
|
||||
var h1: Dictionary = data.hour_def(1)
|
||||
_eq(int(h1.get("files", 0)), 4, "H1: 4 файла")
|
||||
_eq(int(h1.get("request_cap", 0)), 2, "H1: кап 2 запроса")
|
||||
_eq(int(h1.get("active_requests", 0)), 1, "H1: 1 активный запрос")
|
||||
_eq(int(h1.get("files", 0)), 7, "H1: 7 файлов")
|
||||
_eq(int(h1.get("request_cap", 0)), 6, "H1: кап 6 запросов")
|
||||
_eq(int(h1.get("active_requests", 0)), 2, "H1: 2 активных запроса")
|
||||
_eq(int(h1.get("attacks", 0)), 0, "H1: 0 атак")
|
||||
_approx(float(h1.get("income_target", 0.0)), 65.0, EPS, "H1: цель дохода 65")
|
||||
_approx(float(h1.get("income_target", 0.0)), 150.0, EPS, "H1: цель дохода 150")
|
||||
_approx(float(h1.get("send_interval", 0.0)), 12.0, EPS, "H1: send_interval 12")
|
||||
var w1: Dictionary = h1.get("type_weights", {})
|
||||
_approx(float(w1.get("text", 0.0)), 0.70, EPS, "H1: text 70%")
|
||||
_approx(float(w1.get("sound", 0.0)), 0.30, EPS, "H1: sound 30%")
|
||||
_approx(float(w1.get("photo", 0.0)), 0.0, EPS, "H1: photo 0%")
|
||||
_approx(float(w1.get("video", 0.0)), 0.0, EPS, "H1: video 0%")
|
||||
var m1: Dictionary = h1.get("request_mix", {})
|
||||
_approx(float(m1.get("1", 0.0)), 0.70, EPS, "H1: 1 файл 70%")
|
||||
_approx(float(m1.get("2", 0.0)), 0.30, EPS, "H1: 2 файла 30%")
|
||||
_approx(float(m1.get("1", 0.0)), 0.76, EPS, "H1: 1 файл 76%")
|
||||
_approx(float(m1.get("2", 0.0)), 0.24, EPS, "H1: 2 файла 24%")
|
||||
_approx(float(m1.get("3", 0.0)), 0.0, EPS, "H1: 3 файла 0%")
|
||||
_approx(float(m1.get("4", 0.0)), 0.0, EPS, "H1: 4 файла 0%")
|
||||
_eq(int(data.hour_def(2).get("files", 0)), 8, "H2: 8 файлов")
|
||||
_eq(int(data.hour_def(9).get("files", 0)), 20, "H9: 20 файлов")
|
||||
_eq(int(data.hour_def(9).get("request_cap", 0)), 8, "H9: кап 8 запросов")
|
||||
_eq(int(data.hour_def(9).get("request_cap", 0)), 7, "H9: кап 7 запросов")
|
||||
_eq(int(data.hour_def(100).get("hour", 0)), 9, "hour > 9 → последняя строка")
|
||||
_eq(data.hours.size(), 9, "9 строк бюджета часов")
|
||||
|
||||
@@ -94,7 +96,7 @@ func _test_data() -> void:
|
||||
_eq(lot_prices.size(), int(lot.get("max_level", 0)), "prices %s по числу уровней" % str(expected_ids[i]))
|
||||
for price in lot_prices:
|
||||
prices_sum += int(price)
|
||||
_eq(prices_sum, 13100, "сумма цен всех лотов = 13 100")
|
||||
_eq(prices_sum, 8085, "сумма цен всех лотов = 8 085")
|
||||
_eq(str(data.lot_def("lot_speed").get("effect", "")), "transfer_speed", "lot_speed effect")
|
||||
_eq(str(data.lot_def("lot_channel").get("effect", "")), "channel_capacity", "lot_channel effect")
|
||||
_ok(data.lot_def("lot_nope").is_empty(), "неизвестный лот → {}")
|
||||
@@ -105,13 +107,17 @@ func _test_data() -> void:
|
||||
_eq((rules.get("reroll_costs", []) as Array).size(), 3, "3 цены реролла")
|
||||
|
||||
var dots: Dictionary = data.minigame_def("dots")
|
||||
_eq((dots.get("tiers", []) as Array).size(), 4, "dots: 4 ступени")
|
||||
for gid in ["dots", "sound", "wires", "text", "video", "photo", "maze", "junk", "ddos", "phishing", "ransomware"]:
|
||||
_eq((dots.get("tiers", []) as Array).size(), 3, "dots: 3 ступени")
|
||||
for gid in ["dots", "sound", "text", "video", "photo", "maze", "junk", "ddos", "phishing", "ransomware"]:
|
||||
_ok(not data.minigame_def(gid).is_empty(), "мини-игра %s есть" % gid)
|
||||
_ok(not (data.minigames.get("games", {}) as Dictionary).has("wires"), "wires удалена")
|
||||
var sel: Dictionary = data.minigame_selection()
|
||||
_eq((sel.get("order", []) as Array).size(), 7, "send order: 7 игр")
|
||||
_eq((sel.get("order", []) as Array).size(), 6, "send order: 6 игр")
|
||||
var sums: Array = sel.get("achievable_sums", [])
|
||||
_eq(sums.size(), 19, "19 достижимых сумм")
|
||||
_eq(sums.size(), 27, "27 достижимых сумм (1-4 файла)")
|
||||
_eq(int(sel.get("max_index", -1)), 26, "max_index = 26")
|
||||
_eq((sel.get("rank_games", []) as Array).size(), 27, "rank_games: 27 значений")
|
||||
_eq((sel.get("tier_max_rank", []) as Array).size(), 2, "tier_max_rank: 2 границы")
|
||||
var coeff: Dictionary = sel.get("coeff", {})
|
||||
_eq(int(coeff.get("video", 0)), 8, "coeff video = 8 в selection")
|
||||
_eq((data.attack_def("ddos").get("tiers", []) as Array).size(), 4, "ddos: 4 ступени")
|
||||
@@ -169,14 +175,14 @@ func _test_levels_and_caps() -> void:
|
||||
|
||||
game.set_lot_level("lot_speed", 6)
|
||||
_eq(game.level("speed_level"), 6, "speed_level = 6")
|
||||
_approx(game.speed_mult(), 0.606355, 0.0005, "pow(0.92, 6) ≈ 0.606")
|
||||
_approx(E.speed_mult(6), pow(0.92, 6.0), EPS, "speed_mult = pow(0.92, 6) без ^")
|
||||
_approx(game.speed_mult(), 0.531441, 0.0005, "pow(0.90, 6) ≈ 0.531")
|
||||
_approx(E.speed_mult(6), pow(0.90, 6.0), EPS, "speed_mult = pow(0.90, 6) без ^")
|
||||
|
||||
game.set_lot_level("lot_sla", 4)
|
||||
_approx(game.sla_cap(), 200.0, EPS, "4 уровня ИБП → кап 200")
|
||||
_approx(game.sla, 200.0, EPS, "ИБП сразу добавляет +35 за уровень (60 → 200)")
|
||||
_approx(game.sla_cap(), 220.0, EPS, "4 уровня ИБП → кап 220")
|
||||
_approx(game.sla, 220.0, EPS, "ИБП сразу добавляет +40 за уровень (60 → 220)")
|
||||
game.add_sla(1000.0)
|
||||
_approx(game.sla, 200.0, EPS, "add_sla не выше динамического капа 200")
|
||||
_approx(game.sla, 220.0, EPS, "add_sla не выше динамического капа 220")
|
||||
|
||||
game.set_lot_level("lot_money", 8)
|
||||
_approx(game.money_mult(), 1.4, EPS, "8 уровней контракта → ×1.4")
|
||||
@@ -184,7 +190,7 @@ func _test_levels_and_caps() -> void:
|
||||
|
||||
game.set_lot_level("lot_folder", 5)
|
||||
var dims: Vector2i = game.folder_dims()
|
||||
_eq(dims.x, 8, "folder level 5: 8 столбцов")
|
||||
_eq(dims.x, 9, "folder level 5: 9 столбцов")
|
||||
_eq(dims.y, 6, "folder level 5: 6 строк")
|
||||
game.set_lot_level("lot_folder", 1)
|
||||
dims = game.folder_dims()
|
||||
@@ -196,7 +202,7 @@ func _test_levels_and_caps() -> void:
|
||||
_eq(game.buffer_limit(), 12, "6 уровней канала → буфер 12")
|
||||
|
||||
game.set_lot_level("lot_timer", 5)
|
||||
_approx(game.request_timer(), 55.0, EPS, "5 уровней таймера → 55")
|
||||
_approx(game.request_timer(), 65.0, EPS, "5 уровней таймера → 65")
|
||||
game.set_lot_level("lot_timer", 99)
|
||||
_eq(game.level("lot_timer"), 5, "уровень клампится max_level")
|
||||
|
||||
@@ -205,7 +211,7 @@ func _test_levels_and_caps() -> void:
|
||||
_eq(game.shield_charges, 3, "покупка сразу заряжает щит")
|
||||
|
||||
# Сетка уровней 0..5 из таблицы.
|
||||
var expected := [[5, 4], [6, 4], [6, 5], [7, 5], [7, 6], [8, 6]]
|
||||
var expected := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
|
||||
for lvl in range(6):
|
||||
game.set_lot_level("lot_folder", lvl)
|
||||
var d: Vector2i = game.folder_dims()
|
||||
@@ -229,9 +235,9 @@ func _test_sla_rules() -> void:
|
||||
game.sla = 90.0
|
||||
game.hour = 2
|
||||
game.regen_sla_for_hour()
|
||||
_approx(float(game.sla), 95.0, EPS, "реген не превышает кап 95")
|
||||
_approx(float(game.sla), 100.0, EPS, "реген не превышает кап 100")
|
||||
game.regen_sla_for_hour()
|
||||
_approx(float(game.sla), 95.0, EPS, "повторный реген в том же часу — no-op")
|
||||
_approx(float(game.sla), 100.0, EPS, "повторный реген в том же часу — no-op")
|
||||
|
||||
# SLA 0 во время часа = поражение забега.
|
||||
var reasons: Array = []
|
||||
@@ -266,10 +272,10 @@ func _test_channel_and_timers() -> void:
|
||||
_approx(E.packet_timer(1, {}), 20.0, EPS, "packet timer H1 = 20")
|
||||
_approx(E.packet_timer(9, {"timer_level": 5}), 20.0, EPS, "packet timer не меняется")
|
||||
_approx(E.request_timer(1, {}), 30.0, EPS, "request timer base 30")
|
||||
_approx(E.request_timer(5, {"timer_level": 1}), 35.0, EPS, "request timer +1 = 35")
|
||||
_approx(E.request_timer(9, {"timer_level": 5}), 55.0, EPS, "request timer max 55")
|
||||
_approx(E.request_timer(9, {"timer_level": 99}), 55.0, EPS, "request timer cap 55")
|
||||
_approx(E.request_timer(1, {"lot_timer": 2}), 40.0, EPS, "request timer по id лота")
|
||||
_approx(E.request_timer(5, {"timer_level": 1}), 37.0, EPS, "request timer +1 = 37")
|
||||
_approx(E.request_timer(9, {"timer_level": 5}), 65.0, EPS, "request timer max 65")
|
||||
_approx(E.request_timer(9, {"timer_level": 99}), 65.0, EPS, "request timer cap 65")
|
||||
_approx(E.request_timer(1, {"lot_timer": 2}), 44.0, EPS, "request timer по id лота")
|
||||
|
||||
|
||||
func _test_transfer_time() -> void:
|
||||
@@ -278,14 +284,14 @@ func _test_transfer_time() -> void:
|
||||
_approx(E.recv_transfer_time({"base_time_sec": 5}, 1.0, 0), 5.0, EPS, "sound 5 с")
|
||||
_approx(E.recv_transfer_time({"base_time_sec": 8}, 1.0, 0), 8.0, EPS, "photo 8 с")
|
||||
_approx(E.recv_transfer_time({"base_time_sec": 12}, 1.0, 0), 12.0, EPS, "video 12 с")
|
||||
_approx(E.recv_transfer_time({"base_time_sec": 12}, 1.0, 1), 12.0 * 0.92, EPS, "speed 1 → ×0.92")
|
||||
_approx(E.recv_transfer_time({"base_time_sec": 12}, 1.0, 1), 12.0 * 0.90, EPS, "speed 1 → ×0.90")
|
||||
_approx(E.recv_transfer_time({"base_time_sec": 12}, 2.0, 0), 24.0, EPS, "channel factor 2")
|
||||
_approx(E.recv_transfer_time({"base_time_sec": 8}, 1.0, 2), 8.0 * pow(0.92, 2.0), EPS, "speed 2")
|
||||
_approx(E.recv_transfer_time({"base_time_sec": 8}, 1.0, 2), 8.0 * pow(0.90, 2.0), EPS, "speed 2")
|
||||
_approx(E.send_transfer_time({"base_time_sec": 12}, 1.0, 0, "tcp"), 12.0, EPS, "TCP ×1.0")
|
||||
_approx(E.send_transfer_time({"base_time_sec": 12}, 1.0, 0, "udp"), 9.6, EPS, "UDP ×0.8")
|
||||
_approx(E.send_transfer_time({"base_time_sec": 12}, 1.0, 0, &"UDP"), 9.6, EPS, "UDP StringName")
|
||||
_approx(E.recv_time({"base_time_sec": 8}, 1.0, {"speed_level": 2}), 8.0 * pow(0.92, 2.0), EPS, "legacy recv_time + speed")
|
||||
_approx(E.send_time({"base_time_sec": 12}, 1.0, {"speed_level": 1}), 12.0 * 0.92, EPS, "legacy send_time")
|
||||
_approx(E.recv_time({"base_time_sec": 8}, 1.0, {"speed_level": 2}), 8.0 * pow(0.90, 2.0), EPS, "legacy recv_time + speed")
|
||||
_approx(E.send_time({"base_time_sec": 12}, 1.0, {"speed_level": 1}), 12.0 * 0.90, EPS, "legacy send_time")
|
||||
_approx(E.recv_time({"recv_time": 2.0}, 1.0, {}), 2.0, EPS, "legacy поле recv_time")
|
||||
|
||||
|
||||
@@ -293,76 +299,97 @@ func _test_payout() -> void:
|
||||
_set_case("payout")
|
||||
# 100 × 0.8 × 1.0 × 1.15 × 1.15 × 1.1 = 116.38 → 116
|
||||
_eq(E.payout(100.0, 2, 15.0, 30.0, 1.0, "tcp", 5, {}), 116, "полный TCP-пример (multi 1.15)")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 0.5, "udp", 0, {}), 75, "quality 0.5, udp")
|
||||
_eq(E.payout(100.0, 3, 30.0, 30.0, 1.0, "udp", 0, {}), 130, "multi n=3 (1.30)")
|
||||
_eq(E.payout(100.0, 1, 0.0, 30.0, 1.0, "udp", 0, {}), 60, "speed минимум 0.6")
|
||||
_eq(E.payout(100.0, 1, 60.0, 30.0, 1.0, "udp", 0, {}), 100, "time ratio клампится 1")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 0.5, "udp", 0, {}), 64, "quality 0.5, udp ×0.85")
|
||||
_eq(E.payout(100.0, 3, 30.0, 30.0, 1.0, "udp", 0, {}), 111, "multi n=3 (1.30) × udp 0.85")
|
||||
_eq(E.payout(100.0, 1, 0.0, 30.0, 1.0, "udp", 0, {}), 51, "speed минимум 0.6")
|
||||
_eq(E.payout(100.0, 1, 60.0, 30.0, 1.0, "udp", 0, {}), 85, "time ratio клампится 1")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "TCP", 10, {}), 138, "TCP + streak 10")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, &"udp", 10, {}), 120, "streak +20%")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 50, {}), 120, "streak cap 10")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, &"udp", 10, {}), 102, "streak +20% × udp 0.85")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 50, {}), 102, "streak cap 10")
|
||||
_eq(E.payout(100.0, 0, 30.0, 30.0, 1.0, "udp", 0, {}), 0, "n=0 → 0")
|
||||
_eq(E.payout(0.0, 1, 30.0, 30.0, 1.0, "udp", 0, {}), 0, "base 0 → 0")
|
||||
_eq(E.payout(100.0, 1, 30.0, 0.0, 1.0, "udp", 0, {}), 100, "timer_total 0 → полная скорость")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 2.0, "udp", 0, {}), 100, "quality клампится 1")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"money_level": 4}), 120, "money_mult +4 = ×1.2")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"money_level": 8}), 140, "money_mult +8 = ×1.4")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"lot_money": 2}), 110, "money_mult по id лота")
|
||||
_eq(E.payout(100.0, 1, 30.0, 0.0, 1.0, "udp", 0, {}), 85, "timer_total 0 → полная скорость")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 2.0, "udp", 0, {}), 85, "quality клампится 1")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"money_level": 4}), 102, "money_mult +4 = ×1.2")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"money_level": 8}), 119, "money_mult +8 = ×1.4")
|
||||
_eq(E.payout(100.0, 1, 30.0, 30.0, 1.0, "udp", 0, {"lot_money": 2}), 94, "money_mult по id лота")
|
||||
|
||||
# Float-точность: 1/3 и 2/3 не должны стать int/int.
|
||||
# 100 × (0.6 + 0.4/3) × 0.5 = 36.667 → 37 (при int/int было бы 30).
|
||||
_eq(E.payout(100.0, 1, 1.0, 3.0, 0.0, "udp", 0, {}), 37, "time ratio 1/3 — float")
|
||||
# 100 × (0.6 + 0.8/3) × 0.5 = 43.333 → 43.
|
||||
_eq(E.payout(100.0, 1, 2.0, 3.0, 0.0, "udp", 0, {}), 43, "time ratio 2/3 — float")
|
||||
# 100 × (0.6 + 0.4/3) × 0.5 × 0.85 = 31.167 → 31.
|
||||
_eq(E.payout(100.0, 1, 1.0, 3.0, 0.0, "udp", 0, {}), 31, "time ratio 1/3 — float")
|
||||
# 100 × (0.6 + 0.8/3) × 0.5 × 0.85 = 36.833 → 37.
|
||||
_eq(E.payout(100.0, 1, 2.0, 3.0, 0.0, "udp", 0, {}), 37, "time ratio 2/3 — float")
|
||||
|
||||
|
||||
func _test_send_minigame() -> void:
|
||||
_set_case("send_minigame")
|
||||
var m: Dictionary = E.send_minigame_for(["text"])
|
||||
var m: Dictionary = E.send_minigame_for([])
|
||||
_eq(int(m.get("sum", -1)), 0, "S=0")
|
||||
_eq(str(m.get("id", "")), "dots", "S=0 → dots")
|
||||
_eq(int(m.get("tier", 0)), 1, "S=0 → T1")
|
||||
m = E.send_minigame_for(["text"])
|
||||
_eq(int(m.get("sum", 0)), 1, "S=1")
|
||||
_eq(str(m.get("id", "")), "dots", "S=1 → dots")
|
||||
_eq(str(m.get("id", "")), "text", "S=1 → text")
|
||||
_eq(int(m.get("tier", 0)), 1, "S=1 → T1")
|
||||
_eq(int(m.get("index", -1)), 0, "S=1 → rank 0")
|
||||
m = E.send_minigame_for(["sound"])
|
||||
_eq(int(m.get("sum", 0)), 2, "S=2")
|
||||
_eq(str(m.get("id", "")), "sound", "S=2 → sound")
|
||||
_eq(int(m.get("tier", 0)), 1, "S=2 → T1")
|
||||
m = E.send_minigame_for(["text", "sound"])
|
||||
_eq(int(m.get("sum", 0)), 3, "S=3")
|
||||
_eq(str(m.get("id", "")), "wires", "S=3 → wires")
|
||||
m = E.send_minigame_for(["TEXT", "Sound"])
|
||||
_eq(int(m.get("sum", 0)), 3, "S=3 (types lowercase)")
|
||||
_eq(str(m.get("id", "")), "dots", "S=3 → dots")
|
||||
_eq(int(m.get("tier", 0)), 1, "S=3 → T1")
|
||||
m = E.send_minigame_for(["photo"])
|
||||
_eq(int(m.get("sum", 0)), 4, "S=4")
|
||||
_eq(str(m.get("id", "")), "text", "S=4 → text")
|
||||
_eq(str(m.get("id", "")), "photo", "S=4 → photo")
|
||||
_eq(int(m.get("tier", 0)), 1, "S=4 → T1")
|
||||
m = E.send_minigame_for(["video"])
|
||||
_eq(int(m.get("sum", 0)), 8, "S=8")
|
||||
_eq(str(m.get("id", "")), "dots", "S=8 → dots")
|
||||
_eq(int(m.get("tier", 0)), 2, "S=8 → T2")
|
||||
_eq(str(m.get("id", "")), "video", "S=8 → video")
|
||||
_eq(int(m.get("tier", 0)), 1, "S=8 → T1 (rank 7)")
|
||||
_eq(int(m.get("index", -1)), 7, "S=8 → rank 7")
|
||||
m = E.send_minigame_for(["video", "text"])
|
||||
_eq(int(m.get("sum", 0)), 9, "S=9")
|
||||
_eq(int(m.get("tier", 0)), 2, "S=9 → T2")
|
||||
_eq(str(m.get("id", "")), "dots", "S=9 → dots (rank 8)")
|
||||
m = E.send_minigame_for(["video", "video", "video"])
|
||||
_eq(int(m.get("sum", 0)), 24, "S=24")
|
||||
_eq(int(m.get("tier", 0)), 4, "S=24 → T4")
|
||||
# B4: плотный список достижимых сумм, rank 18 → order[18 % 7] = video.
|
||||
_eq(str(m.get("id", "")), "video", "S=24 → video (B4 reindex)")
|
||||
_eq(int(m.get("index", -1)), 18, "S=24 → rank 18")
|
||||
_eq(int(m.get("tier", 0)), 3, "S=24 → T3")
|
||||
_eq(str(m.get("id", "")), "dots", "S=24 → dots (rank 22)")
|
||||
_eq(int(m.get("index", -1)), 22, "S=24 → rank 22")
|
||||
m = E.send_minigame_for(["video", "video", "video", "video"])
|
||||
_eq(int(m.get("sum", 0)), 32, "S=32")
|
||||
_eq(int(m.get("tier", 0)), 3, "S=32 → T3")
|
||||
_eq(int(m.get("index", -1)), 26, "S=32 → rank 26")
|
||||
# ACHIEVABLE_SUMS: ровно 27 значений для 1–4 файлов.
|
||||
_eq(E.achievable_sums().size(), 27, "27 достижимых сумм")
|
||||
var alias: Dictionary = E.selection_for_sum(8)
|
||||
_eq(str(alias.get("id", "")), "video", "selection_for_sum(8) → video")
|
||||
_eq(int(alias.get("tier", 0)), 1, "selection_for_sum(8) → T1")
|
||||
_eq(int(alias.get("index", -1)), 7, "selection_for_sum(8) → rank 7")
|
||||
|
||||
# Все 7 игр и все 4 ступени достижимы при максимум 3 файлах.
|
||||
# Все 6 игр и все 3 ступени достижимы при 1–4 файлах.
|
||||
var pool := ["text", "sound", "photo", "video"]
|
||||
var ids: Dictionary = {}
|
||||
var tiers: Dictionary = {}
|
||||
for a in range(pool.size()):
|
||||
for b in range(pool.size()):
|
||||
for c in range(pool.size()):
|
||||
for combo in [[pool[a]], [pool[a], pool[b]], [pool[a], pool[b], pool[c]]]:
|
||||
var r: Dictionary = E.send_minigame_for(combo)
|
||||
var id := str(r.get("id", ""))
|
||||
ids[id] = true
|
||||
tiers[int(r.get("tier", 0))] = true
|
||||
_ok(E.SEND_ORDER.has(id), "id %s из SEND_ORDER" % id)
|
||||
_eq(ids.size(), 7, "достижимы все 7 игр отправки")
|
||||
for t in [1, 2, 3, 4]:
|
||||
for d in range(pool.size()):
|
||||
for combo in [[pool[a]], [pool[a], pool[b]],
|
||||
[pool[a], pool[b], pool[c]],
|
||||
[pool[a], pool[b], pool[c], pool[d]]]:
|
||||
var r: Dictionary = E.send_minigame_for(combo)
|
||||
var id := str(r.get("id", ""))
|
||||
ids[id] = true
|
||||
tiers[int(r.get("tier", 0))] = true
|
||||
_ok(E.SEND_ORDER.has(id), "id %s из SEND_ORDER" % id)
|
||||
_eq(ids.size(), 6, "достижимы все 6 игр отправки")
|
||||
for t in [1, 2, 3]:
|
||||
_ok(tiers.has(t), "достижима ступень T%d" % t)
|
||||
_ok(not ids.has("junk"), "атакующие игры не выбираются для отправки")
|
||||
_ok(not ids.has("wires"), "wires больше не выбирается")
|
||||
|
||||
|
||||
func _test_attack_tier() -> void:
|
||||
|
||||
@@ -27,6 +27,8 @@ func _init() -> void:
|
||||
return
|
||||
_test_cell_sizes()
|
||||
_test_hit_test()
|
||||
_test_draw_offsets()
|
||||
_test_input_helpers()
|
||||
_test_labels_and_rotation()
|
||||
_test_payload_and_edges()
|
||||
_test_plural()
|
||||
@@ -39,6 +41,8 @@ func _init() -> void:
|
||||
_finish()
|
||||
return
|
||||
await _test_file_grid(registry, folder_id)
|
||||
await _test_rotation_preference()
|
||||
await _test_ghost_rotation()
|
||||
await _test_sorting_table(registry)
|
||||
await _test_drag_controller(folder_id)
|
||||
await _test_file_shape()
|
||||
@@ -92,6 +96,40 @@ func _test_hit_test() -> void:
|
||||
_eq(r, Vector2i(0, 1), "прямоугольный hit-test стола")
|
||||
|
||||
|
||||
func _test_draw_offsets() -> void:
|
||||
_set_case("draw offsets")
|
||||
# BUG A: раньше paint() получал _grid_origin и все файлы падали в (0,0).
|
||||
var origin := Vector2(12, 20)
|
||||
var zero := FG.shape_origin(origin, 100.0, 6.0, Vector2i.ZERO)
|
||||
var at_32 := FG.shape_origin(origin, 100.0, 6.0, Vector2i(3, 2))
|
||||
_eq(zero, Vector2(12, 20), "origin (0,0) = grid origin")
|
||||
_eq(at_32, Vector2(330, 232), "origin (3,2) = grid origin + 3/2 шага")
|
||||
_ok(zero != at_32, "разные origin дают разные позиции рисования")
|
||||
var cell_rect := FS.cell_pixel_rect(at_32, Vector2i.ZERO, 100.0, 6.0)
|
||||
_eq(cell_rect, Rect2(327, 229, 106, 106),
|
||||
"файл в (3,2) рисует клетку (3,2), а не (0,0)")
|
||||
var table_pos := FG.shape_origin_pair(Vector2(9, 11), Vector2(99, 86), 6.0,
|
||||
Vector2i(2, 4))
|
||||
_eq(table_pos, Vector2(219, 379), "стол: origin (2,4) с прямоугольной клеткой")
|
||||
|
||||
|
||||
func _test_input_helpers() -> void:
|
||||
_set_case("rotate input")
|
||||
var key := InputEventKey.new()
|
||||
key.physical_keycode = KEY_R
|
||||
key.keycode = KEY_K # русская раскладка даёт другую букву
|
||||
key.pressed = true
|
||||
_ok(FG.is_rotate_key(key), "R распознаётся по physical_keycode")
|
||||
key.echo = true
|
||||
_ok(not FG.is_rotate_key(key), "echo-повтор R игнорируется")
|
||||
var click := InputEventMouseButton.new()
|
||||
click.button_index = MOUSE_BUTTON_RIGHT
|
||||
click.pressed = true
|
||||
_ok(FG.is_rotate_click(click), "ПКМ распознаётся как поворот во время drag")
|
||||
click.pressed = false
|
||||
_ok(not FG.is_rotate_click(click), "отпускание ПКМ не вращает")
|
||||
|
||||
|
||||
func _test_labels_and_rotation() -> void:
|
||||
_set_case("label cell")
|
||||
_eq(FR.label_cell(FR.shape_cells("S4", 0)), Vector2i(1, 0),
|
||||
@@ -165,7 +203,9 @@ func _test_file_grid(registry, folder_id: String) -> void:
|
||||
_eq(grid.total_cells(), 20, "5×4 = 20 клеток")
|
||||
_eq(grid.occupied_cells(), 0, "пустая папка: 0 клеток")
|
||||
_eq(grid.file_count(), 0, "пустая папка: 0 файлов")
|
||||
_eq(grid.footer_text(), "занято 0 из 20 · свободно 20", "пустой подвал")
|
||||
_eq(grid.footer_text(), _footer(grid.stats()), "пустой подвал")
|
||||
_eq(grid.footer_text(), "0 файлов · 20 клеток",
|
||||
"подвал: файлы и клетки (с плюрализацией)")
|
||||
var m: Dictionary = grid.grid_metrics()
|
||||
_eq(float(m["cell"]), 104.0, "cell при 558×470 → 104")
|
||||
_eq(int(m["cols"]), 5, "cols = 5")
|
||||
@@ -180,8 +220,10 @@ func _test_file_grid(registry, folder_id: String) -> void:
|
||||
grid.refresh()
|
||||
_eq(grid.file_count(), 1, "после создания 1 файл")
|
||||
_eq(grid.occupied_cells(), 1, "текст занимает 1 клетку")
|
||||
_eq(grid.footer_text(), "занято 1 из 20 · свободно 19",
|
||||
_eq(grid.footer_text(), _footer(grid.stats()),
|
||||
"подвал после создания")
|
||||
_eq(grid.footer_text(), "1 файл · 20 клеток",
|
||||
"подвал: 1 файл, 20 клеток")
|
||||
_eq(grid.hidden_count(), 0, "опасных файлов нет")
|
||||
|
||||
var selected: Array = []
|
||||
@@ -229,6 +271,85 @@ func _test_file_grid(registry, folder_id: String) -> void:
|
||||
grid.queue_free()
|
||||
|
||||
|
||||
## Поворот пользователя главнее: сначала requested, авто — только если не влез.
|
||||
func _test_rotation_preference() -> void:
|
||||
_set_case("rotation preference")
|
||||
var grid = FG.new()
|
||||
grid.name = "TestRotationGrid"
|
||||
grid.size = Vector2(558, 470)
|
||||
root.add_child(grid)
|
||||
await process_frame
|
||||
grid._folder = null
|
||||
var payload := {"kind": "file", "id": "probe", "shape_id": "I4H",
|
||||
"rotation": 0, "from": "folder"}
|
||||
var exact: Dictionary = grid._resolve_placement(payload, Vector2i(0, 0), 0)
|
||||
_ok(bool(exact.get("ok", false)), "I4H влезает в (0,0) горизонтально")
|
||||
_eq(int(exact.get("rotation", -1)), 0, "выбранный поворот сохранён")
|
||||
_ok(not bool(exact.get("auto", true)), "без автоповорота auto = false")
|
||||
var turned: Dictionary = grid._resolve_placement(payload, Vector2i(4, 0), 0)
|
||||
_ok(bool(turned.get("ok", false)), "у правого края находится поворот")
|
||||
_eq(int(turned.get("rotation", -1)), 1, "авто-поворот выбрал вертикаль")
|
||||
_ok(bool(turned.get("auto", false)), "автоповорот помечен auto = true")
|
||||
var kept: Dictionary = grid._resolve_placement(payload, Vector2i(0, 0), 1)
|
||||
_eq(int(kept.get("rotation", -1)), 1, "requested=1 уважается, если влезает")
|
||||
root.remove_child(grid)
|
||||
grid.queue_free()
|
||||
|
||||
|
||||
## Ghost и drop используют поворот из DragController, а не из payload.
|
||||
func _test_ghost_rotation() -> void:
|
||||
_set_case("ghost rotation")
|
||||
var grid = FG.new()
|
||||
grid.name = "TestGhostGrid"
|
||||
grid.size = Vector2(558, 470)
|
||||
root.add_child(grid)
|
||||
await process_frame
|
||||
var dc = DC.new()
|
||||
dc.name = "TestGhostDragController"
|
||||
root.add_child(dc)
|
||||
await process_frame
|
||||
grid.drag_controller = dc
|
||||
var preview = FS.new()
|
||||
preview.setup("text", "I4H", 0, "Договор", "txt", 20.0)
|
||||
dc.begin(preview, "shape", 0)
|
||||
var payload := {"kind": "file", "id": "probe", "shape_id": "I4H",
|
||||
"rotation": 0, "from": "folder"}
|
||||
var pos: Vector2 = grid._cell_rect(4, 0).get_center()
|
||||
_ok(grid._can_drop_data(pos, payload), "can_drop у правого края")
|
||||
_eq(int(grid._ghost_rotation), 1, "ghost сам повернулся у края")
|
||||
_eq(int(grid._drag_rotation), 0, "requested поворот ещё 0")
|
||||
dc.set_rotation(1)
|
||||
_eq(int(grid._drag_rotation), 1, "rotation_changed обновил сетку")
|
||||
_eq(int(grid._ghost_rotation), 1, "ghost показывает выбранный поворот")
|
||||
_ok(not bool(grid._ghost_auto), "ручной поворот без auto")
|
||||
grid._can_drop_data(pos, payload)
|
||||
_eq(int(grid._drag_rotation), 1, "can_drop не сбрасывает поворот из payload")
|
||||
# Ghost обязан честно показывать каждый из 4 выбранных поворотов.
|
||||
var square_payload := {"kind": "file", "id": "probe2", "shape_id": "L3a",
|
||||
"rotation": 0, "from": "folder"}
|
||||
var center: Vector2 = grid._cell_rect(2, 2).get_center()
|
||||
for rot in 4:
|
||||
dc.set_rotation(rot)
|
||||
_ok(grid._can_drop_data(center, square_payload),
|
||||
"can_drop для поворота %d" % rot)
|
||||
_eq(int(grid._ghost_rotation), rot, "ghost показывает поворот %d" % rot)
|
||||
_eq(int(grid._drag_rotation), rot, "сетка приняла поворот %d" % rot)
|
||||
var expected_rot := int(grid._ghost_rotation)
|
||||
var dropped: Array = []
|
||||
grid.packet_dropped.connect(func(_p: Dictionary, _origin: Vector2i,
|
||||
rot: int) -> void: dropped.append(rot))
|
||||
grid._drop_data(pos, {"kind": "packet", "id": "p", "shape_id": "I4H",
|
||||
"rotation": 0, "from": "packet"})
|
||||
_eq(dropped.size(), 1, "drop пакета эмитится")
|
||||
if dropped.size() > 0:
|
||||
_eq(int(dropped[0]), expected_rot, "drop сохранил поворот ghost")
|
||||
dc.end()
|
||||
root.remove_child(grid)
|
||||
grid.queue_free()
|
||||
root.remove_child(dc)
|
||||
dc.queue_free()
|
||||
|
||||
|
||||
func _test_sorting_table(registry) -> void:
|
||||
_set_case("SortingTable")
|
||||
var table = ST.new()
|
||||
@@ -240,7 +361,7 @@ func _test_sorting_table(registry) -> void:
|
||||
await process_frame
|
||||
_eq(table.total_cells(), 24, "стол 4×6 = 24 клетки")
|
||||
_eq(table.occupied_cells(), 0, "пустой стол")
|
||||
_eq(table.footer_text(), "24 клетки · занято 0", "подвал пустого стола")
|
||||
_eq(table.footer_text(), "24 клетки", "подвал пустого стола")
|
||||
var m: Dictionary = table.grid_metrics()
|
||||
_eq(m["cell"], Vector2(99, 86), "cell стола 99×86")
|
||||
_eq(int(m["cols"]), 4, "стол: 4 колонки")
|
||||
@@ -252,7 +373,7 @@ func _test_sorting_table(registry) -> void:
|
||||
table.refresh()
|
||||
_eq(table.file_count(), 1, "на столе 1 файл")
|
||||
_eq(table.occupied_cells(), 1, "на столе 1 клетка")
|
||||
_eq(table.footer_text(), "24 клетки · занято 1", "подвал стола с файлом")
|
||||
_eq(table.footer_text(), "24 клетки", "подвал стола с файлом")
|
||||
var fit: Dictionary = table._resolve_placement({
|
||||
"kind": "file", "id": str(file.id), "shape_id": "O1",
|
||||
"rotation": 0, "from": "table",
|
||||
@@ -320,12 +441,40 @@ func _test_drag_controller(folder_id: String) -> void:
|
||||
_ok(dc.preview() == null, "end() освобождает превью")
|
||||
dc.end()
|
||||
_ok(dc.preview() == null, "повторный end() не падает")
|
||||
|
||||
var spin = FS.new()
|
||||
spin.setup("text", "I4H", 2, "Договор", "txt", 20.0)
|
||||
dc.begin(spin, "shape", 2)
|
||||
_ok(dc.is_active(), "is_active() при живом превью")
|
||||
_eq(dc.rotation(), 2, "begin(initial_rotation) сохраняет поворот")
|
||||
var r1: int = int(dc.rotate_cw())
|
||||
_eq(r1, 3, "rotate_cw() → 3")
|
||||
await process_frame
|
||||
_eq(int(dc.rotate_cw()), 0, "rotate_cw() 3 → 0 (posmod)")
|
||||
_eq(spin.shape_rotation, 0, "поворот сразу применяется к превью")
|
||||
dc.set_rotation(5)
|
||||
_eq(dc.rotation(), 1, "set_rotation нормализует 5 → 1")
|
||||
_eq(spin.shape_rotation, 1, "set_rotation обновляет превью")
|
||||
# Пакет (PacketCard) зовёт begin() без третьего аргумента — поворот
|
||||
# должен подхватиться из shape_rotation превью.
|
||||
var packet_preview = FS.new()
|
||||
packet_preview.setup("photo", "L3a", 3, "Пакет", "jpg", 20.0)
|
||||
dc.begin(packet_preview, "shape")
|
||||
_eq(dc.rotation(), 3, "пакет: поворот засеян из превью")
|
||||
dc.end()
|
||||
_eq(dc.rotation(), 0, "end() сбрасывает поворот")
|
||||
_ok(not dc.is_active(), "is_active() false без превью")
|
||||
root.remove_child(dc)
|
||||
dc.queue_free()
|
||||
|
||||
|
||||
func _test_file_shape() -> void:
|
||||
_set_case("FileShape")
|
||||
_eq(FS.label_font_size(56.0), 12, "подпись < 72 px → 12")
|
||||
_eq(FS.label_font_size(71.9), 12, "подпись 71.9 px → 12")
|
||||
_eq(FS.label_font_size(72.0), 14, "подпись 72 px → 14")
|
||||
_eq(FS.label_font_size(96.0), 14, "подпись 96 px → 14")
|
||||
_eq(FS.label_font_size(96.1), 16, "подпись > 96 px → 16")
|
||||
var shape = FS.new()
|
||||
shape.setup("photo", "L3a", 0, "Фото", "jpg", 48.0)
|
||||
_eq(shape.custom_minimum_size, Vector2(96, 96), "L3a bbox 2×2 при 48 px")
|
||||
@@ -359,8 +508,8 @@ func _test_tree_panel() -> void:
|
||||
],
|
||||
}])
|
||||
await process_frame
|
||||
_eq(tree.footer_text(), "2 папок · 2 файлов · 5 клеток занято",
|
||||
"подвал дерева: папки/файлы/клетки")
|
||||
_eq(tree.footer_text(), "2 папки · 2 файла",
|
||||
"подвал дерева: папки/файлы")
|
||||
var section_row = null
|
||||
var folder_row = null
|
||||
for e in tree.get("_rows"):
|
||||
@@ -377,7 +526,7 @@ func _test_tree_panel() -> void:
|
||||
_eq(folder_row.count, 5, "FolderRow.count = занятые клетки")
|
||||
_eq(folder_row.capacity, 20, "FolderRow.capacity = всего клеток")
|
||||
_eq(folder_row.files_count, 2, "FolderRow.files_count = файлы")
|
||||
_eq(folder_row.tooltip_text, "2 файлов · 5 из 20 клеток",
|
||||
_eq(folder_row.tooltip_text, "2 файла · 5 из 20 клеток",
|
||||
"тултип папки в клетках")
|
||||
var opened: Array = []
|
||||
tree.folder_selected.connect(func(id: String) -> void: opened.append(id))
|
||||
@@ -395,7 +544,7 @@ func _test_tree_panel() -> void:
|
||||
tree.update_stats("f1", 3, 7, 24)
|
||||
_eq(folder_row.count, 7, "update_stats обновляет клетки")
|
||||
_eq(folder_row.capacity, 24, "update_stats обновляет ёмкость")
|
||||
_eq(tree.footer_text(), "2 папок · 3 файлов · 7 клеток занято",
|
||||
_eq(tree.footer_text(), "2 папки · 3 файла",
|
||||
"подвал после update_stats")
|
||||
root.remove_child(tree)
|
||||
tree.queue_free()
|
||||
@@ -403,6 +552,14 @@ func _test_tree_panel() -> void:
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
## Подвал FileGrid v3: «N файлов · M клеток» (плюрализация клеток).
|
||||
func _footer(stats: Dictionary) -> String:
|
||||
var total := int(stats.get("total", 0))
|
||||
var files := int(stats.get("files", 0))
|
||||
return "%d %s · %d %s" % [files, FG.plural_files(files), total,
|
||||
FG.plural_cells(total)]
|
||||
|
||||
|
||||
func _first_folder_id(registry) -> String:
|
||||
if registry == null:
|
||||
return ""
|
||||
|
||||
@@ -188,24 +188,23 @@ func _play_tick() -> void:
|
||||
continue
|
||||
_try_receive(hd, str(packet_id))
|
||||
|
||||
# 4. Запросы: докладываем файлы, затем явный start_send.
|
||||
# 4. Запросы: докладываем ИМЕННО required-файлы, затем явный start_send.
|
||||
for req_id in hd.active_requests.keys().duplicate():
|
||||
if not hd.active_requests.has(req_id):
|
||||
continue
|
||||
var req = hd.active_requests[req_id]
|
||||
if req.sending or req.minigame_active or hd.is_request_queued(req_id):
|
||||
if req.sending or req.minigame_active or req.connecting or hd.is_request_queued(req_id):
|
||||
continue
|
||||
var need := int(hd.request_required_count(req_id))
|
||||
var guard := 0
|
||||
while req.attached.size() < need and guard < 8:
|
||||
guard += 1
|
||||
var fid := _available_file(hd)
|
||||
if fid.is_empty():
|
||||
var missing: Array = hd.request_missing(req_id)
|
||||
for fid in missing:
|
||||
if not hd.attach_file(req_id, str(fid)):
|
||||
break
|
||||
if not hd.attach_file(req_id, fid):
|
||||
break
|
||||
if req.attached.size() >= need:
|
||||
if hd.request_missing(req_id).is_empty():
|
||||
if hd.start_send(req_id):
|
||||
# Бот мгновенно проходит мини-игру соединения (UI-слой не подключён).
|
||||
var fresh = hd.active_requests.get(req_id)
|
||||
if fresh != null and bool(fresh.connecting):
|
||||
hd.connection_finished(req_id, true)
|
||||
if hd.is_minigame_active():
|
||||
hd._on_send_minigame_finished(0.8)
|
||||
elif float(req.time_left) < 2.0:
|
||||
|
||||
@@ -23,7 +23,11 @@ var _minigame_shows: int = 0
|
||||
var _minigame_cancels: int = 0
|
||||
var _minigame_closes: int = 0
|
||||
var _connection_shows: int = 0
|
||||
var _connection_request_id: String = ""
|
||||
var _connection_hour: int = 0
|
||||
var _connection_successes: int = -1
|
||||
var _frozen_events: int = 0
|
||||
var _toasts: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -42,6 +46,7 @@ func _init() -> void:
|
||||
_hd.minigame_closed.connect(_on_minigame_closed)
|
||||
_hd.spawn_frozen.connect(_on_spawn_frozen)
|
||||
_hd.show_connection.connect(_on_show_connection)
|
||||
_hd.toast.connect(_on_toast)
|
||||
|
||||
_test_start_hour()
|
||||
_test_budgets_and_weights()
|
||||
@@ -50,6 +55,7 @@ func _init() -> void:
|
||||
_test_receive()
|
||||
_test_receive_cancel_and_no_space()
|
||||
_test_request_caps()
|
||||
_test_starvation_guard()
|
||||
_test_attach_detach()
|
||||
_test_protocol()
|
||||
_test_send_minigame_pause()
|
||||
@@ -58,8 +64,14 @@ func _init() -> void:
|
||||
_test_send_timer_freeze()
|
||||
_test_timeout_preserves_files()
|
||||
_test_hour_end_during_minigame()
|
||||
_test_no_connection_game()
|
||||
_test_connection_flow()
|
||||
_test_lost_required()
|
||||
_test_modal_guard()
|
||||
_test_required_types_stable()
|
||||
_test_no_substitute_while_sending()
|
||||
_test_report()
|
||||
_test_reset_for_prep()
|
||||
_test_move_required_file()
|
||||
_finish()
|
||||
|
||||
|
||||
@@ -67,24 +79,44 @@ func _init() -> void:
|
||||
|
||||
func _test_start_hour() -> void:
|
||||
_set_case("start_hour")
|
||||
_fresh(1)
|
||||
_fresh(1, true)
|
||||
_eq(_hd.hour, 1, "hour = 1")
|
||||
_approx(_hd.time_left, 300.0, 0.0001, "длительность часа 300 с")
|
||||
_ok(_hd.running, "час запущен")
|
||||
_eq(_hd.files_budget, 4, "H1: бюджет 4 файла")
|
||||
# v3: H1 плотнее — значения берём из data/balance.json → hours[0].
|
||||
var h1: Dictionary = _hour_row(1)
|
||||
_eq(_hd.files_budget, int(h1.get("files", -1)), "H1: бюджет файлов из данных")
|
||||
_eq(_hd.files_budget, 7, "H1: 7 файлов")
|
||||
_eq(_hd.files_spawned, 0, "H1: файлы ещё не спавнились")
|
||||
_eq(_hd.request_cap, 2, "H1: кап запросов 2")
|
||||
_eq(_hd.active_request_limit, 1, "H1: 1 активный запрос")
|
||||
_approx(_hd.spawn_window, 225.0, 0.0001, "окно спавна 75% часа = 225 с")
|
||||
_eq(_hd.request_cap, int(h1.get("request_cap", -1)), "H1: кап запросов из данных")
|
||||
_eq(_hd.request_cap, 6, "H1: кап запросов 6")
|
||||
_eq(_hd.active_request_limit, int(h1.get("active_requests", -1)),
|
||||
"H1: active_requests из данных")
|
||||
_eq(_hd.active_request_limit, 2, "H1: 2 активных запроса")
|
||||
_approx(_hd.spawn_window, 300.0 * 0.75, 0.0001, "окно спавна 75% часа = 225 с")
|
||||
_eq(_hd.slots.max_slots(), 1, "H1: 1 слот")
|
||||
_eq(_hd.buffer_limit(), 6, "H1: буфер 6")
|
||||
_approx(_hd._recv_interval(), 12.0, 0.0001, "legacy recv_interval H1 = 12")
|
||||
_approx(_hd._receive_interval(), 225.0 / 4.0, 0.0001, "спавн-интервал 225/4")
|
||||
_approx(_hd._send_interval(), 22.0, 0.0001, "send_interval H1 = 22")
|
||||
_eq(_hd._max_requests(), 1, "max concurrent requests H1 = 1")
|
||||
_approx(_hd._recv_interval(), float(_difficulty_row(1).get("recv_interval", -1)),
|
||||
0.0001, "legacy recv_interval H1 из данных")
|
||||
_approx(_hd._receive_interval(), _hd.spawn_window / float(_hd.files_budget),
|
||||
0.0001, "спавн-интервал = окно / бюджет")
|
||||
_approx(_hd._send_interval(), float(h1.get("send_interval", -1)), 0.0001,
|
||||
"send_interval H1 из данных")
|
||||
_eq(_hd._max_requests(), int(h1.get("active_requests", -1)),
|
||||
"max concurrent requests H1 = active_requests")
|
||||
_eq(_hd._type_pool(), ["text", "sound"], "H1: только text/sound")
|
||||
_eq(_hd.active_packets.size(), 0, "активных пакетов нет")
|
||||
_eq(_hd.active_requests.size(), 0, "активных запросов нет")
|
||||
_eq(_hd.active_requests.size(), 1, "H1: стартовый запрос создан сразу")
|
||||
_eq(_hd.requests_spawned, 1, "H1: стартовый запрос учтён в капе")
|
||||
_eq(_game.registry.file_count(), 1, "H1: создан стартовый файл")
|
||||
var boot_req := str(_hd.active_requests.keys()[0])
|
||||
_eq(_hd.request_required_count(boot_req), 1, "H1: стартовый запрос на 1 файл")
|
||||
var boot_ids: Array = _hd.request_required_ids(boot_req)
|
||||
var boot_file = _game.registry.files.get(str(boot_ids[0]))
|
||||
_ok(boot_file != null and str(boot_file.type) == "text", "стартовый файл — text")
|
||||
if boot_file != null:
|
||||
_eq(str(boot_file.required_by), boot_req, "файл зарезервирован под запрос")
|
||||
_approx(float(_hd._next_spawn_at), 0.0, 0.0001, "H1: первый пакет в t=0")
|
||||
_ok(_hd.attack_director != null, "AttackDirector создан")
|
||||
_eq(_hd.attack_director._schedule.size(), 0, "H1: атак нет")
|
||||
_ok(_hd._minigame == null, "мини-игра не открыта")
|
||||
@@ -94,9 +126,15 @@ func _test_start_hour() -> void:
|
||||
|
||||
func _test_budgets_and_weights() -> void:
|
||||
_set_case("budgets_weights")
|
||||
for pair in [[1, 4], [2, 8], [3, 12], [4, 16], [5, 20], [6, 20], [9, 20]]:
|
||||
for h in [1, 2, 3, 4, 5, 6, 9]:
|
||||
_fresh(int(h))
|
||||
_eq(_hd.files_budget, int(_hour_row(int(h)).get("files", -1)),
|
||||
"H%d: бюджет из данных" % int(h))
|
||||
_eq(_hd.files_budget, 20, "H9: бюджет 20 файлов")
|
||||
for pair in [[1, 7], [2, 8], [3, 12], [4, 16], [5, 20], [6, 20], [9, 20]]:
|
||||
_fresh(int(pair[0]))
|
||||
_eq(_hd.files_budget, int(pair[1]), "H%d: бюджет %d" % [int(pair[0]), int(pair[1])])
|
||||
_eq(_hd.files_budget, int(pair[1]),
|
||||
"H%d: бюджет %d" % [int(pair[0]), int(pair[1])])
|
||||
|
||||
_fresh(1)
|
||||
var seen_h1: Dictionary = {}
|
||||
@@ -126,6 +164,7 @@ func _test_budgets_and_weights() -> void:
|
||||
for i in range(400):
|
||||
seen_9[_hd._roll_required_count()] = true
|
||||
_ok(seen_9.has(1) and seen_9.has(2) and seen_9.has(3), "H9: request_mix даёт 1/2/3")
|
||||
_ok(seen_9.has(4), "H9: request_mix даёт 4 файла")
|
||||
|
||||
|
||||
# --- перенос файлов --------------------------------------------------------
|
||||
@@ -137,13 +176,21 @@ func _test_spawn_window_budget() -> void:
|
||||
_hd.spawned_this_hour = 0
|
||||
_hd.requests_spawned = _hd.request_cap
|
||||
_hd.spawn_recv_accum = 0.0
|
||||
var folder_id := _folder_id()
|
||||
# Буфер H1 (6) меньше бюджета файлов (7): забираем пакеты в папку по мере
|
||||
# спавна, иначе седьмой файл законно ждёт освобождения места.
|
||||
for i in range(3000):
|
||||
_hd.tick(0.1)
|
||||
for pid in _hd.active_packets.keys().duplicate():
|
||||
var packet = _hd.active_packets.get(pid)
|
||||
if packet != null and packet.state == "waiting":
|
||||
_hd.receive_packet(str(pid), folder_id)
|
||||
if _hd.hour_elapsed >= _hd.spawn_window + 0.4:
|
||||
break
|
||||
_eq(_hd.files_spawned, 4, "H1: все 4 файла успевают в окно 225 с")
|
||||
_eq(_hd.spawned_this_hour, 4, "H1: счётчик спавна = 4")
|
||||
_ok(_hd.active_packets.size() <= 4, "в буфере не больше бюджета файлов")
|
||||
_eq(_hd.files_spawned, _hd.files_budget, "H1: все 7 файлов успевают в окно")
|
||||
_eq(_hd.spawned_this_hour, _hd.files_budget, "H1: счётчик спавна = бюджет")
|
||||
_ok(_hd.active_packets.size() <= _hd.buffer_limit(),
|
||||
"в буфере не больше лимита буфера")
|
||||
_ok(_hd.hour_elapsed >= _hd.spawn_window and _hd.hour_elapsed <= _hd.spawn_window + 0.5,
|
||||
"спавн остановился у границы окна")
|
||||
_eq(_hd.carry_files, 0, "перенос ещё не рассчитан (час идёт)")
|
||||
@@ -156,12 +203,15 @@ func _test_carry() -> void:
|
||||
_hd.files_spawned = 1
|
||||
_hd.carry_files = 0
|
||||
_hd._finish_hour()
|
||||
_eq(_hd.carry_files, 3, "недоспавненные 3 файла переносятся")
|
||||
_eq(int(_hd.report().get("files_carry", -1)), 3, "отчёт: files_carry = 3")
|
||||
_eq(_hd.carry_files, _hd.files_budget - 1, "недоспавненные файлы переносятся")
|
||||
_eq(int(_hd.report().get("files_carry", -1)), _hd.files_budget - 1,
|
||||
"отчёт: files_carry = бюджет − 1")
|
||||
|
||||
_game.hour = 2
|
||||
_hd.start_hour(2)
|
||||
_eq(_hd.files_budget, 11, "H2: 8 базовых + 3 переноса")
|
||||
_eq(_hd.files_budget, mini(20, int(_hour_row(2).get("files", 8))
|
||||
+ int(_hour_row(1).get("files", 7)) - 1),
|
||||
"H2: базовые файлы + перенос")
|
||||
_eq(_hd.carry_files, 0, "перенос применён один раз")
|
||||
|
||||
_hd.carry_files = 5
|
||||
@@ -268,6 +318,10 @@ func _test_receive_cancel_and_no_space() -> void:
|
||||
# --- запросы ---------------------------------------------------------------
|
||||
|
||||
func _test_request_caps() -> void:
|
||||
var h1: Dictionary = _hour_row(1)
|
||||
var cap := int(h1.get("request_cap", 6))
|
||||
var active_limit := int(h1.get("active_requests", 2))
|
||||
|
||||
_set_case("request_no_files")
|
||||
_fresh(1)
|
||||
_hd.requests_spawned = 0
|
||||
@@ -286,43 +340,112 @@ func _test_request_caps() -> void:
|
||||
_hd.requests_spawned = 0
|
||||
_hd._send_interval_current = 0.1
|
||||
_hd.spawn_send_accum = 0.0
|
||||
_hd.tick(0.15)
|
||||
_eq(_hd.active_requests.size(), 1, "H1: одновременно 1 запрос")
|
||||
_eq(_hd.requests_spawned, 1, "H1: запрос №1 создан")
|
||||
_hd.tick(0.1)
|
||||
_eq(_hd.active_requests.size(), 1, "H1: первый запрос создан")
|
||||
_eq(_hd.requests_spawned, 1, "H1: первый запрос учтён в бюджете")
|
||||
var req_id := str(_hd.active_requests.keys()[0])
|
||||
var req = _hd.active_requests[req_id]
|
||||
var required_ids: Array = _hd.request_required_ids(req_id)
|
||||
_ok(req.attached.is_empty(), "файлы не прикрепляются автоматически")
|
||||
_ok(req.required_count() == _hd.request_required_count(req_id),
|
||||
"required_count() = число слотов контейнера")
|
||||
for placeholder in req.required:
|
||||
_ok(not _game.registry.files.has(str(placeholder)), "в required нет заранее выбранных файлов")
|
||||
_ok(_hd.request_required_count(req_id) >= 1 and _hd.request_required_count(req_id) <= 2,
|
||||
_ok(required_ids.size() >= 1 and required_ids.size() <= 2,
|
||||
"H1: нужно 1–2 файла")
|
||||
for file_id in required_ids:
|
||||
_ok(_game.registry.files.has(file_id), "required — конкретный файл реестра")
|
||||
_eq(req.required_types.size(), required_ids.size(),
|
||||
"required_types заполнены при создании")
|
||||
_ok(not req.connected and not req.connecting, "новый TCP ещё не подключён")
|
||||
_ok(_hd.request_missing(req_id).size() == required_ids.size(),
|
||||
"request_missing = все required до прикрепления")
|
||||
_ok(_hd.request_required_names(req_id).size() == required_ids.size(),
|
||||
"request_required_names резолвит имена")
|
||||
_ok(not _hd.is_request_ready(req_id), "пустой запрос не готов")
|
||||
_ok(not req.is_ready(), "legacy Request.is_ready тоже false")
|
||||
_approx(req.time_left, req.timer_total, 0.0001, "таймер запроса стартовал сразу")
|
||||
_approx(req.time_left, req.timer_total, 0.0001,
|
||||
"таймер запроса стартует с полного значения")
|
||||
|
||||
# Добираем активный лимит: после спавна интервал снова H1-й (12 с).
|
||||
for i in range(140):
|
||||
_hd.tick(0.1)
|
||||
_eq(_hd.active_requests.size(), active_limit,
|
||||
"H1: одновременно %d запроса" % active_limit)
|
||||
_eq(_hd.requests_spawned, active_limit, "H1: создано запросов = активный лимит")
|
||||
|
||||
_hd._send_interval_current = 0.1
|
||||
_hd.spawn_send_accum = 0.0
|
||||
for i in range(20):
|
||||
_hd.tick(0.1)
|
||||
_eq(_hd.requests_spawned, 1, "при активном лимите новый запрос не появляется")
|
||||
_eq(_hd.requests_spawned, active_limit,
|
||||
"при активном лимите новый запрос не появляется")
|
||||
|
||||
_hd._cleanup_request(req_id, true)
|
||||
_hd._send_interval_current = 0.1
|
||||
_hd.spawn_send_accum = 0.0
|
||||
_hd.tick(0.15)
|
||||
_eq(_hd.active_requests.size(), 1, "после освобождения слота создан второй запрос")
|
||||
_eq(_hd.requests_spawned, 2, "H1: два запроса — кап")
|
||||
var second_id := str(_hd.active_requests.keys()[0])
|
||||
_hd._cleanup_request(second_id, true)
|
||||
_eq(_hd.active_requests.size(), active_limit,
|
||||
"после освобождения слота создан следующий запрос")
|
||||
_eq(_hd.requests_spawned, active_limit + 1,
|
||||
"освободившийся слот потратил бюджет запросов")
|
||||
|
||||
# Добираем бюджет до капа: каждый новый запрос сразу освобождаем.
|
||||
for i in range(40):
|
||||
for rid in _hd.active_requests.keys().duplicate():
|
||||
_hd._cleanup_request(str(rid), true)
|
||||
_hd._send_interval_current = 0.1
|
||||
_hd.spawn_send_accum = 0.0
|
||||
_hd.tick(0.15)
|
||||
if _hd.requests_spawned >= cap:
|
||||
break
|
||||
_eq(_hd.requests_spawned, cap, "H1: кап запросов = %d" % cap)
|
||||
for rid in _hd.active_requests.keys().duplicate():
|
||||
_hd._cleanup_request(str(rid), true)
|
||||
_hd._send_interval_current = 0.1
|
||||
_hd.spawn_send_accum = 0.0
|
||||
_hd.tick(0.15)
|
||||
_eq(_hd.requests_spawned, 2, "H1: третий запрос не создаётся (кап 2)")
|
||||
_eq(_hd.requests_spawned, cap, "сверх капа запрос не создаётся")
|
||||
_eq(_hd.active_requests.size(), 0, "лишних запросов нет")
|
||||
|
||||
_set_case("request_clamp")
|
||||
_fresh(1)
|
||||
_new_file("Один", "text", _folder_id())
|
||||
var clamped_id := str(_hd.spawn_request_now(4))
|
||||
_ok(not clamped_id.is_empty(), "запрос создан при одном доступном файле")
|
||||
_eq(_hd.request_required_count(clamped_id), 1,
|
||||
"запрос на 4 файла зажат до размера пула (1)")
|
||||
_eq(_hd.request_missing(clamped_id).size(), 1, "нужен ровно один файл")
|
||||
|
||||
|
||||
# --- анти-голодание --------------------------------------------------------
|
||||
|
||||
func _test_starvation_guard() -> void:
|
||||
_set_case("starvation_guard")
|
||||
_fresh(1)
|
||||
_eq(_hd.next_request_hint(), "Нет свободных файлов для запроса",
|
||||
"без файлов подсказка объясняет простой")
|
||||
var file = _new_file("Голод", "text", _folder_id())
|
||||
_ok(file != null, "файл создан")
|
||||
_eq(_hd.next_request_hint(), "Следующий запрос ~0 с",
|
||||
"с файлом запрос может появиться сразу")
|
||||
# send_interval H1 = 12 с; спавн должен случиться на первом же тике.
|
||||
_hd.tick(0.1)
|
||||
_eq(_hd.active_requests.size(), 1, "запрос создан без ожидания интервала")
|
||||
_eq(_hd.requests_spawned, 1, "бюджет запросов потрачен")
|
||||
# Второй свободный файл + анти-голодание: добираемся до активного лимита.
|
||||
var file2 = _new_file("Голод 2", "text", _folder_id())
|
||||
_ok(file2 != null, "второй файл создан")
|
||||
for i in range(140):
|
||||
_hd.tick(0.1)
|
||||
_eq(_hd.active_requests.size(), _hd.active_request_limit,
|
||||
"активный лимит заполнен")
|
||||
_eq(_hd.next_request_hint(), "",
|
||||
"при занятом лимите активных запросов подсказка не нужна")
|
||||
# Даже при малом пуле запрос создаётся на 1 файл (clamp).
|
||||
var rid := str(_hd.active_requests.keys()[0])
|
||||
_eq(_hd.request_required_count(rid), 1, "запрос собрался из одного файла")
|
||||
for active_id in _hd.active_requests.keys().duplicate():
|
||||
_hd._cleanup_request(str(active_id), true)
|
||||
|
||||
|
||||
# --- attach/detach ---------------------------------------------------------
|
||||
|
||||
@@ -340,30 +463,72 @@ func _test_attach_detach() -> void:
|
||||
_ok(not req_id.is_empty(), "запрос на 2 файла создан")
|
||||
_eq(_hd.request_required_count(req_id), 2, "required_count = 2")
|
||||
var req = _hd.active_requests[req_id]
|
||||
var ids: Array = _hd.request_required_ids(req_id)
|
||||
_eq(ids.size(), 2, "required содержит 2 конкретных файла")
|
||||
|
||||
_ok(_hd.attach_file(req_id, str(f1.id)), "файл 1 прикреплён")
|
||||
_eq(str(f1.location), "attached", "файл 1 остался в папке (location attached)")
|
||||
_ok(_hd.attach_file(req_id, ids[0]), "файл 1 прикреплён")
|
||||
_eq(str(_game.registry.files[ids[0]].location), "attached",
|
||||
"файл 1 остался в папке (location attached)")
|
||||
_eq(req.attached.size(), 1, "attached = 1")
|
||||
_ok(not req.sending, "attach не запускает отправку")
|
||||
_ok(_hd.attach_file(req_id, str(f2.id)), "файл 2 прикреплён")
|
||||
_ok(_hd.attach_file(req_id, ids[1]), "файл 2 прикреплён")
|
||||
_eq(req.attached.size(), 2, "attached = 2")
|
||||
_ok(not req.sending, "после заполнения автоотправки нет")
|
||||
_ok(_hd.is_request_ready(req_id), "собранный запрос готов")
|
||||
_ok(not _hd.attach_file(req_id, str(f3.id)), "третий файл сверх лимита отклонён")
|
||||
_eq(_hd.request_missing(req_id).size(), 0, "missing = 0 у собранного")
|
||||
|
||||
_ok(_hd.detach_file(req_id, str(f1.id)), "файл 1 откреплён")
|
||||
# Лишний файл, которого нет в required, отклоняется.
|
||||
var extra_id := ""
|
||||
for file in [f1, f2, f3]:
|
||||
if not ids.has(str(file.id)):
|
||||
extra_id = str(file.id)
|
||||
break
|
||||
_ok(not extra_id.is_empty(), "есть файл вне required")
|
||||
_toasts.clear()
|
||||
_ok(not _hd.attach_file(req_id, extra_id), "файл вне required отклонён")
|
||||
_ok(_has_toast("Нужен файл:"), "тост «Нужен файл: …»")
|
||||
|
||||
_ok(_hd.detach_file(req_id, ids[0]), "файл 1 откреплён")
|
||||
_eq(req.attached.size(), 1, "attached = 1 после detach")
|
||||
_eq(str(f1.location), "folder", "detach вернул файл в папку")
|
||||
_ok(not _hd.detach_file(req_id, str(f3.id)), "неприкреплённый файл не открепить")
|
||||
_eq(str(_game.registry.files[ids[0]].location), "folder",
|
||||
"detach вернул файл в папку")
|
||||
_eq(_hd.request_required_ids(req_id), ids, "detach не меняет required")
|
||||
_ok(not _hd.is_request_ready(req_id), "после detach запрос не готов")
|
||||
_ok(_hd.attach_file(req_id, ids[0]), "файл 1 прикреплён повторно")
|
||||
_ok(not _hd.detach_file(req_id, extra_id), "неприкреплённый файл не открепить")
|
||||
|
||||
var req2: String = str(_hd.spawn_request_now(1))
|
||||
_ok(_hd.attach_file(req2, str(f3.id)), "файл 3 прикреплён к другому запросу")
|
||||
_ok(not _hd.attach_file(req_id, str(f3.id)), "занятый файл нельзя прикрепить дважды")
|
||||
_ok(not req2.is_empty(), "второй запрос создан")
|
||||
var ids2: Array = _hd.request_required_ids(req2)
|
||||
_ok(ids2.size() == 1 and not ids.has(ids2[0]),
|
||||
"второй запрос берёт файл, не занятый первым")
|
||||
_ok(_hd.attach_file(req2, ids2[0]), "свободный файл прикреплён к другому запросу")
|
||||
_ok(not _hd.attach_file(req2, ids[0]),
|
||||
"файл, требуемый другим запросом, нельзя прикрепить")
|
||||
|
||||
var f4 = _new_file("Стол", "text", folder_id)
|
||||
var f4 = _new_file("Резерв", "text", folder_id)
|
||||
if f4 != null:
|
||||
_ok(_game.registry.move_file_to_table(str(f4.id)), "файл убран на стол")
|
||||
_ok(not _hd.attach_file(req_id, str(f4.id)), "файл со стола нельзя прикрепить")
|
||||
var fid4 := str(f4.id)
|
||||
var req3: String = str(_hd._create_request(1, "tcp", [fid4], false))
|
||||
_ok(not req3.is_empty(), "третий запрос создан")
|
||||
_eq(str(_game.registry.files[fid4].required_by), req3,
|
||||
"required_by выставлен при создании запроса")
|
||||
_ok(not _game.registry.move_file_to_table(fid4),
|
||||
"required-файл нельзя унести на стол")
|
||||
_ok(not _game.registry.remove_file(fid4, true),
|
||||
"required-файл нельзя удалить")
|
||||
_ok(_hd.attach_file(req3, fid4), "свой запрос может прикрепить резерв")
|
||||
_hd._cleanup_request(req3, true)
|
||||
_eq(str(_game.registry.files[fid4].required_by), "",
|
||||
"cleanup снял required_by")
|
||||
|
||||
var f5 = _new_file("Стол", "text", folder_id)
|
||||
if f5 != null:
|
||||
var fid5 := str(f5.id)
|
||||
_ok(_game.registry.move_file_to_table(fid5), "свободный файл убран на стол")
|
||||
var req4: String = str(_hd._create_request(1, "tcp", [fid5], false))
|
||||
_ok(not _hd.attach_file(req4, fid5), "файл со стола нельзя прикрепить")
|
||||
_hd._cleanup_request(req4, true)
|
||||
_hd._cleanup_request(req_id, true)
|
||||
_hd._cleanup_request(req2, true)
|
||||
|
||||
@@ -374,17 +539,25 @@ func _test_protocol() -> void:
|
||||
_set_case("protocol")
|
||||
_fresh(1)
|
||||
var file = _new_file("Протокол", "text", _folder_id())
|
||||
var req_id: String = str(_hd.spawn_request_now(1))
|
||||
var req_id: String = str(_hd._create_request(1, "tcp", [str(file.id)], false))
|
||||
_eq(str(_hd.active_requests[req_id].protocol), "tcp", "по умолчанию tcp")
|
||||
_ok(_hd.set_request_protocol(req_id, "udp"), "tcp → udp разрешён")
|
||||
_ok(_hd.set_request_protocol(req_id, "udp"), "неподключённый tcp → udp разрешён")
|
||||
_eq(str(_hd.active_requests[req_id].protocol), "udp", "протокол udp")
|
||||
_ok(not _hd.set_request_protocol(req_id, "smtp"), "неизвестный протокол отклонён")
|
||||
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
|
||||
_ok(_hd.start_send(req_id), "мини-игра отправки открыта")
|
||||
_ok(_hd.start_send(req_id), "UDP: мини-игра отправки открыта")
|
||||
_ok(not _hd.set_request_protocol(req_id, "tcp"), "после старта мини-игры протокол не меняется")
|
||||
_hd.force_close_send_minigame()
|
||||
_hd._cleanup_request(req_id, true)
|
||||
|
||||
_set_case("protocol_connected")
|
||||
var file2 = _new_file("Подключён", "text", _folder_id())
|
||||
var req2: String = str(_hd._create_request(1, "tcp", [str(file2.id)], true))
|
||||
_eq(str(_hd.active_requests[req2].protocol), "tcp", "TCP-запрос создан подключённым")
|
||||
_ok(not _hd.set_request_protocol(req2, "udp"),
|
||||
"connected-запрос нельзя переключить на UDP")
|
||||
_hd._cleanup_request(req2, true)
|
||||
|
||||
|
||||
# --- мини-игра отправки ----------------------------------------------------
|
||||
|
||||
@@ -392,7 +565,7 @@ func _test_send_minigame_pause() -> void:
|
||||
_set_case("send_minigame_pause")
|
||||
_fresh(1)
|
||||
var file = _new_file("Мини", "text", _folder_id())
|
||||
var req_id: String = str(_hd.spawn_request_now(1))
|
||||
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
|
||||
_minigame_shows = 0
|
||||
_ok(_hd.start_send(req_id), "start_send → true")
|
||||
@@ -417,7 +590,7 @@ func _test_out_slot_queue() -> void:
|
||||
_set_case("out_slot_queue")
|
||||
_fresh(1)
|
||||
var file = _new_file("Очередь", "text", _folder_id())
|
||||
var req_id: String = str(_hd.spawn_request_now(1))
|
||||
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
|
||||
var blocker := int(_hd.slots.reserve("out", "blocker"))
|
||||
_ok(blocker >= 0, "слот занят другим запросом")
|
||||
@@ -451,7 +624,7 @@ func _test_file_consumption() -> void:
|
||||
_fresh(1)
|
||||
var file = _new_file("Расход", "text", _folder_id())
|
||||
var file_id := str(file.id)
|
||||
var req_id: String = str(_hd.spawn_request_now(1))
|
||||
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
|
||||
_ok(_hd.start_send(req_id), "мини-игра открыта")
|
||||
_hd._on_send_minigame_finished(1.0)
|
||||
@@ -478,7 +651,7 @@ func _test_send_timer_freeze() -> void:
|
||||
_fresh(1)
|
||||
var file = _new_file("Заморозка", "text", _folder_id())
|
||||
var file_id := str(file.id)
|
||||
var req_id: String = str(_hd.spawn_request_now(1))
|
||||
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
|
||||
_ok(_hd.start_send(req_id), "мини-игра открыта")
|
||||
_hd._on_send_minigame_finished(1.0)
|
||||
@@ -509,7 +682,7 @@ func _test_timeout_preserves_files() -> void:
|
||||
_fresh(1)
|
||||
var file = _new_file("Таймаут", "text", _folder_id())
|
||||
var file_id := str(file.id)
|
||||
var req_id: String = str(_hd.spawn_request_now(1))
|
||||
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
|
||||
var req = _hd.active_requests.get(req_id)
|
||||
_ok(req != null and not req.sending, "запрос ещё НЕ отправляется")
|
||||
@@ -534,7 +707,7 @@ func _test_hour_end_during_minigame() -> void:
|
||||
_fresh(1)
|
||||
var file = _new_file("Конец", "text", _folder_id())
|
||||
var file_id := str(file.id)
|
||||
var req_id: String = str(_hd.spawn_request_now(1))
|
||||
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
|
||||
_hd.time_left = 5.0
|
||||
_ok(_hd.start_send(req_id), "мини-игра открыта на 295-й секунде")
|
||||
@@ -557,22 +730,265 @@ func _test_hour_end_during_minigame() -> void:
|
||||
_ok(_hour_report.has("packets_left"), "отчёт содержит packets_left")
|
||||
|
||||
|
||||
# --- соединение удалено ----------------------------------------------------
|
||||
# --- TCP-соединение --------------------------------------------------------
|
||||
|
||||
func _test_no_connection_game() -> void:
|
||||
_set_case("no_connection")
|
||||
func _test_connection_flow() -> void:
|
||||
_set_case("connection")
|
||||
_fresh(1)
|
||||
var folder_id := _folder_id()
|
||||
var file = _new_file("Соединение", "text", folder_id)
|
||||
_ok(file != null, "файл создан")
|
||||
if file == null:
|
||||
return
|
||||
var file_id := str(file.id)
|
||||
var req_id := str(_hd._create_request(1, "tcp", [file_id], false))
|
||||
_ok(not req_id.is_empty(), "TCP-запрос создан")
|
||||
var req = _hd.active_requests.get(req_id)
|
||||
_ok(req != null and not req.connected and not req.connecting,
|
||||
"новый TCP не подключён")
|
||||
_ok(not _hd.is_request_connected(req_id), "is_request_connected = false")
|
||||
_ok(_hd.attach_file(req_id, file_id), "файл прикреплён")
|
||||
|
||||
var money_before := int(_game.money)
|
||||
_connection_shows = 0
|
||||
var packet_id := str(_hd.spawn_packet_now("text"))
|
||||
_ok(not _hd.connect_packet(packet_id), "connect_packet — заглушка false")
|
||||
_ok(not _hd.connect_request("req_x"), "connect_request — заглушка false")
|
||||
_ok(_hd.receive_packet(packet_id, _folder_id()), "приём работает без соединения")
|
||||
for i in range(100):
|
||||
_ok(_hd.start_send(req_id), "start_send запускает соединение")
|
||||
_eq(_connection_shows, 1, "show_connection эмитится один раз")
|
||||
_eq(_connection_request_id, req_id, "show_connection: request_id")
|
||||
_eq(_connection_hour, _hd.hour, "show_connection: hour")
|
||||
_eq(_connection_successes, 0, "show_connection: successes за забег")
|
||||
_ok(req.connecting and not req.minigame_active,
|
||||
"connecting=true, мини-игра отправки не открыта")
|
||||
_eq(req.connect_attempts, 1, "connect_attempts = 1")
|
||||
_ok(not _hd.start_send(req_id), "повторный start_send во время connecting → false")
|
||||
_ok(not _hd.connect_request(req_id), "connect_request во время connecting → false")
|
||||
|
||||
var req_time := float(req.time_left)
|
||||
var hour_time := float(_hd.time_left)
|
||||
for i in range(10):
|
||||
_hd.tick(0.1)
|
||||
if not _hd.active_packets.has(packet_id):
|
||||
break
|
||||
_eq(_connection_shows, 0, "show_connection ни разу не эмитится")
|
||||
_ok(_hd._minigame == null, "Connection/MiniGame не создаётся для приёма")
|
||||
_approx(float(req.time_left), req_time, 0.0001,
|
||||
"таймер запроса заморожен во время соединения")
|
||||
_approx(float(_hd.time_left), hour_time - 1.0, 0.02, "время часа идёт")
|
||||
|
||||
var sla_before_fail := float(_game.sla)
|
||||
_toasts.clear()
|
||||
_ok(_hd.connection_finished(req_id, false, true),
|
||||
"connection_finished(false, missed=true) принят")
|
||||
_ok(not req.connecting and not req.connected, "неудача сбрасывает connecting")
|
||||
_ok(_has_toast("СОЕДИНЕНИЕ НЕ УСТАНОВЛЕНО"), "тост о неустановленном соединении")
|
||||
_eq(int(_game.money), money_before, "неудача соединения без денежного штрафа")
|
||||
_approx(float(_game.sla), sla_before_fail - 4.0, 0.001,
|
||||
"промах соединения: −4 SLA")
|
||||
_approx(float(req.connect_cooldown_until), float(_hd.hour_elapsed) + 8.0, 0.01,
|
||||
"промах: кулдаун 8 с")
|
||||
_ok(_hd.is_request_ready(req_id), "после неудачи запрос готов к повтору")
|
||||
_toasts.clear()
|
||||
_ok(not _hd.start_send(req_id), "start_send во время кулдауна → false")
|
||||
_ok(_has_toast("ПОВТОР ЧЕРЕЗ"), "тост о кулдауне соединения")
|
||||
|
||||
_hd.hour_elapsed = float(req.connect_cooldown_until) + 0.01
|
||||
var sla_after_miss := float(_game.sla)
|
||||
_connection_shows = 0
|
||||
_ok(_hd.connect_request(req_id), "connect_request запускает повтор")
|
||||
_eq(req.connect_attempts, 2, "connect_attempts = 2")
|
||||
_eq(_connection_shows, 1, "повторный show_connection")
|
||||
_ok(_hd.connection_finished(req_id, false, false),
|
||||
"connection_finished(false, missed=false) — отмена")
|
||||
_approx(float(_game.sla), sla_after_miss, 0.001, "отмена соединения без SLA")
|
||||
_approx(float(req.connect_cooldown_until), float(_hd.hour_elapsed) + 4.0, 0.01,
|
||||
"отмена: кулдаун 4 с")
|
||||
|
||||
_hd.hour_elapsed = float(req.connect_cooldown_until) + 0.01
|
||||
_connection_shows = 0
|
||||
_ok(_hd.connect_request(req_id), "третья попытка соединения")
|
||||
_eq(req.connect_attempts, 3, "connect_attempts = 3")
|
||||
_eq(_connection_shows, 1, "show_connection на третью попытку")
|
||||
_minigame_shows = 0
|
||||
_ok(_hd.connection_finished(req_id, true), "connection_finished(true) принят")
|
||||
_ok(req.connected, "connected = true после успеха")
|
||||
_approx(float(req.connect_cooldown_until), 0.0, 0.001, "успех снял кулдаун")
|
||||
_eq(_hd.connect_successes, 1, "connect_successes = 1")
|
||||
_eq(_minigame_shows, 1, "после соединения открылась мини-игра отправки")
|
||||
_ok(req.minigame_active, "мини-игра отправки активна")
|
||||
_hd.force_close_send_minigame()
|
||||
_hd._cleanup_request(req_id, true)
|
||||
|
||||
# UDP не требует соединения: сразу мини-игра отправки.
|
||||
var file2 = _new_file("УДП", "text", folder_id)
|
||||
if file2 != null:
|
||||
var rid2 := str(_hd._create_request(1, "udp", [str(file2.id)], false))
|
||||
_ok(_hd.attach_file(rid2, str(file2.id)), "UDP-файл прикреплён")
|
||||
_connection_shows = 0
|
||||
_ok(_hd.start_send(rid2), "UDP стартует отправку")
|
||||
_eq(_connection_shows, 0, "UDP не показывает соединение")
|
||||
_ok(bool(_hd.active_requests[rid2].minigame_active), "UDP: мини-игра отправки")
|
||||
_hd.force_close_send_minigame()
|
||||
_hd._cleanup_request(rid2, true)
|
||||
|
||||
# Исчезновение required-файла: запрос остаётся отклоняемым.
|
||||
var file3 = _new_file("Пропажа", "text", folder_id)
|
||||
if file3 != null:
|
||||
var fid3 := str(file3.id)
|
||||
var rid3 := str(_hd._create_request(1, "udp", [fid3], true))
|
||||
_ok(_hd.attach_file(rid3, fid3), "файл прикреплён")
|
||||
_game.registry.consume_file(fid3)
|
||||
_hd.tick(0.1)
|
||||
_eq(_hd.request_missing(rid3), [fid3], "исчезнувший файл снова в missing")
|
||||
_eq(_hd.request_missing_names(rid3), ["ПОТЕРЯН"], "имя потерянного файла")
|
||||
_ok(not _hd.start_send(rid3), "запрос без required-файла не отправить")
|
||||
_ok(_hd.reject_request(rid3), "запрос можно отклонить")
|
||||
|
||||
_ok(not _hd.connect_packet("pkt_x"), "connect_packet — заглушка false")
|
||||
_eq(_connection_shows, 0, "приём пакетов не требует соединения")
|
||||
|
||||
|
||||
# --- потерянные required-файлы ---------------------------------------------
|
||||
|
||||
func _test_lost_required() -> void:
|
||||
_set_case("lost_required_substitute")
|
||||
_fresh(1)
|
||||
var folder_id := _folder_id()
|
||||
var f1 = _new_file("Потеря", "text", folder_id)
|
||||
var f2 = _new_file("Замена", "text", folder_id)
|
||||
_ok(f1 != null and f2 != null, "два файла созданы")
|
||||
if f1 == null or f2 == null:
|
||||
return
|
||||
var fid1 := str(f1.id)
|
||||
var fid2 := str(f2.id)
|
||||
var req_id := str(_hd._create_request(1, "tcp", [fid1], false))
|
||||
_eq(str(_game.registry.files[fid1].required_by), req_id, "резерв на первом файле")
|
||||
_game.registry.mark_failed(fid1)
|
||||
for i in range(6):
|
||||
_hd.tick(0.1)
|
||||
var ids: Array = _hd.request_required_ids(req_id)
|
||||
_eq(ids.size(), 1, "required не раздулся")
|
||||
_eq(str(ids[0]), fid2, "потерянный файл заменён однотипным")
|
||||
_eq(str(_game.registry.files[fid1].required_by), "", "required_by старого снят")
|
||||
_eq(str(_game.registry.files[fid2].required_by), req_id, "required_by нового выставлен")
|
||||
_eq(_hd.request_missing(req_id), [fid2], "новый файл ожидает прикрепления")
|
||||
_ok(_hd.attach_file(req_id, fid2), "замену можно прикрепить")
|
||||
_hd._cleanup_request(req_id, true)
|
||||
|
||||
_set_case("lost_required_none")
|
||||
_fresh(1)
|
||||
folder_id = _folder_id()
|
||||
var only = _new_file("Единственный", "text", folder_id)
|
||||
if only == null:
|
||||
return
|
||||
var fid_only := str(only.id)
|
||||
var rid2 := str(_hd._create_request(1, "udp", [fid_only], true))
|
||||
_game.registry.consume_file(fid_only)
|
||||
for i in range(6):
|
||||
_hd.tick(0.1)
|
||||
_eq(_hd.request_required_ids(rid2), [fid_only], "без замены required остаётся прежним")
|
||||
_eq(_hd.request_missing(rid2), [fid_only], "файл числится missing")
|
||||
_eq(_hd.request_missing_names(rid2), ["ПОТЕРЯН"], "имя потерянного файла")
|
||||
_ok(not _hd.start_send(rid2), "запрос без файла не отправить")
|
||||
_ok(_hd.reject_request(rid2), "запрос можно отклонить")
|
||||
|
||||
|
||||
# --- C1/C2: модалка блокирует второй старт --------------------------------
|
||||
|
||||
func _test_modal_guard() -> void:
|
||||
_set_case("modal_guard")
|
||||
_fresh(1)
|
||||
var file = _new_file("Модалка", "text", _folder_id())
|
||||
if file == null:
|
||||
return
|
||||
var req_id := str(_hd._make_request([file], "udp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
|
||||
_minigame_shows = 0
|
||||
_hd.set_modal_open(true)
|
||||
_ok(not _hd.start_send(req_id), "C2: start_send при modal_open → false")
|
||||
_ok(_hd._minigame == null, "C2: вторая мини-игра не создана")
|
||||
_ok(str(_hd._minigame_request_id).is_empty(), "C2: request_id мини-игры пуст")
|
||||
var req = _hd.active_requests.get(req_id)
|
||||
_ok(req != null and not bool(req.minigame_active),
|
||||
"C2: minigame_active не выставлен")
|
||||
_eq(_minigame_shows, 0, "C2: show_minigame не эмитился")
|
||||
_ok(_has_toast("закройте текущее окно"), "C2: тост о занятом окне")
|
||||
_hd.set_modal_open(false)
|
||||
_ok(_hd.start_send(req_id), "после закрытия модалки отправка стартует")
|
||||
_hd.force_close_send_minigame()
|
||||
_hd._cleanup_request(req_id, true)
|
||||
|
||||
# То же для TCP-соединения: модалка не даёт начать connecting.
|
||||
_fresh(1)
|
||||
var file2 = _new_file("Модалка TCP", "text", _folder_id())
|
||||
if file2 == null:
|
||||
return
|
||||
var req2 := str(_hd._make_request([file2], "tcp", false, "", ""))
|
||||
_ok(_hd.attach_file(req2, str(file2.id)), "TCP-файл прикреплён")
|
||||
_connection_shows = 0
|
||||
_hd.set_modal_open(true)
|
||||
_ok(not _hd.connect_request(req2), "C2: connect_request при modal_open → false")
|
||||
var req2_live = _hd.active_requests.get(req2)
|
||||
_ok(req2_live != null and not bool(req2_live.connecting),
|
||||
"C2: connecting не выставлен")
|
||||
_eq(_connection_shows, 0, "C2: show_connection не эмитился")
|
||||
_hd.set_modal_open(false)
|
||||
_hd._cleanup_request(req2, true)
|
||||
|
||||
|
||||
# --- M4: required_types принадлежат слотам, а не порядку вложений ----------
|
||||
|
||||
func _test_required_types_stable() -> void:
|
||||
_set_case("required_types_stable")
|
||||
_fresh(1)
|
||||
var folder := _folder_id()
|
||||
var f_text = _new_file("Слот текст", "text", folder)
|
||||
var f_sound = _new_file("Слот звук", "sound", folder)
|
||||
if f_text == null or f_sound == null:
|
||||
return
|
||||
var t_id := str(f_text.id)
|
||||
var s_id := str(f_sound.id)
|
||||
var req_id := str(_hd._create_request(2, "udp", [t_id, s_id], true))
|
||||
var req = _hd.active_requests.get(req_id)
|
||||
if req == null:
|
||||
return
|
||||
var expected: Array = req.required_types.duplicate()
|
||||
_eq(expected, ["text", "sound"], "required_types по слотам при создании")
|
||||
# Прикрепляем в обратном порядке: раньше это перезаписывало типы слотов.
|
||||
_ok(_hd.attach_file(req_id, s_id), "прикреплён sound")
|
||||
_ok(_hd.attach_file(req_id, t_id), "прикреплён text")
|
||||
_eq(req.required_types, expected,
|
||||
"M4: attach не перезаписывает required_types порядком вложений")
|
||||
_ok(_hd.start_send(req_id), "UDP-мини-игра открыта")
|
||||
_eq(req.required_types, expected,
|
||||
"M4: start_send не перезаписывает required_types вложенными типами")
|
||||
_hd.force_close_send_minigame()
|
||||
_hd._cleanup_request(req_id, true)
|
||||
|
||||
|
||||
# --- M5: во время передачи потеря не подменяется ---------------------------
|
||||
|
||||
func _test_no_substitute_while_sending() -> void:
|
||||
_set_case("no_substitute_while_sending")
|
||||
_fresh(1)
|
||||
var folder := _folder_id()
|
||||
var f1 = _new_file("В пути", "text", folder)
|
||||
var f2 = _new_file("Резерв замены", "text", folder)
|
||||
if f1 == null or f2 == null:
|
||||
return
|
||||
var fid := str(f1.id)
|
||||
var req_id := str(_hd._make_request([f1], "udp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, fid), "файл прикреплён")
|
||||
_ok(_hd.start_send(req_id), "мини-игра открыта")
|
||||
_hd._on_send_minigame_finished(1.0)
|
||||
var req = _hd.active_requests.get(req_id)
|
||||
if req == null:
|
||||
return
|
||||
_ok(bool(req.sending), "передача началась")
|
||||
req.send_duration = 100.0
|
||||
req.send_progress = 0.0
|
||||
_game.registry.mark_failed(fid)
|
||||
for i in range(12):
|
||||
_hd.tick(0.1)
|
||||
_eq(_hd.request_required_ids(req_id), [fid],
|
||||
"M5: во время отправки потерянный required не подменяется")
|
||||
_ok(not _hd.request_required_ids(req_id).has(str(f2.id)),
|
||||
"M5: резервный файл не встал в required")
|
||||
_hd._cleanup_request(req_id, true)
|
||||
|
||||
|
||||
# --- отчёт -----------------------------------------------------------------
|
||||
@@ -580,10 +996,12 @@ func _test_no_connection_game() -> void:
|
||||
func _test_report() -> void:
|
||||
_set_case("report")
|
||||
_fresh(2)
|
||||
_eq(_hd.request_cap, 5, "H2: кап запросов из данных = 5")
|
||||
_eq(_hd.active_request_limit, 2, "H2: 2 активных запроса")
|
||||
_eq(_hd.request_cap, int(_hour_row(2).get("request_cap", -1)),
|
||||
"H2: кап запросов из данных")
|
||||
_eq(_hd.active_request_limit, int(_hour_row(2).get("active_requests", -1)),
|
||||
"H2: активный лимит из данных")
|
||||
var file = _new_file("Отчёт", "sound", _folder_id())
|
||||
var req_id: String = str(_hd.spawn_request_now(1))
|
||||
var req_id: String = str(_hd._make_request([file], "tcp", false, "", ""))
|
||||
_ok(_hd.attach_file(req_id, str(file.id)), "файл прикреплён")
|
||||
_ok(_hd.start_send(req_id), "мини-игра открыта")
|
||||
_minigame_shows = 0
|
||||
@@ -604,9 +1022,108 @@ func _test_report() -> void:
|
||||
_ok(int(_hour_report.get("files_budget", 0)) == 8, "отчёт: files_budget H2 = 8")
|
||||
|
||||
|
||||
# --- подготовка перед H1 ---------------------------------------------------
|
||||
|
||||
## reset_for_prep() оставляет seed-файл, но без активных запросов и резерваций;
|
||||
## старт H1 после этого всё равно выдаёт запрос (starvation guard / bootstrap).
|
||||
func _test_reset_for_prep() -> void:
|
||||
_set_case("reset_for_prep")
|
||||
_game.start_new_run(515)
|
||||
_game.hour = 1
|
||||
_game.phase = &"hour"
|
||||
_hd.reset_carry()
|
||||
_hd.reset_for_prep()
|
||||
_eq(_hd.hour, 1, "reset_for_prep: час 1")
|
||||
_eq(_hd.active_requests.size(), 0, "reset_for_prep: нет активных запросов")
|
||||
_eq(_hd.active_packets.size(), 0, "reset_for_prep: нет активных пакетов")
|
||||
_eq(_hd.requests_spawned, 0, "reset_for_prep: requests_spawned = 0")
|
||||
_ok(not _hd.running, "reset_for_prep: директор не запущен")
|
||||
_eq(_game.registry.file_count(), 1, "reset_for_prep: seed-файл остался")
|
||||
var reserved: Array = []
|
||||
for file in _game.registry.files.values():
|
||||
if file != null and not str(file.required_by).is_empty():
|
||||
reserved.append(str(file.id))
|
||||
_eq(reserved.size(), 0, "reset_for_prep: ни один файл не зарезервирован")
|
||||
|
||||
# Старт реального часа: seed уже есть → bootstrap не дублируется,
|
||||
# запрос создаёт starvation guard на первом же тике.
|
||||
_hd.start_hour(1)
|
||||
_eq(_game.registry.file_count(), 1, "старт H1: второй seed не создан")
|
||||
var spawned_at := -1.0
|
||||
for i in range(50):
|
||||
_hd.tick(0.1)
|
||||
if not _hd.active_requests.is_empty():
|
||||
spawned_at = float(_hd.hour_elapsed)
|
||||
break
|
||||
_eq(_hd.active_requests.size(), 1, "старт H1: запрос появился")
|
||||
_ok(spawned_at >= 0.0 and spawned_at <= 0.5,
|
||||
"H1: запрос сразу на старте (t=%.1f)" % spawned_at)
|
||||
if not _hd.active_requests.is_empty():
|
||||
var req_id := str(_hd.active_requests.keys()[0])
|
||||
var ids: Array = _hd.request_required_ids(req_id)
|
||||
_ok(ids.size() >= 1, "H1: запрос требует файл")
|
||||
for fid in ids:
|
||||
var file = _game.registry.files.get(str(fid))
|
||||
_ok(file != null and str(file.required_by) == req_id,
|
||||
"H1: required_by снова выставлен")
|
||||
|
||||
|
||||
# --- перемещение зарезервированного файла -----------------------------------
|
||||
|
||||
## Решение (BUG 4): зарезервированный файл можно переносить между папками.
|
||||
## Запрос ищет файл по id, поэтому остаётся выполнимым из любой папки.
|
||||
func _test_move_required_file() -> void:
|
||||
_set_case("move_required_file")
|
||||
_game.start_new_run(616)
|
||||
_game.hour = 1
|
||||
_game.phase = &"hour"
|
||||
_hd.reset_carry()
|
||||
_hd.reset_for_prep()
|
||||
var folder_ids: Array = _game.registry.folders.keys()
|
||||
if folder_ids.size() < 2:
|
||||
_ok(false, "нужно минимум 2 папки")
|
||||
return
|
||||
var from_id := str(folder_ids[0])
|
||||
var to_id := str(folder_ids[1])
|
||||
var file = _new_file("Перемещаемый резерв", "text", from_id)
|
||||
_ok(file != null, "файл создан")
|
||||
if file == null:
|
||||
return
|
||||
var fid := str(file.id)
|
||||
var req_id := str(_hd._create_request(1, "udp", [fid], true))
|
||||
_ok(not req_id.is_empty(), "запрос создан")
|
||||
if req_id.is_empty():
|
||||
return
|
||||
_eq(str(file.required_by), req_id, "файл зарезервирован")
|
||||
_ok(bool(_game.registry.move_file(fid, to_id)),
|
||||
"move_file зарезервированного файла разрешён")
|
||||
_eq(str(_game.registry.files[fid].folder_id), to_id,
|
||||
"файл переехал в другую папку")
|
||||
_eq(str(_game.registry.files[fid].required_by), req_id,
|
||||
"резерв сохранился после перемещения")
|
||||
_ok(_hd.attach_file(req_id, fid), "запрос всё ещё может прикрепить файл")
|
||||
_ok(_hd.is_request_ready(req_id), "запрос готов к отправке после перемещения")
|
||||
_hd._cleanup_request(req_id, true)
|
||||
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
|
||||
func _fresh(hour: int) -> void:
|
||||
## Строка hours[] из data/balance.json (null-safe для --script прогона).
|
||||
func _hour_row(hour: int) -> Dictionary:
|
||||
var data := root.get_node_or_null("Data")
|
||||
if data != null and data.has_method("hour_def"):
|
||||
return data.hour_def(int(hour))
|
||||
return {}
|
||||
|
||||
|
||||
func _difficulty_row(hour: int) -> Dictionary:
|
||||
var data := root.get_node_or_null("Data")
|
||||
if data != null and data.has_method("difficulty_for"):
|
||||
return data.difficulty_for(int(hour))
|
||||
return {}
|
||||
|
||||
|
||||
func _fresh(hour: int, keep_seed: bool = false) -> void:
|
||||
_game.start_new_run(1000 + int(hour) * 17)
|
||||
_game.hour = int(hour)
|
||||
_game.phase = &"hour"
|
||||
@@ -616,12 +1133,28 @@ func _fresh(hour: int) -> void:
|
||||
_hd.reset_carry()
|
||||
_hd.start_hour(int(hour))
|
||||
_hd.set_time_scale(1.0)
|
||||
if hour == 1 and not keep_seed:
|
||||
_drop_bootstrap_seed()
|
||||
if _hd.attack_director != null:
|
||||
_hd.attack_director._schedule.clear()
|
||||
_hd.attack_director._next_index = 0
|
||||
_hour_events = 0
|
||||
_hour_report = {}
|
||||
_minigame_cancels = 0
|
||||
_toasts.clear()
|
||||
|
||||
|
||||
## Убирает H1-затравку (файл+запрос), чтобы старые кейсы стартовали с пустого склада.
|
||||
func _drop_bootstrap_seed() -> void:
|
||||
for req_id in _hd.active_requests.keys().duplicate():
|
||||
_hd._cleanup_request(str(req_id), true)
|
||||
for file in _game.registry.files.values().duplicate():
|
||||
_game.registry.consume_file(str(file.id))
|
||||
_hd.requests_spawned = 0
|
||||
_hd._last_request_attempt_t = -_hd.REQUEST_ATTEMPT_MIN_GAP
|
||||
_hd.spawn_send_accum = 0.0
|
||||
_hd.spawn_recv_accum = 0.0
|
||||
_hd._next_spawn_at = 0.0
|
||||
|
||||
|
||||
func _reset(hour: int) -> void:
|
||||
@@ -677,8 +1210,22 @@ func _on_spawn_frozen(_reason: String) -> void:
|
||||
_frozen_events += 1
|
||||
|
||||
|
||||
func _on_show_connection(_game_node) -> void:
|
||||
func _on_show_connection(request_id: String, hour: int, successes: int) -> void:
|
||||
_connection_shows += 1
|
||||
_connection_request_id = request_id
|
||||
_connection_hour = hour
|
||||
_connection_successes = successes
|
||||
|
||||
|
||||
func _on_toast(text: String) -> void:
|
||||
_toasts.append(text)
|
||||
|
||||
|
||||
func _has_toast(fragment: String) -> bool:
|
||||
for text in _toasts:
|
||||
if str(text).contains(fragment):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# --- assert-хелперы --------------------------------------------------------
|
||||
|
||||
@@ -68,7 +68,8 @@ func _test_full_hour() -> void:
|
||||
_ok(report.has(key), "в отчёте есть %s" % key)
|
||||
_ok(not is_nan(float(report.get("sla_delta", 0.0))), "sla_delta не NaN")
|
||||
_ok(not is_nan(float(_game.sla)), "SLA не NaN")
|
||||
_ok(float(_game.sla) <= 200.0 + 0.0001, "SLA ≤ 200")
|
||||
_ok(float(_game.sla) <= float(_game.sla_cap()) + 0.0001,
|
||||
"SLA ≤ кап (%.0f)" % float(_game.sla_cap()))
|
||||
_eq(_hd.slots.used_count(), 0, "все слоты освобождены после конца часа")
|
||||
_ok(_hd.active_packets.is_empty(), "после конца часа нет активных пакетов")
|
||||
_ok(_hd.active_requests.is_empty(), "после конца часа нет активных запросов")
|
||||
@@ -108,7 +109,8 @@ func _test_nine_hours() -> void:
|
||||
_ok(report.has(key), "час %d: в отчёте есть %s" % [h, key])
|
||||
_ok(not is_nan(float(report.get("sla_delta", 0.0))), "час %d: sla_delta не NaN" % h)
|
||||
_ok(not is_nan(float(_game.sla)), "час %d: SLA не NaN" % h)
|
||||
_ok(float(_game.sla) <= 200.0 + 0.0001, "час %d: SLA ≤ 200" % h)
|
||||
_ok(float(_game.sla) <= float(_game.sla_cap()) + 0.0001,
|
||||
"час %d: SLA ≤ кап (%.0f)" % [h, float(_game.sla_cap())])
|
||||
_ok(int(_game.money) >= 0, "час %d: money >= 0" % h)
|
||||
_eq(_attack_marks.size(), expected[h - 1], "час %d: атак по расписанию" % h)
|
||||
for mark in _attack_marks:
|
||||
|
||||
@@ -74,16 +74,14 @@ func _init() -> void:
|
||||
if req == null:
|
||||
_finish()
|
||||
return
|
||||
var fid := ""
|
||||
for file in _game.registry.get_available_files():
|
||||
if str(file.location) == "folder":
|
||||
fid = str(file.id)
|
||||
break
|
||||
_ok(not fid.is_empty(), "есть свободный файл для прикрепления")
|
||||
if fid.is_empty():
|
||||
# v3: прикреплять можно только конкретный required-файл запроса.
|
||||
var missing: Array = director.request_missing(rid)
|
||||
_ok(not missing.is_empty(), "у запроса есть required-файл для прикрепления")
|
||||
if missing.is_empty():
|
||||
_finish()
|
||||
return
|
||||
_ok(director.attach_file(rid, fid), "файл прикреплён к запросу")
|
||||
var fid := str(missing[0])
|
||||
_ok(director.attach_file(rid, fid), "required-файл прикреплён к запросу")
|
||||
_main._refresh_hour()
|
||||
await process_frame
|
||||
await process_frame # даём отработать deferred registry-refresh сетки
|
||||
@@ -171,7 +169,8 @@ func _slot_attached(rid: String, fid: String) -> bool:
|
||||
"required_count": hd.request_required_count(rid),
|
||||
"attached": req.attached,
|
||||
})
|
||||
var index: int = (req.attached as Array).find(fid)
|
||||
var required: Array = hd.request_required_ids(rid)
|
||||
var index: int = required.find(fid)
|
||||
if index < 0 or index >= slots.size() or slots[index] == null:
|
||||
return false
|
||||
return bool((slots[index] as Dictionary).get("attached", false))
|
||||
@@ -188,7 +187,8 @@ func _request_card_attached(rid: String, fid: String) -> bool:
|
||||
var req = _controller.hour_director.active_requests.get(rid)
|
||||
if req == null:
|
||||
return false
|
||||
var index: int = (req.attached as Array).find(fid)
|
||||
var required: Array = _controller.hour_director.request_required_ids(rid)
|
||||
var index: int = required.find(fid)
|
||||
for card in _main.requests:
|
||||
if card == null or not is_instance_valid(card) or str(card.id) != rid:
|
||||
continue
|
||||
|
||||
@@ -9,6 +9,7 @@ extends SceneTree
|
||||
## godot --headless --path . --script tests/test_main_hour.gd
|
||||
|
||||
const MG := preload("res://scripts/minigames/minigame_base.gd")
|
||||
const ECONOMY := preload("res://scripts/core/economy.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _fails := 0
|
||||
@@ -65,6 +66,9 @@ func _init() -> void:
|
||||
_ok(main.sorting_table != null and not main.sorting_table.visible,
|
||||
"стол сортировки скрыт в час")
|
||||
|
||||
var director = main.controller.hour_director
|
||||
_ok(director != null, "hour_director доступен")
|
||||
|
||||
# --- Стартовые статы H1 --------------------------------------------------
|
||||
_eq(str(main.status_bar.get_metric("sla").get("value", "")), "60 / 60",
|
||||
"статус SLA — 60 / 60")
|
||||
@@ -72,11 +76,10 @@ func _init() -> void:
|
||||
"статус слотов — 0 / 1")
|
||||
_eq(str(main.status_bar.get_metric("channel").get("value", "")), "0/10",
|
||||
"статус канала — load/capacity 0/10")
|
||||
_eq(str(main.status_bar.get_metric("buffer").get("value", "")), "0 / 6",
|
||||
"статус буфера — 0 / 6")
|
||||
|
||||
var director = main.controller.hour_director
|
||||
_ok(director != null, "hour_director доступен")
|
||||
# H1: первый пакет спавнится в t=0, поэтому буфер сверяем с директором.
|
||||
_eq(str(main.status_bar.get_metric("buffer").get("value", "")),
|
||||
"%d / %d" % [director.active_packets.size(), director.buffer_limit()],
|
||||
"статус буфера — занято/лимит")
|
||||
|
||||
# --- Пакет: карточка с фигурой и клетками --------------------------------
|
||||
var pid: String = director.spawn_packet_now("sound", "tcp")
|
||||
@@ -92,18 +95,18 @@ func _init() -> void:
|
||||
if main.slot_cells.size() == 5:
|
||||
_ok(main.slot_cells[0].state != "locked", "слот 0 доступен (база 1)")
|
||||
_eq(main.slot_cells[4].state, "locked", "слот 5 закрыт (максимум 5)")
|
||||
if not main.packets.is_empty():
|
||||
var pcard = main.packets[0]
|
||||
var pkt = director.active_packets.get(pid)
|
||||
_ok(pkt != null, "пакет есть в директоре")
|
||||
if pkt != null:
|
||||
_eq(float(pkt.timer_total), 20.0,
|
||||
"таймер пакета H1 = 20 с (%.1f)" % float(pkt.timer_total))
|
||||
_eq(str(pcard.shape_id), str(pkt.shape_id), "карточка показывает shape_id пакета")
|
||||
_ok(pcard.cells_count() >= 2, "звук занимает минимум 2 клетки")
|
||||
_ok(pcard.cells_text().begins_with("ЗАЙМЁТ"),
|
||||
"карточка пакета: %s" % pcard.cells_text())
|
||||
_ok(pcard.get("is_attack") == null, "в карточке пакета нет атаки")
|
||||
var pcard = _packet_card(main, pid)
|
||||
_ok(pcard != null, "карточка пакета найдена")
|
||||
var pkt = director.active_packets.get(pid)
|
||||
_ok(pkt != null, "пакет есть в директоре")
|
||||
if pkt != null and pcard != null:
|
||||
_eq(float(pkt.timer_total), 20.0,
|
||||
"таймер пакета H1 = 20 с (%.1f)" % float(pkt.timer_total))
|
||||
_eq(str(pcard.shape_id), str(pkt.shape_id), "карточка показывает shape_id пакета")
|
||||
_ok(pcard.cells_count() >= 2, "звук занимает минимум 2 клетки")
|
||||
_ok(pcard.cells_text().begins_with("ЗАЙМЁТ"),
|
||||
"карточка пакета: %s" % pcard.cells_text())
|
||||
_ok(pcard.get("is_attack") == null, "в карточке пакета нет атаки")
|
||||
var req0 = director.active_requests.get(rid)
|
||||
if req0 != null:
|
||||
_eq(float(req0.timer_total), 30.0,
|
||||
@@ -118,13 +121,23 @@ func _init() -> void:
|
||||
_eq(card.required_count, 1, "запросу нужен 1 файл")
|
||||
_eq(card.attached_count(), 0, "прикреплено 0 файлов")
|
||||
_ok(card.send_disabled(), "ОТПРАВИТЬ выключена без файлов")
|
||||
_ok(card.minigame_text().contains("—"), "мини-игра не определена без файлов")
|
||||
_ok(card.minigame_text().contains("МИНИ-ИГРА"),
|
||||
"прогноз мини-игры виден до прикрепления: %s" % card.minigame_text())
|
||||
_ok(not card.minigame_text().contains("—"),
|
||||
"прогноз считается по типам required-файлов")
|
||||
var pred_expected: Dictionary = ECONOMY.send_minigame_for(req0.required_types)
|
||||
_eq(int(card.prediction().get("sum", -1)), int(pred_expected.get("sum", -2)),
|
||||
"прогноз: сумма коэффициентов")
|
||||
_eq(str(card.prediction().get("id", "")), str(pred_expected.get("id", "")),
|
||||
"прогноз: игра из Economy")
|
||||
_eq(int(card.prediction().get("tier", -1)), int(pred_expected.get("tier", -2)),
|
||||
"прогноз: ступень из Economy")
|
||||
_eq(card.protocol, "TCP", "протокол по умолчанию TCP")
|
||||
|
||||
# --- Раскладка v2: карточка 108 px, скролл вмещает 4 запроса -------------
|
||||
# --- Раскладка v3: карточка 400×128, скролл вмещает 4 запроса ------------
|
||||
var content: Control = main.send_window.content
|
||||
_eq(int(content.size.y), 252, "контент ОТПРАВКИ: высота 252 px")
|
||||
_eq(float(card.size.y), 108.0, "RequestCard v2: высота 108 px")
|
||||
_eq(card.size, Vector2(400, 128), "RequestCard v3: 400×128")
|
||||
_ok(card.position.y + 24.0 <= content.size.y + 0.5,
|
||||
"карточка %s: заголовок/таймер в клипе" % str(card.name))
|
||||
main._sync_send_scroll(4)
|
||||
@@ -137,7 +150,13 @@ func _init() -> void:
|
||||
main._layout_request_cards(main.requests.size())
|
||||
|
||||
# --- attach через drop карточки: без автостарта --------------------------
|
||||
main._on_file_to_request(rid, 0, {"kind": "file", "id": str(file_ids[0]),
|
||||
var missing: Array = director.request_missing(rid)
|
||||
_ok(missing.size() == 1, "у запроса есть конкретный required-файл")
|
||||
if missing.is_empty():
|
||||
_finish()
|
||||
return
|
||||
var attach_id := str(missing[0])
|
||||
main._on_file_to_request(rid, 0, {"kind": "file", "id": attach_id,
|
||||
"from": "folder"})
|
||||
await process_frame
|
||||
main._refresh_hour()
|
||||
@@ -145,13 +164,18 @@ func _init() -> void:
|
||||
_ok(req != null, "запрос жив после прикрепления")
|
||||
if req != null:
|
||||
_eq(req.attached.size(), 1, "файл прикреплён")
|
||||
_ok(req.attached.has(attach_id), "прикреплён именно required-файл")
|
||||
_ok(not req.minigame_active, "attach НЕ запускает мини-игру")
|
||||
_ok(not req.sending, "attach НЕ запускает отправку")
|
||||
_ok(director.request_missing(rid).is_empty(), "required-файл больше не missing")
|
||||
_ok(card.can_send(), "ОТПРАВИТЬ доступна после прикрепления")
|
||||
_ok(not card.send_disabled(), "кнопка ОТПРАВИТЬ включена")
|
||||
_ok(card.minigame_text().contains("МИНИ-ИГРА"), "подсказка мини-игры: %s"
|
||||
% card.minigame_text())
|
||||
_eq(card.difficulty_text(), "СЛОЖНОСТЬ: 1", "сложность текста = 1")
|
||||
var attached_sum := int(ECONOMY.send_minigame_for(
|
||||
[game.registry.files[attach_id].type]).get("sum", -1))
|
||||
_eq(card.difficulty_text(), "СУММА: %d" % attached_sum,
|
||||
"на карточке сумма коэффициентов")
|
||||
_eq(card.attached_count(), 1, "карточка: прикреплено 1/1")
|
||||
|
||||
# --- TCP/UDP -------------------------------------------------------------
|
||||
@@ -209,15 +233,29 @@ func _init() -> void:
|
||||
"перегруженный канал помечен красным")
|
||||
main._refresh_hour()
|
||||
|
||||
# --- Мини-игра отправки через подменную фабрику -------------------------
|
||||
# --- TCP: кнопка СОЕДИНИТЬСЯ → ConnectionGame → мини-игра отправки ------
|
||||
var holder := {"game": null}
|
||||
director.send_game_factory = func(_id, _tier, _seed, _ctx):
|
||||
var g := FakeGame.new()
|
||||
holder["game"] = g
|
||||
return g
|
||||
main._on_request_send(rid)
|
||||
_eq(card._send_btn.caption, "СОЕДИНИТЬСЯ",
|
||||
"TCP без соединения: кнопка СОЕДИНИТЬСЯ")
|
||||
card._send_btn.pressed.emit()
|
||||
await process_frame
|
||||
_ok(director.active_requests[rid].minigame_active, "ОТПРАВИТЬ открывает мини-игру")
|
||||
var req_live = director.active_requests.get(rid)
|
||||
_ok(req_live != null and req_live.connecting,
|
||||
"нажатие запускает установку TCP-соединения")
|
||||
_ok(main._modal_game != null, "модалка соединения показана")
|
||||
_eq(str(main._modal_kind), "connection", "показана модалка соединения")
|
||||
var conn_game = main._modal_game
|
||||
_ok(conn_game != null, "ConnectionGame создан контроллером")
|
||||
main._on_connection_finished(true, rid, conn_game)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_ok(req_live != null and req_live.connected, "соединение установлено")
|
||||
_ok(req_live != null and req_live.minigame_active,
|
||||
"после соединения открылась мини-игра отправки")
|
||||
_ok(main._modal_game != null, "модалка мини-игры показана")
|
||||
_ok(bool(main.controller.modal_open), "controller.modal_open = true на мини-игре")
|
||||
_fake_game = holder.get("game")
|
||||
@@ -294,16 +332,123 @@ func _init() -> void:
|
||||
_ok(main._modal_game == null, "_modal_game снят после ПРОДОЛЖИТЬ")
|
||||
_ok(not bool(main.controller.modal_open), "modal_open сброшен после ПРОДОЛЖИТЬ")
|
||||
|
||||
# --- M8: spawn_frozen подключён к UI и троттлится ----------------------
|
||||
_ok(director.spawn_frozen.get_connections().size() > 0,
|
||||
"M8: spawn_frozen подключён к тосту UI")
|
||||
main._spawn_frozen_toast_ms = -100000
|
||||
main._on_spawn_frozen_ui("buffer_full")
|
||||
var frozen_ms := int(main._spawn_frozen_toast_ms)
|
||||
main._on_spawn_frozen_ui("buffer_full")
|
||||
_eq(int(main._spawn_frozen_toast_ms), frozen_ms,
|
||||
"M8: повторный spawn_frozen в пределах 5 с не спамит")
|
||||
|
||||
# --- M2: шаг обучения «send» засчитывается на пути закрытия мини-игры ---
|
||||
main._tutorial_reset()
|
||||
main._tutorial_active = true
|
||||
main._tutorial_shown = true
|
||||
main._tutorial_step = 3 # «send» в TUTORIAL_STEPS
|
||||
main._tutorial_step_elapsed = 0.0
|
||||
main._tutorial_ack_left = 0.0
|
||||
var tut_mg = MG.new()
|
||||
tut_mg.start("dots", 1, 777, {})
|
||||
_ok(main._show_modal_game(tut_mg, "send"), "M2: send-модалка открыта")
|
||||
main._on_minigame_closed_ui("tut_req")
|
||||
_eq(main._tutorial_step, 4,
|
||||
"M2: шаг send завершён через _on_minigame_closed_ui")
|
||||
main._on_minigame_closed_ui("tut_req")
|
||||
_eq(main._tutorial_step, 4, "M2: повторный close не двигает обучение дважды")
|
||||
main._on_modal_game_finished(null, tut_mg)
|
||||
_eq(main._tutorial_step, 4, "M2: finished не дублирует уведомление send")
|
||||
tut_mg.queue_free()
|
||||
main._tutorial_reset()
|
||||
main._modal_game = null
|
||||
main._modal_kind = ""
|
||||
main.controller.set_modal_open(false)
|
||||
await process_frame
|
||||
|
||||
# --- C1: ввод под модалкой не открывает вторую мини-игру -----------------
|
||||
var guard_modal := Node.new()
|
||||
_ok(main._show_modal_game(guard_modal, "attack"),
|
||||
"C1: тестовая модалка атаки открыта")
|
||||
var guard_file = game.registry.create_file("guard_modal", "text", folder_id)
|
||||
var guard_rid := ""
|
||||
if guard_file != null:
|
||||
guard_rid = str(director._create_request(
|
||||
1, "udp", [str(guard_file.id)], true))
|
||||
_ok(not guard_rid.is_empty(), "C1: тестовый UDP-запрос создан")
|
||||
_ok(director.attach_file(guard_rid, str(guard_file.id)),
|
||||
"C1: файл тестового запроса прикреплён")
|
||||
main._on_request_send(guard_rid)
|
||||
await process_frame
|
||||
var guard_req = director.active_requests.get(guard_rid)
|
||||
_ok(main._modal_game == guard_modal,
|
||||
"C1: чужая (атакующая) модалка не освобождена")
|
||||
_ok(director._minigame == null,
|
||||
"C1: вторая мини-игра за модалкой не создана")
|
||||
_ok(guard_req != null and not bool(guard_req.minigame_active),
|
||||
"C1: minigame_active у заблокированного запроса не выставлен")
|
||||
# Belt-and-braces: даже если директор всё же эмитит show_minigame,
|
||||
# Main отказывает и откатывает мини-игру, не трогая чужую модалку.
|
||||
_ok(director._launch_send_minigame(guard_rid),
|
||||
"C1: директор принудительно эмитил мини-игру")
|
||||
await process_frame
|
||||
_ok(main._modal_game == guard_modal,
|
||||
"C1: откат send не освободил чужую модалку")
|
||||
_ok(director._minigame == null,
|
||||
"C1: send-мини-игра откатана force_close_send_minigame")
|
||||
_ok(guard_req != null and not bool(guard_req.minigame_active),
|
||||
"C1: minigame_active снят откатом send")
|
||||
# То же для соединения: эмитим show_connection в обход UI-гарда.
|
||||
var conn_file = game.registry.create_file("guard_conn", "text", folder_id)
|
||||
if conn_file != null:
|
||||
var conn_rid := str(director._create_request(
|
||||
1, "tcp", [str(conn_file.id)], false))
|
||||
_ok(director.attach_file(conn_rid, str(conn_file.id)),
|
||||
"C1: файл для отката соединения прикреплён")
|
||||
var conn_req = director.active_requests.get(conn_rid)
|
||||
director._begin_connection(conn_req)
|
||||
await process_frame
|
||||
_ok(main._modal_game == guard_modal,
|
||||
"C1: откат connection не освободил чужую модалку")
|
||||
_ok(conn_req != null and not bool(conn_req.connecting),
|
||||
"C1: connecting снят connection_finished(false)")
|
||||
director._cleanup_request(conn_rid, true)
|
||||
director._cleanup_request(guard_rid, true)
|
||||
guard_modal.queue_free()
|
||||
await process_frame
|
||||
main._clear_modal_if_dead()
|
||||
main.controller.set_modal_open(false)
|
||||
main._refresh_hour()
|
||||
|
||||
# --- H1: detach по required-индексу, а не по порядку вложений -----------
|
||||
var det_a = game.registry.create_file("detach_a", "text", folder_id)
|
||||
var det_b = game.registry.create_file("detach_b", "sound", folder_id)
|
||||
if det_a != null and det_b != null:
|
||||
var detach_rid := str(director._create_request(
|
||||
2, "udp", [str(det_a.id), str(det_b.id)], true))
|
||||
var detach_req = director.active_requests.get(detach_rid)
|
||||
_ok(detach_req != null, "H1: запрос на 2 required создан")
|
||||
# Прикрепляем ВТОРОЙ required первым: attached != required по порядку.
|
||||
_ok(director.attach_file(detach_rid, str(det_b.id)),
|
||||
"H1: второй required прикреплён первым")
|
||||
_eq(detach_req.attached, [str(det_b.id)],
|
||||
"H1: attached хранится в порядке вложения")
|
||||
main._on_request_detach(detach_rid, 1)
|
||||
await process_frame
|
||||
_ok(detach_req != null and not detach_req.attached.has(str(det_b.id)),
|
||||
"H1: X по required-индексу 1 снял второй файл")
|
||||
_eq(detach_req.attached.size(), 0, "H1: attached пуст после detach")
|
||||
director._cleanup_request(detach_rid, true)
|
||||
|
||||
# --- Конец часа при открытой мини-игре: не оставляем висячий _modal_game --
|
||||
var rid3: String = director.spawn_request_now(1)
|
||||
_ok(not rid3.is_empty(), "третий запрос создан для проверки конца часа")
|
||||
var leftover_id := ""
|
||||
for f in game.registry.files.values():
|
||||
if str(f.attached_request).is_empty() and str(f.location) == "folder":
|
||||
leftover_id = str(f.id)
|
||||
break
|
||||
var missing3: Array = director.request_missing(rid3) if not rid3.is_empty() else []
|
||||
var leftover_id := str(missing3[0]) if not missing3.is_empty() else ""
|
||||
if not rid3.is_empty() and not leftover_id.is_empty():
|
||||
_ok(director.attach_file(rid3, leftover_id), "файл прикреплён к третьему запросу")
|
||||
_ok(director.set_request_protocol(rid3, "udp"),
|
||||
"третий запрос переведён на UDP (без соединения)")
|
||||
var holder2 := {"game": null}
|
||||
director.send_game_factory = func(_id, _tier, _seed, _ctx):
|
||||
var g := FakeGame.new()
|
||||
@@ -371,6 +516,14 @@ func _init() -> void:
|
||||
_finish()
|
||||
|
||||
|
||||
## Карточка пакета по id (в ПРИЁМЕ могут быть и автоспавненные пакеты).
|
||||
func _packet_card(main, packet_id: String):
|
||||
for c in main.packets:
|
||||
if c != null and is_instance_valid(c) and str(c.id) == packet_id:
|
||||
return c
|
||||
return null
|
||||
|
||||
|
||||
## Карточка запроса по id (живая, видимая).
|
||||
func _request_card(main, req_id: String):
|
||||
for c in main.requests:
|
||||
|
||||
@@ -75,6 +75,7 @@ func _init() -> void:
|
||||
await _test_reject()
|
||||
_test_protocol_toggle()
|
||||
_test_prediction()
|
||||
await _test_four_slots()
|
||||
|
||||
await process_frame
|
||||
print("")
|
||||
@@ -130,14 +131,15 @@ func _test_attach_and_explicit_send() -> void:
|
||||
return
|
||||
var req = _director.active_requests[rid]
|
||||
_eq(int(_director.request_required_count(rid)), 1, "запросу нужен 1 файл")
|
||||
var fid := _available_file()
|
||||
_ok(not fid.is_empty(), "есть свободный файл для attach")
|
||||
if fid.is_empty():
|
||||
var missing: Array = _director.request_missing(rid)
|
||||
_ok(missing.size() >= 1, "есть конкретный required-файл для attach")
|
||||
if missing.is_empty():
|
||||
return
|
||||
var fid := str(missing[0])
|
||||
|
||||
_main._on_file_to_request(rid, 0, {"kind": "file", "id": fid, "from": "folder"})
|
||||
_eq(req.attached.size(), 1, "файл прикреплён через UI")
|
||||
_ok(req.attached.has(fid), "req.attached содержит файл")
|
||||
_ok(req.attached.has(fid), "req.attached содержит required-файл")
|
||||
_ok(not req.sending, "attach НЕ запускает отправку")
|
||||
_ok(not req.minigame_active, "attach НЕ открывает мини-игру")
|
||||
_ok(_main._modal_game == null, "после attach модалки нет")
|
||||
@@ -148,11 +150,14 @@ func _test_attach_and_explicit_send() -> void:
|
||||
_ok(card != null, "карточка запроса найдена")
|
||||
if card == null:
|
||||
return
|
||||
_ok(card.get_node_or_null("ConnectButton") == null, "у запроса нет кнопки СОЕДИНИТЬСЯ")
|
||||
_ok(card.can_send(), "ОТПРАВИТЬ доступна после attach")
|
||||
_ok(not card.send_disabled(), "кнопка ОТПРАВИТЬ включена")
|
||||
_ok(card.get_node_or_null("ConnectButton") == null,
|
||||
"у запроса нет legacy-кнопки ConnectButton")
|
||||
_ok(card.can_send(), "кнопка доступна после attach")
|
||||
_ok(not card.send_disabled(), "кнопка включена после attach")
|
||||
_eq(card._send_btn.caption, "СОЕДИНИТЬСЯ",
|
||||
"TCP без соединения: кнопка СОЕДИНИТЬСЯ")
|
||||
|
||||
# Подменная мини-игра: проверяем реальный путь show_minigame → modal_root.
|
||||
# Подменная мини-игра: реальный путь connection → show_minigame → modal_root.
|
||||
var holder := {"game": null}
|
||||
_director.send_game_factory = func(_id, _tier, _seed, _ctx):
|
||||
var g := FakeGame.new()
|
||||
@@ -160,11 +165,20 @@ func _test_attach_and_explicit_send() -> void:
|
||||
return g
|
||||
card._send_btn.pressed.emit()
|
||||
await process_frame
|
||||
_ok(req.connecting, "нажатие запускает TCP-соединение")
|
||||
_ok(_main._modal_game != null and str(_main._modal_kind) == "connection",
|
||||
"показана модалка соединения")
|
||||
var conn_game = _main._modal_game
|
||||
_main._on_connection_finished(true, rid, conn_game)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_fake_game = holder.get("game")
|
||||
_ok(req.connected, "соединение установлено")
|
||||
_ok(_fake_game != null, "мини-игра отправки создана")
|
||||
_ok(req.minigame_active, "ОТПРАВИТЬ включает мини-игру")
|
||||
_ok(req.minigame_active, "после соединения включается мини-игра")
|
||||
_ok(_main._modal_game == _fake_game, "Main запомнил модалку мини-игры")
|
||||
_ok(_fake_game.get_parent() == _main.modal_root, "мини-игра лежит в modal_root")
|
||||
if _fake_game != null:
|
||||
_ok(_fake_game.get_parent() == _main.modal_root, "мини-игра лежит в modal_root")
|
||||
_ok(bool(_controller.modal_open), "controller.modal_open = true на мини-игре")
|
||||
|
||||
# Пока мини-игра открыта, таймер запроса не идёт.
|
||||
@@ -209,11 +223,14 @@ func _test_queue_pause_and_timeout() -> void:
|
||||
_ok(req != null, "запрос для очереди создан")
|
||||
if req == null:
|
||||
return
|
||||
var fid := _available_file()
|
||||
_ok(not fid.is_empty(), "есть файл для запроса в очередь")
|
||||
if fid.is_empty():
|
||||
var missing_q: Array = _director.request_missing(rid)
|
||||
_ok(missing_q.size() >= 1, "есть required-файл для запроса в очередь")
|
||||
if missing_q.is_empty():
|
||||
return
|
||||
var fid := str(missing_q[0])
|
||||
_ok(_director.attach_file(rid, fid), "файл прикреплён к запросу очереди")
|
||||
_ok(_director.set_request_protocol(rid, "udp"),
|
||||
"запрос очереди переведён на UDP")
|
||||
|
||||
var holder := {"game": null}
|
||||
_director.send_game_factory = func(_id, _tier, _seed, _ctx):
|
||||
@@ -272,9 +289,10 @@ func _test_reject() -> void:
|
||||
_ok(req != null, "запрос для отказа создан")
|
||||
if req == null:
|
||||
return
|
||||
var fid := _available_file()
|
||||
var missing_r: Array = _director.request_missing(rid)
|
||||
var fid := str(missing_r[0]) if not missing_r.is_empty() else ""
|
||||
if not fid.is_empty():
|
||||
_ok(_director.attach_file(rid, fid), "файл прикреплён перед отказом")
|
||||
_ok(_director.attach_file(rid, fid), "required-файл прикреплён перед отказом")
|
||||
var money_before := int(_game.money)
|
||||
var sla_before := float(_game.sla)
|
||||
_main._on_request_reject(rid)
|
||||
@@ -299,15 +317,16 @@ func _test_protocol_toggle() -> void:
|
||||
_director.reject_request(rid)
|
||||
|
||||
|
||||
## Предсказание RequestCard совпадает с реальным выбором Economy (B4) для
|
||||
## всех ключевых сумм, включая S=8 (dots/T2) и S=24 (video/T4).
|
||||
## Предсказание RequestCard совпадает с реальным выбором Economy: сумма
|
||||
## коэффициентов 1/2/4/8, 27 достижимых сумм, 3 ступени, без «wires».
|
||||
func _test_prediction() -> void:
|
||||
var cases := [
|
||||
{"types": ["text"], "sum": 1, "id": "dots", "tier": 1},
|
||||
{"types": ["text", "sound"], "sum": 3, "id": "wires", "tier": 1},
|
||||
{"types": ["photo"], "sum": 4, "id": "text", "tier": 1},
|
||||
{"types": ["video"], "sum": 8, "id": "dots", "tier": 2},
|
||||
{"types": ["video", "video", "video"], "sum": 24, "id": "video", "tier": 4},
|
||||
{"types": ["text"], "sum": 1, "id": "text", "tier": 1},
|
||||
{"types": ["text", "sound"], "sum": 3, "id": "dots", "tier": 1},
|
||||
{"types": ["photo"], "sum": 4, "id": "photo", "tier": 1},
|
||||
{"types": ["video"], "sum": 8, "id": "video", "tier": 1},
|
||||
{"types": ["video", "video", "video"], "sum": 24, "id": "dots", "tier": 3},
|
||||
{"types": ["video", "video", "video", "video"], "sum": 32, "id": "video", "tier": 3},
|
||||
]
|
||||
for entry in cases:
|
||||
var tc: Dictionary = entry
|
||||
@@ -329,9 +348,43 @@ func _test_prediction() -> void:
|
||||
"S=%d: sum совпадает с Economy" % int(tc["sum"]))
|
||||
_eq(str(p.get("id", "")), str(tc["id"]), "S=%d: ожидаемая игра" % int(tc["sum"]))
|
||||
_eq(int(p.get("tier", 0)), int(tc["tier"]), "S=%d: ожидаемая ступень" % int(tc["sum"]))
|
||||
_ok(str(p.get("id", "")) != "wires", "S=%d: wires не выбирается" % int(tc["sum"]))
|
||||
card.free()
|
||||
|
||||
|
||||
## v3: карточка держит контейнер до 4 обязательных файлов и сумму 4 text.
|
||||
func _test_four_slots() -> void:
|
||||
var ids: Array = []
|
||||
for i in 4:
|
||||
var f = _game.registry.create_file("слот_%d" % i, "text", _folder_id)
|
||||
if f != null:
|
||||
ids.append(str(f.id))
|
||||
_eq(ids.size(), 4, "созданы 4 файла для контейнера")
|
||||
if ids.size() < 4:
|
||||
return
|
||||
var rid := str(_director._create_request(4, "tcp", ids, true))
|
||||
_ok(not rid.is_empty(), "запрос на 4 файла создан")
|
||||
_main._refresh_hour()
|
||||
await process_frame
|
||||
var card = _request_card(rid)
|
||||
_ok(card != null, "карточка 4-слотового запроса найдена")
|
||||
if card == null:
|
||||
_director._cleanup_request(rid, true)
|
||||
return
|
||||
_eq(card.required_count, 4, "контейнер на 4 обязательных файла")
|
||||
_eq(card.slots.size(), 4, "в карточке 4 слота")
|
||||
var expected: Dictionary = ECONOMY.send_minigame_for(["text", "text", "text", "text"])
|
||||
_eq(int(card.prediction().get("sum", -1)), int(expected.get("sum", -2)),
|
||||
"сумма 4 text = 4")
|
||||
_eq(str(card.prediction().get("id", "")), str(expected.get("id", "")),
|
||||
"игра для S=4 из Economy")
|
||||
_eq(int(card.prediction().get("tier", -1)), int(expected.get("tier", -2)),
|
||||
"ступень для S=4 из Economy")
|
||||
_eq(card.difficulty_text(), "СУММА: %d" % int(expected.get("sum", 0)),
|
||||
"подпись суммы на карточке")
|
||||
_director._cleanup_request(rid, true)
|
||||
|
||||
|
||||
# --- хелперы ----------------------------------------------------------------
|
||||
|
||||
func _tick(count: int) -> void:
|
||||
|
||||
@@ -110,6 +110,11 @@ func _test_receive_scroll() -> void:
|
||||
_ok(director != null, "hour_director доступен")
|
||||
if director == null:
|
||||
return
|
||||
# H1-затравка: первый пакет спавнится в t=0, поэтому очищаем буфер,
|
||||
# чтобы проверка «ровно 4 пакета» не зависела от порядка кадров.
|
||||
for pid in director.active_packets.keys().duplicate():
|
||||
director._remove_packet(str(pid))
|
||||
director.files_spawned = director.files_budget
|
||||
|
||||
# 4 пакета (базовый буфер 6) — всё влезает, скроллбар скрыт.
|
||||
for i in 4:
|
||||
|
||||
@@ -60,10 +60,12 @@ func _init() -> void:
|
||||
_eq(int(grid_stats.get("total", 0)), 20, "сетка 5×4 = 20 клеток")
|
||||
_eq(int(main.grid.grid_metrics().get("cols", 0)), 5, "сетка: 5 столбцов")
|
||||
_eq(int(main.grid.grid_metrics().get("rows", 0)), 4, "сетка: 4 строки")
|
||||
_eq(str(main.grid.footer_text()), "занято 0 из 20 · свободно 20",
|
||||
"подвал сетки считает клетки")
|
||||
_ok(main.files_window.title_hint.contains("занято 0 из 20"),
|
||||
"подвал ФАЙЛЫ считает клетки: %s" % main.files_window.title_hint)
|
||||
var footer_files := int(grid_stats.get("files", 0))
|
||||
_eq(str(main.grid.footer_text()),
|
||||
"%d %s · 20 клеток" % [footer_files, _plural_files(footer_files)],
|
||||
"подвал сетки: файлы и клетки")
|
||||
_ok(main.files_window.title_hint.is_empty(),
|
||||
"подвал ФАЙЛЫ без подписи занятости: %s" % main.files_window.title_hint)
|
||||
|
||||
# --- Статус-бар ----------------------------------------------------------
|
||||
_eq(str(main.status_bar.get_metric("money").get("value", "")), "$ 50",
|
||||
@@ -105,7 +107,8 @@ func _init() -> void:
|
||||
if not row.is_section and str(row.folder_id) == folder_id:
|
||||
folder_row_count = int(row.count)
|
||||
folder_row_capacity = int(row.capacity)
|
||||
_eq(folder_row_count, 0, "дерево: папка показывает 0 занятых клеток")
|
||||
_eq(folder_row_count, int(grid_stats.get("occupied", 0)),
|
||||
"дерево: папка показывает занятые клетки (H1-затравка)")
|
||||
_eq(folder_row_capacity, 20, "дерево: вместимость папки 20 клеток")
|
||||
|
||||
# --- Плюрализация заголовка ДИСКИ ---------------------------------------
|
||||
@@ -172,8 +175,8 @@ func _test_registry_autorefresh(main, game) -> void:
|
||||
main.grid.file_count()])
|
||||
_eq(main.grid.occupied_cells(), before_cells + 1,
|
||||
"занятые клетки выросли на 1 (текст)")
|
||||
_ok(main.files_window.title_hint.contains("занято %d из 20"
|
||||
% main.grid.occupied_cells()), "подвал ФАЙЛЫ обновился: %s"
|
||||
_ok(main.files_window.title_hint.is_empty(),
|
||||
"подвал ФАЙЛЫ не дублирует занятость: %s"
|
||||
% main.files_window.title_hint)
|
||||
var count_ok := false
|
||||
for e in main.tree.get("_rows"):
|
||||
@@ -240,12 +243,14 @@ func _test_search(main) -> void:
|
||||
|
||||
|
||||
## Имена новых папок генерируются и остаются уникальными в секции.
|
||||
## D2 (H5): папки №3–6 стоят `folder_new_cost` (100 $), №7 упирается в
|
||||
## v3: папки №3–6 стоят `folder_new_cost` (180 $), №7 упирается в
|
||||
## `folders_max` (6); без денег новая папка не создаётся.
|
||||
func _test_folder_name_generation(main, game) -> void:
|
||||
_eq(main._unique_folder_name("C", "Разное"), "Разное 2",
|
||||
"занятое имя получает суффикс 2")
|
||||
|
||||
var cost := int(_const_value("folder_new_cost", 180))
|
||||
var folders_max := int(_const_value("folders_max", 6))
|
||||
var before: int = game.registry.folder_count()
|
||||
_eq(before, 2, "на старте ровно 2 папки")
|
||||
# Без денег третья папка не создаётся.
|
||||
@@ -254,31 +259,36 @@ func _test_folder_name_generation(main, game) -> void:
|
||||
_eq(game.registry.folder_count(), before, "без денег папка не создаётся")
|
||||
_eq(int(game.money), 0, "неудачная покупка не уводит деньги в минус")
|
||||
|
||||
# 3-я папка: ровно 100 $ и списание.
|
||||
game.add_money(100)
|
||||
# 3-я папка: ровно folder_new_cost и списание.
|
||||
game.add_money(cost)
|
||||
main._new_folder_in("C")
|
||||
_eq(game.registry.folder_count(), before + 1, "3-я папка создана за 100 $")
|
||||
_eq(int(game.money), 0, "3-я папка списала 100 $")
|
||||
_eq(game.registry.folder_count(), before + 1,
|
||||
"3-я папка создана за %d $" % cost)
|
||||
_eq(int(game.money), 0, "3-я папка списала %d $" % cost)
|
||||
var third_id := str(main.grid.folder_id)
|
||||
_ok(game.registry.rename_folder(third_id, "Разное 2"),
|
||||
"третья папка переименована для проверки имён")
|
||||
_eq(main._unique_folder_name("C", "Разное"), "Разное 3",
|
||||
"следующий дубль получает суффикс 3")
|
||||
|
||||
# 4-я, 5-я, 6-я папки — тоже по 100 $.
|
||||
for expected in [4, 5, 6]:
|
||||
game.add_money(100)
|
||||
# 4-я … folders_max-я папки — тоже по folder_new_cost.
|
||||
var expected_count := before + 2
|
||||
while expected_count <= folders_max:
|
||||
game.add_money(cost)
|
||||
main._new_folder_in("C")
|
||||
_eq(game.registry.folder_count(), expected,
|
||||
"%d-я папка создана за 100 $" % expected)
|
||||
_eq(int(game.money), 0, "%d-я папка списала 100 $" % expected)
|
||||
_ok(game.registry.folder_count() <= 6, "всего не больше 6 папок")
|
||||
_eq(game.registry.folder_count(), expected_count,
|
||||
"%d-я папка создана за %d $" % [expected_count, cost])
|
||||
_eq(int(game.money), 0, "%d-я папка списала %d $" % [expected_count, cost])
|
||||
expected_count += 1
|
||||
_ok(game.registry.folder_count() <= folders_max,
|
||||
"всего не больше %d папок" % folders_max)
|
||||
|
||||
# 7-я папка запрещена лимитом, деньги не списываются.
|
||||
game.add_money(100)
|
||||
# Следующая папка запрещена лимитом, деньги не списываются.
|
||||
game.add_money(cost)
|
||||
main._new_folder_in("C")
|
||||
_eq(game.registry.folder_count(), 6, "7-я папка заблокирована лимитом")
|
||||
_eq(int(game.money), 100, "заблокированная 7-я папка не списала деньги")
|
||||
_eq(game.registry.folder_count(), folders_max,
|
||||
"%d-я папка заблокирована лимитом" % (folders_max + 1))
|
||||
_eq(int(game.money), cost, "заблокированная папка не списала деньги")
|
||||
main._refresh_prep()
|
||||
|
||||
|
||||
@@ -445,6 +455,24 @@ func _test_shop_after_hour(main, game) -> void:
|
||||
main._shop_btn.visible = true
|
||||
|
||||
|
||||
## Плюрализация «файл/файла/файлов» — как в FileGrid.
|
||||
func _plural_files(n: int) -> String:
|
||||
var n10 := n % 10
|
||||
var n100 := n % 100
|
||||
if n10 == 1 and n100 != 11:
|
||||
return "файл"
|
||||
if n10 >= 2 and n10 <= 4 and (n100 < 12 or n100 > 14):
|
||||
return "файла"
|
||||
return "файлов"
|
||||
|
||||
|
||||
func _const_value(key: String, fallback = null):
|
||||
var data := root.get_node_or_null("Data")
|
||||
if data != null and data.has_method("const_value"):
|
||||
return data.const_value(key, fallback)
|
||||
return fallback
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if _fails == 0:
|
||||
print("PASS")
|
||||
|
||||
@@ -4,6 +4,8 @@ extends SceneTree
|
||||
|
||||
const MG := preload("res://scripts/minigames/minigame_base.gd")
|
||||
const FACTORY := preload("res://scripts/minigames/minigame_factory.gd")
|
||||
const ECONOMY := preload("res://scripts/core/economy.gd")
|
||||
const CONNECTION := preload("res://scripts/minigames/connection_game.gd")
|
||||
|
||||
var _checks: int = 0
|
||||
var _fails: int = 0
|
||||
@@ -22,6 +24,7 @@ func _init() -> void:
|
||||
_test_selection()
|
||||
_test_attack_placeholder()
|
||||
_test_no_timers()
|
||||
_test_connection_grace()
|
||||
_finish()
|
||||
|
||||
|
||||
@@ -42,6 +45,7 @@ func _test_base_contract() -> void:
|
||||
_ok(mg.visible, "окно видимо")
|
||||
_eq(str(mg.context.get("request_id", "")), "req_1", "context сохранён")
|
||||
_approx(mg.quality(), 0.0, 0.0001, "quality стартует с 0")
|
||||
_eq(str(mg.tier_label()), "СЛОЖНОСТЬ 2/3", "tier_label отправки 2/3")
|
||||
|
||||
# Два одинаковых сида дают одинаковую последовательность локального RNG.
|
||||
var a = MG.new()
|
||||
@@ -106,7 +110,7 @@ func _test_base_skip_and_esc() -> void:
|
||||
_finish_count = 0
|
||||
_last_q = -1.0
|
||||
mg.finished.connect(_on_finished)
|
||||
mg.start("wires", 1, 3)
|
||||
mg.start("sound", 1, 3)
|
||||
mg._on_skip_clicked()
|
||||
_ok(mg._skip_armed, "первый клик только взводит пропуск")
|
||||
_ok(not mg.is_finished(), "первый клик не завершает")
|
||||
@@ -137,7 +141,7 @@ func _test_verdicts() -> void:
|
||||
|
||||
func _test_factory_send() -> void:
|
||||
_set_case("factory_send")
|
||||
_eq(FACTORY.send_ids().size(), 7, "зарегистрировано 7 игр")
|
||||
_eq(FACTORY.send_ids().size(), 6, "зарегистрировано 6 игр")
|
||||
for id in FACTORY.send_ids():
|
||||
var game = FACTORY.create_send(str(id), 1, 123)
|
||||
_ok(game != null, "create_send(%s) не null" % str(id))
|
||||
@@ -150,11 +154,12 @@ func _test_factory_send() -> void:
|
||||
_ok(game.visible, "игра показана после start")
|
||||
game.free()
|
||||
|
||||
for id in ["dots", "sound", "wires", "text", "video", "photo", "maze"]:
|
||||
for id in ["dots", "sound", "text", "video", "photo", "maze"]:
|
||||
var hard = FACTORY.create_send(id, 4, 99)
|
||||
_ok(hard != null, "T4 create_send(%s)" % id)
|
||||
if hard != null:
|
||||
_eq(hard.tier, 4, "T4 tier у %s" % id)
|
||||
_eq(hard.tier, 3, "tier 4 клампится в 3 у %s" % id)
|
||||
_eq(str(hard.tier_label()), "СЛОЖНОСТЬ 3/3", "tier_label отправки %s" % id)
|
||||
hard.free()
|
||||
|
||||
_ok(FACTORY.create_send("nope", 1, 1) == null, "неизвестная игра → null")
|
||||
@@ -164,52 +169,107 @@ func _test_factory_send() -> void:
|
||||
_eq(FACTORY.tier_for_hour(2), 1, "tier_for_hour 2 → 1")
|
||||
_eq(FACTORY.tier_for_hour(4), 2, "tier_for_hour 4 → 2")
|
||||
_eq(FACTORY.tier_for_hour(6), 3, "tier_for_hour 6 → 3")
|
||||
_eq(FACTORY.tier_for_hour(8), 4, "tier_for_hour 8 → 4")
|
||||
_eq(FACTORY.tier_for_hour(9), 4, "tier_for_hour 9 → 4")
|
||||
_eq(FACTORY.tier_for_hour(8), 3, "tier_for_hour 8 → 3")
|
||||
_eq(FACTORY.tier_for_hour(9), 3, "tier_for_hour 9 → 3")
|
||||
|
||||
|
||||
func _test_selection() -> void:
|
||||
_set_case("selection")
|
||||
var one := FACTORY.send_for_types(["text"])
|
||||
_eq(str(one.get("id", "")), "dots", "S=1 → dots")
|
||||
_eq(int(one.get("tier", 0)), 1, "S=1 → T1")
|
||||
_eq(int(one.get("sum", 0)), 1, "sum=1")
|
||||
var data = _data()
|
||||
var selection: Dictionary = {}
|
||||
if data != null and data.has_method("minigame_selection"):
|
||||
selection = data.minigame_selection()
|
||||
var sums: Array = selection.get("achievable_sums", ECONOMY.achievable_sums())
|
||||
var rank_games: Array = selection.get("rank_games", ECONOMY.RANK_GAMES)
|
||||
var tier_max: Array = selection.get("tier_max_rank", ECONOMY.TIER_MAX_RANK)
|
||||
var coeff: Dictionary = selection.get("coeff", ECONOMY.MINIGAME_COEFF)
|
||||
|
||||
var video := FACTORY.send_for_types(["video"])
|
||||
_eq(str(video.get("id", "")), "dots", "S=8 → dots")
|
||||
_eq(int(video.get("tier", 0)), 2, "S=8 → T2")
|
||||
# Схема §6: ровно 27 достижимых сумм коэффициентов 1/2/4/8 для 1–4 файлов.
|
||||
_eq(ECONOMY.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],
|
||||
"27 достижимых сумм [1..22,24,25,26,28,32]")
|
||||
_eq(sums.size(), 27, "в данных 27 сумм")
|
||||
_eq(_ints(ECONOMY.achievable_sums()), _ints(sums),
|
||||
"Economy и Data согласованы по суммам")
|
||||
_eq(str(ECONOMY.RANK_GAMES), str(rank_games), "Economy и Data согласованы по rank_games")
|
||||
_eq(_ints(ECONOMY.TIER_MAX_RANK), _ints(tier_max),
|
||||
"Economy и Data согласованы по границам ступеней")
|
||||
for type_id in ["text", "sound", "photo", "video"]:
|
||||
_eq(int(coeff.get(type_id, -1)), int(ECONOMY.MINIGAME_COEFF[type_id]),
|
||||
"коэффициент %s = %d" % [type_id, int(ECONOMY.MINIGAME_COEFF[type_id])])
|
||||
|
||||
var three_video := FACTORY.send_for_types(["video", "video", "video"])
|
||||
_ok(FACTORY.send_ids().has(str(three_video.get("id", ""))), "S=24 → известная игра")
|
||||
_ok(int(three_video.get("tier", 0)) >= 1 and int(three_video.get("tier", 0)) <= 4,
|
||||
"S=24 → допустимая ступень")
|
||||
_eq(int(three_video.get("sum", 0)), 24, "sum=24")
|
||||
# Каноническая формула спецификации (idx = S-1) отдельно от Economy-делегата.
|
||||
var canonical := FACTORY.selection_for_sum(24)
|
||||
_eq(str(canonical.get("id", "")), "wires", "канон S=24 → wires")
|
||||
_eq(int(canonical.get("tier", 0)), 4, "канон S=24 → T4")
|
||||
_eq(str(FACTORY.selection_for_sum(8).get("id", "")), "dots", "канон S=8 → dots/T2")
|
||||
_eq(int(FACTORY.selection_for_sum(8).get("tier", 0)), 2, "канон S=8 → T2")
|
||||
# Суммы действительно достижимы 1–4 файлами (и других нет).
|
||||
var coeffs: Array = [1, 2, 4, 8]
|
||||
var reachable: Dictionary = {}
|
||||
for a in coeffs:
|
||||
reachable[a] = true
|
||||
for b in coeffs:
|
||||
if b < a:
|
||||
continue
|
||||
reachable[a + b] = true
|
||||
for c in coeffs:
|
||||
if c < b:
|
||||
continue
|
||||
reachable[a + b + c] = true
|
||||
for d in coeffs:
|
||||
if d < c:
|
||||
continue
|
||||
reachable[a + b + c + d] = true
|
||||
var reachable_sorted: Array = reachable.keys()
|
||||
reachable_sorted.sort()
|
||||
_eq(_ints(reachable_sorted), _ints(sums),
|
||||
"список сумм = все суммы 1–4 коэффициентов")
|
||||
|
||||
var mix := FACTORY.send_for_types(["text", "sound"])
|
||||
_eq(str(mix.get("id", "")), "wires", "S=3 → wires")
|
||||
_eq(int(mix.get("tier", 0)), 1, "S=3 → T1")
|
||||
# rank → игра и ступень: 0–7 → T1, 8–13 → T2, 14–26 → T3.
|
||||
for idx in range(sums.size()):
|
||||
var total := int(sums[idx])
|
||||
var pick: Dictionary = FACTORY.selection_for_sum(total)
|
||||
_eq(int(pick.get("index", -1)), idx, "S=%d: rank %d" % [total, idx])
|
||||
_eq(int(pick.get("sum", -1)), total, "S=%d: сумма сохранена" % total)
|
||||
_eq(str(pick.get("id", "")), str(rank_games[idx]),
|
||||
"S=%d: игра из GAME_BY_RANK[%d]" % [total, idx])
|
||||
var expected_tier := 1
|
||||
if idx > int(tier_max[1]):
|
||||
expected_tier = 3
|
||||
elif idx > int(tier_max[0]):
|
||||
expected_tier = 2
|
||||
_eq(int(pick.get("tier", 0)), expected_tier,
|
||||
"S=%d: ступень %d" % [total, expected_tier])
|
||||
_ok(FACTORY.send_ids().has(str(pick.get("id", ""))),
|
||||
"S=%d: игра зарегистрирована" % total)
|
||||
_ok(str(pick.get("id", "")) != "wires", "S=%d: wires не выбирается" % total)
|
||||
|
||||
var photo := FACTORY.send_for_types(["photo"])
|
||||
_eq(str(photo.get("id", "")), "text", "S=4 → text")
|
||||
# Коэффициенты типов дают ту же сумму/игру, что и прямой выбор по rank.
|
||||
var combos := [
|
||||
["text"], ["sound"], ["photo"], ["video"],
|
||||
["text", "sound"], ["text", "text"], ["sound", "sound"],
|
||||
["video", "video"], ["video", "video", "video"],
|
||||
["video", "video", "video", "video"],
|
||||
]
|
||||
for types in combos:
|
||||
var total := 0
|
||||
for t in types:
|
||||
total += int(coeff.get(str(t), 1))
|
||||
var direct: Dictionary = FACTORY.selection_for_sum(total)
|
||||
var pick: Dictionary = FACTORY.send_for_types(types)
|
||||
_eq(str(pick.get("id", "")), str(direct.get("id", "")),
|
||||
"%s: игра совпадает с выбором по сумме %d" % [str(types), total])
|
||||
_eq(int(pick.get("tier", 0)), int(direct.get("tier", 0)),
|
||||
"%s: ступень совпадает с суммой %d" % [str(types), total])
|
||||
_eq(int(pick.get("sum", -1)), total, "%s: sum=%d" % [str(types), total])
|
||||
_ok(str(pick.get("id", "")) != "wires", "%s: не wires" % str(types))
|
||||
|
||||
var seven := FACTORY.send_for_types(["text", "sound", "photo"])
|
||||
_ok(FACTORY.send_ids().has(str(seven.get("id", ""))), "S=7 → известная игра")
|
||||
_eq(int(seven.get("sum", 0)), 7, "S=7 sum")
|
||||
_eq(str(FACTORY.selection_for_sum(7).get("id", "")), "maze", "канон S=7 → maze")
|
||||
_eq(int(FACTORY.selection_for_sum(7).get("tier", 0)), 1, "канон S=7 → T1")
|
||||
|
||||
var empty := FACTORY.selection_for_sum(0)
|
||||
# S=0 клампится в dots/T1, сверх максимума — последний rank.
|
||||
var empty: Dictionary = FACTORY.selection_for_sum(0)
|
||||
_eq(str(empty.get("id", "")), "dots", "S=0 клампится в dots/T1")
|
||||
_eq(int(empty.get("tier", 0)), 1, "S=0 → T1")
|
||||
var cap := FACTORY.selection_for_sum(99)
|
||||
_eq(int(cap.get("index", -1)), 27, "idx клампится в 27")
|
||||
_eq(int(cap.get("tier", 0)), 4, "idx 27 → T4")
|
||||
_eq(int(empty.get("index", -1)), 0, "S=0 → rank 0")
|
||||
var cap: Dictionary = FACTORY.selection_for_sum(99)
|
||||
_eq(int(cap.get("index", -1)), sums.size() - 1, "idx клампится в последний rank")
|
||||
_eq(int(cap.get("tier", 0)), 3, "последний rank → T3")
|
||||
_eq(str(cap.get("id", "")), str(rank_games[rank_games.size() - 1]),
|
||||
"последний rank → игра из таблицы")
|
||||
|
||||
|
||||
func _test_attack_placeholder() -> void:
|
||||
@@ -219,6 +279,7 @@ func _test_attack_placeholder() -> void:
|
||||
_ok(true, "атакующий скрипт ещё не добавлен — null допустим")
|
||||
else:
|
||||
_ok(attack is MiniGame, "существующая атака — MiniGame")
|
||||
_eq(str(attack.tier_label()), "СЛОЖНОСТЬ 1/4", "у атаки 4 ступени")
|
||||
attack.free()
|
||||
var missing = FACTORY.create_attack("not_a_real_attack", 1, 7)
|
||||
_ok(missing == null, "неизвестная атака → null")
|
||||
@@ -238,6 +299,36 @@ func _test_no_timers() -> void:
|
||||
game.free()
|
||||
|
||||
|
||||
func _test_connection_grace() -> void:
|
||||
_set_case("connection_grace")
|
||||
var game = CONNECTION.new()
|
||||
root.add_child(game)
|
||||
game.start(1, 0, 0, "req")
|
||||
_ok(game._input_grace > 0.0, "start() взводит grace")
|
||||
_ok(not game._armed, "до истечения grace попытки запрещены")
|
||||
|
||||
# Клик, который открыл модалку (и его release), не должен быть попыткой.
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
press.position = game.global_position + Vector2(320.0, 140.0)
|
||||
game._input(press)
|
||||
_ok(not game.missed, "клик в grace не даёт промах")
|
||||
_approx(game.flash_left, 0.0, 0.0001, "клик в grace не включает красную вспышку")
|
||||
_eq(game.active_index, 0, "клик в grace не засчитывает попадание")
|
||||
_ok(not game._emitted, "клик в grace не завершает игру")
|
||||
|
||||
# После grace палочка уходит из зелёной зоны — обычный клик штрафует.
|
||||
game._process(0.4)
|
||||
_ok(game._input_grace <= 0.0, "grace истекает по _process")
|
||||
_ok(game._armed, "после grace попытки разрешены")
|
||||
_ok(game.stick_positions[0] < game.zone_start_x(), "палочка вне зоны")
|
||||
game._input(press)
|
||||
_ok(game.missed, "клик вне зоны после grace = промах")
|
||||
_ok(game.flash_left > 0.0, "промах включает красную вспышку")
|
||||
game.free()
|
||||
|
||||
|
||||
func _count_timers(node: Node) -> int:
|
||||
var count := 0
|
||||
for child in node.get_children():
|
||||
@@ -249,6 +340,18 @@ func _count_timers(node: Node) -> int:
|
||||
|
||||
# --- assert-хелперы --------------------------------------------------------
|
||||
|
||||
func _data():
|
||||
return root.get_node_or_null("Data")
|
||||
|
||||
|
||||
## JSON парсит числа как float — нормализуем к int для сравнения таблиц.
|
||||
func _ints(values: Array) -> Array:
|
||||
var out: Array = []
|
||||
for value in values:
|
||||
out.append(int(value))
|
||||
return out
|
||||
|
||||
|
||||
func _set_case(name: String) -> void:
|
||||
_case = name
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
extends SceneTree
|
||||
## Headless-юнит-тесты чистых функций семи игр отправки.
|
||||
## Headless-юнит-тесты чистых функций шести игр отправки.
|
||||
## Запуск: godot --headless --path . --script tests/test_send_games.gd
|
||||
|
||||
const DOTS := preload("res://scripts/minigames/send/dots.gd")
|
||||
const SOUND := preload("res://scripts/minigames/send/sound.gd")
|
||||
const WIRES := preload("res://scripts/minigames/send/wires.gd")
|
||||
const TEXT := preload("res://scripts/minigames/send/text.gd")
|
||||
const VIDEO := preload("res://scripts/minigames/send/video.gd")
|
||||
const PHOTO := preload("res://scripts/minigames/send/photo.gd")
|
||||
@@ -18,8 +17,8 @@ var _case: String = ""
|
||||
func _init() -> void:
|
||||
await process_frame
|
||||
_test_dots()
|
||||
_test_dots_drag()
|
||||
_test_sound()
|
||||
_test_wires()
|
||||
_test_text()
|
||||
_test_video()
|
||||
_test_photo()
|
||||
@@ -32,12 +31,11 @@ func _init() -> void:
|
||||
func _test_dots() -> void:
|
||||
_set_case("dots")
|
||||
var expected := {
|
||||
1: {"cols": 3, "rows": 3, "obstacles": 0},
|
||||
2: {"cols": 4, "rows": 3, "obstacles": 2},
|
||||
3: {"cols": 4, "rows": 4, "obstacles": 4},
|
||||
4: {"cols": 5, "rows": 4, "obstacles": 6},
|
||||
1: {"cols": 4, "rows": 3, "obstacles": 2},
|
||||
2: {"cols": 4, "rows": 4, "obstacles": 4},
|
||||
3: {"cols": 5, "rows": 4, "obstacles": 6},
|
||||
}
|
||||
for tier in range(1, 5):
|
||||
for tier in range(1, 4):
|
||||
var spec: Dictionary = expected[tier]
|
||||
var p := DOTS.tier_params(tier)
|
||||
_eq(int(p["cols"]), int(spec["cols"]), "T%d cols" % tier)
|
||||
@@ -84,15 +82,82 @@ func _test_dots() -> void:
|
||||
_approx(DOTS.quality_for(2, 0, 2), 0.92, 0.0001, "dots 2 отката T2 = 0.92")
|
||||
_approx(DOTS.quality_for(0, 3, 1), 0.7, 0.0001, "dots 3 ошибки = 0.7")
|
||||
_approx(DOTS.quality_for(50, 50, 4), 0.0, 0.0001, "dots q без нижнего порога 0.3")
|
||||
_eq(int(DOTS.tier_params(4)["cols"]), int(DOTS.tier_params(3)["cols"]),
|
||||
"dots tier 4 клампится в 3")
|
||||
|
||||
|
||||
# --- dots: ввод ------------------------------------------------------------
|
||||
|
||||
func _test_dots_drag() -> void:
|
||||
_set_case("dots_drag")
|
||||
var mg = DOTS.new()
|
||||
mg.start("dots", 1, 12345)
|
||||
_ok(mg.board.has("path"), "dots drag: доска сгенерирована")
|
||||
var path: Array = mg.board["path"]
|
||||
var start := int(path[0])
|
||||
mg._on_pointer_pressed(mg._cell_center(start), null)
|
||||
_eq(mg._path.size(), 1, "press на «С» начинает линию")
|
||||
_ok(mg._dragging, "press включает drag")
|
||||
mg._on_pointer_released(mg._cell_center(int(path[1])), null)
|
||||
_ok(not mg._dragging, "release выключает drag")
|
||||
# Клик-по-клику продолжает работать.
|
||||
mg._on_pointer_pressed(mg._cell_center(int(path[1])), null)
|
||||
_eq(mg._path.size(), 2, "клик по соседней клетке продлевает линию")
|
||||
# Клик по предыдущей клетке — откат.
|
||||
mg._on_pointer_pressed(mg._cell_center(start), null)
|
||||
_eq(mg._path.size(), 1, "клик по предыдущей клетке откатывает")
|
||||
_eq(mg._undos, 1, "откат учтён один раз")
|
||||
# Дальше ведём drag по всем клеткам пути.
|
||||
for i in range(1, path.size()):
|
||||
mg._on_pointer_moved(mg._cell_center(int(path[i])), null)
|
||||
_ok(mg.is_finished(), "drag по всему пути завершает игру")
|
||||
_approx(mg.quality(), 0.96, 0.0001, "1 откат без ошибок = 0.96")
|
||||
mg.free()
|
||||
|
||||
# Быстрый проход: один motion сразу на две клетки вперёд по прямой.
|
||||
var fast = DOTS.new()
|
||||
var found := false
|
||||
for seed in range(1, 61):
|
||||
fast.start("dots", 1, seed)
|
||||
var fp: Array = fast.board["path"]
|
||||
if fp.size() < 3:
|
||||
continue
|
||||
var a: Vector2 = fast._cell_center(int(fp[0]))
|
||||
var b: Vector2 = fast._cell_center(int(fp[1]))
|
||||
var c: Vector2 = fast._cell_center(int(fp[2]))
|
||||
if (b - a).is_equal_approx(c - b):
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
_ok(true, "dots drag: прямой префикс не найден — проверка пропущена")
|
||||
fast.free()
|
||||
return
|
||||
var fpath: Array = fast.board["path"]
|
||||
var blocked: Array = fast.board.get("blocked", [])
|
||||
fast._on_pointer_pressed(fast._cell_center(int(fpath[0])), null)
|
||||
fast._on_pointer_moved(fast._cell_center(int(fpath[2])), null)
|
||||
_ok(fast._path.size() >= 3,
|
||||
"быстрый motion через клетку заполняет обе клетки (%d)" % fast._path.size())
|
||||
# Движение над стеной/недопустимой клеткой не считается ошибкой.
|
||||
var before_invalid: int = fast._invalid
|
||||
if not blocked.is_empty():
|
||||
fast._on_pointer_moved(fast._cell_center(int(blocked[0])), null)
|
||||
_eq(fast._invalid, before_invalid, "motion по стене не увеличивает invalid")
|
||||
# А осознанный клик по стене — считается.
|
||||
fast._on_pointer_pressed(fast._cell_center(int(blocked[0])), null)
|
||||
_eq(fast._invalid, before_invalid + 1, "клик по стене — реальная ошибка")
|
||||
fast._on_pointer_released(Vector2.ZERO, null)
|
||||
_ok(not fast._dragging, "release после drag сбрасывает флаг")
|
||||
fast.free()
|
||||
|
||||
|
||||
# --- sound -----------------------------------------------------------------
|
||||
|
||||
func _test_sound() -> void:
|
||||
_set_case("sound")
|
||||
var expected_counts := [3, 4, 5, 6]
|
||||
var expected_tol := [0.16, 0.11, 0.08, 0.06]
|
||||
for tier in range(1, 5):
|
||||
var expected_counts := [4, 5, 6]
|
||||
var expected_tol := [0.11, 0.08, 0.06]
|
||||
for tier in range(1, 4):
|
||||
for seed in [1, 7, 42, 99, 1234]:
|
||||
var data := SOUND.generate(tier, seed)
|
||||
var count := int(data["count"])
|
||||
@@ -114,14 +179,14 @@ func _test_sound() -> void:
|
||||
_ok(absf(float(targets[i]) - float(targets[j])) >= 2.0 * tol - 0.001,
|
||||
"sound T%d цели не сливаются" % tier)
|
||||
var drift := SOUND.generate(tier, 5)
|
||||
if tier <= 2:
|
||||
_approx(float(drift["drift_amp"]), 0.0, 0.0001, "sound T%d без дрейфа" % tier)
|
||||
elif tier == 3:
|
||||
_approx(float(drift["drift_amp"]), 0.02, 0.0001, "sound T3 дрейф 2%")
|
||||
_approx(float(drift["drift_hz"]), 0.15, 0.0001, "sound T3 дрейф 0.15 Гц")
|
||||
if tier == 1:
|
||||
_approx(float(drift["drift_amp"]), 0.0, 0.0001, "sound T1 без дрейфа")
|
||||
elif tier == 2:
|
||||
_approx(float(drift["drift_amp"]), 0.02, 0.0001, "sound T2 дрейф 2%")
|
||||
_approx(float(drift["drift_hz"]), 0.15, 0.0001, "sound T2 дрейф 0.15 Гц")
|
||||
else:
|
||||
_approx(float(drift["drift_amp"]), 0.03, 0.0001, "sound T4 дрейф 3%")
|
||||
_approx(float(drift["drift_hz"]), 0.25, 0.0001, "sound T4 дрейф 0.25 Гц")
|
||||
_approx(float(drift["drift_amp"]), 0.03, 0.0001, "sound T3 дрейф 3%")
|
||||
_approx(float(drift["drift_hz"]), 0.25, 0.0001, "sound T3 дрейф 0.25 Гц")
|
||||
|
||||
_approx(SOUND.channel_quality(0.5, 0.5, 0.16), 1.0, 0.0001, "sound центр = 1")
|
||||
_approx(SOUND.channel_quality(0.66, 0.5, 0.16), 0.5, 0.0001, "sound край = 0.5")
|
||||
@@ -135,94 +200,16 @@ func _test_sound() -> void:
|
||||
"sound с дрейфом цель смещается")
|
||||
|
||||
|
||||
# --- wires -----------------------------------------------------------------
|
||||
|
||||
func _test_wires() -> void:
|
||||
_set_case("wires")
|
||||
var expected := {
|
||||
1: {"wires": 3, "sockets": 3},
|
||||
2: {"wires": 4, "sockets": 4},
|
||||
3: {"wires": 5, "sockets": 6},
|
||||
4: {"wires": 6, "sockets": 6},
|
||||
}
|
||||
for tier in range(1, 5):
|
||||
var geo := WIRES.geometry(tier)
|
||||
_approx(float(geo["box"]), 34.0, 0.0001, "wires T%d box 34" % tier)
|
||||
_approx(float(geo["pitch"]), 40.0, 0.0001, "wires T%d pitch 40" % tier)
|
||||
_ok(float(geo["gap"]) >= 300.0 - 0.001, "wires T%d зазор >= 300" % tier)
|
||||
_eq((geo["left_rects"] as Array).size(), int(expected[tier]["wires"]),
|
||||
"wires T%d число левых портов" % tier)
|
||||
_eq((geo["right_rects"] as Array).size(), int(expected[tier]["sockets"]),
|
||||
"wires T%d число правых портов" % tier)
|
||||
var left_rack: Rect2 = geo["left_rack"]
|
||||
var right_rack: Rect2 = geo["right_rack"]
|
||||
_ok(left_rack.end.y <= 320.0, "wires T%d левая стойка <= 320" % tier)
|
||||
_ok(right_rack.end.y <= 320.0, "wires T%d правая стойка <= 320" % tier)
|
||||
for side in ["left_rects", "right_rects"]:
|
||||
var rects: Array = geo[side]
|
||||
var rack := left_rack if side == "left_rects" else right_rack
|
||||
for i in range(rects.size()):
|
||||
var a: Rect2 = rects[i]
|
||||
_ok(a.position.x >= rack.position.x and a.position.y >= rack.position.y
|
||||
and a.end.x <= rack.end.x + 0.001 and a.end.y <= rack.end.y + 0.001,
|
||||
"wires T%d порт внутри стойки" % tier)
|
||||
for j in range(i + 1, rects.size()):
|
||||
_ok(not a.intersects(rects[j]), "wires T%d порты не пересекаются" % tier)
|
||||
var lanes: Array = geo["lanes"]
|
||||
for i in range(1, lanes.size()):
|
||||
_ok(absf(float(lanes[i]) - float(lanes[i - 1])) >= 8.0 - 0.001,
|
||||
"wires T%d полосы >= 8 px" % tier)
|
||||
for seed in [1, 7, 42, 99, 1234, 2026]:
|
||||
var data := WIRES.generate(tier, seed)
|
||||
_eq((data["wires"] as Array).size(), int(expected[tier]["wires"]),
|
||||
"wires T%d wires" % tier)
|
||||
_eq((data["sockets"] as Array).size(), int(expected[tier]["sockets"]),
|
||||
"wires T%d sockets" % tier)
|
||||
_ok(WIRES.validate_pairs(data), "wires T%d seed %d пары уникальны/матчатся" % [tier, seed])
|
||||
var sockets: Array = data["sockets"]
|
||||
var empty_count := 0
|
||||
for socket in sockets:
|
||||
if bool(socket.get("empty", false)):
|
||||
empty_count += 1
|
||||
if tier == 3:
|
||||
_eq(empty_count, 1, "wires T3 одно пустое гнездо")
|
||||
else:
|
||||
_eq(empty_count, 0, "wires T%d без пустых" % tier)
|
||||
if tier >= 4:
|
||||
var color_counts := {}
|
||||
var shape_counts := {}
|
||||
for wire in data["wires"]:
|
||||
var c := str(wire["color"])
|
||||
var s := str(wire["shape"])
|
||||
color_counts[c] = int(color_counts.get(c, 0)) + 1
|
||||
shape_counts[s] = int(shape_counts.get(s, 0)) + 1
|
||||
var shared_colors := 0
|
||||
var shared_shapes := 0
|
||||
for k in color_counts.keys():
|
||||
if int(color_counts[k]) >= 2:
|
||||
shared_colors += 1
|
||||
for k in shape_counts.keys():
|
||||
if int(shape_counts[k]) >= 2:
|
||||
shared_shapes += 1
|
||||
_ok(shared_colors >= 2, "wires T4 два общих цвета")
|
||||
_ok(shared_shapes >= 2, "wires T4 две общие формы")
|
||||
|
||||
_approx(WIRES.quality_for(3, 0, 3), 1.0, 0.0001, "wires все подключены = 1")
|
||||
_approx(WIRES.quality_for(2, 1, 4), 0.4375, 0.0001, "wires 2/4 и 1 ошибка = 0.4375")
|
||||
_approx(WIRES.quality_for(0, 20, 6), 0.0, 0.0001, "wires q клампится в 0")
|
||||
|
||||
|
||||
# --- text ------------------------------------------------------------------
|
||||
|
||||
func _test_text() -> void:
|
||||
_set_case("text")
|
||||
var expected := {
|
||||
1: {"words": 11, "aliens": 1, "lines": 2},
|
||||
2: {"words": 15, "aliens": 2, "lines": 2},
|
||||
3: {"words": 19, "aliens": 3, "lines": 3},
|
||||
4: {"words": 24, "aliens": 4, "lines": 3},
|
||||
1: {"words": 15, "aliens": 2, "lines": 2},
|
||||
2: {"words": 19, "aliens": 3, "lines": 3},
|
||||
3: {"words": 24, "aliens": 4, "lines": 3},
|
||||
}
|
||||
for tier in range(1, 5):
|
||||
for tier in range(1, 4):
|
||||
for seed in [1, 7, 42, 99, 1234, 2026]:
|
||||
var data := TEXT.generate(tier, seed)
|
||||
_eq((data["words"] as Array).size(), int(expected[tier]["words"]),
|
||||
@@ -256,13 +243,12 @@ func _test_text() -> void:
|
||||
func _test_video() -> void:
|
||||
_set_case("video")
|
||||
var expected := {
|
||||
1: {"cards": 3, "slots": 3},
|
||||
2: {"cards": 4, "slots": 4},
|
||||
3: {"cards": 5, "slots": 4},
|
||||
4: {"cards": 6, "slots": 5},
|
||||
1: {"cards": 4, "slots": 4},
|
||||
2: {"cards": 5, "slots": 4},
|
||||
3: {"cards": 6, "slots": 5},
|
||||
}
|
||||
var seen_timecodes := {}
|
||||
for tier in range(1, 5):
|
||||
for tier in range(1, 4):
|
||||
for seed in [1, 7, 42, 99, 1234, 2026]:
|
||||
var data := VIDEO.generate(tier, seed)
|
||||
_eq((data["cards"] as Array).size(), int(expected[tier]["cards"]),
|
||||
@@ -282,7 +268,7 @@ func _test_video() -> void:
|
||||
for i in range(int(expected[tier]["slots"])):
|
||||
_ok(real_slots.has(i), "video T%d слот %d имеет реальный кадр" % [tier, i])
|
||||
_eq(real_slots.size(), int(expected[tier]["slots"]), "video T%d уникальные верные слоты" % tier)
|
||||
if tier <= 2:
|
||||
if tier == 1:
|
||||
_eq(decoys, 0, "video T%d без брака" % tier)
|
||||
_eq(mirrored, 0, "video T%d без зеркала" % tier)
|
||||
else:
|
||||
@@ -295,8 +281,8 @@ func _test_video() -> void:
|
||||
if int(card.get("id", -1)) == int(info["duplicate_real"]):
|
||||
real_shape = str(card.get("shape", ""))
|
||||
_ok(real_shape != str(info["decoy_shape"]), "video T%d силуэт брака отличается" % tier)
|
||||
if tier == 4:
|
||||
_eq(mirrored, 1, "video T4 ровно 1 зеркальный кадр")
|
||||
if tier == 3:
|
||||
_eq(mirrored, 1, "video T3 ровно 1 зеркальный кадр")
|
||||
else:
|
||||
_eq(mirrored, 0, "video T%d без зеркала" % tier)
|
||||
var tc_key := "|".join(data.get("slot_timecodes", []))
|
||||
@@ -314,11 +300,11 @@ func _test_video() -> void:
|
||||
|
||||
func _test_photo() -> void:
|
||||
_set_case("photo")
|
||||
var speeds := [90.0, 140.0, 200.0, 260.0]
|
||||
var frame_w := [220.0, 180.0, 150.0, 120.0]
|
||||
var frame_h := [150.0, 120.0, 100.0, 80.0]
|
||||
var shots := [3, 4, 5, 6]
|
||||
for tier in range(1, 5):
|
||||
var speeds := [140.0, 200.0, 260.0]
|
||||
var frame_w := [180.0, 150.0, 120.0]
|
||||
var frame_h := [120.0, 100.0, 80.0]
|
||||
var shots := [4, 5, 6]
|
||||
for tier in range(1, 4):
|
||||
var p := PHOTO.tier_params(tier)
|
||||
_approx(float(p["speed"]), speeds[tier - 1], 0.0001, "photo T%d speed" % tier)
|
||||
_approx(float(p["frame_w"]), frame_w[tier - 1], 0.0001, "photo T%d frame_w" % tier)
|
||||
@@ -359,12 +345,11 @@ func _test_photo() -> void:
|
||||
func _test_maze() -> void:
|
||||
_set_case("maze")
|
||||
var expected := {
|
||||
1: {"cols": 9, "rows": 4, "field_w": 360.0, "field_h": 160.0, "corridor": 28.0, "speed": 420.0},
|
||||
2: {"cols": 11, "rows": 4, "field_w": 440.0, "field_h": 160.0, "corridor": 24.0, "speed": 470.0},
|
||||
3: {"cols": 13, "rows": 4, "field_w": 494.0, "field_h": 152.0, "corridor": 20.0, "speed": 520.0},
|
||||
4: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0, "corridor": 18.0, "speed": 560.0},
|
||||
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},
|
||||
}
|
||||
for tier in range(1, 5):
|
||||
for tier in range(1, 4):
|
||||
var p := MAZE.tier_params(tier)
|
||||
var spec: Dictionary = expected[tier]
|
||||
for key in ["cols", "rows"]:
|
||||
|
||||
@@ -11,8 +11,8 @@ const IDS := [
|
||||
"lot_money", "lot_folder", "lot_channel", "lot_timer",
|
||||
]
|
||||
const FIRST_PRICES := {
|
||||
"lot_slots": 120, "lot_speed": 90, "lot_sla": 120, "lot_shield": 150,
|
||||
"lot_money": 80, "lot_folder": 80, "lot_channel": 70, "lot_timer": 70,
|
||||
"lot_slots": 90, "lot_speed": 55, "lot_sla": 80, "lot_shield": 100,
|
||||
"lot_money": 70, "lot_folder": 50, "lot_channel": 45, "lot_timer": 45,
|
||||
}
|
||||
const MAX_LEVELS := {
|
||||
"lot_slots": 4, "lot_speed": 6, "lot_sla": 4, "lot_shield": 3,
|
||||
@@ -64,7 +64,7 @@ func _test_catalog() -> void:
|
||||
_eq(shop.level(id), 0, "начальный уровень %s" % id)
|
||||
for p in item.get("prices", []):
|
||||
total += int(p)
|
||||
_eq(total, 13100, "сумма цен всех лотов = 13 100")
|
||||
_eq(total, 8085, "сумма цен всех лотов = 8 085")
|
||||
_ok(shop.get_item("scanner").is_empty(), "сканера нет")
|
||||
_eq(shop.max_level("нет_товара"), 0, "неизвестный лот → max_level 0")
|
||||
_eq(shop.price("нет_товара"), 0, "неизвестный лот → price 0")
|
||||
@@ -145,18 +145,22 @@ func _test_affordable() -> void:
|
||||
shop.refresh(2, 0, true)
|
||||
_eq(shop.offers().size(), 3, "при 0 $ всё равно 3 оффера (без краша)")
|
||||
for i in range(30):
|
||||
_game.money = 71
|
||||
shop.refresh(2, 71, true)
|
||||
_game.money = 49
|
||||
shop.refresh(2, 49, true)
|
||||
var ofs: Array = shop.offers()
|
||||
var has_cheap: bool = ofs.has("lot_channel") or ofs.has("lot_timer")
|
||||
var has_cheap := false
|
||||
for id in ofs:
|
||||
var cost := int(shop.price(str(id)))
|
||||
if cost >= 0 and cost <= 49:
|
||||
has_cheap = true
|
||||
if not has_cheap:
|
||||
_fail("при 71 $ нет доступного лота за 70 (офферы: %s)" % str(ofs))
|
||||
_fail("при 49 $ нет доступного лота (офферы: %s)" % str(ofs))
|
||||
break
|
||||
_ok(true, "при 71 $ всегда есть доступный лот за 70")
|
||||
_ok(true, "при 49 $ всегда есть доступный лот")
|
||||
# Дорогие лоты не заменяются, если доступных нет.
|
||||
_game.money = 60
|
||||
shop.refresh(2, 60, true)
|
||||
_eq(shop.offers().size(), 3, "при 60 $ предложения не ломаются")
|
||||
_game.money = 40
|
||||
shop.refresh(2, 40, true)
|
||||
_eq(shop.offers().size(), 3, "при 40 $ предложения не ломаются")
|
||||
|
||||
|
||||
func _test_reroll() -> void:
|
||||
@@ -227,18 +231,18 @@ func _test_buy_and_effects() -> void:
|
||||
_set_case("buy")
|
||||
var shop = _reset(2, 10000)
|
||||
_set_offers(["lot_sla", "lot_slots", "lot_money"])
|
||||
_eq(shop.price("lot_sla"), 120, "lot_sla цена 120")
|
||||
_eq(shop.price("lot_sla"), 80, "lot_sla цена 80")
|
||||
_ok(shop.buy("lot_sla"), "покупка lot_sla")
|
||||
_eq(int(_game.money), 9880, "списано 120")
|
||||
_eq(int(_game.money), 9920, "списано 80")
|
||||
_eq(shop.level("lot_sla"), 1, "уровень lot_sla = 1")
|
||||
_eq(_game.level("sla_level"), 1, "агрегат sla_level = 1")
|
||||
_approx(_game.sla_cap(), 95.0, 0.0001, "кап SLA 60 → 95")
|
||||
_approx(float(_game.sla), 95.0, 0.0001, "SLA сразу 60 → 95")
|
||||
_eq(shop.price("lot_sla"), 260, "цена 2-го уровня 260")
|
||||
_approx(_game.sla_cap(), 100.0, 0.0001, "кап SLA 60 → 100")
|
||||
_approx(float(_game.sla), 100.0, 0.0001, "SLA сразу 60 → 100")
|
||||
_eq(shop.price("lot_sla"), 160, "цена 2-го уровня 160")
|
||||
_ok(shop.buy("lot_sla"), "покупка lot_sla 2-й раз")
|
||||
_eq(shop.price("lot_sla"), 480, "цена 3-го уровня 480")
|
||||
_eq(int(_game.money), 9620, "после второй покупки 9620")
|
||||
_approx(float(_game.sla), 130.0, 0.0001, "SLA 95 → 130")
|
||||
_eq(shop.price("lot_sla"), 270, "цена 3-го уровня 270")
|
||||
_eq(int(_game.money), 9760, "после второй покупки 9760")
|
||||
_approx(float(_game.sla), 140.0, 0.0001, "SLA 100 → 140")
|
||||
|
||||
_ok(shop.buy("lot_slots"), "покупка lot_slots")
|
||||
_eq(shop.level("lot_slots"), 1, "lot_slots = 1")
|
||||
@@ -253,9 +257,9 @@ func _test_buy_and_effects() -> void:
|
||||
_approx(_game.channel_capacity(), 16.0, 0.0001, "канал 10 → 16")
|
||||
_eq(_game.buffer_limit(), 7, "буфер 6 → 7")
|
||||
_ok(shop.buy("lot_speed"), "покупка lot_speed")
|
||||
_approx(_game.speed_mult(), 0.92, 0.0001, "скорость ×0.92")
|
||||
_approx(_game.speed_mult(), 0.90, 0.0001, "скорость ×0.90")
|
||||
_ok(shop.buy("lot_timer"), "покупка lot_timer")
|
||||
_approx(_game.request_timer(), 35.0, 0.0001, "таймер запроса 30 → 35")
|
||||
_approx(_game.request_timer(), 37.0, 0.0001, "таймер запроса 30 → 37")
|
||||
|
||||
_set_offers(["lot_shield", "lot_sla", "lot_money"])
|
||||
_ok(shop.buy("lot_shield"), "покупка lot_shield")
|
||||
@@ -271,10 +275,10 @@ func _test_buy_and_effects() -> void:
|
||||
# Недостаточно денег.
|
||||
shop = _reset(2, 10000)
|
||||
_set_offers(["lot_slots"])
|
||||
_game.money = 119
|
||||
_ok(not shop.can_buy("lot_slots"), "119 $ мало для 120")
|
||||
_game.money = 89
|
||||
_ok(not shop.can_buy("lot_slots"), "89 $ мало для 90")
|
||||
_ok(not shop.buy("lot_slots"), "buy без денег → false")
|
||||
_eq(int(_game.money), 119, "деньги не списаны")
|
||||
_eq(int(_game.money), 89, "деньги не списаны")
|
||||
|
||||
|
||||
func _test_slot_and_folder_effects() -> void:
|
||||
@@ -320,10 +324,10 @@ func _test_slot_and_folder_effects() -> void:
|
||||
for i in range(4):
|
||||
shop.buy("lot_folder")
|
||||
dims = _game.folder_dims()
|
||||
_eq(dims.x, 8, "folder max: 8 столбцов")
|
||||
_eq(dims.x, 9, "folder max: 9 столбцов")
|
||||
_eq(dims.y, 6, "folder max: 6 строк")
|
||||
if folder != null:
|
||||
_eq(int(folder.cols), 8, "существующая папка выросла до 8 столбцов")
|
||||
_eq(int(folder.cols), 9, "существующая папка выросла до 9 столбцов")
|
||||
_eq(int(folder.rows), 6, "существующая папка выросла до 6 строк")
|
||||
if probe != null:
|
||||
_eq(probe.origin, probe_origin, "origin файла не изменился на max")
|
||||
|
||||